前言
在Flutter开发中,处理大型设计文件一直是个挑战。特别是 Photoshop 的 PSD 文件,动辄几百 MB,包含数十甚至上百个图层。如果直接在移动端进行解析和渲染,极易导致内存溢出(OOM)。
本文将分享一个创新的解决方案:通过 H5 页面桥接调用 ag-psd 进行解析,配合分块图片编码技术,将大图逐行写入磁盘,并且完美规避 OOM 问题。
效果展示

项目架构概览
整个方案分为三个核心部分:
- H5 解析层 :使用
ag-psd库在 WebView 中解析 PSD 文件 - Flutter 桥接层 :通过
flutter_inappwebview实现双向通信 - 分块编码层:利用 FFI 调用 C 库,逐行写入 PNG 数据
技术栈
yaml
dependencies:
flutter_inappwebview: ^6.2.0-beta.2 # WebView 容器
chunked_widget_to_image: ^1.0.1 # 分块图片编码
ffi: ^2.2.0 # FFI 支持
path_provider: ^2.1.5 # 路径管理
json_annotation: ^4.9.0 # JSON 序列化
核心痛点:为什么需要分块处理?
传统方案的困境
假设我们要处理一个 2000x2000 像素的 PSD 图层:
dart
// ❌ 传统方式:一次性加载整张图片到内存
Uint8List imageData = await canvas.toImage().toByteData();
// 内存占用:2000 * 2000 * 4 = 16MB(RGBA)
// 如果有 50 个图层,峰值内存将达到 800MB+
在移动端,这样的内存消耗极易触发 OOM,尤其是在低端设备上。
分块方案的优势
dart
// ✅ 分块方式:每次只处理 128KB 的数据
const maxChunkBytes = 128 * 1024;
final rowsPerChunk = Math.floor(maxChunkBytes / (width * 4));
// 每次仅占用 128KB,内存占用降低 99%+
技术实现详解
一、H5 层:PSD 解析与分块读取
1.1 引入 ag-psd 库
我们使用 ag-psd 这个强大的 JavaScript 库来解析 PSD 文件:
html
<script src="psd-bundle.js"></script>
1.2 文件选择与解析
javascript
async function uploadFile() {
const fileInput = document.getElementById('psdFileInput');
const file = fileInput.files[0];
// 读取文件为 ArrayBuffer
const reader = new FileReader();
reader.onload = async function (e) {
const buffer = e.target.result;
// 解析 PSD,跳过合成图像,保留图层数据
const psd = agPsd.readPsd(buffer, {
skipCompositeImageData: true,
skipLayerImageData: false,
});
// 递归处理所有图层
await processLayersRecursively(psd.children || [], imagePaths, issues);
// 清理后的 PSD 数据(移除 Canvas 等大对象)
const cleanedPsd = cleanPsdForJson(psd);
// 通过桥接发送结果给 Flutter
await callFlutter('psdUpdated', {
fileName: file.name,
fileSize: file.size,
imageCount: imagePaths.length,
psd: cleanedPsd,
images: imagePaths,
issues,
});
};
reader.readAsArrayBuffer(file);
}
关键点:
skipCompositeImageData: true- 跳过画布合成图,减少内存cleanPsdForJson()- 清理 Canvas、ImageData 等无法序列化的对象
1.3 图层递归处理
javascript
async function processLayersRecursively(layers, imagePaths, issues, parentPath = '') {
for (let index = 0; index < layers.length; index++) {
const layer = layers[index];
const layerPath = parentPath ? `${parentPath}/${index}` : `${index}`;
if (layer.children && layer.children.length > 0) {
// 递归处理子图层(图层组)
await processLayersRecursively(layer.children, imagePaths, issues, layerPath);
continue;
}
// 处理单个图层
await processLayer(layer, layerPath, imagePaths, issues);
}
}
1.4 分块读取 Canvas 数据(核心)
这是避免 OOM 的关键步骤:
javascript
async function writeCanvasRgbaChunks(canvas, transferId) {
const context = canvas.getContext('2d', { willReadFrequently: true });
const srcStride = canvas.width * 4; // 每行字节数
// 计算每块的行数,确保每块不超过 128KB
const maxChunkBytes = 128 * 1024;
const rowsPerChunk = Math.max(1, Math.floor(maxChunkBytes / srcStride));
// 分块读取并发送
for (let y = 0; y < canvas.height; y += rowsPerChunk) {
const rows = Math.min(rowsPerChunk, canvas.height - y);
// 获取当前块的 RGBA 数据
const imageData = context.getImageData(0, y, canvas.width, rows);
// 转换为 Base64
const base64 = bytesToBase64(imageData.data);
// 发送给 Flutter 端
await callFlutter('appendImageChunk', {
transferId,
base64,
rows,
srcStride,
});
// 等待下一帧,给 GC 时间
await waitForNextFrame();
}
}
优化细节:
-
动态分块大小:根据图片宽度自动计算每块行数
javascript// 例如:1000px 宽的图片 // srcStride = 1000 * 4 = 4000 bytes // rowsPerChunk = floor(128 * 1024 / 4000) = 32 行 -
Base64 分段编码:避免大数组操作卡顿
javascriptfunction bytesToBase64(bytes) { let binary = ''; const subChunkSize = 0x10000; // 64KB for (let offset = 0; offset < bytes.length; offset += subChunkSize) { const subChunk = bytes.subarray(offset, offset + subChunkSize); binary += String.fromCharCode.apply(null, subChunk); } return btoa(binary); } -
主动让出主线程:每处理一块后等待下一帧
javascriptfunction waitForNextFrame() { return new Promise((resolve) => { window.requestAnimationFrame(() => resolve()); }); } -
及时释放内存:处理完图层后立即清理
javascriptfinally { canvas.width = 1; canvas.height = 1; delete layer.canvas; delete layer.imageData; await waitForMemoryRelease(); }
二、Flutter 桥接层:双向通信
2.1 WebView 初始化与 Handler 注册
dart
class Psd2JsonDialog extends StatefulWidget {
@override
Psd2JsonState createState() => Psd2JsonState();
}
class Psd2JsonState extends State<Psd2JsonDialog> {
InAppWebViewController? webViewController;
final Map<String, _ImageSaveSession> _imageSaveSessions = {};
@override
Widget build(BuildContext context) {
return Dialog(
child: InAppWebView(
initialFile: "assets/html/psd-index.html",
onWebViewCreated: (controller) async {
webViewController = controller;
// 注册多个 Handler
_registerHandlers();
},
),
);
}
void _registerHandlers() {
// 1. 显示加载状态
webViewController?.addJavaScriptHandler(
handlerName: 'showLoading',
callback: (args) async {
_updateStatus(args.first.toString(), isParsing: true);
},
);
// 2. 开始保存图片(创建会话)
webViewController?.addJavaScriptHandler(
handlerName: 'startImageSave',
callback: (args) async {
final payload = Map<String, dynamic>.from(args.first);
final directoryPath = await getImageDirectoryPath();
// 生成文件路径
final name = _safeFileName(payload['name']?.toString() ?? 'layer');
final id = _safeFileName(payload['id']?.toString());
final filePath = '${directoryPath}layer_${id}_${name}_${DateTime.now().millisecondsSinceEpoch}.png';
// 创建空文件
await File(filePath).create(recursive: true);
// 创建分块写入会话
final width = (payload['width'] as num).toInt();
final height = (payload['height'] as num).toInt();
final transferId = '${DateTime.now().microsecondsSinceEpoch}_${_imageSaveSessions.length}';
_imageSaveSessions[transferId] = _ImageSaveSession(
writer: ChunkedCanvasImageWriter.create(
filePath: filePath,
width: width,
height: height,
),
);
return {'transferId': transferId, 'filePath': filePath};
},
);
// 3. 追加图片分块
webViewController?.addJavaScriptHandler(
handlerName: 'appendImageChunk',
callback: (args) async {
final payload = Map<String, dynamic>.from(args.first);
final transferId = payload['transferId'] as String;
final session = _imageSaveSessions[transferId];
if (session == null) {
throw StateError('图片保存会话不存在:$transferId');
}
// 写入一个分块
return session.writer.appendBase64RgbaChunk(
base64: payload['base64'] as String,
rows: (payload['rows'] as num).toInt(),
srcStride: (payload['srcStride'] as num).toInt(),
);
},
);
// 4. 完成图片保存
webViewController?.addJavaScriptHandler(
handlerName: 'finishImageSave',
callback: (args) async {
final payload = Map<String, dynamic>.from(args.first);
final transferId = payload['transferId'] as String;
final session = _imageSaveSessions.remove(transferId);
if (session == null) {
throw StateError('图片保存会话不存在:$transferId');
}
// 完成写入,返回文件路径
return session.writer.finish();
},
);
// 5. 接收解析结果
webViewController?.addJavaScriptHandler(
handlerName: 'psdUpdated',
callback: (args) async {
final data = Map<String, dynamic>.from(args.first);
final result = await PsdParseResult.fromBridge(data);
widget.onParsed(result);
// 清理 WebView
await webViewController?.loadData(data: '<html></html>');
if (context.mounted) {
Navigator.of(context).pop();
}
},
);
}
}
2.2 会话管理机制
dart
class _ImageSaveSession {
final ChunkedCanvasImageWriter writer;
_ImageSaveSession({required this.writer});
void dispose({bool deleteFile = false}) {
if (deleteFile) {
File(writer.filePath).delete().ignore();
}
}
}
每个图层对应一个独立的写入会话,通过 transferId 唯一标识,支持并发处理多个图层。
三、分块编码层:FFI 调用 C 库
3.1 ChunkedCanvasImageWriter 实现
这是整个方案的核心,通过 FFI 调用 libpng 实现逐行写入:
dart
class ChunkedCanvasImageWriter {
static final ChunkedImageBindings _bindings = ChunkedImageBindings(
_openDynamicLibrary(),
);
final String filePath;
final int width;
final int height;
final ffi.Pointer<ImageContext> _context;
int _writtenRows = 0;
bool _closed = false;
/// 创建 PNG 写入上下文
static ChunkedCanvasImageWriter create({
required String filePath,
required int width,
required int height,
}) {
final cPath = filePath.toNativeUtf8();
try {
final context = _bindings.createPngContext(
cPath.cast<ffi.Char>(),
width,
height,
);
if (context.address == 0) {
throw StateError('创建 PNG 写入上下文失败');
}
return ChunkedCanvasImageWriter._(
filePath: filePath,
width: width,
height: height,
context: context,
);
} finally {
calloc.free(cPath);
}
}
/// 追加一个 Base64 编码的 RGBA 分块
int appendBase64RgbaChunk({
required String base64,
required int rows,
required int srcStride,
}) {
if (_closed) {
throw StateError('PNG 写入上下文已关闭');
}
// 解码 Base64
final bytes = base64Decode(base64);
final expectedLength = rows * srcStride;
if (bytes.lengthInBytes != expectedLength) {
throw StateError(
'RGBA 分块大小不匹配:${bytes.lengthInBytes}/$expectedLength bytes',
);
}
// 复制到 native 内存
final sourcePointer = _copyToPointer(bytes);
try {
// 调用 C 函数写入数据
final result = _bindings.writePngData(
_context,
sourcePointer,
srcStride,
rows,
);
if (result != 0 && result != 1) {
throw StateError('写入 PNG 分块失败:$result');
}
_writtenRows += rows;
return _writtenRows;
} finally {
calloc.free(sourcePointer);
}
}
/// 完成写入并保存文件
Future<String> finish() async {
if (_closed) {
return filePath;
}
_closed = true;
// 验证行数完整性
if (_writtenRows != height) {
throw StateError('PNG 行数不完整:$_writtenRows/$height');
}
// 调用 C 函数完成 PNG 编码
final result = _bindings.savePngImage(_context);
if (result != 0) {
throw StateError('保存 PNG 失败:$result');
}
// 验证文件是否成功写入
final file = File(filePath);
if (!await file.exists() || await file.length() <= 0) {
throw StateError('PNG 文件未写入:$filePath');
}
return filePath;
}
ffi.Pointer<ffi.Uint8> _copyToPointer(Uint8List bytes) {
final pointer = calloc<ffi.Uint8>(bytes.lengthInBytes);
pointer.asTypedList(bytes.lengthInBytes).setAll(0, bytes);
return pointer;
}
}
3.2 FFI 绑定定义
dart
import 'dart:ffi' as ffi;
class ChunkedImageBindings {
final ffi.Pointer<T> Function<T extends ffi.NativeType>(String symbolName) _lookup;
ChunkedImageBindings(ffi.DynamicLibrary dynamicLibrary)
: _lookup = dynamicLibrary.lookup;
/// 创建 PNG 上下文
late final _createPngContext =
_lookup<ffi.NativeFunction<
ffi.Pointer<ImageContext> Function(
ffi.Pointer<ffi.Char>,
ffi.Int,
ffi.Int,
)
>>('create_png_context')
.asFunction<
ffi.Pointer<ImageContext> Function(ffi.Pointer<ffi.Char>, int, int)
>();
/// 写入 PNG 数据块
late final _writePngData =
_lookup<ffi.NativeFunction<
ffi.Int Function(
ffi.Pointer<ImageContext>,
ffi.Pointer<ffi.Uint8>,
ffi.Int,
ffi.Int,
)
>>('write_png_data')
.asFunction<
int Function(
ffi.Pointer<ImageContext>,
ffi.Pointer<ffi.Uint8>,
int,
int,
)
>();
/// 保存 PNG 文件
late final _savePngImage =
_lookup<ffi.NativeFunction<ffi.Int Function(ffi.Pointer<ImageContext>)>>(
'save_png_image',
).asFunction<int Function(ffi.Pointer<ImageContext>)>();
ffi.Pointer<ImageContext> createPngContext(
ffi.Pointer<ffi.Char> filePath,
int width,
int height,
) {
return _createPngContext(filePath, width, height);
}
int writePngData(
ffi.Pointer<ImageContext> context,
ffi.Pointer<ffi.Uint8> rgbaData,
int srcStride,
int rowCount,
) {
return _writePngData(context, rgbaData, srcStride, rowCount);
}
int savePngImage(ffi.Pointer<ImageContext> context) {
return _savePngImage(context);
}
}
/// PNG 写入上下文结构
final class ImageContext extends ffi.Struct {
@ffi.Int()
external int width;
@ffi.Int()
external int height;
@ffi.Int()
external int currentRow;
external ffi.Pointer<ffi.Void> filePtr; // FILE*
external ffi.Pointer<ffi.Void> imagePtr; // png_structp
external ffi.Pointer<ffi.Void> infoPtr; // png_infop
}
3.3 动态库加载
dart
ffi.DynamicLibrary _openDynamicLibrary() {
const libName = 'chunked_widget_to_image';
if (Platform.isMacOS || Platform.isIOS) {
return ffi.DynamicLibrary.open('$libName.framework/$libName');
}
if (Platform.isAndroid || Platform.isLinux) {
return ffi.DynamicLibrary.open('lib$libName.so');
}
if (Platform.isWindows) {
return ffi.DynamicLibrary.open('$libName.dll');
}
throw UnsupportedError('Unknown platform: ${Platform.operatingSystem}');
}
完整流程图解
yaml
用户选择 PSD 文件
↓
┌──────────────────────┐
│ H5: FileReader 读取 │
└──────────────────────┘
↓
┌──────────────────────┐
│ H5: ag-psd 解析 PSD │
└──────────────────────┘
↓
┌──────────────────────────┐
│ H5: 递归遍历所有图层 │
└──────────────────────────┘
↓
┌──────────────────────────────┐
│ H5: 对每个图层执行: │
│ 1. 调用 startImageSave │
│ 2. 分块读取 Canvas 数据 │
│ 3. 逐块调用 appendImageChunk│
│ 4. 调用 finishImageSave │
└──────────────────────────────┘
↓
┌─────────────────────────────────┐
│ Flutter: 创建写入会话 │
│ ChunkedCanvasImageWriter.create│
└─────────────────────────────────┘
↓
┌──────────────────────────────────┐
│ Flutter: 逐块写入 PNG │
│ 1. Base64 解码 │
│ 2. 复制到 Native 内存 │
│ 3. 调用 libpng 写入 │
└──────────────────────────────────┘
↓
┌──────────────────────────────┐
│ Flutter: 完成 PNG 编码 │
│ 保存到本地文件系统 │
└──────────────────────────────┘
↓
┌──────────────────────────────┐
│ H5: 清理 PSD 数据结构 │
│ 移除 Canvas/ImageData │
└──────────────────────────────┘
↓
┌──────────────────────────────┐
│ H5: 发送最终结果给 Flutter │
│ - JSON 结构数据 │
│ - 图片文件路径列表 │
└──────────────────────────────┘
↓
┌──────────────────────────────┐
│ Flutter: 保存 JSON 文件 │
│ 展示解析结果 │
└──────────────────────────────┘
性能对比
内存占用对比
| 方案 | 单图层内存峰值 | 50 图层总峰值 | OOM 风险 |
|---|---|---|---|
| 传统方案 | ~16 MB | ~800 MB | ⚠️ 极高 |
| 分块方案 | ~128 KB | ~6.4 MB | ✅ 极低 |
内存降低 99%+ 🎉
关键优化技巧总结
1. H5 端优化
✅ 分块大小动态计算
javascript
const rowsPerChunk = Math.floor(128 * 1024 / (width * 4));
✅ 主动让出主线程
javascript
await waitForNextFrame(); // requestAnimationFrame
✅ 及时释放内存
javascript
canvas.width = 1;
canvas.height = 1;
delete layer.canvas;
✅ Base64 分段编码
javascript
const subChunkSize = 0x10000; // 64KB
2. Flutter 端优化
✅ 会话化管理
dart
final Map<String, _ImageSaveSession> _imageSaveSessions = {};
✅ Native 内存及时释放
dart
finally {
calloc.free(sourcePointer);
}
✅ 完整性校验
dart
if (_writtenRows != height) {
throw StateError('PNG 行数不完整');
}
3. 架构层面优化
✅ 职责分离
- H5 负责解析和数据读取
- Flutter 负责文件写入和业务逻辑
✅ 流式处理
- 边解析边保存,无需等待全部完成
✅ 错误隔离
- 单个图层失败不影响其他图层
常见问题与解决方案
Q1: 为什么选择 ag-psd 而不是其他库?
A: ag-psd 是目前最成熟的 JavaScript PSD 解析库,具有以下优势:
- 完整的图层信息解析(包括效果、文本、矢量蒙版等)
- 支持 Canvas 导出,便于后续处理
- 活跃的社区维护和良好的文档
Q2: 为什么不直接在 Flutter 端解析 PSD?
A:
- Dart 生态缺乏成熟的 PSD 解析库
- 使用 FFI 调用 C/C++ 库会增加包体积和复杂度
- H5 方案更灵活,易于调试和更新
Q3: 为什么不用 psd_sdk + FFI?
A: 评估 psd_sdk(C++ 库)方案,但发现存在以下问题:
-
接入成本高
- 需要通过 FFI 编写大量 C/C++ 绑定代码
- 跨语言调用栈调试困难,内存管理容易出错
- 需要处理不同平台的编译和适配(iOS/Android/macOS/Windows/Linux)
-
编译成本高
- 各平台需要单独编译动态库
- CI/CD 流程复杂,构建时间显著增加
-
需要自己解析 PSD DOM
- psd_sdk 只提供底层数据读取,不构建完整的图层树结构
- 需要自行实现图层关系、效果、样式等高级特性的解析逻辑
- 工作量巨大,相当于重新实现一个 ag-psd
相比之下,H5 + ag-psd 方案可以直接获得完整的 PSD DOM 结构,开发效率更高,包体积更小,更适合移动端 Flutter 应用。
源码地址
完整代码已开源:
🔗 GitHub: psd_to_json