这段时间在把公司一个 Flutter 项目往 OpenHarmony(鸿蒙)上做适配,最折磨我的不是 Dart 代码兼容性,也不是 UI 差异,而是每天要在终端里敲一堆长得离谱的命令。构建 HAP 要记 hvigor 的参数,装到模拟器要敲一长串 hdc 命令,签名时翻了半天才想起 keytool、openssl 的具体写法,更别说每次还得先 flutter pub get、再 analyze、再 test。工具链来回切,思路一断,效率直接归零。
后来我把 Flutter 生态里的 derry 这个三方库引了进来,用它统一管理这些脚本,相当于在鸿蒙项目里搭建了一个自定义脚本控制台。这篇文章就把这段落地方案完整拆一遍:从 derry 的脚本模型、OpenHarmony 项目里的安装配置,到几条实际工作流脚本的逐行分析,最后是这一路踩过的坑和沉淀下来的工程化习惯。适合正在做 Flutter for OpenHarmony 适配、或者想优化本地工作流的团队和开发者参考。
1. 鸿蒙 Flutter 项目的命令碎片化问题:为什么脚本控制台是刚需
1.1 从 flutter build apk 到 flutter build hap:工具链一下子变多了
先交代一下背景。普通 Flutter 项目的工作流非常固定:pub get、analyze、test、build apk(或 ipa),几条命令来回用就行。但到了 OpenHarmony 这边,Flutter 是跑在鸿蒙生态里的,构建目标从 Android APK 变成了 HAP(HarmonyOS Ability Package,鸿蒙应用的应用包格式)。这意味着参与每个环节的工具瞬间变多了:
- 构建阶段:需要先同步鸿蒙侧的依赖和配置,再用适配后的 Flutter 命令或 hvigor 构建出 HAP。
- 安装阶段:要用 hdc(鸿蒙设备调试工具)连接模拟器或真机,参数不比 adb 短。
- 签名阶段:HAP 的签名校验严格,命令涉及 keytool、openssl、hap-sign-tool 等,一条命令动辄几行长参数。
- 调试阶段:日志、网络抓包、数据库导出,每一个都有独立的工具和命令入口。
我统计过自己一天的典型操作,从改完代码到在设备上看到效果,中间至少有六到八次命令切换。每次切换都要回忆"我上次是怎么写的来着",偶尔还要打开浏览器去翻之前的笔记,这种隐形开销才是最消耗精力的。
1.2 长命令的危害:记不住只是一个开始
有人觉得命令记不住,翻翻文档就行,何必专门搞个工具。但实际项目里还有几个更隐蔽的问题:
- 拼写错误导致的低效排查:hdc 的某些参数大小写敏感,端口参数、路径转义,任何一步错了,报错信息往往长得像天书,排查起来非常消耗耐心。
- 不同开发环境的差异:团队成员有人用 Windows,有人用 macOS,同一套 shell 命令写下来,Windows 的 cmd/PowerShell 与 bash 的表现完全不一样,命令"写一次到处跑"基本是奢望。
- 知识只存在于个别人脑中:新同学入职,光是搞明白"怎么把这个项目跑起来"可能就要折腾半天,因为他不知道哪些操作是有顺序依赖的。
- 容易拿到过期产物:比如只想构建 release HAP,但忘了清理上一轮的输出,最后签名、安装、测试了老半天,才发现跑的根本不是刚改的代码。
脚本控制台要解决的正是这一类系统性问题,而不是单纯帮你少敲几个字。它把"操作知识"从人脑里搬到项目仓库里,变成团队共享资产。
1.3 为什么我不选 Makefile、shell 或 melos
在决定用 derry 之前,我把常见的自动化方案都过了一遍,也踩过一些浅坑,这里直接说结论:
- shell 脚本的问题在于跨平台。我在 Mac 上写得顺手,Windows 同事同步过去就是编码、换行、语法各种问题。除非团队统一用 Git Bash 或 WSL,否则维护成本很高。
- Makefile 在 Windows 上要装额外环境,而且它对 Tab 和缩进极端敏感,团队里不是每个人都愿意把精力花在这上面。
- melos 功能很强,但是它是为多 package 仓库设计的。我的场景就是一个 Flutter 应用加几个内部工具脚本,用它属于杀鸡用牛刀。
derry 就很有意思了:它把配置写在 pubspec.yaml 里,和 Flutter 项目天然绑定;命令通过derry run <name>统一触发,天然跨平台;复杂逻辑可以写成 Dart 脚本,依赖 Flutter/Dart 生态已有的各种库。它的定位非常清楚——做一个"给 Flutter 项目用的跨平台语义化命令层"。
1.4 什么样的项目值得引入脚本控制台
我根据自己的经验列了一个参考标准,如果你遇到下面任意两条,就值得试试 derry:
- 日常构建不是一条命令能搞定的,经常要组合多个环节才能跑出结果。
- 项目里有签名、打包、部署、数据同步这类跨工具链操作。
- 团队有 Windows 和 macOS 等多个开发平台,共享命令不方便。
- 新人上手成本高,大量"看文档才知道怎么跑"的操作。
- 本地手动操作和 CI 流水线操作不一致,本地容易走样。
如果只是写 demo,flutter run一条命令跑到底,那确实不需要。但做 Flutter for OpenHarmony 这种需要多环节组合的项目,脚本控制台真的不是锦上添花,是刚需。
2. derry 的脚本模型:pubspec.yaml 里如何从零定义一条可执行工作流
2.1 三条核心设计理念
第一眼看到 derry 的 README,很多人觉得这不就是"命令起个别名"吗?实际用下来,它比别名要成熟不少,有三条设计理念值得一说。
第一,脚本声明在 pubspec.yaml 的 derry 字段里,与项目配置同源。脚本随仓库走,天然版本化、天然共享,不会像零散的 .sh 文件一样,写着写着就和代码脱节了。
第二,它支持三种命令体:直接执行的 shell 命令、按顺序执行的多条命令数组、Dart 脚本文件。这个分层非常务实:简单命令用字符串,固定流程用数组,复杂逻辑放进 Dart 脚本。
第三,它内建了 pre/post 生命周期钩子。比如定义一个 build 脚本,还可以定义 pre:build 和 post:build,分别对应执行前后自动触发的逻辑。构建类工作流里的"先清理、再构建、后复制产物"这种固定模式,用钩子表达再自然不过。
2.2 三种命令体的适用场景与写法
先看最简单的字符串命令体。适合场景:单条命令本身就够用、无跨平台顾虑的情况。
derry: analyze: flutter analyze test: flutter test get: flutter pub get再看数组命令体。适合场景:多条命令必须按固定顺序执行,希望一次触发全部跑完。
derry: check: - dart format --set-exit-if-changed lib test - flutter analyze - flutter test这种写法比在 shell 里用&&拼接清晰得多,哪一步挂了你看输出就知道,不会整条命令因为一个中间环节失败而让人摸不着头脑。
最后是 Dart 脚本命令体。适合场景:里面需要判断、循环、文件读写、调外部工具。写法是在 pubspec.yaml 里指向一个 Dart 文件:
derry: deploy: dart:scripts/deploy_hap.dart这里的 Dart 脚本是一个标准 Dart 程序,入口是 main() 函数,脚本依赖可以通过 dev_dependencies 正常引入。这个能力是 derry 相比纯 shell 方案最大的纵深优势。
2.3 pre/post 生命周期钩子:让依赖操作自动串联
pre/post 钩子是 derry 最容易被忽略、但实际最实用的机制。拿一条构建流程举例:
- 构建前:需要先生成代码(类似 build_runner 的代码生成),并清理旧的构建产物。
- 构建中:执行 flutter build hap。
- 构建后:把产物复制到 release 目录,打印产物路径。
用 derry 可以这样定义:
derry: pre:build: - flutter pub run build_runner build --delete-conflicting-outputs - rm -rf build/hap build: flutter build hap post:build: - cp -r build/hap release/ - echo "build finished"之后每次执行derry run build,它都会自动把 pre:build 跑完,再跑 build,最后跑 post:build。用熟了以后,你可以把整条 CI 的本地版本完全压进 derry 的钩子里,比如 pre:build 里跑代码生成和协议同步,post:build 里自动打 tag、发通知,可行性非常高。
2.4 和 Makefile / shell / melos 的一次实际对比
我把自己试过的几种方案整理成一张表,方便大家选型:
| 指标 | derry | Makefile | shell 脚本 | melos |
|---|---|---|---|---|
| 跨平台 | 好 | 差,Windows 需额外环境 | 一般,cmd/bash/PowerShell 差异大 | 好 |
| 与 Flutter 项目耦合度 | 高,配置在 pubspec.yaml | 低 | 低 | 高,但偏向多包管理 |
| 支持 Dart 脚本 | 支持 | 不支持 | 不支持 | 支持 |
| 生命周期钩子 | 内置 pre/post | 需要手动模拟 | 需要手动模拟 | 有类似机制 |
| 适合场景 | 单/多包 Flutter 项目工作流 | 通用构建系统 | 通用系统管理 | 多 package 仓库流水线 |
| 学习成本 | 低 | 中 | 中 | 中高 |
derry 最擅长的事情,就是"在 Flutter 项目里当命令总入口"。它不追求成为通用任务管理工具,只服务好 Flutter 这"一亩三分地",而这恰恰是 Flutter for OpenHarmony 项目最需要的。
3. 在 OpenHarmony Flutter 项目里把 derry 跑起来:安装、布局与最小配置
3.1 安装 derry,以及最容易忽略的 PATH 问题
derry 是 Dart 生态的命令行工具,安装很简单:
dart pub global activate derry但这里有个高频坑:装完之后终端提示derry: command not found。这不是安装失败,而是 Dart 的全局 bin 目录没加进 PATH。
不同系统下目录位置不一样:
- macOS / Linux:
~/.pub-cache/bin - Windows:
%LOCALAPPDATA%\Pub\Cache\bin
把对应目录加进 PATH,新开一个终端窗口,执行derry --help,能看到帮助信息就说明环境没问题。这个坑和刚装好 Flutter SDK 后要新开终端才生效是同一个道理,都是 PATH 环境变量没有刷新。
3.2 脚本目录布局:别把一切塞进 pubspec.yaml
脚本数量少时直接写在 pubspec.yaml 里没问题,但一旦脚本变多,我建议专门建一个目录来管理:
scripts/ build_hap.dart connect_device.dart sync_db.dart deploy.dart utils/ logger.dart device.dartpubspec.yaml 里的 derry 字段只留入口名称,具体逻辑拆分到不同 Dart 文件里,公共逻辑放到 utils 下。这样脚本不再是"一堆没法维护的命令",而是一个有依赖关系、可读性强的代码模块。后续想加单元测试也完全可行。
3.3 一个可运行的 OpenHarmony 最小配置样例
下面是一个我实际调整过的最简配置,命令路径按你们项目实际情况替换即可,结构是通用的:
dev_dependencies: derry: ^1.4.0 derry: prepare: - flutter pub get - dart pub global activate derry analyze: flutter analyze test: flutter test build:hap: - flutter clean - flutter pub get - flutter build hap --release hdc:list: hdc list targets deploy: dart:scripts/deploy_hap.dart这里有两个推荐的习惯:
- 命令名里使用冒号做分组,比如 build:hap、deploy:emulator、data:pull。团队大了以后,一眼就能看出脚本属于哪个环节。
- 环境准备类操作单独放一条 prepare 命令,新人 clone 仓库后执行一次
derry run prepare就能完成依赖安装和工具激活,不用逐个文档翻。
3.4 先手动跑通一条核心路径,再固化成脚本
配置好了不等于能用,我建议先手动跑通一条最核心的链路——从 flutter build hap 到 hdc 安装——再封装。我当时的链路是:
- 确认当前 flutter 命令指向的是 OpenHarmony 适配版 SDK(可以执行
flutter --version或flutter doctor看一眼,避免和原生 Flutter SDK 混用)。 - 执行
flutter build hap --release,构建出 HAP 包。 - 执行
hdc list targets查看当前连接的模拟器或真机。 - 执行
hdc install <hap 文件路径>安装到设备。
这条链路跑通之后,才把命令固化成 derry 脚本。为什么要这么谨慎?因为如果你连原始命令都不能稳定执行,封装出来的脚本只是在给错误命令做美化,后续排错反而更痛苦。
第4步中如果 hdc 工具的路径不在全局 PATH 里,脚本里需要显式带上工具路径,或者用一个 env 配置统一管理,避免在不同人电脑上行为不一致。
4. 四个高频工作流脚本拆解:从构建、部署到抓包与数据同步
下面四个场景是我在项目里实际在用的,你可以直接抄回去改成自己的。每个场景我都会给配置或脚本、说明为什么这样写,以及实际使用时要注意什么。
4.1 场景一:一键构建 HAP、签名并部署到设备
OpenHarmony 的 Flutter 应用要落地,必然要经历构建 HAP、签名、安装三个环节。如果每次都手动操作,费时且极易出错。Dart 脚本的核心逻辑大致如下:
import 'dart:io'; Future<void> main(List<String> args) async { final buildMode = args.contains('release') ? 'release' : 'debug'; // 1. 构建 HAP final buildResult = await Process.run( 'flutter', ['build', 'hap', '--$buildMode'], runInShell: true, ); stdout.write(buildResult.stdout); if (buildResult.exitCode != 0) { stderr.writeln('构建失败,退出码: ${buildResult.exitCode}'); exit(buildResult.exitCode); } // 2. 查找产物目录 final hapDir = Directory('build/hap/$buildMode'); if (!hapDir.existsSync()) { stderr.writeln('未找到 HAP 产物目录: ${hapDir.path}'); exit(1); } // 3. 执行签名(实际签名命令以项目里的脚本为准) final signResult = await Process.run( 'bash', ['scripts/sign_hap.sh', hapDir.path], runInShell: true, ); stdout.write(signResult.stdout); if (signResult.exitCode != 0) { stderr.writeln('签名失败'); exit(signResult.exitCode); } // 4. 安装到设备 final installResult = await Process.run( 'hdc', ['install', '${hapDir.path}/entry-default-signed.hap'], runInShell: true, ); stdout.write(installResult.stdout); if (installResult.exitCode != 0) { stderr.writeln('安装失败'); exit(installResult.exitCode); } stdout.writeln('构建、签名、安装完成'); }写这套脚本时有几个经验可以分享:
- Process.run 里加
runInShell: true,是避免在不同操作系统下因可执行文件路径解析问题导致找不到命令。 - 每一步都检查 exitCode,失败立刻退出并给出明确提示。脚本控制台要让人放心用,核心原则就是"失败必须大声报出来"。
- 签名过程单独拆到 sign_hap.sh 里,不写进主脚本。这样换签名工具时不需要动主流程,也方便调试。
我把这条命令注册为:
derry: deploy:script: dart:scripts/deploy_hap.dart之后每次改完代码,一行derry run deploy:script -- release就能完成从构建到安装的全流程。
4.2 场景二:多台设备与多环境切换
鸿蒙开发经常会同时挂着模拟器和真机,你的部署目标要是可选的。derry 的 Dart 脚本可以接收--之后的参数,比如derry run deploy -- device=emulator。
脚本里可以这样处理:
import 'dart:io'; Future<void> main(List<String> args) async { final target = args.contains('device=emulator') ? 'emulator' : args.contains('device=phone') ? 'phone' : null; if (target == null) { // 没指定目标时,列出在线设备并提示用法 final result = await Process.run('hdc', ['list', 'targets'], runInShell: true); stdout.writeln('在线设备:'); stdout.writeln(result.stdout); stdout.writeln('使用方式:derry run deploy -- device=emulator'); exit(0); } stdout.writeln('目标设备:$target'); // 后续按 target 走不同的构建、签名、安装逻辑 }这里有个特别容易踩的坑:往 derry 命令后面传用户参数时,一定要在参数前加--,否则参数会被 derry 自己解析掉,根本到不了脚本的 main() 里。我第一次写的时候没加,排查了半天才发现是这个原因。
4.3 场景三:调试辅助——一键配置抓包监听与内嵌数据库导出
这一块刚好对应很多人关心的"flutter dio 如何抓包"和"flutter 内嵌数据库"两个话题。平时在鸿蒙设备上调试 Flutter 网络请求,最烦的就是每次要手动配置抓包监听;如果你用了 sqflite 或 drift 这类内嵌数据库,想在真机上导出数据也要费一番功夫。这些重复劳动非常适合脚本化。
抓包监听可以封装成两条命令:
derry: debug:capture:on: - hdc shell param set persist.net.dns_server 192.168.1.100 - hdc shell settings put global http_proxy 192.168.1.100:8888 debug:capture:off: - hdc shell settings delete global http_proxy这里的 IP 和端口是示例,你需要替换成自己电脑的局域网地址和 Charles / mitmproxy 之类工具实际监听的端口。鸿蒙不同版本对 settings/param 的细节支持可能有差异,稳妥的做法是先手动执行一次确认可用,再固化成脚本。抓包结束后跑一条derry run debug:capture:off,顺手把监听关掉,干净利落。
再看数据库导出。如果你的应用用了本地数据库,调试时经常要看看库里到底存了什么。封装成脚本后:
derry: db:pull: - hdc shell ls data/app/el2/100/base/com.example.app/files/database - hdc file recv data/app/el2/100/base/com.example.app/files/database/app.db ./backup/当然,具体路径要按你项目的实际包名和沙箱路径来写,思路是通用的。这一步把平时要打开 DevEco Studio 文件浏览器才能做的操作,压缩成了几秒钟的一条命令,调试效率提升非常直观。
4.4 场景四:一键质量门禁(analyze + test + build)
最后是一个我非常推荐团队使用的"质量门禁"脚本。它解决的核心问题是:每次动完代码,都能快速判断"这次改动有没有破坏什么"。
用 derry 的数组命令和钩子配合,可以这样写:
derry: pre:check: - dart format --set-exit-if-changed lib test - flutter analyze check: - flutter test post:check: - echo "all checks passed"为什么把 analyze 放在 pre:check 里而不是直接用数组顺序执行?两种写法效果差不多,我这里是在展示钩子的另一种用法:把"前提条件"放进 pre 里,把"核心动作"放进主命令。这样你单独跑derry run check时,也会自动把格式校验、静态分析带出来。后续如果想跳过某一步,直接去掉对应钩子即可,主命令不用改。
5. 打磨脚本控制台:我踩过的坑与最终的工程化习惯
这一章把我这一路实际遇到的坑和最后沉淀下来的习惯都列出来,希望能帮大家少走弯路。
5.1 坑一:IDE 内嵌终端里 derry 命令找不到
PATH 问题我之前讲过一遍,这里补充一个更隐蔽的场景:系统终端里 derry 好好的,但 IDE 内嵌终端里就是提示 command not found。原因往往是 IDE 启动时没有加载 shell 的 rc 文件,导致它看不到你后来追加的 PATH。
解决办法有两个:要么在 IDE 的终端设置里指定当前用户的 shell 类型,要么把 Dart 全局 bin 目录写进系统的用户环境变量而不是 shell 的 rc 文件。我最后选了后者,因为它对团队里用 VS Code 和 IDEA 的同事都生效。
5.2 坑二:Windows 下 shell 命令的引号和转义不一致
同一条命令在 macOS 的 bash 里能跑,在 Windows 的 cmd 或 PowerShell 里就是另一回事。双引号、单引号、$ 符号、管道符、路径分隔符,两边语义差异很大。我最终采取的策略是:凡是涉及复杂 shell 语法的逻辑,一律改成 Dart 脚本。在 Dart 里通过 Process.run 只调用最基础的可执行程序,复杂逻辑用 Dart 的字符串和条件判断处理,不跟 shell 语法纠缠。
路径分隔符也要单独注意:在 Dart 里用File(path)没问题,但如果要把路径拼进外部命令,建议使用 Platform.isWindows 判断后转换,或者用 pub 上的 path 包统一管理,避免 Windows 上传参时用反斜杠被工具链误解。
5.3 坑三:输出太长导致"有没有成功"都不确定
脚本跑完后,终端输出经常一大片,早期我经常在一堆日志里找哪里报错了。后来我在每个脚本的末尾统一加了一个"成功标记",比如输出固定内容的成功提示,同时在 Process.run 之后判断 exitCode,非零就立刻抛错退出。这样任何人跑完脚本,只要看到最后的 SUCCESS 标记,就知道全流程通过了,不需要中间日志里翻找。
5.4 坑四:pre/post 钩子隐式执行让队友困惑
钩子太方便也有副作用:团队里不熟悉 derry 的同事,看到一个derry run build突然跑出一堆意料之外的操作,会以为哪里出问题了。因为 build 前自动触发了代码生成、清理,build 后又自动复制产物、输出路径,这在不知情的人看来就像"脚本失控"。
后来我在项目的 README 里加了一张"命令对照表",把每条 derry 命令实际会触发的所有子命令都列出来。这个方法很笨,但非常有效,新同事看完就对脚本行为一目了然。
5.5 工程化习惯:每条脚本都要能"被独立运行"
最后分享一个我认为最重要的经验。设计 derry 脚本时,我坚持让每一条命令都是"独立可理解"的,而不是必须依赖先跑 A 再跑 B。pre/post 钩子可以自动串联,但核心动作本身不应该隐藏前置条件。
举个例子:derry run deploy即使不依赖任何 hook,也应该自己判断是否需要构建;如果没有可用的构建产物,它要明确报错并提示先执行derry run build。这样每次执行都是确定性的,不会因为外部状态不同而出现"明明执行了 deploy,却因为产物太旧导致手忙脚乱"的情况。这个习惯帮我避开了非常多看似玄学的"环境问题"。
从引入 derry 到现在,我们团队在鸿蒙 Flutter 项目上的本地工作流越来越接近"一条命令搞定一切"。最初两天我还在为 hdc 命令、签名脚本各种着急,现在已经变成了derry run prepare初始化、derry run check做质量门禁、derry run deploy:script -- release直接出包部署。如果你也在被一堆记不全的长命令折磨,建议先花半小时把最小配置跑通,你会很快感受到一个直观变化:注意力重新放回了业务代码,而不是工具链本身。下一步我打算把这套脚本入口和 CI 流水线完全统一起来,让本地构建和服务器构建走同一个语义化入口,这是脚本控制台后续最大的想象空间。