最近在忙一个 Flutter 命令行工具链的鸿蒙化改造,核心是把 thunder_cli 这个三方库从 Android/iOS 平台搬到鸿蒙系统上。thunder_cli 在 Flutter 生态里解决的是“命令交互”这个很细但很痛的场景:它把子进程命令行封装成一套优雅的 Dart API,支持命令拼接、参数转义、流式输出、超时控制和任务编排,本质上就是一个给 Flutter 应用用的 CLI 自动化任务中台。为什么要做鸿蒙化适配?因为鸿蒙应用如果想像桌面工具那样执行外部脚本、批量处理任务,原生侧虽然有 childProcess 相关能力,但和 Flutter 侧的对接并不顺畅,尤其是异步流式输出、任务调度、错误码归一化这些环节,几乎处处是坑。我把这次适配踩过的坑和踩平之后沉淀下来的思路完整记下来,适合正在做 Flutter 鸿蒙化、想给应用加 CLI 能力、或者对跨端进程通信感兴趣的同学参考。
1. 先搞明白 thunder_cli 到底解决什么问题
1.1 CLI 交互库的定位与典型场景
thunder_cli 这个名字不是白起的,“雷霆之势”说的并不是单条命令执行得有多快,而是从一次性的零散命令调用,升级成批量化、可编排、可观测的命令执行平台。单个命令本身很简单,谁都会调,真正复杂的是命令变多之后的管理问题。比如你现在要跑一个由三条命令组成的自动化任务链:
- 先执行一个构建脚本,实时读取进度输出;
- 构建成功后再触发打包脚本,失败则直接中断;
- 打包过程中持续监听 stderr,一旦出现特定错误关键字,立刻取消整个任务。
这种需求用 dart:io 的 Process 硬写,第一版能跑,第二版就开始乱了。最常见的几个乱象:进程退出的回调没有触发、stdout 流还没读完就被关掉、并发任务把 CPU 拉满、超时之后进程没有真正被杀掉。thunder_cli 把这一层全部抽象成任务状态机,每个命令行调用都是一个可追踪的对象,状态、输出、退出码、错误分类统一放在一处管理。
鸿蒙化之前,需要先想清楚应用里到底要跑什么命令。普通大众应用想在沙箱里执行任意系统命令,这个方向在鸿蒙上基本走不通。但下面几类场景是真实存在的刚需:
- 开发者工具类应用:内部调试面板、自动化测试工具,需要执行 shell 脚本或批处理命令。
- 企业定制终端:门店管理平板、工业巡检设备,需要批量修改配置、上传日志、执行系统维护脚本。
- 开发期辅助:开发阶段在真机上执行设备侧命令,辅助日志采集和状态分析。
这类场景下,thunder_cli 的鸿蒙化就有实际价值,而且值得做成一个可复用的能力底座,而不是每次都在业务里 new 一个 Process 出来。
1.2 为什么“鸿蒙化适配”不是简单换个包名
很多人一听“鸿蒙化适配”,觉得就是把 flutter build 的目标平台改成 ohos,然后重新编译一遍。实际上完全不是这么回事。thunder_cli 底层依赖 Dart 的 Process 实现,而 Dart 的 Process 在鸿蒙 Flutter 引擎上支持到什么程度,是鸿蒙化适配遇到的第一道坎。
我在实际工程里测过(基于当时的某个 Flutter ohos 分支),Process.start 的一部分能力能跑,但有的版本在 stdout 流事件上就是有缺陷,exitCode 也可能迟迟拿不到。与其和引擎层这种不确定行为纠缠,不如把进程创建这一层从 dart:io 里解耦出来。核心思路是:thunder_cli 面对的底层不再直接是 Dart 的 Process,而是我们抽象出的一个可注入执行器。鸿蒙化的时候,这个执行器内部走 MethodChannel 调鸿蒙原生的 childProcess,上层的 API 形状保持不变。
这么设计还有一个额外的好处:单元测试变简单了。之前测试 thunder_cli 必须真实跑子进程,经常因为测试机器上缺命令而悬挂。换成可注入执行器以后,测试里注入一个模拟执行器,所有任务调度逻辑都可以在纯内存环境里验证,不需要真实进程参与。
下面是我抽出来的执行器抽象,形状大概是这样:
abstract class CliExecutor { Future<CliProcess> start( String command, List<String> args, { String? workingDirectory, Map<String, String>? environment, }); Future<int> waitProcess(CliProcess process); Stream<List<int>> streamStdout(CliProcess process); Stream<List<int>> streamStderr(CliProcess process); Future<void> terminate(CliProcess process, {String signal = 'SIGTERM'}); }这套接口把“命令去哪儿执行”完全黑盒化了。在鸿蒙上只需要提供一套基于 platform channel 的 OhosCliExecutor,thunder_cli 的任务队列、超时控制、错误分类全部复用。所谓“适配”,重点不是重写功能,而是把平台依赖的缝隙找到,用一层薄的桥接补上。
2. 鸿蒙侧的 CLI 能力盘点与环境准备
2.1 鸿蒙系统对子进程与命令行支持现状
先说结论:鸿蒙系统(我以 HarmonyOS NEXT 和 OpenHarmony API 12 之后的版本为参考)是支持应用创建子进程的,但能力边界非常明确,不是你想跑什么就能跑什么。
从 API 层面看,核心模块是 childProcess,它提供 spawn、exec、spawnSync 等接口。spawn 的优势是流式输出和实时交互,exec 适合一次性短命令,但会把输出全量缓存在内存里。thunder_cli 的鸿蒙化我强烈建议只走 spawn 路线。原因很实际:exec 拿不到过程数据,长任务跑起来内存占用会明显上升,而且中途无法感知进度。只有 spawn 能让你拿到 stdout/stderr 流,配合事件通道实时回传。
真正的边界在系统权限和应用沙箱上。HarmonyOS 应用默认跑在沙箱里,文件系统访问受限,子进程同样继承这套限制。这意味着 thunder_cli 里一些典型的 shell 命令,比如读取 /proc 下其他进程的信息、访问其他应用的数据目录,在真机上可能会失败,或者拿到一个空结果。适配的时候不能把这些失败掩盖掉,而应该在错误提示里明确说“当前命令受沙箱限制,请检查目标目录权限”,让调用方一眼就明白问题出在哪,而不是在那里瞎猜是不是命令写错了。
另一个对 CLI 适配特别重要的点:环境变量。鸿蒙子进程的环境变量不是天然继承应用环境的,PATH 这种关键变量必须由调用方显式传入。这个坑在后面的实操里会专门展开,因为它真的能把人绕晕。
2.2 开发环境与依赖准备
准备环境这一步,大多数人会低估它的耗时。我的建议是提前锁定一套稳定的版本组合,之后不要随手升级。需要准备的东西:
- DevEco Studio,用来创建和编译鸿蒙工程,拿到鸿蒙 SDK 和模拟器。模拟器可以先做界面验证,但 CLI 能力建议直接在真机上测,模拟器有些系统行为模拟得不到位。
- Flutter SDK 的 ohos 分支,安装后用 flutter doctor 确认 ohos 平台被识别。
- 一台开了开发者模式的鸿蒙真机,方便装包和看日志。
版本匹配是最大的坑。Flutter 版本太新,鸿蒙侧工具链可能还没跟上;DevEco Studio 版本太新,Flutter 插件编译又可能报 ArkTS 语法不兼容。我的处理办法是先用 flutter create --platforms=ohos 建一个最小工程跑通,再在这个验证好的工程上做 thunder_cli 集成,而不是直接拿大项目开刀。如果最小工程能跑起来,再逐个加依赖,问题定位会清晰很多。
在动手改代码之前,先用 grep 把 thunder_cli 源码里所有碰 dart:io 的地方找出来。重点搜 Process、ProcessInfo、File 这些关键符号,把后端相关代码标记出来。我当时列的表大概是:命令解析 parser、任务执行 executor、输出流处理 stream_handler、错误类型处理器 error_mapper。把这些点找齐,后面的改动才有方向感,不然就是盲改。
2.3 适配方案选型:平台通道桥接 vs 插件直通
鸿蒙化适配做到一半,很多人会纠结是做成 Flutter plugin 还是直接用 MethodChannel 桥接。我做过对比:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| MethodChannel 桥接 | 改动集中、工程结构简单、不破坏原库形态 | 原生逻辑重时得自己维护通道生命周期 | thunder_cli 这类纯 Dart 库 |
| Flutter 插件直通 | 符合插件规范,构建流程和原生代码隔离 | 需要处理插件注册、平台工程拆分,维护成本高 | 独立可复用的跨端能力插件 |
我最终选了 MethodChannel 而不是插件直通,有两个原因。第一,thunder_cli 仍然要兼容 Android、iOS、桌面平台,插件直通会把原生代码拆成一个独立工程,这些平台的适配反而更割裂。第二,我们只需要在鸿蒙侧维护一个子进程服务,通道数量不多,方法也不复杂,用 MethodChannel 足够干净,没必要引入插件工程的复杂度。
事件通道的设计要提前定下来。MethodChannel 适合“请求-响应”,但 stdout 是持续不断的流,光靠 MethodChannel 的回调式处理很难受。所以桥接层采用“MethodChannel 发命令 + EventChannel 收事件”的组合:Dart 侧通过方法通道发起 start、terminate,通过事件通道监听 stdout、stderr、exit。这个结构在动手前就要定死,不然后期改造成本很高。
3. 核心架构设计:从原生调用到任务中台
3.1 四层架构:Dart API 层 / 代理层 / 桥接层 / 原生执行层
thunder_cli 要真正变成鸿蒙上的“自动化任务中台”,我建议把代码拆成四层,每一层的职责单一清晰:
Dart API 层:对外暴露 ThunderTask、TaskResult、OutputLine、TaskException 这些类型。这一层是全平台统一的,上层业务不感知底层是哪个操作系统。
代理层:也就是前面说的执行器抽象。因为 dart:io 的 Process 类型不能直接透传到上层,所以需要定义 CliProcess 这样的中间类型,把 stdout、stderr、exitCode 这些字段统一包裹起来。thunder_cli 的任务调度器只依赖这一层。
桥接层:把代理层的请求转成 MethodChannel 调用,同时把 EventChannel 收到的事件转回 Dart 流。参数序列化、错误码归一化、编码转换都发生在这里。
原生执行层:ArkTS 侧的 SubProcessService,负责真正调用 childProcess.spawn,维护 taskId 到子进程对象的映射,处理 stdout/stderr 的读取、exit 事件回调,以及信号分发。
这个四层架构不是拍脑袋定的。我的第一版实现把原生调用直接写在了任务类里,结果命令执行和任务调度的逻辑纠缠在一起,有一个超时的 bug 查了两天才定位到是原生层回调延迟。拆层之后,每一层都能单独 mock、单独测试,定位问题也快得多。
3.2 命令协议与参数序列化设计
MethodChannel 只支持传递基本类型和 Map/List,所以命令参数必须序列化成 JSON。但 CLI 恰恰是最容易在序列化上出 bug 的地方。
我的协议约定长这样:
{ "taskId": "task_20250001", "command": "/system/bin/sh", "args": ["-c", "cat /data/local/tmp/build.log"], "workingDirectory": "/data/local/tmp", "environment": { "PATH": "/system/bin:/vendor/bin", "HOME": "/data/local/tmp" }, "timeoutMs": 30000, "collectStdout": true, "collectStderr": true, "mergeStderr": false }这里有几个设计细节很关键,都是踩过坑之后才定下来的。
第一,永远传“命令 + 参数数组”,而不是传一整个 shell 字符串让原生侧去 split。不同系统对引号、空格、转义的处理规则不一样,Dart 侧把用户输入的字符串解析成数组,原生侧原样传给 spawn,从根上躲开解析不一致的问题。
第二,环境变量不能偷懒。很多命令默认依赖 PATH,不显式传 PATH 的后果就是明明装了工具却报 command not found。所以桥接层强制要求 environment 必须带,即使调用方不传,也要塞一组默认值进去。
第三,stdout 和 stderr 的字节流可能是交错到达的,EventChannel 推送时必须带上 taskId 和流类型,否则多任务并发的时候根本无法区分这段输出属于谁。
3.3 任务生命周期与状态机设计
thunder_cli 里一个任务的生命周期,我把它拆成 created、ready、running、succeeded、failed、canceled、timedout、terminated 这几个状态。鸿蒙侧原生进程的状态必须能准确映射到这套状态机上。
状态映射关系如下:
| 顶层状态 | 鸿蒙侧表现 | 触发路径 |
|---|---|---|
| running | spawn 已返回子进程句柄 | start 成功之后 |
| succeeded | 进程退出码为 0 | exit 事件触发 |
| failed | 退出码非 0,或 spawn 抛出异常 | exit 事件 / spawn 异常捕获 |
| canceled | kill(SIGTERM) 调用成功 | 收到 cancel 请求 |
| timedout | 原生侧计时器到时 | 超时逻辑触发 |
| terminated | 进程被外部强杀 | SIGKILL 或系统回收 |
这里有一个容易被忽略的坑:超时和取消同时发生时,必须先取消超时定时器再派发事件,否则一个任务可能同时回调“已超时”和“已取消”。我在桥接层加了一个状态守卫模块,每次状态迁移前先检查当前状态是否已是终态,一旦终态就拒绝新的状态事件。这个模块虽然很小,但把大量竞态 bug 挡在了外面。
4. 实操:跑通第一条跨端命令
4.1 工程配置与目录结构
下面进入动手环节。为了避免一次引入太多变量,建议先在鸿蒙工程里跑一个最小的“执行 echo”示例,确认链路通之后再接入 thunder_cli 的任务调度。
我的工程目录结构大致是这样:
my_flutter_app/ ├─ lib/ │ ├─ executor/ │ │ ├─ cli_executor.dart │ │ ├─ ohos_cli_executor.dart │ │ └─ models.dart │ └─ main.dart └─ harmony/ └─ entry/ └─ src/ └─ main/ └─ ets/ ├─ entryability/ └─ service/ └─ sub_process_service.ets关键配置有两个。一个是在 Flutter 侧注册 MethodChannel 的时机,必须在 runApp 之前完成通道初始化,否则命令请求到达时原生侧可能还没有注册监听。另一个是在 ArkTS 侧初始化 SubProcessService 的位置,建议放在 EntryAbility 的 onCreate 里,持有全局单例,避免每次命令调用都新建服务实例。
4.2 鸿蒙侧原生调用代码实现
ArkTS 侧的 SubProcessService 核心代码,我给出一个简化版本,具体 API 以本地的 SDK 版本为准:
import { childProcess } from '@kit.BasicServicesKit'; export class SubProcessService { private tasks = new Map<string, childProcess.ChildProcess>(); private eventSink?: (event: Record<string, Object>) => void; attachListener(callback: (event: Record<string, Object>) => void) { this.eventSink = callback; } async start(taskId: string, command: string, args: string[], workdir: string, env: Record<string, string>): Promise<number> { const child = await childProcess.spawn(command, args, { workingDirectory: workdir, environment: env, stdio: ['pipe', 'pipe', 'pipe'], }); this.tasks.set(taskId, child); this.pushEvent({ taskId: taskId, type: 'started' }); child.stdout.on('data', (chunk: Uint8Array) => { this.pushEvent({ taskId: taskId, type: 'stdout', data: bufferToBase64(chunk) }); }); child.stderr.on('data', (chunk: Uint8Array) => { this.pushEvent({ taskId: taskId, type: 'stderr', data: bufferToBase64(chunk) }); }); child.on('exit', (code: number, signal: number) => { this.tasks.delete(taskId); this.pushEvent({ taskId: taskId, type: 'exit', code: code, signal: signal }); }); return 0; } private pushEvent(event: Record<string, Object>) { if (this.eventSink) { this.eventSink(event); } } }注意 stdio 里的三个字段。要读取输出必须用 'pipe',如果设置成 'inherit',输出会直接打到系统标准输出,Dart 侧永远收不到;改成 ignore 同样拿不到数据。这是我最早期踩的坑,后来定型为 pipe 才通。
child.stdout 的 chunk 类型是 Uint8Array,走 MethodChannel 传递时我用 base64 编码,避免二进制内容被字符串化之后丢数据。base64 编解码在鸿蒙基础库里就有,不用自己实现。
4.3 Flutter 侧 MethodChannel 与 EventChannel 对接
Dart 侧,我新建了一个 OhosCliExecutor,内部维护两个通道:
class OhosCliExecutor implements CliExecutor { OhosCliExecutor() { _eventChannel.receiveBroadcastStream().listen(_onEvent); } static const _methodChannel = MethodChannel('thunder_cli/process'); static const _eventChannel = EventChannel('thunder_cli/process_events'); final Map<String, StreamController<List<int>>> _stdoutControllers = {}; final Map<String, StreamController<List<int>>> _stderrControllers = {}; }关键点在于,EventChannel 的流只有一个,所有任务的事件都从这一个流进来,所以必须用 taskId 做分发器。在 _onEvent 里,根据 event['taskId'] 找到对应的 StreamController,再把 base64 解码后塞给对应的 stdout 或 stderr 流。多任务并发时这个分发器绝对不能丢,否则后台任务会串流。
启动命令的调用长这样:
await _methodChannel.invokeMethod('start', { 'taskId': taskId, 'command': cmd, 'args': args, 'workingDirectory': workdir ?? '', 'environment': env ?? const {}, 'timeoutMs': timeoutMs, 'collectStdout': true, 'collectStderr': true, });有一个接口细节:workdir 为空时我会传空字符串而不是 null。MethodChannel 对 null 的处理在不同版本上偶有差异,空字符串在原生侧再映射为默认工作目录,表现更稳定。
4.4 同步返回与流式输出
thunder_cli 上层既需要“跑完拿结果”的同步模式,也需要“边跑边看”的流式模式。
同步模式的处理并不复杂:Dart 侧在调用 start 之后,等待 EventChannel 推送 exit 事件。收到 exit 后,再从原生侧取一次最终退出码,然后返回 TaskResult。这里唯一要注意的是必须等 stdout 和 stderr 全部读完再返回,否则输出会不完整。我在实现里给输出流加了一个 done 标志,exit 事件到达之后继续等待一小段排空时间,给管道留出把残余数据推完的机会。这个等待时间不能太短,太短丢数据,太长影响体验。
流式模式就丰富一些:thunder_cli 提供 onOutput 回调,每次 stdout 事件触发就回调。实时进度条、构建日志展示这类场景全靠它。EventChannel 的推送频率在命令大量输出时很高,每次 push 一个小 chunk 会导致 UI 频繁刷新,我建议做一个 50 毫秒的 debounce,把区间内的输出合并成一批再推给 UI,这样界面不会一直抖动,流畅度也上来了。
5. 把任务中台的关键能力补齐
5.1 任务队列与并发控制
光能执行单条命令还不够,所谓“自动化任务中台”,核心能力其实是任务调度。thunder_cli 本身带队列实现,但鸿蒙化之后必须额外关注并发数。
我试过在低端鸿蒙真机上同时启动 10 个 childProcess,结果 UI 线程直接掉帧,甚至出现系统无响应的情况。所以在 executor 外层加了一个简单的信号量:
class TaskSemaphore { TaskSemaphore(this.maxPermits); final int maxPermits; int _used = 0; final Queue<Completer<void>> _waiters = Queue(); Future<T> run<T>(Future<T> Function() task) async { final permit = await _acquire(); try { return await task(); } finally { _release(); } } }默认并发数我调成 4,具体可以根据设备性能调整。这么做还有一个好处:不会一次创建几十个进程,系统级文件描述符压力也会小很多。
队列里还应该有优先级。我维护了一个带优先级的任务列表,比如日志采集的优先级低于用户主动触发的命令任务。一次我在设备上批量采集日志,用户手动点击“获取设备状态”后卡了十几秒,加优先级之后问题立刻消失。对于中台型应用,这个细节很影响实际体验。
5.2 超时、取消与强制终止
thunder_cli 支持为每个任务配置 timeoutMs,鸿蒙侧实现时,我觉得最值得分享的是“超时检测放哪一层”。
第一版我把超时计时器放在 Dart 侧,结果发现一个隐蔽的问题:Dart 侧的计时器回调依赖事件循环,如果主 isolate 被大对象序列化之类的操作卡住,超时时间就不准。后来我把超时检测下沉到原生侧,由 ArkTS 的定时器触发,超时后主动 kill 子进程并推送 timeout 事件。这样即使 Dart 侧暂时卡顿,进程也能被及时回收。
取消走类似逻辑。Dart 侧发起 terminate 后,不能马上认为进程已结束,必须等原生侧返回确认。我在 MethodChannel 的 terminate 接口里返回了取消后的退出码,方便 UI 准确提示“任务已取消”。
如果是命令脚本自己又 fork 了子进程的场景,SIGTERM 可能杀不掉整棵进程树。这种情况我建议要么用 shell -c 统一调度,要么在业务侧约定“命令脚本自己负责清理子进程”。我最终还是绕开了对进程组强依赖的方案,在业务侧做约束,省心很多。
5.3 日志采集与持久化
中台没有日志是走不远的。thunder_cli 本身有回调日志,我在鸿蒙化之后把它分成三层:
- 界面日志:实时显示在终端的 stdout/stderr 区域。
- 会话日志:一次自动化任务链的完整记录,包括命令、参数、状态迁移、耗时。
- 快照日志:任务失败时自动保存最后 200 行输出,带上时间戳和退出码。
会话日志和快照日志写到应用缓存目录下的 thunder_cli/ 子目录。用 path_provider 拿路径,不要硬编码,否则在鸿蒙沙箱里会踩权限坑。
这里有一个性能细节:日志文件不要实时写,高频输出时 IO 会成为瓶颈。我的做法是内存里放一个环形缓冲区,每 200 毫秒批量 flush 一次,会话结束时强制 flush 一次。这样既保住了日志完整性,又不影响命令本身的执行性能。
5.4 权限与安全边界
权限这块,前面已经提过沙箱限制。开发中真正要注意的不是“能不能执行命令”,而是“执行之后影响范围有多大”。鸿蒙子进程继承应用沙箱的文件读写边界,所以 thunder_cli 适配层必须在命令执行前做一次“命令白名单 + 路径合法性”检查。
我的做法是在桥接层加了一个 CommandGuard 组件,负责两件事:
- 命令白名单:维护一个允许执行的命令前缀列表,不在列表里的直接拒绝。
- 路径校验:检查 workingDirectory 是否在应用沙箱可访问范围内,避免用户传一个无法访问的系统目录导致 spawn 报错。
命令白名单看起来保守,但实际收益很大。有一次我把一个调试面板的入口漏到了测试版应用里,如果当时没做白名单,任何命令都能在测试机上跑,风险很难收拾。对于“自动化任务中台”这一定位,安全边界不是可选项,而是必选项。
6. 常见问题与排查技巧实录
6.1 任务状态成功但没有任何输出
我遇到最多的问题是:任务状态已经是 succeeded,但 stdout 是空的。排查通常是三步走:
- 先看原生侧 spawn 是否真的用了 pipe。只要 stdio 里 stdout 不是 pipe,就拿不到数据,这是头号嫌疑。
- 再确认 EventChannel 在 start 之前已经处于监听状态。订阅时机太晚,前面推送的 stdout 事件就丢了,而且不会重发。
- 最后看是不是 debounce 把最后一小段输出吞了。如果命令输出又短又高频,合并窗口里可能还没 flush 就触发了 exit。这种需要在 exit 事件后强制 flush 一次。
6.2 PATH 与环境变量缺失
“command not found”出现时,真不一定是因为命令不存在,更可能是 PATH 没传对。鸿蒙子进程的环境变量不会自动复制应用进程,所以桥接层组装 environment 时要默认带上 PATH=/system/bin:/vendor/bin。如果命令依赖其他路径,调用方必须显式补充。
我还遇到过 HOME 变量为空导致某些脚本初始化失败的情况。处理办法是给一个默认 HOME 指向应用缓存目录,基本都能解决。
6.3 输出中文乱码
中文乱码的原因一般是编码不匹配。鸿蒙 childProcess 默认输出的是 UTF-8 字节流,Dart 侧用 utf8.decode 解码正常情况下没问题。但如果脚本里写死了 GBK,或者系统 locale 不是 UTF-8,就会乱。
我在桥接层加了一个 encoding 参数,默认 utf8,允许调用方手动指定 latin1、gbk 等。编码转换统一在 Dart 侧做,原生侧只负责把字节流 base64 传回来,跨端逻辑最清晰。
6.4 长任务被系统回收
应用退到后台,长命令很容易被挂起甚至杀进程。这个不算桥接本身的问题,是鸿蒙后台任务限制。如果需要后台执行长任务,得使用鸿蒙的后台任务申请能力,申请挂起任务。我在项目里是把“批量日志上传”这类长任务的时长控制在系统允许范围内,超过就拆成多段任务,逐段续跑,绕开单次任务时长上限。
6.5 跨端调试的小工具
排查这些跨端问题,光靠业务日志盲猜很累。我后来在原生侧加了一个全局调试开关,编译期打开 debugBridge 之后,所有跨端消息(请求和事件)都会打印出来,包括任务 ID、命令全文、环境变量和返回时间。这个开关生产构建里自动关闭,不影响性能,开发期排查效率直接翻倍。
另一个非常实用的经验:MethodChannel 的参数 key 拼写必须与原生侧完全对齐,差一个字母就静默失败。我重构时把 workingDirectory 误写成 working_dir,结果原生侧没匹配上,命令一直拿不到工作目录,排查了很久。后来我把参数名收敛成 camelCase,并把这个协议抽成一个 constants 类,两端引用同一个常量生成,从根上减少这类问题。
7. 写在最后的感触
最后说说我个人的体会。thunder_cli 的鸿蒙化,技术难点不在 ArkTS 语法,也不在 MethodChannel 用法,而在于你要真正理解“跨端能力边界在哪里”。Dart 侧那些看似跨平台的 Process、File API,在鸿蒙上不一定完全可用;原生侧那些看似底层的 childProcess API,沙箱和权限又画了好几条线。适配的本质就是把这条边界搞清楚,然后在上层把它翻译成合理的错误分类和用户可理解的提示。
如果你们也在做类似的 Flutter 跨端库鸿蒙化,记住一个顺序:先把“原生不可用”的问题列干净,再动手改代码。我最初是边改边探边界,结果反复推翻重来。后来老老实实画了一张“平台 API 映射表”,把 Dart 侧每个能力点要替换成什么原生方案写清楚,后面所有工作都顺畅了。希望这份踩坑记录能帮后来者少走一半弯路。