维护过 Flutter 构建工具链的朋友,大概率都遇到过这种名场面:为了跑一次离线构建,得在系统临时目录里手工安排一堆文件,执行完外部命令后还要小心翼翼地把目录清理干净,稍微漏一个引用,下次构建就能给你整出各种幺蛾子。scratch_space这个三方库解决的就是这段脏活累活——它给 Flutter 构建流程提供了一套临时构建空间管理能力,再加上外部指令协同的调度机制,让临时文件有处可去、有迹可循、用完即焚。但一旦把目光放到鸿蒙系统上,问题就来了:路径规则不一样、沙箱机制不一样、平台通道的对接方式也不一样,直接拿原来那套代码跑,大概率连临时目录都建不出来。
这篇文章我就围绕scratch_space的鸿蒙化过程,把适配思路、代码改造点、协同调度设计,以及我在实际集成中踩过的坑完整复盘一遍。适合正在做 Flutter 鸿蒙化迁移的插件作者、把构建工具链搬到鸿蒙设备上的开发者,以及想理解三方库如何做跨平台兼容的 Flutter 进阶用户。
1. 拆解 scratch_space:临时空间与外部指令协同到底做了什么
1.1 包的职责边界,比想象中更窄也更专
scratch_space不是一个通用的文件管理库,它的职责非常聚焦:在一个指定根目录下,为某次构建任务创建一块独立的临时工作区,并提供创建子目录、写入文件、解析路径、最后整体回收的能力。如果你用过它,应该很熟悉那套很典型的 API:create()初始化根目录,newDir()和newFile()在根目录下批量生成工作文件,dispose()在任务结束后把整个临时空间连根拔起。
真正让它区别于普通文件工具的,是“外部指令协同”这一层。构建任务通常不是单打独斗的——你可能需要在临时目录里调用dart、git、hvigor,甚至是某个内网自研的构建工具,把生成的中间文件落在临时空间内,然后由后续流程读取。这个库的价值就在于把“临时目录生命周期管理”和“外部命令执行过程中的路径传递”合并成一个可控流程:外部命令的工作目录指向临时空间,产物落在临时空间,命令跑完,整个空间可以干净利落地销毁。
有一个细节容易被忽略:scratch_space对路径解析做了很细的约束。它不允许工作区里的文件路径逃出根目录,所有生成的路径都必须通过resolve()这类方法拿到,而不是让上层自己靠字符串拼接。这个设计在桌面端和移动端都很有必要,因为它避免了临时文件被误写到业务目录。鸿蒙化的时候,这种路径约束反而成了我们的保护伞。
1.2 为什么鸿蒙化不是换一个目录名那么简单
很多人第一反应是:不就是在getTemporaryDirectory()下面多套一层目录嘛?真不是。鸿蒙系统的沙箱机制和路径规则,和 Android、iOS 有本质区别。
在鸿蒙上,应用能访问的文件系统范围由应用沙箱决定,临时目录通常挂在应用沙箱内部的 cache 路径下,而不是全局/tmp。更关键的是,鸿蒙上可执行的外部指令范围和 Android 并不完全一致,有些 Android 上能直接跑的二进制指令,在鸿蒙沙箱内要么没有执行权限,要么根本不在搜索路径里。虽然 Dart 层跑在 Flutter 引擎之上,但dart:io的Process到底能不能像在 Android 上一样顺利拉起子进程,取决于鸿蒙运行时对进程创建、文件描述符传递、环境变量继承的具体实现力度。
还有一个更现实的问题:scratch_space如果只依赖dart:io,那它就是纯 Dart 实现,理论上在鸿蒙 Flutter 运行时里能直接编译。但很多实际使用场景里,外部命令的路径、临时空间的根目录,需要由原生侧传入,这就绕不开平台通道。要么我们在鸿蒙侧实现一套临时目录获取逻辑,通过 MethodChannel 回传给 Dart;要么在 Dart 侧用Platform.environment猜路径——后者显然不够稳健。所以鸿蒙化的核心不是翻译代码,而是重新定义“临时空间从哪来、外部命令怎么跑、目录怎么回收”这三件事。
2. 鸿蒙化适配的前置条件与环境预检
2.1 Flutter 鸿蒙运行时现状与三方库兼容面
开始动代码之前,先确认你手上的 Flutter 环境支持鸿蒙目标。目前社区常见的方案是使用 Flutter 的 OpenHarmony 移植分支,也就是flutter-ohos这类维护分支,它们会在标准 Flutter 引擎基础上补充鸿蒙平台的 embedding 和 shell,让 Flutter 应用能以鸿蒙应用的形式跑起来。判断你的库能不能在上面运行,主要看两条:依赖树里有多少是纯 Dart 包,多少是带原生代码的插件。
scratch_space本身如果只依赖dart:io,那它的 Dart 层理论上不挑平台。但我建议不要盲信“纯 Dart 一定兼容”这种结论,因为鸿蒙运行时里dart:io的一些细节行为和 Android 不完全一致,比如目录权限检查、符号链接解析、进程环境变量读取等。预检阶段最好做一次静态扫描:把包源码里用到的dart:ioAPI 全部列出来,逐项对照鸿蒙 Flutter 分支的实现状态。
这一步我一般这么操作:先跑flutter pub deps --style=compact看依赖,再针对scratch_space源码做一次全局搜索,重点搜Directory、File、Process、Platform.environment。那些只出现在非关键路径上的偶发调用可以暂时放过,但Process的调用必须全部列出,因为外部指令协同是标题里的核心功能,也是后面改造工作量最大的地方。
2.2 从源码级别拆解调度逻辑,找出隐式的原生依赖
如果scratch_space的某个版本加了一层平台抽象,情况会更复杂。比如它可能定义了一个ScratchSpacePlatform接口,在 Android 上用path_provider拿缓存目录,在 iOS 上用NSTemporaryDirectory。这种设计在鸿蒙化时反而好办,因为我们可以顺着这个接口再加一个OhosScratchSpacePlatform实现,不至于改动上层业务代码。
但如果没有这层抽象,临时目录来源是写死在 Dart 里的,那适配时就要做一次小规模重构:把“获取临时根目录”的逻辑抽出来,让它在不同平台返回不同路径。鸿蒙上优先从原生侧拿 cache 目录,拿不到时再回退到Directory.systemTemp。这算一个典型的防御性设计,因为鸿蒙 Flutter 分支对Directory.systemTemp的映射位置在不同版本里出现过调整,靠原生上下文拿到的路径更可信。
外部指令协同的调度逻辑也要拆。你需要看清楚库在发起外部指令时,是否显式设置了workingDirectory、environment,以及它对 stdout/stderr 是流式读取还是一次性收集。这三个参数在鸿蒙上直接决定协同引擎能不能跑通。流式读取如果不及时消费输出缓冲区,子进程可能直接被阻塞;工作目录如果设置成一个不存在的路径,Process 启动会静默失败;环境变量如果依赖PATH里的某个二进制,而鸿蒙沙箱没有把那个路径加进去,那就只能靠绝对路径。预检阶段把这些点标注清楚,后面改造才有依据。
3. 实操:为 scratch_space 建立鸿蒙平台支持
3.1 创建 ohos 平台目录与最小工程结构
假设你已经有一个 Flutter 插件工程,里面同时有android、ios目录。要为鸿蒙添加支持,首先看用的是哪种鸿蒙 Flutter 分支,不同分支对插件目录的约定不完全一样。常见做法是在工程根目录建立一个ohos目录,里面放鸿蒙侧的插件代码。
我刚迁移时踩过一个坑:光建目录不写编译配置,Flutter 工程会直接忽略这个平台的插件,MethodChannel调过去就是MissingPluginException。所以目录结构必须完整,至少包括:
ohos/oh-pubspec.yaml:给鸿蒙侧包管理器用的声明文件,类似于给 Flutter 看的pubspec.yaml。ohos/entry:示例入口模块,配合调试。ohos/src/main:插件核心代码,使用 ArkTS 实现。ohos/build-profile.json5、oh-package.json5等基础工程文件。
在pubspec.yaml里,还需要把鸿蒙平台注册进去。不同维护分支的字段写法略有差异,但逻辑都一样:告诉 Flutter 工具链“这个插件在 ohos 平台上有原生实现”。这一步比较机械,但漏掉任何一个文件,编译期不会报错,运行期插件不注册,排查起来非常隐蔽。我的经验是先把一个空插件跑通,再往里面填逻辑,避免一开始就叠加多变量。
3.2 在 ArkTS 侧实现临时空间插件
鸿蒙端的插件实现,核心是用 ArkTS 写一个类,实现平台插件接口,并注册到 Flutter 引擎上。临时空间相关的能力,我把它收敛成几个必要的方法通道,不贪多:
getScratchRoot:返回临时空间的根目录,内部通过鸿蒙上下文拿到 cache 路径。newDir:在根目录下创建子目录,返回绝对路径。newFile:在指定目录下创建文件,可附带初始字节内容。resolve:把一个相对路径解析成绝对路径,同时做路径合法性检查。dispose:递归删除临时空间。
为什么临时目录信息要走原生通道,而不是在 Dart 侧直接用Directory.systemTemp?因为鸿蒙沙箱里,应用真正的缓存目录需要通过鸿蒙上下文获取,纯 Dart 的 systemTemp 在不同版本上解析结果可能不一致,有的分支返回的甚至是只读路径。实测下来,通过原生通道拿到的 cache 路径最可靠,写入和删除的权限也都是正常的。
在 ArkTS 实现文件操作时,另一个容易踩的坑是路径分隔符。鸿蒙底层虽然是类 Unix 文件系统,但上层 API 在某些场景会输出带统一资源标识符前缀的路径,直接拼到File操作里会解析失败。我在实现resolve时做了一层路径归一化,把带前缀的路径转成裸路径,再交给 Dart 侧使用。这个细节不处理,后面所有外部指令的workingDirectory都会出错。
3.3 Dart 侧兼容层设计,保持上层 API 不变
原生侧就绪后,Dart 侧要做的不是改scratch_space的公开 API,而是在内部增加平台分支。最理想的是利用一个平台接口,让原实现和新实现共存:
- 原有逻辑保留,作为非鸿蒙平台的默认实现。
- 新增
OhosScratchSpace,复用它对外暴露的create、newDir、newFile、dispose方法,方法签名完全对齐,内部绕道 MethodChannel。
这样上层构建引擎的代码一行都不用改,就能在鸿蒙上跑起来。我在改造时还特意保留了原生通道的兜底:如果拿不到原生响应,就回退到纯 Dart 的临时目录方案。兜底不是为了偷懒,而是为了实现在真机上验证时,遇到边缘情况(比如鸿蒙分支早期版本的原生通道注册时序问题)不至于整个构建流程崩溃,至少能把错误信息打到日志里,方便定位。
一个值得注意的细节:dispose的清理动作不要放在进程退出时依赖finally做同步删除。鸿蒙沙箱对路径删除有自身调度,有时候目录刚被上一个指令的进程占用,立刻删除会失败。我在实现时给清理动作加了两次重试,间隔很短,实测能把“偶发删除失败”的概率降到非常低。这种妙处在文档里很难找到,纯粹是跑真实构建场景跑出来的。
4. 外部指令协同的鸿蒙化策略
4.1 进程执行:优先保留 Dart 侧 Process,调整参数与超时
外部指令协同引擎在scratch_space的定位,是让某个构建任务能在临时空间里执行一段外部命令,并把产物留在工作区内。鸿蒙化之后,这部分我选择尽量留在 Dart 层,用Process.start或Process.run发起指令,原因很简单:Dart 的ProcessAPI 在鸿蒙 Flutter 运行时里有比较完整的移植,而且它对跨平台差异做了统一处理,我用同一套代码就能在 Android、桌面、鸿蒙之间切换,心智负担最小。
但参数上要动刀。第一是workingDirectory,必须显式传给临时空间根目录,不能依赖进程继承的当前目录,因为鸿蒙应用启动后的进程当前目录往往不是你期望的位置。第二是环境变量,如果外部指令依赖$PATH里的可执行文件,建议把可执行文件的绝对路径解析好再传给指令,避免沙箱裁剪PATH导致指令找不到。第三是超时,鸿蒙真机上首次执行某些外部指令时,可能会有权限弹窗或沙箱初始化流程,第一次跑会明显慢于后续,超时设置太保守容易误杀。
我习惯用Process.start而不是Process.run,因为它返回流式 stdout/stderr,可以边跑边看输出,方便把执行日志透传到构建引擎的日志体系里。但流式读取必须及时排空缓冲区,否则子进程写满管道后会阻塞。鸿蒙分支上这个阻塞现象比 Android 更明显,我在适配时专门加了一个 output 消费协程,保证主流程不会被子进程的输出拖挂。
4.2 指令产物的存取与清理,要处理好文件占用窗口
外部指令执行完,产物一般都直接写在workingDirectory下,也就是临时空间的子目录里。这不算复杂,难的是“外部指令协同”过程中,指令引擎可能还会持有产物文件的句柄,尤其是指令引擎有后台驻留进程时。这时候如果上层立刻调用dispose,就撞上文件占用窗口。
我的处理方式是给临时空间增加一个显式的disposeAfterCommands语义:在所有外部指令完成后,先做一次 active 句柄检查,再执行清理。具体实现不需要特别复杂,可以记录每次外部指令的进程对象,在dispose之前统一做结束等待。某些长时间驻留进程如果无法正常结束,会输出一条告警并跳过删除,避免把整个调用方拖死。
清理失败本身也不应该向上抛致命异常。我见过不少工具链代码,因为临时目录没删掉就输出一个红色错误,搞得整个构建失败,其实这个目录在重启后早就没用了。鸿蒙上文件占用窗口出现的概率比 Android 略高,所以我在清理层做的是尽力回收:先尝试删除,失败则标记待清理路径,并在下次create时把历史残留一起清掉。这个机制看起来不起眼,但非常提升稳健性。
5. 工程验证与回归测试方法
5.1 搭一个最小验证工程,端到端跑通核心链路
鸿蒙化完成后,别急着往大项目里集成,先搭一个最小验证工程。这个工程不需要复杂业务,只要在启动后做三件事:
- 用
scratch_space的create创建临时空间。 - 在临时空间里创建几个测试文件和一个子目录。
- 调用一次外部指令,比如在临时空间内执行
echo hello > hello.txt,再读取确认产物已经落盘。
这三步走下来,临时空间创建、路径解析、外部指令协同、产物存取、清理,整条链路就全部覆盖了。如果你在鸿蒙模拟器上跑,建议同时在真机上也跑一遍,因为沙箱路径和权限在真机上的表现更接近实际用户环境。我在验证时遇到过模拟器正常、真机上却拿不到上游构建工具产物的情况,最后定位是外部指令的工作目录在真机上被沙箱重定向了,可见真机验证是绕不开的一环。
最小工程的日志输出也要有讲究。建议显式打印临时空间根目录、指令工作目录、输出文件的实际路径,方便和鸿蒙侧的文件管理器对照。很多“文件没生成”的问题,其实是生成了但路径和你预期不一致,直接打印路径能省大量排查时间。
5.2 真实项目集成后的验证清单
最小工程通过后,再把它集成进真实的构建流程。这个阶段建议用表格列一个验证清单,逐项打钩,尤其是涉及外部指令协同的环节:
| 验证项 | 预期结果 | 备注 |
|---|---|---|
| 临时空间创建后目录存在 | 路径可访问,权限可写 | 记录实际路径便于对比 |
| 多个 newDir 生成目录唯一 | 子目录名不冲突 | 覆盖顺序创建的场景 |
| 外部指令在工作目录内执行 | 产物文件正确落盘 | 验证 stdout/stderr 流转 |
| 外部指令超时及输出堆积 | 进程可被终止,不阻塞主流程 | 用大量输出的命令压测 |
| dispose 后目录完全删除 | 根目录消失 | 如果失败,确认文件占用 |
| 连续多次使用不残留垃圾 | 各次临时空间相互隔离 | 验证路径唯一性 |
我在集成过程中发现最值得关注的不是单次执行,而是“连续执行”时的隔离性。如果第一次构建的临时空间没有清干净,第二次构建又恰好复用了相似的路径前缀,就可能出现文件互相覆盖或新旧产物混入的现象。scratch_space的路径唯一性设计本来就是为了规避这个,鸿蒙化之后要确保唯一性逻辑不被破坏。
真实集成还有一个容易被忽略的点:外部指令的环境变量可能会被打过多层包装。鸿蒙应用如果在启动时设置了沙箱相关的环境变量,而这些变量被子进程继承后导致指令行为异常,往往很难从日志里直接看出来。我的做法是在外部指令启动前打印关键环境变量的子集,并在问题疑难时全量导出一次,方便逐项比对。
6. 常见问题与排查技巧实录
6.1 鸿蒙化适配中最容易踩的坑,排成速查表
坦白说,鸿蒙 Flutter 生态还在快速演进,很多报错信息在社区里都未必搜得到。我把实际适配中遇到的典型问题整理成速查表,每条都对应真实的排查过程:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
MissingPluginException | 插件未在 ohos 平台注册 | 检查 ohos 目录结构、注册代码、构建配置 |
| 临时目录创建失败 | 路径带统一资源标识符前缀 | 做路径归一化,换成裸路径 |
| 外部指令找不到 | PATH 被沙箱裁剪 | 使用可执行文件绝对路径或显式设置环境变量 |
| 子进程执行卡死 | stdout/stderr 缓冲区未排空 | 用流式消费逻辑及时读取 |
| 目录删除偶发失败 | 文件被外部进程占用 | 清理前等待进程结束,增加重试 |
| 文件写入后立即读取为空 | 沙箱缓存未刷新 | 检查文件是否真正落盘,确认路径无重定向 |
| 模拟器正常真机异常 | 沙箱权限差异 | 以真机为准,打印实际路径比对 |
这些坑每一个背后都有具体场景。比如MissingPluginException,我一开始以为是注册代码写错了,折腾半天发现是工程里漏掉了ohos平台声明,Flutter 工具链压根没把插件编译进鸿蒙产物。这种问题看报错完全看不出端倪,只能从工程配置一层层排除。所以速查表里的每一条,我都建议在真实环境里复现一次,理解背后的机制,而不是只是“记住了答案”。
6.2 提高构建空间稳健性的三个实战技巧
先把最关键的技巧说出来:不要相信一次性成功。构建空间这东西,只要你不是只跑一次,就要把“残留、占用、路径漂移”当成常规状态来处理。我做的第一个增强是每一次create都生成全局唯一的子目录名,不沿用固定路径,最大程度避免上次残留对本次构建的污染。
第二个技巧是给外部指令协同加上前置自检。在真正执行构建指令之前,先在工作目录里写一个探针文件,再让一个进程去读它,确认进程的工作目录设置和文件读取权限都正常。探针成本极低,但能在三分钟内区分“协同引擎配置错误”和“外部指令本身失败”两个问题,调试效率会高很多。
第三个技巧是日志分级,把临时空间的路径变化、外部指令的启动参数、指令退出码这些关键信息单独拉一层日志,而不是混在业务日志里。鸿蒙端的 Flutter 调试日志本来就比 Android 少,信息越集中越好定位。我在交付这个适配方案时,和团队约定“所有外部指令协同日志统一加[ScratchSpace]前缀”,配合过滤规则,一次真机问题定位的时间直接缩短了一半。
7. 这套改造方式的后续扩展
鸿蒙化的这层兼容层一旦沉淀下来,想继续扩展其实是一件非常自然的事。我在完成基础适配后,下一步计划是把临时空间的管理从“仅外部指令协同”延伸到一个更完整的构建缓存生命周期:输入文件哈希一致时复用临时空间,不一致时自动重建。这个方向不算超前,因为构建空间的痛点从来不只是“创建和销毁”,而是“如何在多次构建之间高效复用”。
如果你只想要一个能在鸿蒙上跑通的临时目录工具,那按前文的流程走就足够了。但如果你期望构建系统在鸿蒙设备上长期稳定跑下去,我强烈建议把路径策略、进程调度、清理重试这些机制抽象成独立模块,而不是散布在业务代码里。scratch_space当初设计的价值也正在于此:把脏活封装好,让上层业务不用关心临时文件在哪、怎么回收、外部指令怎么协同。鸿蒙化适配只是把这个价值重新延伸到新平台,而我们收获的,是对这套机制更深入的理解。