ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter鸿蒙适配实战:cli_launcher进程管理迁移与避坑指南

Flutter鸿蒙适配实战:cli_launcher进程管理迁移与避坑指南 年初接了个内部工具链迁移的活儿把一套跑在 Flutter 桌面端的自动化脚本分发框架迁到鸿蒙设备上。框架里最核心的一块就是基于 cli_launcher 拉起本机命令行、管理进程生命周期、实时回收 stdout/stderr 输出。项目不大坑是真不少。鸿蒙的沙箱机制、Flutter 引擎的 dart:io 差异、插件通道的编码问题每一项都能让人折腾到半夜。这篇就把 cli_launcher 的鸿蒙化适配思路、关键实现和避坑记录完整理一遍给正在折腾 Flutter 鸿蒙 命令行进程管理的同学一个可参考的落地方案。1. 这次到底要适配什么又是为什么选 cli_launcher1.1 先说 cli_launcher 帮我解决的问题Flutter 跨端做“命令行控制”这件事dart:io不是不能跑但跑长任务的时候特别容易出问题启动一个几十秒甚至几分钟的构建脚本中途要崩溃、要停止、要拿到实时进度直接用Process.start写起来非常别扭。而且 Flutter 要跨 Windows、Linux、macOS 跑同一套代码各个平台对进程组、信号、退出码的处理还不一样业务代码一旦写在 UI 层后面基本就是灾难。cli_launcher 做的事情就是把这些脏活封装掉。它在 Dart 侧提供一个 Client在后台拉起一个 Server由 Server 去 spawn 真正的 shell 进程客户端再通过它启动命令、监听输出、发信号、拿退出码。好处是 API 统一我不用关心 Windows 上taskkill和 Linux 上kill的差异也不用担心进程变成孤儿进程。当时选它的另一个原因是因为这套框架要跑在很多不同的宿主环境里既有 Windows 工作站的桌面工具也有 Linux 服务器的巡检脚本。cli_launcher 对这三个桌面平台的支持都比较成熟我实测下来行为一致所以后来鸿蒙设备加入阵营时第一反应是继续用 cli_launcher加一层鸿蒙适配而不是重新写一套进程管理。能力cli_launcher 提供的价值我实际用到的点启动外部命令/脚本Server 统一调度不占用 UI isolate自动化构建脚本、巡检脚本分发实时获取 stdout/stderr流式返回可显示进度条渲染任务进度展示按信号停止进程支持 SIGTERM、SIGKILL 等用户点取消时强制终止拿到退出码与错误原因返回 exitCode stderr失败任务的定位依据跨平台一致 API屏蔽平台差异Windows、Linux、macOS、鸿蒙统一业务层1.2 为什么鸿蒙上“直接用”会翻车鸿蒙的 Flutter 路线和标准 Flutter 是有差异的。OpenHarmony 上的 Flutter 引擎并不是 Google 官方直接发布的运行时而是由鸿蒙团队基于 Flutter 分支维护的那个版本。dart:io 里的Process相关实现在鸿蒙上并没有像 Android 或桌面端那样完整地接到底层 POSIX API 上或者说它接到的是一套被鸿蒙沙箱限制过的能力。我一开始天真地想直接用 cli_launcher 跑一个ls试试总可以吧结果直接在运行时抛了一堆 Unhandled Exception控制台里能看到类似dart_vm_initializer.cc(41)这种报错核心就是Process.start在鸿蒙的 Dart 层没有找到可用实现。后来定位到鸿蒙应用进程本身跑在沙箱里标准 Flutter 插件里很多和“发起子进程”相关的通道根本没有注册进去。所以结论很明确鸿蒙上不能直接照搬 cli_launcher而是要把 cli_launcher 解决问题的能力“翻译”成鸿蒙原生能力调用再在 Dart 侧包一层和 cli_launcher 相同体验的接口。2. 核心设计拆解把 cli_launcher 的能力“翻译”到鸿蒙2.1 先翻译生命周期模型cli_launcher 的进程生命周期本质上是一个状态机。手动用Process.start时这个状态机得自己在业务层维护启动成功了吗进程是正常退出还是被我 kill 的资源回收了没有于是适配的第一步就是把 cli_launcher 里那套“进程句柄 事件监听 信号控制”抽象成鸿蒙侧可复制的模型。我把它拆成四个阶段IDLE未启动、RUNNING运行中、TERMINATED已退出、RECLAIMED资源已回收。正常情况下是从 IDLE 到 RUNNING再到 TERMINATED最后 RECLAIMED。但有一条路径很容易被忽略进程因为系统 OOM 被杀死、或者设备睡眠被挂起进程已经没了Dart 侧却还认为它在 RUNNING这时候再发信号就发不出去。鸿蒙适配时我强制的规则是所有退出事件只能由原生侧上报Dart 侧不猜。原生侧用waitpid去等退出通过方法通道回调告诉 Dart“进程真退出了”。这样就绕开了“进程明明死了但业务层还挂着”的假活状态。2.2 用 MethodChannel 做桥而不是 FFI适配层的数据通路我选了 MethodChannel。也考虑过 FFI但后来放弃了。FFI 适合做高性能纯计算但命令行进程管理这个场景里有大量异步事件——stdout 持续到达、子进程退出、信号响应这些用 FFI 回调处理起来非常麻烦还得自己管理内存和线程同步。MethodChannel 的优势是它天然跟着 Flutter 引擎的线程模型走Dart 侧发invokeMethod原生侧在 Napi 环境里处理处理后通过回调把结果送回 Dart。对进程管理这种低频但长连接的事件流来说它的性能开销完全够用。唯一的坑是通道一次只能传一个结果所以 stdout 这种持续性的数据我没有做成单次 MethodChannel 返回而是让原生侧反复向 Dart 侧发起output事件Dart 侧再聚合成一个 Stream。还有一个细节MethodChannel 来回传数据时如果直接传 String遇到非 UTF-8 编码的输出就会炸。鸿蒙设备上跑命令行输出经常带各种 ANSI 转义和 GBK 编码的中文字节流里出现 0x00 也很正常。所以原生侧统一回传Uint8ListDart 侧再按业务场景解码这个在后面实操部分会再展开。2.3 关键 API 要保留命名尽量贴近原有习惯为了让业务代码改动最小我保留了 cli_launcher 风格的三层调用习惯一个启动器入口传入命令和参数一个进程句柄对象负责监听输出一个进程控制对象负责 kill、拿退出码。具体到代码上我定义了一个CliStartOptions作为启动参数集合和一个NativeCliProcess作为运行中进程的抽象。业务层原来怎么写 cli_launcher 的现在就可以改成这个鸿蒙兼容层的调用命名上基本一一对应。设计意图很简单如果你团队里已经有人熟悉 cli_launcher换到鸿蒙上不需要重新学习一套 API。而且后续如果鸿蒙的 Flutter 引擎把 dart:io 的 Process 补全了兼容层内部一换业务代码甚至都不用动。3. 实操从零实现鸿蒙级启动器3.1 工程怎么接先明确一下环境基线DevEco Studio 4.xOpenHarmony SDK 版本要能匹配你当前 Flutter 鸿蒙引擎要求的 API 级别。我在项目里用的是 Flutter 3.7 分支的鸿蒙适配版本OpenHarmony API 9 起步建议直接 API 10 或更高否则 Napi 模块的线程安全回调接口不够完整。工程结构上我推荐在 Flutter 项目里建一个独立的ohos插件模块不要把源码混到 entry 里。一个可用的目录长这样my_app/ ohos/ entry/ # 主工程 cli_launcher_ohos/ # 插件模块 src/main/cpp/ # Napi 原生代码 src/main/ets/ # ArkTS 桥接层 oh-package.json5 # 鸿蒙包依赖声明 lib/ native_cli_launcher.dart # Dart 侧兼容层插件模块在oh-package.json5里声明好name和version主工程通过dependencies依赖。因为鸿蒙工程最终构建出的是 HAR 包传统 Flutter 里那个 Android AAR 的思路别带到这一步来鸿蒙侧直接走 ohpm 依赖版本冲突最少。3.2 原生侧用 Napi 把进程能力暴露给 Dart鸿蒙原生侧的核心就是“用 C 实现进程管理再用 Napi 导出给 Dart 调用”。这里不能用 ArkTS 直接去套child_process虽然鸿蒙上存在一些 Node.js 兼容层 API但沙箱应用里直接调用限制很多不如自己在 Napi 里用 POSIX 方式实现更可控。我实现了三个核心函数StartCommand、KillProcess、WaitExit。启动部分核心代码如下思路是pipe建通道fork创建子进程execvp替换成目标命令setpgid把子进程放进独立进程组方便后续整组回收static napi_value StartCommand(napi_env env, napi_callback_info info) { // 解析参数command, args, workingDirectory int pipefd[2]; pipe(pipefd); pid_t pid fork(); if (pid 0) { // 子进程 setpgid(0, 0); dup2(pipefd[1], STDOUT_FILENO); dup2(pipefd[1], STDERR_FILENO); close(pipefd[0]); close(pipefd[1]); char* argv[] { const_castchar*(command.c_str()), arg1, ... }; execvp(command.c_str(), argv); exit(127); // exec 失败 } // 父进程 setpgid(pid, pid); close(pipefd[1]); // 保存 pid 和 pipefd[0]后续用于读取输出 return CreateResult(env, pid, pipefd[0]); }这里有个关键点setpgid(pid, pid)要在父进程中再调用一次是为了处理 fork 之后子进程和父进程的竞争。如果不做这一步子进程在 exec 之前可能已经跑起来了进程组还没设置好后面kill(-pid, signal)就杀不掉整个进程树只剩一个外层 shell 死了真正干活的子进程还活着形成一个漏网之鱼。输出读取我单独开了一个 C 线程循环read(pipefd[0])每次拿到一段字节就直接通过 Napi 回调送给 Dart直到读到 EOF 才关闭管道。void ReadOutputLoop(ThreadSafeContext* ctx, int fd, napi_env env) { char buffer[4096]; while (true) { ssize_t n read(fd, buffer, sizeof(buffer)); if (n 0) break; CallJsWithBytes(ctx, buffer, n); // 通过线程安全函数回调 } NotifyExit(ctx); // 通知进程退出 }为什么不用waitpid阻塞等退出因为主线程不能卡不然方法通道全被堵住了。我是在 EOF 之后再去waitpid(pid, status, WNOHANG)拿退出码循环几次确保资源回收。这种做法的好处是所有原生侧的异常都不会直接抛到 Flutter 的 isolate 里避免出现那种dart_vm_initializer.cc下的 Unhandled Exception 日志。3.3 Dart 侧封装成 cli_launcher 同款体验Dart 侧不能直接操作 pid 和信号所有操作都走方法通道。我定义了一个NativeCliProcess类业务层拿到的就是一个行为非常接近 cli_launcher 的进程对象。核心结构class NativeCliProcess { final int _pid; final MethodChannel _channel; final StreamControllerUint8List _stdoutCtrl; final StreamControllerUint8List _stderrCtrl; StreamUint8List get stdout _stdoutCtrl.stream; StreamUint8List get stderr _stderrCtrl.stream; Futurevoid kill([String signal SIGTERM]) async { await _channel.invokeMethod(killProcess, {pid: _pid, signal: signal}); } Futureint? get exitCode async { final code await _channel.invokeMethod(exitCode, {pid: _pid}); return code null ? null : (code as num).toInt(); } }启动入口我封装在NativeCliLauncher里start方法会向原生侧发startCommand拿到返回的 pid 后创建一个NativeCliProcess实例。注意先注册好原生侧的output事件监听再启动命令免得命令执行太快前几行输出已经过来了Dart 侧监听却还没挂上。兼容层还需要把Uint8List转成字符串我这里严格按 UTF-8 解码并过滤 ANSI 转义序列。很多命令行程序输出会带颜色码直接显示就是一堆[31m需要自己写一个正则去掉。别用默认的utf8.decode硬解遇到非法字节会直接抛异常应该用allowMalformed: true。3.4 自动化脚本分发实战这是整个适配里最有代表性的一块场景把构建、巡检、日志收集这类 shell 脚本从 Flutter 应用分发到鸿蒙沙箱然后通过启动器跑起来。鸿蒙沙箱应用能自由读写的路径通常是自己应用的files目录即/data/storage/el2/base/haps/entry/files。Flutter 的 assets 里放了scripts/build.sh主流程先把它拷贝到这个目录再给它加执行权限最后交给 NativeCliLauncher 去跑。Futurevoid distributeAndRunScript() async { final dir await getAppFilesDir(); // 通过channel获取沙箱files目录 final targetPath $dir/scripts/build.sh; final data await rootBundle.load(assets/scripts/build.sh); final bytes data.buffer.asUint8List(); File(targetPath).writeAsBytesSync(bytes, flush: true); // 调用原生 chmod注意是文件权限不是 Dart 层假权限 await _channel.invokeMethod(chmod, {path: targetPath, mode: 0x1ED}); // 0755 final launcher NativeCliLauncher(_channel); final proc await launcher.start(targetPath, args: [--moderelease]); proc.stdout.transform(utf8.decoder).listen((line) { log.info([script] $line); }); final code await proc.exitCode; if (code ! 0) { throw ProcessException(脚本执行失败, code); } }这一步踩过的坑是脚本里的换行符。如果脚本是在 Windows 上写的带了\r\n到鸿蒙上会执行出错报/bin/sh^M: not found。分发前要做一次\r\n到\n的转换。脚本执行依赖的 PATH 也和桌面 Linux 不同鸿蒙沙箱里的 PATH 通常比较精简。如果脚本内部调用了python3、node这类外部解释器不能用相对路径或简写最好拿到绝对路径再写进脚本里。我就在现场踩过这个坑脚本在模拟器上跑得好好的真机上python3: not found最后发现是 PATH 不一致。4. 常见问题与排查技巧实录4.1 进程起不来文件权限、路径、可执行环境这是最高频的失败原因而且报错往往很统一要么是No such file or directory要么是Permission denied。现象原因排查方法No such file or directory脚本用了相对路径沙箱内 PATH 不对改成绝对路径或用which先查解释器位置Permission denied脚本没有执行权限chmod 0755且原生侧要真调 chmod不能只是 Dart 层标记execvp 返回 127命令不存在或解释器不存在把execvp的 perror 回传到 Dart 侧别吞掉启动后立刻退出脚本第一行 shebang 指向错误解释器检查#!/bin/sh是否带\r检查解释器绝对路径排查时最好先给 Napi 侧加一个DebugExecute方法手动执行一次which sh或者echo $PATH结果直接回传给 Dart。这比反复改脚本快得多能快速分清是“脚本问题”还是“原生层问题”。4.2 中文乱码和输出丢失鸿蒙设备上行命令行的输出中文经常出现乱码。原因不复杂沙箱里的工具链和宿主环境字符集不同有些命令的输出是 GBK有些是 UTF-8直接用utf8.decode就会变成一堆乱码。我最后的处理方式很朴素原生侧完全不关心编码所有数据都以Uint8List原样回传Dart 侧先做一次字节序探测发现不合法的 UTF-8 序列时换用 GBK 解码器。这里注意不能一上来就允许 malformed否则真正的编码错误会被静默吞掉输出莫名其妙少了一段。用allowMalformed: false去探测报错了再降级到 GBK最稳妥。输出丢失的问题也遇到过。原因是管道缓冲区满了。如果一个子进程输出太快而 Dart 侧监听 Stream 没有及时消费原生侧write就会阻塞子进程卡住整个命令超时。解决方案是原生侧的读循环里当read返回EAGAIN时做短暂 sleep 而不是死等同时 Dart 侧用Stream的分流模式至少保证有一个消费者在持续读走数据。4.3 僵尸进程、SIGCHLD 和清理进程结束之后如果父进程没有及时waitpid子进程会变成僵尸进程。鸿蒙沙箱里的应用如果反复启动、结束脚本不清理的话僵尸进程会越堆越多最终导致资源泄露甚至进程启动失败。使用waitpid(pid, status, WNOHANG)在 EOF 后循环几次通常就能回收。但真机上我遇过一种情况子进程 fork 了孙子进程孙子进程还挂在那里waitpid回收不了因为那个孙子的父进程不是我们。这种必须靠进程组管理启动时 setpgid杀掉时用kill(-pid, signal)把整组一起干掉。如果已经出现僵尸可以试试kill(-pid, SIGKILL)很多时候能一并清掉。还有一个容易被忽略的点不要在SIGCHLD的信号处理器里做回收。信号处理器里不是所有系统函数都能安全调用尤其不能碰 mutex 和 Napi 回调。我就在这上面栽过一次启动器调多了直接卡死。后来改成在输出读循环中发现 EOF 之后再回收信号处理器里只设置一个标记什么都不做。4.4 Unhandled Exception 与热重启问题适配过程中最烦人的就是dart_vm_initializer.cc里出现的 Unhandled Exception。多数情况下不是进程管理本身炸了而是原生侧通过方法通道抛了一个PlatformExceptionDart 侧某个地方没有 catch就直接冒泡到引擎层了。我的兜底策略是所有invokeMethod调用都包一层try-catch且 catch 之后返回一个带错误码的局部对象而不是让异常继续往上抛。原生侧也一样凡是可能失败的步骤比如execvp失败、waitpid超时都要返回结构化错误给 Dart不要把异常丢到方法通道外面。热重启是另一个魔鬼。Flutter 开发时经常点R热重启这时候 Dart 侧代码会重新执行方法通道重新注册但原生侧那个跑着的进程并不会被 Flutter 杀掉。于是一热重启进程变成了孤儿再次调用相同命令时会产生两个进程同时跑数据和文件全乱了。解决方案是每次创建NativeCliLauncher时先向原生侧注册一个“清理当前所有由本模块启动的进程”的入口Dart 侧初始化时主动调用一次把上一轮残留的进程整组杀掉。问题触发原因我的处理dart_vm_initializer.cc 的 Unhandled ExceptionPlatformException 没被 Dart 侧捕获所有 invokeMethod 包 try-catch原生侧返回结构化错误码热重启后进程重复运行原生侧进程不随 Flutter 引擎销毁Launcher 初始化时调用原生清理接口按 pid 回收残留进程僵尸进程堆积子进程退出后没及时 waitpidEOF 后循环 waitpid WNOHANG不依赖 SIGCHLD 处理器输出乱码字节编码未知UTF-8 硬解失败原生侧回传 Uint8ListDart 侧按合法 UTF-8 探测降级 GBK命令找不到鸿蒙沙箱 PATH 精简脚本内统一用绝对路径适配层查 PATH 并回传日志最后分享一个我踩过两次的坑如果你在原生侧用了 C 全局变量来缓存 pid 和回调上下文千万别忽略清理顺序。Flutter 的插件注册和销毁并不保证和dispose完全同步一旦先销毁了上下文后到的 Napi 回调就会野指针崩溃。我也会在每次清理后把全局缓存全部置空并在 Dart 侧把 StreamController 关闭时避免往已关闭的 controller 里塞数据这两步做完整个启动器才算是真正稳了。这套方案我们已经在鸿蒙真机上跑了两个多月自动化脚本分发和进程管理再没出过幺蛾子。
返回列表