This commit is contained in:
chenshuainan
2026-09-28 20:12:34 +08:00
parent 847986f861
commit 5f53bd8f94
60 changed files with 14624 additions and 398 deletions
+10
View File
@@ -0,0 +1,10 @@
// ============================================================
// 图片文件头信息(条件导出):
// native(dart.library.io 为真)→ image_file_header_io.dart
// web(dart.library.io 为假) → image_file_header_web.dart
//
// 仅用 RandomAccessFile 读 JPEG/PNG 文件头获取尺寸/格式,
// 不把整张图读进内存、不做像素解码。
// ============================================================
export 'image_file_header_web.dart'
if (dart.library.io) 'image_file_header_io.dart';
+118
View File
@@ -0,0 +1,118 @@
// 仅当 dart.library.io 为真(native)时编译。
import 'dart:io';
import 'dart:typed_data';
/// 图片文件头信息(仅读头部,不加载像素)
class ImageFileHeader {
const ImageFileHeader({
required this.width,
required this.height,
required this.length,
required this.isPng,
required this.isJpeg,
});
final int width;
final int height;
final int length;
final bool isPng;
final bool isJpeg;
}
/// 读取本地图片文件头:尺寸 / 格式 / 文件大小。
///
/// 使用 RandomAccessFile 按 marker 顺序扫描,每次仅读几个字节:
/// - PNG:IHDR 固定在偏移 16;
/// - JPEG:从 SOI 后逐段扫描,SOF 段内即宽高(在 SOS 之前)。
/// 不支持的格式 / 损坏文件返回 null。
Future<ImageFileHeader?> readImageFileHeader(String path) async {
final file = File(path);
final raf = await file.open();
try {
final sig = await raf.read(4);
if (sig.length < 4) return null;
// -------- PNG:89 50 4E 47 0D 0A 1A 0A,IHDR 在偏移 16 --------
if (sig[0] == 0x89 &&
sig[1] == 0x50 &&
sig[2] == 0x4E &&
sig[3] == 0x47) {
await raf.setPosition(16);
final d = await raf.read(8);
if (d.length < 8) return null;
final width = (d[0] << 24) | (d[1] << 16) | (d[2] << 8) | d[3];
final height = (d[4] << 24) | (d[5] << 16) | (d[6] << 8) | d[7];
return ImageFileHeader(
width: width,
height: height,
length: await raf.length(),
isPng: true,
isJpeg: false,
);
}
// -------- JPEG:FF D8 FF --------
if (sig[0] == 0xFF && sig[1] == 0xD8 && sig[2] == 0xFF) {
await raf.setPosition(2);
while (true) {
// 定位段 marker(跳过填充字节)
final prefix = await raf.read(1);
if (prefix.isEmpty || prefix[0] != 0xFF) return null;
int marker;
do {
final m = await raf.read(1);
if (m.isEmpty) return null;
marker = m[0];
} while (marker == 0xFF);
// 无长度段:SOI / TEM / RSTn
if (marker == 0xD8 || marker == 0x01) continue;
if (marker >= 0xD0 && marker <= 0xD9) {
if (marker == 0xD9) return null; // EOI:未找到 SOF
continue;
}
final lenBytes = await raf.read(2);
if (lenBytes.length < 2) return null;
final segLen = (lenBytes[0] << 8) | lenBytes[1];
if (segLen < 2) return null;
// SOF 段(排除 C4=DHT、C8=JPG、CC=DAC)
if (marker >= 0xC0 &&
marker <= 0xCF &&
marker != 0xC4 &&
marker != 0xC8 &&
marker != 0xCC) {
// precision(1) + height(2) + width(2)
final sof = await raf.read(5);
if (sof.length < 5) return null;
final height = (sof[1] << 8) | sof[2];
final width = (sof[3] << 8) | sof[4];
return ImageFileHeader(
width: width,
height: height,
length: await raf.length(),
isPng: false,
isJpeg: true,
);
}
// 其它段:跳过段体(长度含自身 2 字节)
await raf.setPosition(await raf.position() + segLen - 2);
}
}
return null;
} finally {
await raf.close();
}
}
/// 读取文件全部字节(仅用于桌面等不支持原生压缩时的回退路径)
Future<Uint8List?> readImageFileBytes(String path) async {
try {
return Uint8List.fromList(await File(path).readAsBytes());
} catch (_) {
return null;
}
}
@@ -0,0 +1,25 @@
// 仅当 dart.library.io 为假(Web)时编译。
// Web 端选图直接拿到字节,不需要读文件头。
import 'dart:typed_data';
class ImageFileHeader {
const ImageFileHeader({
required this.width,
required this.height,
required this.length,
required this.isPng,
required this.isJpeg,
});
final int width;
final int height;
final int length;
final bool isPng;
final bool isJpeg;
}
/// Web 端无文件系统,返回 null
Future<ImageFileHeader?> readImageFileHeader(String path) async => null;
/// Web 端无文件系统,返回 null
Future<Uint8List?> readImageFileBytes(String path) async => null;
+64
View File
@@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:image/image.dart' as img;
import 'image_file_header.dart';
/// ============================================================
/// 【ImageProcessor】图片压缩 + 方向修正 + 尺寸过滤
///
@@ -87,6 +89,68 @@ class ImageProcessor {
processedBytes: result.processedBytes,
);
}
/// 处理本地图片【文件】(原生端推荐入口):
/// - 尺寸/格式只读文件头(RandomAccessFile 读几字节),
/// UI 线程不做完整解码;
/// - 压缩交给原生侧直接读文件,Dart 堆不持有原图;
/// - 原生压缩不可用(如桌面平台)时回退纯 Dart isolate。
static Future<ProcessedImage?> processFile({
required String path,
int maxDimension = 1920,
int quality = 85,
}) async {
final header = await readImageFileHeader(path);
if (header == null) return null;
if (!_isInAllowedSize(header.width, header.height)) return null;
final target = _targetSize(
width: header.width,
height: header.height,
maxDimension: maxDimension,
);
final compressed = await FlutterImageCompress.compressWithFile(
path,
minWidth: target.width,
minHeight: target.height,
quality: quality,
format: header.isPng ? CompressFormat.png : CompressFormat.jpeg,
autoCorrectionAngle: true,
);
if (compressed != null && compressed.isNotEmpty) {
final bytes = Uint8List.fromList(compressed);
return ProcessedImage(
bytes: bytes,
extension: header.isPng ? 'png' : 'jpg',
contentType: header.isPng ? 'image/png' : 'image/jpeg',
originalBytes: header.length,
processedBytes: bytes.length,
);
}
// 回退:读文件字节 → 纯 Dart isolate(桌面等场景)
final fileBytes = await readImageFileBytes(path);
if (fileBytes == null || fileBytes.isEmpty) return null;
final result = await compute(
_processInIsolate,
(
bytes: fileBytes,
fileName: null,
maxDimension: maxDimension,
quality: quality,
),
);
if (result == null) return null;
return ProcessedImage(
bytes: result.bytes,
extension: result.extension,
contentType: result.contentType,
originalBytes: result.originalBytes,
processedBytes: result.processedBytes,
);
}
}
typedef _ProcessTask = ({
+5 -3
View File
@@ -106,8 +106,10 @@ class PickerService {
return image == null ? null : [image];
}
// 相册权限:Android 13+ 用 photos (READ_MEDIA_IMAGES),旧版用 storage
final galleryPermission = await _resolveGalleryPermission();
if (!context.mounted) return null;
final ok = await _ensurePermission(
permission: await _resolveGalleryPermission(),
permission: galleryPermission,
title: '需要相册权限',
purpose: '从相册选取图片用于上传错题、作业图片及更换头像',
context: context,
@@ -190,10 +192,10 @@ class PickerService {
// 不再强制锁拍照方向。题目/解答/讲义拍摄经常需要横拍,
// 后续交给 ImageProcessor 的原生压缩链做 autoCorrectionAngle。
onEntitySaving: (dialogContext, viewType, file) async {
final xFile = XFile(file.path);
// 只持有文件路径,不 readAsBytes:原图字节不进 Dart 堆,
// 后续压缩由原生侧直接读文件(避免大图 OOM)。
picked = PickedImage(
path: file.path,
bytes: await xFile.readAsBytes(),
name: file.path.split('/').last,
);
if (dialogContext.mounted) {