去年把部门里的 Flutter 数据同步模块从“全量加载”切到“分块加载”的时候团队里好几个老同事都觉得我在给自己找活干。但等我们在鸿蒙设备上真正跑起 2GB 级别的日志文件传输场景时这套方案的价值就彻底压不住了——内存占用从 1.4GB 一路回落到不到 90MB整体传输时间反而缩短了 37%。这篇内容就聊聊我们团队把 Flutter 三方库 block 完整适配到鸿蒙的全过程核心是分块加载在超大数据处理场景下的性能哲学以及怎么用这套思路搭出一个可用的工业级文件传输系统。无论你是正在做鸿蒙应用移植、需要处理大文件的 Flutter 开发者还是单纯对“分块加载为什么快”有兴趣这篇文章都值得你花十分钟读完。1. 为什么需要 block 库分块加载不是简单的“分批读文件”1.1 一次性加载的三大痛点先说说我们最开始是怎么被逼到分块这条路上的。项目里有条日志上传链路客户端会周期性把本地缓存的日志文件发送到服务端。日志文件小的时候没感受但一旦在弱网环境里攒了半个月单个文件轻松突破 1GB。最开始的做法非常简单粗暴直接把文件整个读进内存。第一步一行File(path).readAsBytes()一个 1.2GB 的文件Dart 侧一次性分配了 1.2GB 的 Uint8List。这个行为放在 PC 上还好放到鸿蒙手机、平板上几乎立刻触发三个问题内存首当其冲。1.2GB 的堆外内存加上 Dart 本身的对象头、GC 预留空间App 直接被系统杀掉是常事。其次是磁盘 I/O 不友好。大块顺序读虽然在盘片上是高效的但内存被占满后系统页面缓存吃紧其他进程跟着遭殃。最坑的是进度不可控。传输过程只有“成功”或者“消失”连个像样的进度回调都很难做用户看着永远停在 0% 的进度条直接就想卸载。这三个痛点放在一起恰好指向一个结论把所有数据一次性装进内存本质上是把“传输问题”转化成了“内存问题”这个转换得不偿失。1.2 block 库的核心设计理念block 这个名字很容易让人误解成 blockchain其实它干的就是最朴素的“分块”这件事。它的核心设计理念可以概括成三点分块、并发和校验。分块就是把一个大文件从物理和逻辑上切成等长的数据段每一段作为一个独立的传输单元。并发是指允许多个块同时进行读取、写入或校验利用底层设备的调度能力让 I/O 不至于白白空转。校验是每个块在读写前后都计算一遍哈希通常用 CRC32 或 MD5这样即使某一个块传输失败也只需要重新传输那一个块而不是整个文件。这三点的组合带来一个很有意思的“性能哲学”传输一个超大文件的性能瓶颈从来不是 CPU 算不过来而是内存装不下、I/O 没喂饱、失败恢复太慢。block 恰好把这三个瓶颈逐一拆掉了。实际工程里我们一度以为“块越小越灵活”但实测发现块太小比如 64KB会导致系统调用次数暴涨反而把 CPU 跑满了。后面会详细讲怎么定这个参数。2. 鸿蒙化适配前的工程准备2.1 Flutter 工程与鸿蒙工程的集成方式鸿蒙生态目前的 Flutter 路线本质上是通过 OpenHarmony 侧的 Flutter 引擎来跑 Dart 代码平台能力靠“自定义插件 MethodChannel”打入鸿蒙原生层。这个过程和我们当年写 Flutter 插件给 Android 用非常像但也有几个关键的差异。首先是工程结构。鸿蒙的 Flutter 插件工程目录是ohos模块里面放 ets 源码通过module.json5声明插件入口。我们当时用的 DevEco Studio 5.0.x 加对应版本的鸿蒙 SDK在 Flutter 工程的pubspec.yaml里加上依赖后还需要在ohos目录下手动维护Index.ets作为入口文件。这一步很容易被漏掉导致编译不过。其次是权限声明。鸿蒙的权限模型比 Android 更细涉及文件读写必须区分沙箱内路径和公共路径。如果是应用沙箱目录下的文件不需要额外权限一旦涉及用户公共文档、媒体库就要在module.json5里配requestPermissions。我们这次适配主要针对应用自己沙箱内的日志文件所以权限上省了很多事但如果你要读公共目录记得提前配好。最后是编译链配置。Flutter 的鸿蒙引擎目前对 OpenHarmony SDK 的版本要求比较挑我们踩过一个坑SDK 版本太低fileIo的openSync不可用版本太高Flutter 引擎编译失败。最后锁定的是一套固定的版本组合这个我在文章末尾的速查表里列出照着配能少走弯路。2.2 通道选型MethodChannel 够不够Flutter 和鸿蒙原生之间的通信通道有几种MethodChannel、EventChannel、BasicMessageChannel。对 block 库来说最核心的是“命令-响应”式的调用比如创建任务、查询状态、取消任务这些用 MethodChannel 完全够用。但文件传输过程中有大量的进度回调如果每次都从鸿蒙侧 invokeMethod 反打给 Flutter一来频繁二来性能堪忧。我们实测过用 MethodChannel 做每 1MB 报一次进度传输 2GB 文件要回调 2000 次每次回调的平均耗时接近 0.8ms累计多出近 1.6 秒。这还没算 GC 压力。所以最终方案是控制面走 MethodChannel数据面走 EventChannel。具体来说鸿蒙侧只在一个全局的单例里维护传输状态Flutter 侧注册一个 EventChannel 监听鸿蒙侧每完成一个块而不是每 1MB就往 EventStream 里推一条进度消息。这样回调频率从 2000 次降到 256 次按 8MB 一块性能损耗几乎可以忽略。另外还有一个容易被忽视的细节MethodChannel 调用在主线程上执行而大文件的 I/O 绝对不能放在主线程。鸿蒙侧要用 taskpool 或者异步的await fileIo.read来干避免卡掉 UI 主线程。我们在第一版适配里就是因为直接在方法里同步读了文件导致界面直接卡死后来改成异步加 taskpool 才恢复正常。细节在第 3 节展开。3. 核心适配逻辑从通道到文件 I/O 的完整链路3.1 Flutter 侧 API 设计让调用方感受不到平台差异适配的目标是让原先在 Android/iOS 上调用 block 的代码在鸿蒙上无感。我们设计得比较克制的 API 面是这样的class BlockTask { final int taskId; final int totalBytes; final int completedBytes; final int chunkSize; double get progress completedBytes / totalBytes; } class BlockTransporter { static const MethodChannel _channel MethodChannel(com.example.block/io); static FutureBlockTask createTask({ required String src, required String dst, int chunkSize 8 * 1024 * 1024, int concurrency 3, }) async { final args String, dynamic{ src: src, dst: dst, chunkSize: chunkSize, concurrency: concurrency, }; final result await _channel.invokeMethodMapdynamic, dynamic(createTask, args); return BlockTask( taskId: result[taskId] as int, totalBytes: result[totalBytes] as int, completedBytes: result[completedBytes] as int, chunkSize: chunkSize, ); } static Futurevoid cancel(int taskId) async { await _channel.invokeMethod(cancel, {taskId: taskId}); } }这个 API 设计有几个考虑。第一chunkSize和concurrency暴露给调用方默认值不激进8MB 和 3 是我们在多款设备上测出来的“甜点值”。第二BlockTask只暴露总字节数和已完成字节数不暴露内部缓冲区信息避免调用方误操作底层细节。第三所有方法都是 asyncFlutter 侧直接 await不需要调用方自己管理线程。在鸿蒙侧的对应实现里createTask会去openSync打开两个文件句柄然后启动一个异步任务做实际的分块读写。这里有个极易踩的坑如果直接从 MethodChannel 里 return 一个包含句柄 ID 的 map后续的所有操作都在这个句柄上做那么就必须保证这个句柄不被 GC 回收。鸿蒙侧的 ArkTS 对象如果被 GC 清掉了句柄会自动 close导致后面的读写全部报错。我们的做法是把句柄封装在BlockTaskNative类里同时在模块级别维持一个Mapnumber, BlockTaskNative强引用直到任务结束或取消才移除。3.2 鸿蒙侧实现fileIo 的分块读写鸿蒙原生层的核心代码不复杂但每一步都有讲究。下面是一个简化版的分块写入循环import { fileIo } from kit.CoreFileKit; import { taskpool } from kit.ArkTS; Concurrent async function blockCopy( srcFd: number, dstFd: number, start: number, size: number, bufferSize: number, result: Mapstring, number, ): Promisevoid { const buffer new ArrayBuffer(bufferSize); let offset 0; while (offset size) { const readBytes await fileIo.readSync(srcFd, buffer, { offset: start offset, length: size - offset, }); if (readBytes 0) { break; } const writeBytes await fileIo.writeSync(dstFd, buffer, { offset: start offset, length: readBytes, }); offset writeBytes; } result[offset] offset; }这里有三个细节需要留意。第一fileIo.readSync和writeSync虽然有 Sync 后缀但配合Concurrent装饰器运行在 taskpool 里并不会阻塞 UI 主线程。真机上实测下来Concurrent函数配合ArrayBuffer在后台执行比在主 async 函数里直接 await 更稳定。第二Buffer 的offset参数填的是“文件内的绝对偏移”不是“块内偏移”非常容易写错。我们第一版在这里把start offset写成了offset结果多个块写到了文件开头的同一区域整个文件都被覆盖烂了。第三writeSync的返回值不保证等于readBytes极端情况下会写少几个字节所以循环里必须用writeBytes继续推进而不是盲目地offset readBytes。配合并发控制我们在原生层维护一个“待处理块队列”用一个简单的计数器控制最大并发数。每个块完成或失败时更新全局的completedBytes并发数降到 0 时触发任务结束回调。这里不建议直接用原生Promise.all同时启动所有块的拷贝任务——如果文件有 1024 个块同时发起 1024 个并发任务taskpool 会直接拒绝而且内存占用瞬间爆炸。工程上我们用的是一组固定数量的工作协程从队列里取块取完为止。3.3 分块参数的计算逻辑分块大小是最容易让人纠结的参数也是 block 库最核心的调优点。我把测试过的几组数据列在这里供参考块大小并发数2GB 文件传输耗时峰值内存CPU 占用64KB4约 41s12MB高512KB4约 26s25MB中4MB3约 14s35MB低8MB3约 12.8s58MB低16MB4约 13.5s110MB低可以看到块太小的时候系统调用开销占比太高整体耗时反而上去了。块太大的时候虽然调用次数少了但内存占用线性增长而且并发带来的收益会被单块耗时拖累。综合来看8MB 是一个在“耗时、内存、稳定性”三者之间比较平衡的取值。那有没有一个公式可以直接算我们后来总结了一个经验算法chunkSize clamp(fileSize / (concurrency * 64), 1MB, 16MB)。意思是先按并发数估算出每个并发任务需要处理的块数块数太少则缩小块避免尾块过多同时限制在 1MB 到 16MB 之间防止极端小文件或超大文件走到极端参数。这个公式不是科学定律但它能帮你在没有真实设备的情况下快速得到一个可用的起点后续再根据设备表现微调。并发数的选择也有讲究。我们一开始以为并发越大越好直接把 concurrency 拉到 8结果发现鸿蒙的文件 IO 引擎在部分低端设备上只会串行执行同一文件的写入并发 8 和并发 3 的耗时差距不到 5%内存却翻了两倍多。所以默认值我们定在 3对绝大多数 eMMC 和 UFS 设备都友好。4. 打造工业级文件传输系统链路设计与实践4.1 传输业务的完整链路有了分块读写能力离“工业级文件传输系统”还差得远。我们实际做的事情有任务队列、进度通知、取消恢复、断点续传、失败重试和完整性校验。这里我把传输链路完整画一条出来调用方传入源文件路径、目标文件路径和可选参数。鸿蒙侧打开源文件句柄拿到文件总大小同时创建目标文件句柄。根据总大小和参数计算出块列表初始化任务状态。启动固定数量的工作协程从块队列中顺序取块。每个块执行“读-写-校验”三步完成后更新已传输字节数。所有块完成后对目标文件做整文件校验返回最终结果。期间任何一步失败根据失败类型决定是重试当前块还是终止任务。这个链路的重点在于把“任务状态机”独立出来。我们在鸿蒙侧维护一个BlockTaskManager每个任务有 idle、running、paused、done、failed 五种状态。所有状态迁移都通过封装好的方法完成不直接在回调里改状态。这样做的好处是后续要接 UI、接推送、接服务端指令都是往状态机上挂事件不用动底层 IO 逻辑。任务队列也不是可有可无的。我们遇到过用户连续点击“同步”按钮瞬间创建了三个相同任务三个任务同时读写同一个文件直接把文件写成乱码。后来在传输层加了按路径去重的队列同一对源目标路径只允许存在一个 active 任务其他请求直接返回 existing task。4.2 断点续传的实现思路断点续传是“工业级”传输系统最核心的需求之一。原理并不复杂但实现细节决定体验。我们在创建任务时会先检查目标位置是否已经存在一个.block.meta文件。这个文件里记录了源文件的名称、大小、块大小、总块数以及每个块的完成状态。完成状态我们用的是 bitmap 式的紧凑表示每个块占一个 bit1 表示已完成。一个 2GB 文件分成 256 个 8MB 块只要 32 字节就能完整记录进度持久化成本极低。恢复流程是这样的任务启动时读取 meta 文件如果不存在则从第 0 块开始如果存在则跳过 bitmap 中标为已完成的所有块直接从第一个未完成的块继续。每个块在写入目标文件并校验通过后立即更新内存中的 bitmap并定期写回 meta 文件。这里有一个很关键的细节什么时候写回 meta如果每个块完成后都写会有频繁的磁盘小写拖慢整体速度。我们采用的是“每隔完成 4 个块写一次外加任务暂停或结束时强制写一次”的策略。这样即使中途崩溃最多丢失 4 个块的进度重传成本完全可接受。还有一个容易忽略的问题断点续传不能只记录块完成还要校验源文件是否变化。我们会在 meta 里记录源文件的最后修改时间恢复时如果发现源文件变了直接丢弃 meta 重新全量传输否则会把新文件写到旧文件的半截上。这个坑我们踩过一次用户改了文件内容再点同步结果目标文件新旧内容混杂排查了很久才定位到。4.3 校验机制块校验与整文件校验双重保险block 库对校验的实现分两级。第一级是块级校验每个块在成功写入后计算一次 CRC32与读取前的 CRC32 比对不一致则重试当前块。块级校验能在第一时间发现传输中的损坏避免把坏数据继续往后写。第二级是整文件校验。所有块完成后对目标文件做一次 MD5 计算与源文件的 MD5 比对。这里有个取舍MD5 计算本身要遍历整个文件等于额外增加了一次全量读取的耗时。对于 2GB 文件这条路径大约多花 1.5 秒到 2 秒。这个开销能不能接受我们认为在“工业级”场景下必须接受因为块级 CRC32 只能发现单个块内部的数据错误无法发现块与块之间的拼接错误比如前面提到的 offset 写错导致文件重叠覆盖的问题。这里也要提一个性能优化的点整文件 MD5 可以和块级 CRC32 并行做。blockCopy 循环里每读出一块除了写目标文件还把块数据同时喂给一个独立的 MD5 更新器。这样最后一个块写完时MD5 也刚好算完省掉了额外的整文件遍历。这种“边传输边计算”的思路同样可以用在增量备份、镜像同步等场景中。5. 踩坑记录与性能实测5.1 典型问题速查表问题现象根本原因解决方案任务创建后未执行回调无响应Kotlin 侧插件未正确注册检查module.json5和Index.ets入口文件读写报错句柄不可用ArkTS 对象被 GC 回收导致句柄关闭用模块级 Map 强引用持有任务对象块写入位置错乱文件被覆盖readSync/writeSync的 offset 填错使用“文件绝对偏移 块起始偏移 块内偏移”界面卡死点击无反应文件 I/O 同步执行在主线程改用 taskpool 或Concurrent后台执行传输进度回调过于频繁每 1MB 触发一次 MethodChannel 回调合并为每块一次通过 EventChannel 推送断点续传恢复后文件损坏未检查源文件修改时间meta 文件中记录源文件的修改时间变化则全量重传并发 8 比并发 3 快不了多少低端设备同一文件写入实际串行默认并发 3按设备实际表现微调这里面最想强调的是第一行。我们刚开始做鸿蒙适配时Flutter 编译、运行都没报错createTask也正常返回了但鸿蒙侧方法就是没有被调起来。折腾了一天发现是Index.ets文件里的注册入口没有把自定义插件类实例化MethodChannel 发过来的消息根本没人接。这个不报错但跑不通的现象非常坑建议所有做鸿蒙 Flutter 插件适配的人第一件事就是确认自己的插件入口类有没有被真正加载。5.2 性能实测数据文章开头提到的“耗时缩短 37%”是在我们自己的测试机上得出的结果。具体测试环境是一台搭载麒麟 9000S 的平板HarmonyOS NEXT 开发者预览版2GB 日志文件从应用沙箱拷贝到公共目录。全量加载方案用时约 20.3 秒峰值内存 1.4GB。分块加载方案8MB 块、并发 3用时约 12.8 秒峰值内存 58MB。原先以为分块读写的频繁 syscall 会拖慢速度实际却因为内存压力降低、系统缓存更活跃反而快了不少。当然如果你的设备是低端机文件系统性能比较差这个差距会缩小但分块方案的内存优势始终存在。另外我们还单独测了“边传输边计算 MD5”的损耗块级 CRC32 加整文件 MD5 并行计算总耗时只比纯传输多约 0.8%基本可以忽略。这个数字侧面验证了“校验成本可控”这件事工业级传输场景完全负担得起。5.3 后续扩展这块还能怎么玩block 的鸿蒙化适配做完之后我们发现这套分块思想还能延伸到不少场景。比如跨设备传输时可以引入独立的块级重传协议某个块传输超时就只重发这个块而不是回滚全部进度又比如配合鸿蒙的分布式文件能力可以做到手机和平板之间的增量同步。我们目前已经在规划把这套 block 封装成可配置的传输服务向上层业务屏蔽任务管理和校验细节下层通过统一的 BlockIO 接口对接不同的文件系统。一个更现实的方向是把它和鸿蒙应用的后台任务能力结合。大文件传输经常需要 App 进入后台甚至锁屏后继续执行目前鸿蒙对后台任务有相应的长时任务申请机制把传输任务绑定到长时任务上配合分块进度写入就能实现真正意义上的静默续传这对日志回传、媒体文件备份这类场景意义很大。6. 写在最后我的几点亲身感受如果只从这篇适配里提炼一句给后来者的话我会说分块加载不是一种“优化技巧”而是一种看待超大数据处理的思维方式。传统的一次性加载把问题和内存对立起来分块加载则通过把数据切成可控粒度同时解决了内存、进度、恢复和校验四个问题。这不是巧合而是分而治之思想在 IO 场景下的自然延伸。在实际操作中还有一点体会很深鸿蒙生态的 Flutter 适配还在快速演进中文档和工具链不像 Android 那样成熟遇到问题别急着怀疑自己的代码先确认 SDK 版本、引擎版本和插件注册链路是否一致。用最短的时间把最小可用的通道跑通再逐步叠加功能是投入产出比最高的路径。最后再分享一个小技巧如果你们团队也在做类似的 Flutter 插件鸿蒙化适配建议在工程里同时保留 Android 和 HarmonyOS 两套底层实现抽象层共用这样同一套业务代码可以在两个平台上快速验证。block 库的 API 层我们是完全平台无关的换底层实现时 Flutter 侧一行代码都不用改这个投入非常值得。
阅读完成 · 觉得有帮助?