去年把部门里的 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 Future<BlockTask> 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.invokeMethod<Map<dynamic, dynamic>>('createTask', args); return BlockTask( taskId: result['taskId'] as int, totalBytes: result['totalBytes'] as int, completedBytes: result['completedBytes'] as int, chunkSize: chunkSize, ); } static Future<void> cancel(int taskId) async { await _channel.invokeMethod('cancel', {'taskId': taskId}); } }这个 API 设计有几个考虑。第一,chunkSize和concurrency暴露给调用方,默认值不激进,8MB 和 3 是我们在多款设备上测出来的“甜点值”。第二,BlockTask只暴露总字节数和已完成字节数,不暴露内部缓冲区信息,避免调用方误操作底层细节。第三,所有方法都是 async,Flutter 侧直接 await,不需要调用方自己管理线程。
在鸿蒙侧的对应实现里,createTask会去openSync打开两个文件句柄,然后启动一个异步任务做实际的分块读写。这里有个极易踩的坑:如果直接从 MethodChannel 里 return 一个包含句柄 ID 的 map,后续的所有操作都在这个句柄上做,那么就必须保证这个句柄不被 GC 回收。鸿蒙侧的 ArkTS 对象如果被 GC 清掉了,句柄会自动 close,导致后面的读写全部报错。我们的做法是:把句柄封装在BlockTaskNative类里,同时在模块级别维持一个Map<number, 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: Map<string, number>, ): Promise<void> { 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 占用 |
|---|---|---|---|---|
| 64KB | 4 | 约 41s | 12MB | 高 |
| 512KB | 4 | 约 26s | 25MB | 中 |
| 4MB | 3 | 约 14s | 35MB | 低 |
| 8MB | 3 | 约 12.8s | 58MB | 低 |
| 16MB | 4 | 约 13.5s | 110MB | 低 |
可以看到,块太小的时候,系统调用开销占比太高,整体耗时反而上去了。块太大的时候,虽然调用次数少了,但内存占用线性增长,而且并发带来的收益会被单块耗时拖累。综合来看,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 式的紧凑表示:每个块占一个 bit,1 表示已完成。一个 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 侧一行代码都不用改,这个投入非常值得。