更新
This commit is contained in:
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user