鸿蒙生态这几年的节奏大家都有体会,Flutter 开发者一边要跟上主流的版本演进,一边还要面对“这个三方库能不能在鸿蒙上跑”的灵魂拷问。我所在的小组被拉去做鸿蒙适配之后,最先遇到的就是命令行工具链的断档:Flutter 生态里大量的自动化脚本、脚手架工具,都依赖 dcli 这类 Dart 命令行库,而鸿蒙侧的工具链从 adb 换成了 hdc,从 Gradle 换成了 hvigor,从 npm 换成了 ohpm,原来的 dcli_scripts 直接拿过来用基本是废的。
这篇内容就是我们把 dcli_scripts 这一整套基于 dcli 的脚本库做鸿蒙化适配的完整记录。我不会泛泛讲“鸿蒙化的重要性”,而是把思路、改动点、踩过的坑都摊开说。已经做完 Flutter 鸿蒙化接入的团队可以照着抄作业;正准备开始的,也能在动手前把坑位看清,至少省掉一周的试错时间。
1. 先想明白一件事:dcli_scripts“鸿蒙化”到底改的是什么
很多人一听“鸿蒙化适配”,第一反应是把三方库的 API 签名改成鸿蒙 SDK 的 API。这个直觉在原生插件、UI 组件库上是成立的,但对 dcli_scripts 这类命令行脚本库来说,方向完全不对。dcli 脚本跑在开发者的宿主机上,它不跑在鸿蒙设备里,所以你的 Dart 代码一行都不用因为“鸿蒙”而改写。真正要动的是脚本内部对“设备、构建、签名、包管理”这四个对象的调用方式。
1.1 dcli_scripts 的真实定位:它是“开发机上的自动化中枢”
dcli 是 Dart 生态里的命令行脚本库,你可以把它理解成“用 Dart 写 shell 脚本”。比纯 bash 强的地方是它有类型、有包管理、能解析参数,还能直接复用你用 Dart 写的那堆业务逻辑。dcli_scripts 则是我们团队维护的一组基于 dcli 的脚本集合,覆盖了模板代码生成、工程初始化、版本号注入、构建发布这类日常高频动作。
在 Flutter 时代,这套脚本依赖的是 adb、Gradle、Flutter SDK,它们之间通过 Process 调用来协作,比如脚本生成好一个 Android 工程结构,然后调 gradlew 去打包。鸿蒙化之后,底层工具链全换了,但脚本的“骨架”——参数解析、流程控制、日志、文件处理——完全没变。所以我在接手时给团队定的调子是:不要重写脚本,只换“与外部工具链的适配层”。
1.2 鸿蒙给 Flutter 开发者的命令行工具链长什么样
搞 Flutter 的人都熟悉这么一套链子:flutter pub get拉依赖、flutter build apk构建、adb install安装、adb logcat看日志。鸿蒙侧对应的三件套是:
| 用途 | Flutter/Android 时代的命令 | 鸿蒙侧的命令/工具 | 差异点 |
|---|---|---|---|
| 设备连接与安装 | adb | hdc(HarmonyOS Device Connector) | 命令结构类似,但输出格式和授权机制不同 |
| 工程构建 | gradlew / flutter build | hvigorw / hvigor | HAP 产物路径和构建参数体系完全不同 |
| 包管理 | pub / npm | ohpm | 配置文件从 pubspec.yaml 变成了 oh-package.json5 |
| 产物签名 | apksigner / 手工 keystore | hap-sign-tool 或 DevEco 签名配置 | 更依赖工程里的构建配置文件 |
Flutter 应用鸿蒙化的整体构建链路一般是:先用 Flutter 工具链编出鸿蒙适配的 framework 产物,再交给 hvigor 把工程打包成 HAP。这意味着 dcli_scripts 里的构建脚本可能要同时调两套工具链,顺序和条件判断都要梳理清楚,不然就会出现“Flutter 侧编完了,hvigor 侧不知道给它塞到哪个模块”的尴尬。
1.3 先用一张适配评估矩阵盘家底,别急着动手
我建议第一步不是改代码,而是把 dcli_scripts 仓库里每个脚本按“依赖的外部工具”拆开,做一次影响面评估。我用的矩阵大概是这样的:
| 脚本类型 | 是否受影响 | 说明 |
|---|---|---|
| 纯文件处理、模板渲染 | 不受影响 | 只依赖 Dart 标准库和 pubspec 里的依赖,和鸿蒙无关 |
| 路径解析、工程结构识别 | 轻微影响 | 要识别鸿蒙的 module 结构和 HAP 输出目录 |
| 调 adb 的安装/日志脚本 | 重写适配层 | 换成 hdc 命令,输出解析逻辑全要换 |
| 调 flutter build 的脚本 | 需要扩展 | 保留 flutter build,后面追加 hvigor 打包步骤 |
| 读 Android 的 local.properties | 需要扩展 | 补读鸿蒙 SDK 路径、HarmonyOS SDK 配置 |
| 依赖原生插件做运行时能力的 | 基本重写 | 这类脚本如果涉及设备侧执行,要评估 ArkTS 侧方案 |
当时我们盘完之后发现,真正需要大改的只有 20% 左右的脚本,剩下 80% 的骨架直接复用。明确这个边界之后,团队的焦虑感一下就降下来了。
2. 环境基线:适配前的检查与准备,少一项后面全部白搭
dcli_scripts 之所以容易“莫名其妙失败”,绝大多数时候不是脚本逻辑错了,而是宿主机的环境没有达到脚本的预期。鸿蒙化的适配中,环境基线要从“Android SDK 在不在”变成“hdc、ohpm、hvigor 在不在,鸿蒙 SDK 路径能不能被探测到”。
2.1 把鸿蒙开发环境当成可以被脚本探测的目标
一个好的 dcli 脚本,第一步永远是探测环境,而不是直接跑命令。dcli 里有which()可以查可执行文件,我用它做了一组基线检查,大概长这样:
import 'package:dcli/dcli.dart'; void checkHarmonyEnv() { final hdcPath = which('hdc'); if (hdcPath == null) { printerr('未找到 hdc,请确认 DevEco Studio 已安装且命令行工具已加入 PATH'); exit(2); } final hvigorw = which('hvigorw'); if (hvigorw == null) { // 鸿蒙工程根目录下一般有 hvigorw,如果全局没有也不强求 print('警告:未找到全局 hvigorw,后续将尝试使用工程内的 wrapper'); } final ohpmPath = which('ohpm'); if (ohpmPath == null) { printerr('未找到 ohpm,鸿蒙三方依赖无法安装'); exit(2); } }这里有一个很容易忽略的点:hdc 在 DevEco Studio 的安装目录里,但默认不一定进了 PATH。很多脚本挂在“hdc command not found”上,排查了一圈发现就是环境变量没配。我在基线检查时会把常见的 DevEco Studio 安装路径也加进which的搜索路径里,避免让用户手动去配环境变量。
2.2 识别鸿蒙工程结构的关键文件
dcli_scripts 里的很多脚本要解析工程结构,Flutter 时代读的是 pubspec.yaml 和 android/local.properties,鸿蒙侧则要认这几类文件:
oh-package.json5:鸿蒙工程的依赖清单,相当于 package.json,里面有@ohos相关的三方依赖。build-profile.json5:鸿蒙工程构建配置,定义了 product、签名配置、模块列表,是脚本最需要读取的文件。hvigorfile.ts:构建脚本入口,里面能看到可用的构建任务。entry/src/main/module.json5:应用模块配置文件,bundleName、versionCode这些关键信息都在这里。
脚本里要读build-profile.json5的时候,会有一个很隐蔽的坑:Dart 自带的jsonDecode只认标准 JSON,不认 JSON5。鸿蒙工程里这个文件经常带注释,或者 key 不带引号,直接解析会炸。我的处理方式是先做一次注释剥离再jsonDecode:
String stripJson5Comments(String source) { // 只做简单剥离,足够处理 build-profile.json5 里的 // 和 /* */ 注释 return source .replaceAll(RegExp(r'"(?:\\.|[^"\\])*"'), (m) => m[0]!) .replaceAll(RegExp(r'//[^\n]*'), '') .replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), ''); }这个正则先匹配字符串字面量,再删注释,不会误伤//出现在 URL 里的情况。实测下来处理标准 DevEco 生成的工程文件够用了。
2.3 我建议的基线检查表
下面这张表直接贴到团队文档里,任何脚本在跑之前都先过一遍:
| 检查项 | 检查命令/方法 | 失败的表现 | 处理建议 |
|---|---|---|---|
| hdc 可用 | hdc list targets | 提示 command not found | 把 DevEco 的 toolchains 目录加入 PATH |
| 鸿蒙 SDK 路径可探测 | 检查local.properties或环境变量 | 构建脚本找不到 SDK | 读取 HarmonyOS SDK 路径,设置DEVECO_SDK_HOME |
| 工程依赖已安装 | ohpm install执行过 | hvigor 构建时报缺依赖 | 脚本里加ohpm install的兜底调用 |
| 设备已连接并授权 | hdc list targets有非 unauthorized 的设备 | 安装脚本挂起 | 脚本检测到 unauthorized 时给出明确提示 |
| hvigorw 存在 | 工程根目录或全局 | 打包步骤无法执行 | 优先用工程根目录的hvigorwwrapper |
现在网上很多教程直接讲“hdc 能跑就行”,但我实际用下来,“hdc 能跑”和“脚本能在无人值守的情况下连续跑 100 次不挂”之间,差了上面这张表里的每一行。
3. 实战改造:让 dcli_scripts 一键完成鸿蒙的装、编、签、跑
评估做完、环境基线列清楚之后,就开始进入真正的改造。我拿团队里最常用的一个场景来讲完整链路:一条命令,把当前的 Flutter 鸿蒙工程编译、打包、签名、安装到真机。这套流程在 DevEco Studio 里手动点,至少得两三分钟还容易点错;脚本化之后十秒上下,而且每一次的操作都是确定性的。
3.1 把 dcli 脚本封装成项目级 command,而不是散落的可执行文件
dcli_scripts 内部我习惯用统一的入口dcli.dart,通过子命令分发,类似dcli install、dcli build、dcli logs。这样做的原因是:鸿蒙化改造之后,命令之间往往存在依赖关系,统一入口方便在公共层做环境检查和日志初始化。
import 'package:dcli/dcli.dart'; void main(List<String> args) { final command = args.isEmpty ? 'help' : args[0]; switch (command) { case 'install': installHap(); break; case 'build': buildHap(); break; case 'logs': streamDeviceLogs(); break; default: usage(); } }这里的installHap、buildHap就是我们要鸿蒙化的核心函数。很多教程会教你把每个脚本写成一个独立的.dart文件,然后各自执行,但我强烈建议统一入口,因为环境检查、路径解析这些逻辑收敛到一处,后面维护成本会低很多。
3.2 在脚本里正确调用 hdc:路径、超时、嵌套 shell 的处理
dcli 调外部命令有两种方式:run()是同步执行并拿到结果,适合短命令;Process.start()是异步流式,适合长耗时或需要持续读输出的命令。我在调 hdc 的时候,绝大多数用run(),但会做两件事:显式传超时时间,捕获非零退出码。
Future<void> installHap() async { final hapPath = await locateBuiltHap(); // 在 3.4 小节说明 final targets = run('hdc list targets', timeout: const Duration(seconds: 10)); if (targets.contains('unauthorized')) { printerr('设备未授权,请先在 DevEco Studio 中确认连接弹窗'); exit(3); } if (!targets.contains('Connected')) { printerr('没有已连接的鸿蒙设备或模拟器'); exit(3); } final result = run( 'hdc install -r $hapPath', timeout: const Duration(seconds: 60), ); if (result.exitCode != 0) { printerr('hdc install 失败:${result.stderr}'); exit(3); } print('安装完成:$hapPath'); }这里特别说明一下hdc install -r:-r表示覆盖安装,对应 adb install -r。我们第一次适配的时候漏了-r,迭代开发时每次都要先hdc uninstall才能装新的,体验非常差。dcli 的run在 Windows 上走的是cmd.exe,如果命令里出现>、|这类 shell 操作符,建议改成runInShell,但也要评估引入 shell 带来的转义风险。实践上我尽量不用字符串拼 shell 管道,而是分两步在 Dart 里做输出处理。
3.3 让脚本读懂鸿蒙的构建产物,而不是凭感觉找 HAP 文件
定位 HAP 文件是这次适配里最值得注意的一个点。鸿蒙工程可以有多个 module,每个 module 的产物路径类似entry/build/default/outputs/default/entry-default-signed.hap,但这个路径在不同 DevEco 版本里可能带不同的版本号或签名后缀。我之前踩过写死路径的坑,后来改成解析build-profile.json5加目录扫描双保险:
Future<String> locateBuiltHap() async { final projectRoot = Dir.current.path; final buildProfile = File('$projectRoot/build-profile.json5').readAsStringSync(); final cleaned = stripJson5Comments(buildProfile); final json = jsonDecode(cleaned) as Map<String, dynamic>; // 常见配置里的 app/products,取第一个 product 的名称 final products = (json['app']?['products'] as List?) ?? []; final productName = products.isNotEmpty ? (products[0] as Map)['name'].toString() : 'default'; final outputDir = Directory('$projectRoot/entry/build/default/outputs/default'); if (!outputDir.existsSync()) { printerr('未找到构建产物目录,请先执行构建命令'); exit(4); } return outputDir .listSync() .whereType<File>() .firstWhere((f) => f.path.endsWith('.hap'), orElse: () { printerr('outputs 目录下没有 .hap 文件'); exit(4); }).path; }代码不复杂,核心思想是:永远不要在生产脚本里写死产物路径。只用default是因为我们适配期一般跑的是 debug 包,如果碰到release场景,再把 productName 作为参数传进来,路径就要相应改成release目录。
3.4 构建链路的顺序安排:Flutter 产物在前,hvigor 打包在后
Flutter 鸿蒙化工程里,两种工具链是串联的关系。我们最终确定的标准顺序是:
flutter pub get:保证 Dart 侧的依赖完整。flutter build针对鸿蒙目标的产物生成,不同 Flutter 鸿蒙分支的命令参数略有差异,这一步留了可配置项,让不同团队填自己的分支路径。ohpm install:保证鸿蒙工程侧的依赖完整。hvigorw assembleHap:把上面两步的产物收拢并打包成 HAP。- 调用自定义签名配置或者直接使用工程里的 Debug 签名。
hdc install -r:安装到设备。
顺序不能乱,尤其是flutter pub get和ohpm install的位置,我们最初把ohpm install放到了最后,结果 hvigor 解析模块阶段就报缺包,白白浪费了一轮构建时间。另外hvigorw在工程根目录,脚本里建议用绝对路径调用,因为 dcli 的工作目录不一定总在工程根目录。
4. 踩坑记录:hdc 输出编码、设备授权和 dcli 卡死的三板斧
适配过程里真正的体感差异,不是“API 能不能调通”,而是“小问题会不会把你卡死”。鸿蒙工具链和 Flutter 生态之间的摩擦,集中体现在下面这几个点上。
4.1 中文系统下 hdc 输出 GBK/UTF-8 乱码的坑
我们在 Windows 开发机上跑脚本时,hdc list targets的输出偶尔会变成乱码。原因不复杂:hdc 在部分 Windows 版本上输出编码跟随系统代码页,而 dcli 的run默认按 UTF-8 解码。中文设备名、中文应用名最容易触发。
我的解决方式是在脚本开头强制设置环境变量,再执行 hdc 命令:
final result = run( 'hdc list targets', timeout: const Duration(seconds: 10), environment: { 'PYTHONIOENCODING': 'utf-8', 'LANG': 'en_US.UTF-8', }, );这个办法并不能百分之百覆盖所有 hdc 版本的问题,更稳妥的做法是拿到输出字节后,用gbk解码兜底。dcli 提供了result.stdout的原始字节访问,可以在utf8.decode失败时再尝试gbk解码。我建议在脚本里做一个decodeHdcOutput的函数统一处理,不要每个命令都重复写一遍。
4.2 设备授权弹窗:adb 时代的老问题换了一层皮
Flutter 开发者对adb devices里的unauthorized应该不陌生。鸿蒙的 hdc 也有类似的机制,第一次连接真机时,设备上会弹授权框,没确认的话hdc list targets就显示unauthorized。
但这里有个和 adb 不太一样的细节:鸿蒙对“开发者模式”的开启要求更严格,有些设备型号还要先在设置里打开“USB 调试”才可以。我们的安装脚本因此加了一个前置等待逻辑:如果检测到 unauthorized,就打印提示并轮询等待,最长 30 秒,给人在设备上点授权的缓冲时间。
for (var i = 0; i < 30; i++) { final output = run('hdc list targets', timeout: const Duration(seconds: 5)) .stdout; if (output.isNotEmpty && !output.contains('unauthorized')) { break; } stderr('等待设备授权... ${30 - i}s'); sleep(1.second); }这个小逻辑上线后,团队里的新人跑脚本挂掉的概率明显下降。
4.3 dcli 脚本看似“卡死”的真相:子进程输出缓冲和同步阻塞
说实话,这次适配里最有价值的教训来自一个“脚本执行到 hvigor 好像卡住不动了”的问题。排查半天,问题出在 hvigor 的告警输出实在太多,而 dcli 的run是同步攒输出、结束后一次性返回。构建任务明明还在跑,但因为输出没刷新到终端,看起来就是卡死了。
解决办法是放弃run,换成Process.start流式读取输出:
final process = await Process.start('hvigorw', ['assembleHap'], runInShell: true); process.stdout.listen((chunk) { stdout.add(chunk); }); process.stderr.listen((chunk) { stderr.add(chunk); }); final exitCode = await process.exitCode; if (exitCode != 0) { printerr('hvigor build 失败,退出码:$exitCode'); exit(4); }这个改动比想象中影响大。流式输出之后,团队在 CI 上也能看到实时构建日志了,排错体验提升了一大截。另外,给 hvigor 传--no-daemon或类似参数可以减少偶发的构建挂起问题,具体看 hvigor 版本支持情况。
5. 让 CLI 用起来像产品而不是玩具:日志规范、退出码和进度反馈
我们可以把一长串 hdc 和 hvigor 命令拼凑成“能跑”的脚本,但如果团队里其他人也要用、CI 也要跑,那脚本就得有工程化的体面。这一节聊的是把 dcli_scripts 当产品来打磨的几条准则。
5.1 给 dcli 脚本制定统一的退出码和日志分级
直接 print 一大段文字在终端上,人还能忍受;到 CI 日志里,就是灾难。我们给脚本定了一套简单粗暴的退出码约定,团队里所有人都遵守:
| 退出码 | 含义 | 典型场景 |
|---|---|---|
| 0 | 成功 | 构建、安装、日志轮转正常结束 |
| 1 | 参数错误 | 传入的模块名或 product 不存在 |
| 2 | 环境缺失 | hdc/ohpm 未找到,SDK 路径未配置 |
| 3 | 设备错误 | 设备未连接、未授权、安装失败 |
| 4 | 构建失败 | flutter build 或 hvigor assembleHap 失败 |
日志分级我推荐用 dcli 自带的Logger,而不是到处print。info 留给关键步骤,warn 给可能影响结果的告警,error 只给真正阻断流程的信息。团队有个隐性规定:error 日志里必须包含排查线索,比如命令的完整调用、退出码、以及建议检查的环境变量。
5.2 长耗时任务里给开发者“它还在工作”的反馈
hvigor 打包可能耗时几十秒,这段等待里如果在终端只看到一个静止的光标,人会立刻怀疑脚本挂了。dcli 提供了progress和spinner,可以在两个关键节点之间给用户反馈:
final spinner = Spinner(); spinner.start(title: '正在执行 ohpm install ...'); final installResult = run('ohpm install', timeout: const Duration(minutes: 5)); spinner.stop();这里要提个经验:spinner 适合短等待,真正冗长的构建过程还是靠流式日志最靠谱,人能看到“编到哪个模块了”才算安心。我们是 combo 用法:阶段切换用 spinner,长任务用流式输出。
5.3 与 CI/CD 的磨合点:非交互模式、彩色输出和密钥处理
我们很快就把这套脚本接进了团队的 CI,原本本机跑的脚本到了 CI 上就暴露出几个问题:
- 脚本里有
ask()这类交互式输入,CI 没有 TTY,直接挂。处理方式是规定所有命令默认非交互,必需的可变参数一律通过命令行参数或环境变量传入。 - 彩色输出在 CI 日志里会变成乱码转义符。处理方式是给脚本加
--no-color参数,或者检测到CI环境变量就自动关闭彩色输出。 - 签名密钥和 keystore 密码绝不能出现在脚本代码里。鸿蒙应用的签名配置优先走
build-profile.json5里引用的环境变量,脚本只负责读取路径,不负责托管密钥。
这些点如果是个人脚本,基本不会想到;一旦要团队化和自动化,就是必踩的坑。
6. 如果还想更彻底:设备侧命令行能力的鸿蒙化边界
到这里,我们已经把“宿主机上的命令行工具链”鸿蒙化得差不多了。但经常有同事问:dcli_scripts 能不能在鸿蒙设备上直接跑?这个问题要分清楚两个完全不同的场景。
6.1 Dart 代码能否直接跑在鸿蒙设备上:取决于运行时
dcli 本身依赖 Dart VM 和宿主操作系统的 Process 能力,它不是为嵌入式设备设计的交互式命令环境。鸿蒙设备上的 Flutter 应用确实可以运行 Dart 逻辑,但那是在集成好的 App 沙箱里,不会给你一个可以随便执行hvigor这类宿主编译命令的终端环境。
所以我的结论是:dcli_scripts 继续留在宿主机,负责调用 hdc 指挥设备。不要把“在 App 内执行 Dart 脚本”和“命令行终端化”混为一谈。前者能做,但那是 Flutter 鸿蒙应用内部的功能逻辑,不是开发者工具链的问题。
6.2 真正的设备侧替代:ArkTS 封装、hdc shell 与原子化服务
有一种需求确实需要“设备内部有自动化能力”,比如给门店设备批量做配置、巡检本地存储、控制外设。这种情况下我最常用的路径是:
- 在宿主机用 dcli 脚本通过
hdc shell执行设备侧的命令,比如检查进程、读系统信息。 - 如果逻辑比较复杂,再用 ArkTS 写一个小的本地任务模块,通过 hdc 的调试接口触发。
- 如果要做成用户可感知的维护工具,就做成原子化服务,在后台执行定期任务。
这里给一个决策表,把场景和方案对应清楚:
| 需求场景 | 推荐方式 | 理由 |
|---|---|---|
| 开发期批量安装、抓日志 | 宿主机 dcli 脚本 + hdc | 最直接,复用现有工具链 |
| 设备侧执行 shell 过命令 | hdc shell | 轻量,无需开发 App 功能 |
| 设备侧周期性自检、上报 | ArkTS 本地任务 + 原子化服务 | 要常驻后台,只有系统级能力才稳 |
| 给运维团队做个可视化维护面板 | Flutter 鸿蒙应用 + 项目管理 | 是完整的应用开发,交给应用层 |
从工具链角度说,设备侧方案其实是另一个生态在管的事。我们团队现在的分工也很清晰:dcli_scripts 管好宿主机侧所有的自动化,设备侧只暴露最少量的 hdc shell 接口,需要更复杂能力再走 ArkTS。
如果要给这次鸿蒙化适配打一个总结,我不会说“我们完美解决了所有问题”。现实是,第一版核心链路周末两天就通了,但让它在各种开发机上稳定跑、让 CI 上不再突然冒出莫名其妙的乱码和授权问题,前前后后用了三周。dcli_scripts 的鸿蒙化,本质不是把 API 签个名那么简单,而是把你对设备、构建、签名、包管理这条链路的理解重新梳理了一遍。最后分享一个小技巧:动大改之前,先写一个二十行的 POC——只做一件事,检查 hdc 连接、选一个最小的 HAP 装上真机。这个 POC 跑通,剩下的就是一层层往里加逻辑;跑不通,说明环境还没对齐,别急着写后面的代码。