ARTICLE DETAIL

资讯详情

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

鸿蒙端Flutter三方库executable适配:从CLI到执行契约

鸿蒙端Flutter三方库executable适配:从CLI到执行契约 很多做 Flutter 的兄弟应该都遇到过这种场景本地开发机上某个三方库的 CLI 用得特别顺手。比如说我在项目里集成过一个坐标解析库它的 pubspec.yaml 里声明了executable: geo_tool: bin/geo_tool.dart我dart pub global activate之后想跑个经纬度解析直接在终端敲一句geo_tool parse --lat 39.9042 --lng 116.4074就能出结果还把输出重定向到 JSON 文件。可等到要把这个库移植到鸿蒙端的时候所有人都会开始问同一个问题三方库的 executable 入口去哪了鸿蒙应用里到底该怎么触发这个能力这篇文章就是来回答这个问题的。我会先讲清楚 executable 在原生 Dart/Flutter 链路里的工作机制再解释为什么它在鸿蒙端不能原样照搬然后给出我实际在鸿蒙适配中采用的方案把 CLI 重新定义成一份“执行契约”在鸿蒙端做一个标准化的入口管理模块让 Dart 核心逻辑、ArkTS 调用层、甚至后来的 IDE 插件都能共享同一套规则。内容偏实战适合正在做 Flutter 三方库鸿蒙化改造的开发者也适合想理解“跨端能力抽象”思路的架构师。1. 先别急着移植搞懂 Flutter executable 在原生链路上的真相很多人在鸿蒙适配的时候第一步就栽了不是技术问题而是根本没弄明白 executable 在原生环境下的运行机制然后就开始瞎猜、乱试。我建议你先跟着我从头梳理一遍这条链路再谈鸿蒙端怎么设计。1.1 pubspec 里的 executable 到底做了什么executable是 Dart 包发布规范里定义的一个顶层字段它和dependencies、dev_dependencies平级。写法是这样的# pubspec.yaml name: geo_tool version: 1.0.0 executable: geo_tool: bin/geo_tool.dart这行的意思是当我的包被发布到 pub.dev并且被其他人用dart pub global activate geo_tool全局激活之后pub 会在全局 bin 目录macOS/Linux 下通常是~/.pub-cache/binWindows 下是%LOCALAPPDATA%\Pub\Cache\bin生成一个名为geo_tool的可执行脚本。这个脚本本质是个薄壳内容大致是定位到 pub cache 里对应的包目录然后调用dart run bin/geo_tool.dart把这个脚本收到的所有命令行参数原样传给 Dart 入口函数的ListString args。所以你在终端看到的geo_tool parse --lat ...最终会变成 Dart VM 执行dart run bin/geo_tool.dart parse --lat ...。需要注意executable的值并不一定指向bin/目录官方允许指向lib/目录下的文件只是规范上更推荐放在bin/。1.2 从“命令”到“进程”的完整链路如果你把一个 executable 命令的完整生命周期拆开它其实是这么走的用户输入命令名比如geo_tool parse --lat 39.9042 --lng 116.4074。系统的 shell 在 PATH 环境变量里找到geo_tool这个可执行文件。该文件是一个包装脚本内部定位到 pub cache 里对应包路径。脚本判断当前环境中dart可执行文件的位置然后用dart启动目标 Dart 文件。Dart VM 解析并运行bin/geo_tool.dart把ListString args传给main函数。程序执行逻辑通过print或stdout.write输出结果。进程退出退出码作为命令执行成功与否的最终信号。直接依赖这个包的项目还不太一样。如果你在自己的项目里把geo_tool写在dependencies里那么本地执行dart run geo_tool也是可以的pubspec 会告诉 pub 工具链怎么映射命令名到 Entry Point。这条逻辑由 package_config.json 维持这些文件存在于你项目根目录的.dart_tool/下。这套机制之所以能成立靠的是三件事一个全局可用的 dart 运行时、一套 pub 管理机制、一个用户能操作的终端 shell。1.3 为什么到了鸿蒙端这条链路就断了把上面的链路搬到鸿蒙设备上你会发现三个环节全部断裂鸿蒙应用是运行在用户设备上的不是运行在开发机上的设备里没有dart命令也没有 pub 全局缓存。三方库不能也不应该尝试去获取系统级 shell 的执行权限一个 App 里跑Process.run去执行外部命令在鸿蒙的沙箱体系里基本做不到也不应该这么做。Flutter 的鸿蒙适配现在主要支持的是 UI 层 running 的能力它不是一个通用的 dart CLI 运行时dart:io里的Process类在设备端非常受限。说白了executable 的设计初衷是给“开发期工具链”用的比如代码生成器、静态检查器、脚手架。这类工具在 CI、开发者本地终端里跑没有任何问题。但是到了鸿蒙端它的“使用场景”彻底变了能调用的主体不再是终端用户而是另一个业务模块或者一个后台服务输出不再是 stdout 流而是一个需要被程序消费的结构化结果。所以你会看到很多团队的适配方案混乱不堪有人把 Dart 里的 command 逻辑硬塞进 Widget 生命周期里跑有人用 MethodChannel 把参数传进去用 EventChannel 把结果打回来中间还隔着乱七八糟的字符串拼接更常见的是CLI 参数解析逻辑散落在多个文件里没有任何统一入口后续越改越乱。所有这些混乱本质上都是因为没有先把“执行契约”定义清楚。2. 鸿蒙端没有“终端”只有“执行契约”诚然鸿蒙开发者可以通过hdc shell进到设备终端里敲命令但那是给开发调试用的。作为一个三方库你在用户设备上没有任何合法途径去拉起一个系统终端更不可能在里面执行 dart 命令。所以我们要做的不是“把 CLI 搬进鸿蒙”而是把“CLI 能提供的能力”重新封装成一套鸿蒙侧可程序化调用的接口。2.1 先接受一个事实设备端不存在 Shell直观一点说CLI 对外暴露的是“命令字 参数 标准输出 退出码”。如果我们在鸿蒙端想保留这笔“遗产”就必须让这四个要素换一种形态存在命令字变成接口方法名或能力名称例如geo_tool.parse。参数变成结构化的参数对象而不是一段空格分隔的字符串。标准输出变成返回值结构化数据用 JSON文本数据用字符串二进制数据用字节数组。退出码变成错误码并通过应用的错误处理机制抛给调用方。这四个转化其实就是标准的“CLI 契约化”过程。也就是说我们不是在鸿蒙端模拟一个终端而是定义一种“程序调程序”的契约调用方提交一个 CommandRequest执行方返回一个 CommandResponse。业务上怎么实现没关系接口上必须稳定。这个思路也是对三方库开发者最友好的。因为一旦契约定下来Dart 侧的核心逻辑、鸿蒙侧的 ArkTS 调用层、以及未来可能的 IDE 插件、自动化测试工具都能基于同一份契约做各自平台的适配谁也不用迁就谁。2.2 鸿蒙侧可落地的三种入口形态选型契约化之后真正的技术选型才摆上台面。鸿蒙应用侧到底用什么载体来承载这些“命令”我整理过三种比较常见的落地形态各有各的适用场景。形态实现方式优势限制ArkTS 服务接口在 HarmonyOS 模块中封装一个 Service / Manager 类提供同步或异步方法类型安全和 ArkTS 调用方无缝协作调试方便需要模块内的注册与生命周期管理不能跨进程直接调用N-API / FFI 原生入口编写 C/C 桥接层动态注册 Native 方法通过 N-API 暴露给 ArkTS适合高频小命令能复用已有 C/C 工具链内存管理成本高跨层数据转换需要自己做不宜承载复杂业务HSP 共享包内置命令模块把命令实现打进 HSP 包里随应用分发通过 HSP 的导出接口对外提供服务天然隔离权限边界清晰适合“插件市场”式管理分发路径较长同一 Bundle 之间依赖共享包约束这里要特别解释一下 HSP 是什么。HarmonyOS Shared Package 是鸿蒙的一种共享包形态类似动态共享库它可以把公共能力打包成独立模块供多个业务模块加载。如果你的 CLI 能力实际上是被多个开发者共同使用的把它做成 HSP 是一个很正当的选择。但要注意HSP 的加载和生命周期管理比较复杂如果只是一个普通三方库的 CLI 适配不建议一开始就上 HSP否则会引入不必要的复杂度。2.3 我的选型结论ArkTS 服务入口为主FFI 作为高频旁路我最后采用的结构是默认所有命令都走 ArkTS 服务入口用一个统一的 CommandRouter 做注册与分发同时把少数高频、热路径的命令比如坐标换算、哈希计算旁路到 FFI 原生入口。这么做有几个原因第一CLI 命令的参数校验、帮助文本生成、版本探测这类逻辑用 ArkTS 写更直观排查起来也方便。第二鸿蒙侧的调用方都是 ArkTS 应用走 ArkTS 服务接口不需要跨层转换可以拿到完整类型信息。第三FFI 适合的是短执行时间、参数简单、纯计算的命令它不应该承载文件 IO、日志输出这些复杂逻辑。所以你会发现选型的关键不是“哪个技术更高级”而是“每个命令的执行特征适合放在哪一层”。在后面的工程实现里我会告诉你 CommandRouter 如何同时兼容这两种执行路径而不让调用方感知差异。3. 定义执行契约把一次命令行调用当成一次标准 RPC原生 CLI 时代命令和命令之间的耦合是松散的大家都是main(ListString args)谁爱怎么解析就怎么解析参数规范全凭各包自觉。鸿蒙化之后不行因为你要跨语言、跨模块、甚至跨团队协作没有清晰的契约适配立马变成一场灾难。这一节我会把契约的每个字段拆开讲透。3.1 契约长什么样CommandRequest / CommandResponse / CommandDef我建议你为每条可执行命令维护一份 CommandDef它描述“这条命令叫什么、需要哪些参数、有什么行为约束”。下面是我在项目里用到的 JSON 结构你可以直接用也可以按需裁剪{ command: geo:parse, summary: 将经纬度字符串解析为结构化坐标对象, version: 1.0.0, parameters: [ { name: input, type: string, required: true, description: 经纬度字符串例如 39.9042,116.4074 }, { name: format, type: string, default: json, enum: [json, text, csv], description: 输出格式 } ], timeoutMs: 5000, stdout: json, exitCodes: { 0: ok, 2: argument-error, 3: runtime-error, 4: business-error } }CommandRequest 是调用方提交给执行方的数据{ requestId: a1b2c3d4, command: geo:parse, args: { input: 39.9042,116.4074, format: json }, caller: com.example.business, timestamp: 1735689600000 }CommandResponse 是执行方返回给调用方的结果{ requestId: a1b2c3d4, code: 0, stdout: {\lat\:39.9042,\lng\:116.4074}, stderr: , costMs: 8, data: { lat: 39.9042, lng: 116.4074 } }你可能会问为什么这里要同时保留stdout字符串和data结构化对象因为兼容性需要。Dart 侧的核心逻辑原本就是往 stdout 打印文本的我不可能要求所有三方库全部改成返回对象保留 stdout 字段可以让 Dart 侧逻辑原样落地数据也能以字符串形式透传。data则是给鸿蒙直调方用的省去一段 JSON 解析。3.2 参数解析的归一化规则参数解析是最容易埋坑的地方尤其当你从 Dart 的args包切到 ArkTS 手写解析器时会发现两边对参数形态的理解天然不一致。我的建议是把参数解析规则彻底固化下来不要让两端各搞一套。一套可用的归一化规则如下命令参数统一使用--kebab-case形式例如--geo-input内部转换为geoInput。支持--key value和--keyvalue两种写法两者等价。布尔参数支持--flag和--no-flag--no-flag视为flagfalse。子命令用冒号或点号连接例如geo:parse拆分成 commandPath[geo, parse]。位置参数只用于极少数场景且必须排在所有命名参数之前。这套规则的出发点很简单命名约定一旦固定无论是 Dart 侧还是 ArkTS 侧解析代码都可以严格按规则走谁也不用猜对方怎么传。实际开发中我把这套归一化逻辑实现在 ArkTS 侧因为鸿蒙调用方才是参数的第一产生者Dart 侧收到的已经是从 Map 转换好的类型安全参数。3.3 错误码、退出码与超时语义CLI 的退出码在终端时代只分“成功/失败”但在鸿蒙端调用方往往需要知道“为什么失败”是参数写错了还是业务逻辑本身执行不了。所以我的错误码设计是这样的code含义对应终端退出码习惯ArkTS 侧处理建议0执行成功0正常返回1内部异常1归入兜底异常2参数错误2抛出参数校验错误3超时124抛出超时错误4业务错误如坐标不合法非零自定义抛出业务错误附带 data每个 CommandDef 都可以自定义100以上的错误码给业务使用但0/1/2/3/4这五个码必须是全局统一的。这就相当于给所有命令建立了一套“共通语言”排查问题时你只要看 Response 里的 code就能快速定位到是调用方问题还是执行方问题。超时语义尤其重要。原来在终端里命令超时了用户可以直接 CtrlC或者等它自然结束。鸿蒙侧不行一个命令如果长时间不返回会占用调用方资源还可能让用户感觉 App 卡死。契约里必须强制要求每条命令声明自己的timeoutMsCommandRouter 在分发时用统一的计时器管理一旦超时立刻返回 code3不再等待底层任务。3.4 幂等性与并发控制为什么必须在契约里带 requestId我在第一版契约设计里没有 requestId后来在真机上吃了几次亏才补上去。原因在于鸿蒙侧的调用方可能会并发地发起同一命令比如同时解析两条坐标第一个请求还在执行第二个请求就到了。如果执行方内部没有请求标识日志无从对齐超时重试也没法判断是哪个请求失败了。requestId 的价值主要体现在三个场景并发追踪同一批请求同时发出后可以根据 requestId 把日志、耗时、错误串起来。超时重试调用方发现 requestId 超时后发起重试不会把两次执行的结果搞混。幂等控制某些命令比如文件生成必须具备幂等性如果同一个 requestId 重复提交执行方可以选择直接返回第一次的结果避免重复执行副作用。所以契约里我都会强制要求调用方生成 requestId执行方则要保证“同一个 requestId 连续提交两次第二次执行应当是无副作用或幂等的”。4. 鸿蒙端标准化 CLI 入口管理的具体实现契约有了接下来就是落在代码里。我的实现思路是把 CLI 能力从业务代码里剥离出来单独做一个模块模块内部由 ArgParser、CommandRegistry、CommandRouter、CliExecutor 四个核心类协作对外只暴露一个execute(request)方法。下面我按目录结构、Dart 侧裁剪、ArkTS 实现、桥接选型四个维度来说。4.1 目录结构规划怎么把 CLI 能力拆成一个独立模块如果只是临时适配很多人会把代码直接塞在页面里或者塞在某个 Util 里。但我的建议是把它做成一个独立模块哪怕一开始只是一个子目录也要保证以后能升级成 HSP 或独立 HAR 都不用动骨架。这是我实际使用的目录结构ohos_module/ ├── ets/ │ ├── cli/ │ │ ├── ArgParser.ets │ │ ├── CommandRegistry.ets │ │ ├── CommandRouter.ets │ │ ├── CliExecutor.ets │ │ └── types/ │ │ ├── CommandDef.ets │ │ ├── CommandRequest.ets │ │ └── CommandResponse.ets │ └── service/ │ └── CliService.ets └── cpp/ └── cli_bridge.cpp // 可选的高频旁路core 目录下四个核心类各司其职ArgParser负责把所有字符串参数按照契约归一化为结构化对象。CommandRegistry维护命令名到执行器的注册表。CommandRouter负责接收请求、查表、分发、处理内置命令、管理超时。CliExecutor是 Dart 侧的桥也就是真正跑 Dart 逻辑的那一层。这样的好处是以后如果要把某个命令改成 FFI 实现我只需要在 CommandRegistry 的注册环节换一个执行器不需要改动上层任何一个调用点。4.2 Dart 侧核心逻辑的裁剪原则很多人以为鸿蒙化就是“把 Dart 代码跑在鸿蒙 Flutter 框架里”这其实是个大误区。Flutter 的鸿蒙适配主要跑的是 UI 和框架层一个包如果被用于 CLI 工具它的main入口和设备端 Flutter 运行时的生命周期并不匹配。我的建议是Dart 侧只保留纯逻辑不给它依赖任何特定宿主能力。所谓纯逻辑就是输入一堆数据、输出一堆数据的计算过程比如字符串解析、数值换算、文本处理、JSON 生成。这类代码可以脱离dart:io独立运行。我在 Dart 侧定义的命令接口是这样的abstract class CliCommand { String get name; FutureCliResult execute(CliContext context); } class CliContext { final MapString, dynamic args; const CliContext({required this.args}); } class CliResult { final int code; final String? stdout; final MapString, dynamic? data; const CliResult({required this.code, this.stdout, this.data}); }注意CliContext.args是已经归一化好的Map不是原始字符串数组。ArkTS 侧负责把字符串数组解析成 MapDart 侧拿到的就是一个可靠的结构体。这样带来的最大好处是测试太好写了你在开发机里直接构造一个CliContext传入合法或非法的参数 Map就能把核心逻辑完整验证一遍根本不用开鸿蒙真机。4.3 ArkTS 侧参数解析器 ArgParser 的实现思路ArkTS 是 TypeScript 的一个子集天然比 JavaScript 更严格不支持一些动态特性。所以写 ArgParser 的时候你的代码必须类型清晰、不用 any。下面是我实现的解析器核心片段处理的是--key value、--keyvalue、--flag、--no-flag四类形态export class ArgParser { public static parse(rawArgs: string[]): Mapstring, Object { let result new Mapstring, Object(); let i: number 0; while (i rawArgs.length) { let token: string rawArgs[i]; if (token.startsWith(--no-)) { let key: string ArgParser.toCamelCase(token.substring(5)); result.set(key, false); } else if (token.startsWith(--)) { let keyValue: string token.substring(2); let eqIndex: number keyValue.indexOf(); if (eqIndex 0) { let key: string ArgParser.toCamelCase(keyValue.substring(0, eqIndex)); let value: string keyValue.substring(eqIndex 1); result.set(key, value); } else { let key: string ArgParser.toCamelCase(keyValue); if (i 1 rawArgs.length !rawArgs[i 1].startsWith(--)) { result.set(key, rawArgs[i 1]); i; } else { result.set(key, true); } } } i; } return result; } private static toCamelCase(input: string): string { let parts: string[] input.split(-); let result: string parts[0]; for (let j: number 1; j parts.length; j) { result parts[j].charAt(0).toUpperCase() parts[j].slice(1); } return result; } }这个解析器虽然只有几十行但把常见的命令行参数形式都覆盖了。实测中我踩过一个小坑如果 value 本身以--开头比如传一个负数经纬度--no-分支会把它误判为布尔参数。后来我在分支里加了一个类型校验如果 token 以--开头且后续能安全转成数值就按形式解析。这里建议你根据自己的命令集合补充类型声明不要过度追求通用解析器。4.4 CommandRouter注册表、内置命令与路由分发CommandRouter 是整个入口管理模块的心脏。它维护一个从命令名到执行器的映射表同时内置了help和version两个系统命令让调用方随时可以自检。下面是一个精简版实现export class CommandRouter { private registry new Mapstring, CliExecutor(); public register(command: string, executor: CliExecutor): void { this.registry.set(command, executor); } public async execute(request: CommandRequest): PromiseCommandResponse { if (request.command help) { return this.handleHelp(request); } if (request.command version) { return this.handleVersion(request); } let executor: CliExecutor | undefined this.registry.get(request.command); if (!executor) { return this.errorResponse(request, 2, unknown command: request.command); } let def: CommandDef executor.def; let validationError: string | null this.validateParams(request.args, def); if (validationError) { return this.errorResponse(request, 2, validationError); } let timeoutMs: number def.timeoutMs 0 ? def.timeoutMs : 5000; let timer: number setTimeout(() { // 标记该请求超时 }, timeoutMs); try { let result: CommandResponse await executor.execute(request); return result; } finally { clearTimeout(timer); } } private validateParams(args: Mapstring, Object, def: CommandDef): string | null { for (let p of def.parameters) { if (p.required !args.has(p.name)) { return missing required parameter: p.name; } let value: Object | undefined args.get(p.name); if (value ! undefined p.enum p.enum.length 0) { let strValue: string value.toString(); if (!p.enum.includes(strValue)) { return invalid value for p.name : strValue; } } } return null; } }这里有三个我在实际设计中很看重的点校验先行参数错误必须在执行器执行之前被拦截避免无效请求消耗 Dart 侧的算力。超时统一管理每个请求进来都启动一个超时计时器执行超过 timeoutMs 就直接返回 code3。内置命令自带help和version不是业务命令但属于入口管理的基本能力必须内置而不是靠业务方自己注册。4.5 桥接层选型MethodChannel 与 EventChannel 怎么配合CLI 的核心逻辑跑在 Dart 侧鸿蒙侧是入口和分发层两者之间的桥怎么搭直接决定了性能上限和代码的清爽程度。我在项目里主要用了 Flutter 标准的 MethodChannel 和 EventChannel但对它们的使用边界做了严格划分通道类型适合场景我的用法MethodChannel短请求、结果即响应绝大多数命令参数序列化为 JSON一次性返回结果EventChannel长任务、流式输出大量日志导出、批量文件处理、进度通知FFI高频纯计算命令坐标换算、哈希校验等 CPU 密集场景一个比较重要的建议是能走 MethodChannel 就不要走 EventChannel。因为 EventChannel 在鸿蒙适配层的历史问题比较多调用方如果没及时取消订阅很容易出现资源泄漏。只有当你确实需要持续收到进度或者数据片段的时候才用 EventChannel 做流式返回。桥接命名上我也定了一个规则channel 名必须能反查命令。比如命令geo:parse对应 MethodChannel 是com.example.cli/geo_parse。为什么这么定因为鸿蒙端会有多个模块同时调用 CLI如果所有命令都挤在一个 channel 里日志里根本看不出来是哪条命令在跑。拆开命名之后抓包和排查都方便很多。5. 实测阶段最容易翻车的五个问题前面讲的都是设计层面的东西真到了真机验证阶段你会碰到一堆文档里根本不会写的坑。我把这批坑一个个列出来每一个都是我实际踩过之后才找到原因的。5.1 路径语义完全不同沙箱路径 vs 开发机路径第一次实测的时候我在 Dart 侧写了一句File(output.json).writeAsString(result)开发机上跑得好好的上了鸿蒙真机直接报权限错误。后来才意识到鸿蒙应用跑在沙箱里它的根目录、文件路径规则和开发机的文件系统根本不是一回事。三方库不能假设自己有操作任意路径的权限。我的解决方案是所有路径类参数都由调用方显式传入CLI 内部不做任何默认路径假设。比如导出结果的命令调用方必须传一个outputPath而这个路径必须是鸿蒙侧通过正规 API 拿到的沙箱路径。Dart 侧与路径相关的所有逻辑都只做拼接和写入不再自己猜路径。5.2 字符编码不一致快照测试比一次炸一次这个坑特别隐蔽。我在 Dart 侧给一条命令写快照测试输出一段中文文本在 macOS 上对比通过结果同一个测试在 Windows 开发机上跑就乱码。原因很简单Dart 侧stdout在 Windows 下默认输出 GBK而我在鸿蒙侧的 Echo 逻辑统一按 UTF-8 接收两边不一致快照自然比对不上。后来我直接在 Dart 侧收口所有命令的文本输出统一先经utf8.encode编码然后以字节或 base64 的形式放进 CommandResponse。这样无论开发机是什么操作系统最终到鸿蒙侧都是 UTF-8 流。文档里我也会把这条写死CLI 输出字符编码统一为 UTF-8不区分平台。5.3 MethodChannel 的串行阻塞与并发丢失这是个性能坑。我一开始把全部命令都放在同一个 MethodChannel 上结果真机上一口气发起 10 个并发请求时耗时几乎线性叠加。看堆栈才发现同一个 channel 上的 method call 在 Dart 侧是排队处理的前一个 handler 没返回后一个就一直等着。我的解法是两层第一把命令按业务域拆成多个 channel比如geo_*命令走一个export_*命令走另一个第二Dart 侧 handler 内部不再同步等待而是用 Future 配合多个 isolate 并行处理。这样切完以后同类命令还是串行但不同类命令之间不会互相阻塞。如果并发量再大就该考虑把高频计算命令搬到 FFI 层了。5.4 鸿蒙 Ability 生命周期回收导致长任务中断这个坑比前几个都严重。我在一次批量坐标转换任务里遇到的问题是任务跑到一半执行方模块所属的 Ability 被系统回收了正在运行的 Dart isolate 直接被销毁CommandResponse 永远没有返回。调用方还傻傻地等在那边直到超时才算结束。遇到这种场景我的建议是把“长任务”拆成“可重入的检查点任务”。也就是说不要在一条命令里跑一个几十秒的循环而是让命令支持分批执行比如第一批处理前 1000 条记录返回hasMoretrue调用方拿着进度参数再发第二批。这样即使中途被杀掉重试也只需要从检查点继续。契约里可以加一个progress字段来记录这个检查点。5.5 包体积和 VM 依赖不该带的东西坚决不带最后一个是工程层面的问题。有人为了在鸿蒙端跑 Dart 逻辑尝试把整个 Dart SDK 或者 Flutter 引擎以动态库形式带进应用结果包体积暴涨启动还慢。说实话这对一个三方库来说完全是反模式。正确姿势是把 Dart 侧的核心逻辑做成纯 Dart 包只依赖dart:core和dart:convert这种基础库能不用dart:io就尽量不用。如果确实需要 IO也不要直接用平台文件 API而是让调用方把文件内容读成字节传进来。这样既保证了逻辑的可测试性也避免了给主应用塞进去一个沉重的运行时。6. 把执行契约变成一份可共享的工程资产做了这么多适配之后我最大的体会是真正解决 executable 鸿蒙化问题的不是某一段漂亮的代码而是那份被所有人遵守的“执行契约”。它一旦定义清楚就不再只是鸿蒙端的内部约定而是可以变成整个项目的共享资产。6.1 一张 JSON 规格表驱动所有端侧实现我的建议是把 CommandDef 集合统一导出成一个commands.json文件放在仓库根目录。任何端侧实现都要以这个文件作为唯一事实来源。如果你是靠人肉同步 Dart 侧、ArkTS 侧、文档三处的命令定义迟早会有一处过期。用一份 JSON 驱动至少可以保证Dart 侧可以写一个自动生成器从commands.json生成抽象基类或默认实现模板。ArkTS 侧可以用它做 CommandRegistry 的自动注册。文档可以直接从这个文件生成命令行帮助手册。测试用例可以根据parameters里的required、enum、default字段自动构造非法参数用例。我实际做下来这套流程对适配效率的提升非常明显。以前最痛苦的“改一下参数全端跟着动”的问题现在基本只需要改 JSON 和对应的 Dart 逻辑其他端重新生成就行。6.2 最后分享一个小技巧先用模拟器把契约测稳再上真机很多团队一上来就直接上真机联调结果日志维度不够出了问题只能盲猜。我的习惯是先在开发机的模拟器环境里把契约完全调稳——包括参数校验、错误码、超时行为——再搬到鸿蒙真机。因为在开发机模拟器上你可以直接用 dart 测试框架跑 CommandRouter 同款逻辑错误路径一目了然真机上去之后反而要面对各种沙箱和生命周期问题排查环境更差。如果可能再进一步写一个“宿主对齐测试”在开发机上用 dart 运行一组标准的命令用例然后在鸿蒙端通过 CliService 跑同一组命令把两边的 CommandResponse 逐字段对比。这个对比脚本值得做因为你一旦把契约定下来它就是你所有端侧的“验收标准”以后改逻辑、升版本都靠它兜底。
返回列表