Flutter PSD 解析实践:利用ag-psd 解析 + 分块图片编码,同时解决移动端OOM

前言

在Flutter开发中,处理大型设计文件一直是个挑战。特别是 Photoshop 的 PSD 文件,动辄几百 MB,包含数十甚至上百个图层。如果直接在移动端进行解析和渲染,极易导致内存溢出(OOM)。

本文将分享一个创新的解决方案:通过 H5 页面桥接调用 ag-psd 进行解析,配合分块图片编码技术,将大图逐行写入磁盘,并且完美规避 OOM 问题

效果展示

项目架构概览

整个方案分为三个核心部分:

  1. H5 解析层 :使用 ag-psd 库在 WebView 中解析 PSD 文件
  2. Flutter 桥接层 :通过 flutter_inappwebview 实现双向通信
  3. 分块编码层:利用 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();
    }
}

优化细节

  1. 动态分块大小:根据图片宽度自动计算每块行数

    javascript 复制代码
    // 例如:1000px 宽的图片
    // srcStride = 1000 * 4 = 4000 bytes
    // rowsPerChunk = floor(128 * 1024 / 4000) = 32 行
  2. Base64 分段编码:避免大数组操作卡顿

    javascript 复制代码
    function 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);
    }
  3. 主动让出主线程:每处理一块后等待下一帧

    javascript 复制代码
    function waitForNextFrame() {
        return new Promise((resolve) => {
            window.requestAnimationFrame(() => resolve());
        });
    }
  4. 及时释放内存:处理完图层后立即清理

    javascript 复制代码
    finally {
        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:

  1. Dart 生态缺乏成熟的 PSD 解析库
  2. 使用 FFI 调用 C/C++ 库会增加包体积和复杂度
  3. H5 方案更灵活,易于调试和更新

Q3: 为什么不用 psd_sdk + FFI?

A: 评估 psd_sdk(C++ 库)方案,但发现存在以下问题:

  1. 接入成本高

    • 需要通过 FFI 编写大量 C/C++ 绑定代码
    • 跨语言调用栈调试困难,内存管理容易出错
    • 需要处理不同平台的编译和适配(iOS/Android/macOS/Windows/Linux)
  2. 编译成本高

    • 各平台需要单独编译动态库
    • CI/CD 流程复杂,构建时间显著增加
  3. 需要自己解析 PSD DOM

    • psd_sdk 只提供底层数据读取,不构建完整的图层树结构
    • 需要自行实现图层关系、效果、样式等高级特性的解析逻辑
    • 工作量巨大,相当于重新实现一个 ag-psd

相比之下,H5 + ag-psd 方案可以直接获得完整的 PSD DOM 结构,开发效率更高,包体积更小,更适合移动端 Flutter 应用。

源码地址

完整代码已开源:

🔗 GitHub: psd_to_json

相关推荐
恋猫de小郭10 小时前
Flutter GenUI 0.9 和 A2UI 0.9 发布,全动动态 UI 支持,AI 在 App 里直出界面
android·flutter·ios
KKei163810 小时前
Flutter for OpenHarmony 学习专注模式APP技术文章
学习·flutter·华为·harmonyos
UnicornDev10 小时前
【Flutter x HarmonyOS 6】挑战功能的业务逻辑实现
flutter·华为·harmonyos·鸿蒙·鸿蒙系统
Lan_Se_Tian_Ma10 小时前
使用Cursor封装Flutter项目基建框架
前端·人工智能·flutter
天天开发10 小时前
Flutter Widget Previewer使用指南:提升开发效率的利器
前端·javascript·flutter
liulian09162 天前
Flutter 网络状态与内容分享库:connectivity_plus 与 share_plus 的 OpenHarmony 适配指南
网络·flutter
KKei16382 天前
Flutter for OpenHarmony 学习视频播放器技术文章
学习·flutter·华为·音视频·harmonyos
KKei16382 天前
Flutter for OpenHarmony 健身计划与运动打卡APP
flutter·华为·harmonyos
KKei16382 天前
Flutter for OpenHarmony 在线考试与自测系统APP技术文章
flutter·华为·harmonyos