在 Flutter 往鸿蒙迁移的过程中,平台通道(Platform Channel)一直是个绕不开的环节。pigeon 这个官方代码生成工具帮我解决了 Dart 与原生端接口协议不一致的问题,但到了鸿蒙这边,因为目标语言换成了 ArkTS、底层互操作机制也变了,原来开箱即用的生成器直接失效。我在做三方库鸿蒙化适配的时候,花了不少精力把 pigeon_generator 的内部实现完整过了一遍,最后落地了一版能在鸿蒙侧自动生成桥接代码的小引擎。这篇文章就把我的改造路径、关键决策和踩坑记录完整分享出来,重点聚焦“为什么这么改”和“实际怎么做”,给正在做 Flutter 鸿蒙适配的开发者一条可复制的路线。
1. 为什么要给 pigeon_generator 做鸿蒙化适配
1.1 鸿蒙上 Flutter 桥接代码的痛点
先聊聊背景。Flutter 在鸿蒙上跑起来以后,Dart 层和鸿蒙原生层之间的通信靠的是平台通道,这套机制本身是 Flutter 跨平台能力的核心,Android、iOS 甚至 Web 都有各自的实现。鸿蒙的 Flutter 适配层虽然把通道协议带了过来,但问题出在“通道两端怎么写”这件事上。
在 Android 上,你可以用 Java/Kotlin 直接在 MethodChannel 里处理字符串、Map 和基本类型;在 iOS 上用 Objective-C/Swift 处理也有一套成熟写法。但鸿蒙的应用层开发语言是 ArkTS,底层又涉及 N-API 和 JS 引擎的互操作,Flutter 端发过来的消息需要经过一层转换才能变成 ArkTS 能识别的对象结构。如果没有一个统一的代码生成工具,你只能手写这些桥接逻辑,接口一旦多起来,维护成本直线上升。
另一个痛点在于多端一致性。同一个功能在不同端上通道名必须完全一致,方法签名必须严格对应,参数顺序不能错,否则就是最典型的“编译全通过、运行就跑不通”的诡异问题。手写代码很难保证多个端之间的协议一致性,试过的人应该都懂那种排查到深夜最后发现只是参数名拼写不一致的绝望感。
1.2 pigeon_generator 在 Flutter 生态里的定位
pigeon 这个工具,在 Flutter 官方仓库里不是什么新鲜东西了。它的核心思路很简单:你在 Dart 文件里用注解声明接口和数据结构,pigeon 帮你解析这些声明,然后自动生成 Dart 侧调用代码和 Android、iOS、Web、macOS 等平台的承接代码。
有了 pigeon,你不再需要手写通道常量、消息编解码和类型转换,接口变更的时候重新跑一次生成命令就能把所有端同步更新。它本质上是一个“桥接代码的 DSL 编译器”,目标文件就是通道两端的绑定代码。
pigeon_generator 这个词在社区里有时被用来泛指基于 pigeon 的生成器工程,因为它本身是基于 Dart 的 source_gen 框架开发的,也具备被二次扩展的条件——你可以往里面加新的生成后端,或者改模板输出新的语言。这就是鸿蒙化适配的切入点:既然 pigeon 已经有 Android 后端、iOS 后端,那理论上也可以加一个鸿蒙后端。
1.3 适配鸿蒙的核心价值
鸿蒙化的核心价值在于两个字:自动。一次定义 API 声明,同时产出 Dart 层、ArkTS 层以及底层互操作胶水代码,通道协议、方法路由、类型映射这些重复劳动全部交给机器完成。对应到工程实践上,至少能带来三个直接好处:
第一,减少跨端协议不一致的概率。手工维护三份代码,几乎必然出现某一个端漏改方法签名的情况;生成器保证所有端都从同一份声明文件推导,协议一致性是结构性的。
第二,降低鸿蒙适配的学习门槛。团队里的 Flutter 开发者不必每个人都精通 ArkTS 的 N-API 互操作细节,他们只需要维护 Dart 侧 API 声明即可。
第三,提升迁移效率。一个拥有几十个平台方法的三方库,如果每个方法都要手写鸿蒙侧实现,工作量是线性增长的;用生成器的话,这个增长曲线会被显著压平。
2. pigeon_generator 的代码生成机制详解
2.1 从 API 定义文件到代码产物的完整链路
要改造一个生成器,你先得理解它内部是怎么工作的。pigeon_generator 的整体链路可以拆成三块:输入解析、中间表示、代码输出。
输入解析阶段,pigeon 读取带注解的 Dart 文件,用 Dart Analyzer 的解析器把文件转成语法树,然后从语法树里挑出标有@pigeon注解的类和方法。类会被当作消息模型处理,它的字段会被提取出来作为消息数据的结构定义;方法则会被当作平台 API 处理,方法名变成通道路由名,参数类型和返回类型被记录下来。
中间表示阶段,这些解析结果会被转换成一套与语言无关的模型对象,比如Api、Method、Field、TypeDeclaration。这个设计非常关键,它意味着后端的输出逻辑可以完全基于中间表示来做,而不需要关心原始 Dart 语法树的细节。
代码输出阶段,pigeon 根据目标平台选择对应的模板或者代码生成器,把中间表示渲染成目标语言的代码。Android 后端生成 Kotlin 或 Java,iOS 后端生成 Swift 或 Objective-C,Web 后端生成 TypeScript。每种后端做的事情类似:生成消息编解码器、生成方法路由分发器、生成高层 API 接口。
提示:如果你想扩展 pigeon,中间表示层是核心资产。不要试图从语法树直接生成目标代码,那会绕开很多已经被消化掉的细节。
2.2 平台通道与事件通道的生成逻辑
pigeon 生成代码的时候会区分两类通道:基础消息通道(BasicMessageChannel)和方法通道(MethodChannel)。
方法通道用于“请求-响应”型的单向调用,生成器会在 Dart 侧输出一个代理类,这个类内部通过MethodChannel发送方法名和参数,然后在binaryMessenger.send的回调里解析返回值。原生侧则生成一个抽象接口,由开发者具体实现,框架层负责接住通道消息并分发到对应的方法实现上。
事件通道(EventChannel)用于流式数据传递,比如传感器数据和播放进度。pigeon 对事件通道的支持相对晚一些,它通过生成EventChannelReceiver和EventChannelSender这样的辅助类来封装事件流的生命周期。鸿蒙化时这一块的适配会比较麻烦,因为事件通道的底层要求通道名的注册和注销时机完全对齐,鸿蒙侧对事件流的处理又跟 Android 的StreamHandler不尽相同。
2.3 鸿蒙化需要动哪些环节
理解了整个链路以后,鸿蒙化要动哪些环节就清楚了:
第一,后端注册。pigeon 的生成命令里有一个--input和后端选项,要让它能生成鸿蒙代码,需要新增一个鸿蒙后端的注册入口。
第二,类型映射。Dart 的基本类型映射到 ArkTS 的时候,有些可以直接对应,有些需要做包装。比如 Dart 的int对应 ArkTS 的number,但 Dart 的Map<Object?, Object?>在 ArkTS 里怎么表达就需要谨慎处理。
第三,通道协议。鸿蒙侧接收 Flutter 消息的底层机制跟 Android 不一样,pigeon 生成原生代码时调用的BinaryMessenger接口在鸿蒙上可能改名了,或者实现方式不同,这些需要在后端模板里做适配。
第四,异步模型。鸿蒙侧方法如果是异步实现,返回值的传递方式跟 Java/Kotlin 的Result回调模型不同。pigeon 为 Android 生成的方法接口通常用Result回调来返回结果,鸿蒙侧用什么机制对应,需要在模板层面设计好。
3. 鸿蒙化改造全流程
3.1 搭建适配环境
动手之前先准备环境。pigeon_generator 是一个 Dart 工程,你需要一个可用的 Dart SDK,建议 3.0 以上版本,同时把 pigeon 的源码克隆到本地改造。我当时的做法是用一个独立的 fork 仓库维护鸿蒙后端,避免污染官方主线。
接着要确认鸿蒙侧 Flutter 适配层的依赖关系。鸿蒙的 Flutter 引擎有自己的一套 Platform Channel 实现,适配开发时需要引入相应的插件 SDK,比如@ohos/flutter_ohos这类包。这个包的 API 会直接出现在生成的鸿蒙代码的 import 语句里,所以它的版本和导出符号决定了生成模板怎么写。
还有一件重要的事:准备一个最小的鸿蒙 Flutter 应用工程,用于跑通端到端的通道通信。这个工程不需要复杂逻辑,只要能创建 Flutter 页面并加载 Dart 层代码即可,后续所有生成结果的验证都在它上面做。
3.2 改造配置解析模块
pigeon 的处理入口是一个generate函数,它的参数里包含配置、文件读取后的解析结果和输出目录。鸿蒙后端的开关,我选择通过注解里的@HostApi(dartHostTestHandler: ...)这类字段的扩展来触发。
具体做法是,在 pigeon 的配置解析逻辑里增加一段识别逻辑:当检测到输入文件中包含@HarmonyApi()注解时,自动启用鸿蒙后端;没有这个注解则走原有逻辑,保证对既有项目的零侵入。
配置解析模块还需要处理一个问题:输出的文件命名和目录结构。Android 后端默认输出到.dart_tool/pigeon之类的临时目录,鸿蒙后端我改成输出到harmonyOs/entry/src/main/ets/pigeon,这样生成的 ArkTS 文件会自动落入 DevEco Studio 工程的源码目录里。
3.3 新增鸿蒙后端模板
鸿蒙后端是这次改造的核心。我的做法是在 pigeon 的generator目录下增加一个harmonyos.dart文件,实现同一个抽象接口,跟已有的 Android、iOS、Web 后端保持平行关系。
这个后端要做两件事:生成 ArkTS 侧的模型类定义,以及生成 ArkTS 侧的通道处理代码。
模型类这块,Dart 类里的每个字段都要对应到 ArkTS 的类属性。比如:
// Dart 侧声明 class User { final String name; final int age; const User({required this.name, required this.age}); }鸿蒙侧生成出来类似:
export class User { name: string; age: number; constructor(name: string, age: number) { this.name = name; this.age = age; } }通道处理这部分是技术难点。鸿蒙侧接收 Flutter 消息时,方式通常是把一层MessageListener注册到某个通道上,收到的是原始字节流。你需要把字节流解析成 pigeon 的编解码格式——这里最稳妥的做法是复用 pigeon 的编解码规则:用标准二进制编码顺序依次读出类型标记、字段长度和字段值。
注意:不要在这里偷懒改成 JSON 编解码,除非你同时改 Dart 侧的编解码器。协议一致性是整个生成器改造的生命线。
3.4 处理 ArkTS 的类型映射
类型映射是生成器里最容易翻车的地方。Dart 的类型系统跟 ArkTS 的类型系统差异比想象中要大,尤其在可空类型、集合类型和泛型上。
先看基本类型映射表:
| Dart 类型 | ArkTS 类型 | 说明 |
|---|---|---|
int | number | ArkTS 没有独立的 64 位整数类型,精度超过 53 位时建议用字符串传递 |
double | number | 一一对应 |
bool | boolean | 一一对应 |
String | string | 一一对应 |
Uint8List | ArrayBuffer | 二进制数据在鸿蒙上用 ArrayBuffer 表达 |
List<T> | Array<T> | 多数场景可以直接映射 |
Map<K, V> | Map<K, V> | 注意 ArkTS 的 Map 是严格类型化的 |
Object? | Object | undefined | 可空类型必须显式标注,否则编译期报错 |
这个表格背后有一个隐藏问题:pigeon 对Object?的序列化是运行时判断的,ArkTS 的Object类型虽然能接收任意值,但在编译期做类型收窄时非常别扭。如果你的 API 设计里大量使用Map<Object?, Object?>这种宽松结构,鸿蒙侧代码会有很多强制类型转换,生成代码的可读性会下降。
我的建议是,在 API 声明阶段就尽量避免过宽的类型,能用强类型模型解决的问题不要依赖运行时类型判断。这不光是鸿蒙适配的问题,对 Flutter 侧的质量也有好处。
3.5 生成 N-API 互操作代码
鸿蒙侧的 Flutter 适配层底层是 N-API,但你在 ArkTS 层面通常不需要直接写 N-API 调用——适配层已经把这些封装成了类。不过有一类场景需要手动处理,就是当中介代码需要访问 Flutter 引擎提供的原生资源时。
举个例子,如果你的方法需要把一张图片的内存数据传给鸿蒙侧进行图像处理,ArkTS 侧接收到的ArrayBuffer需要转成PixelMap或者写入一个ImageSource,这时光靠生成代码就不够了,需要在模板里留出“用户自定义实现”的接口,让开发者填入具体逻辑。
我的模板设计方案是:生成器为每个方法生成一个默认实现类(名字叫XxxBase),类里的方法默认抛出“未实现”异常,开发者继承这个类并重写需要实现的方法即可。这样既保证了生成的代码完整可编译,又把实现空间留给了开发者。
4. 在实际项目里跑通适配版生成器
4.1 定义一份跨端 API 文件
拿一个具体例子来说明整个流程。假设我要做一个简单的“文件摘要计算”能力,Flutter 侧传一个文件路径和盐值,鸿蒙侧计算 HMAC 摘要后返回十六进制字符串。定义文件长这样:
import 'package:pigeon/pigeon.dart'; class DigestRequest { final String filePath; final String salt; const DigestRequest({ required this.filePath, required this.salt, }); } @HostApi() abstract class FileDigestApi { String computeDigest(DigestRequest request); }这里DigestRequest是消息模型,FileDigestApi是接口声明。@HostApi()表示这个接口由宿主平台(鸿蒙)实现,Flutter 侧调用。
4.2 生成 Dart 侧代码
运行适配版生成命令:
dart run pigeon --input lib/digest.dart --dart_out lib/digest.g.dart --harmony_out harmonyOs/entry/src/main/ets/pigeon/digest.g.ets这里多了一个--harmony_out参数,对应新增的鸿蒙后端。命令执行完以后,digest.g.dart里面会生成一个FileDigestApi的代理类,Flutter 侧调用computeDigest时实际上是往通道里发了一条消息,消息内容包含方法名computeDigest和一个编码后的DigestRequest。
Dart 侧的生成代码不关心对端是 Android 还是鸿蒙,它的协议是统一的。反过来,这也意味着鸿蒙侧只要按同样的协议接收和解析,两个端就能互相对上话。
4.3 生成鸿蒙侧代码
鸿蒙侧生成的文件里包含两大部分:消息编解码器和接口实现骨架。编解码器部分负责把 Flutter 传来的二进制消息解析成DigestRequest对象;接口骨架部分长这样:
export class FileDigestApiImpl { computeDigest(request: DigestRequest): string { // 在这里实现你的业务逻辑 throw new Error("Not implemented"); } }作为开发者,你要做的就是把throw new Error("Not implemented")替换成真实的实现逻辑。我之前在 DevEco Studio 里把生成的文件直接拖进工程,然后在一个EntryAbility里注册通道:
import { FileDigestApiImpl } from '../pigeon/digest.g.ets'; registerPigeonHandler(new FileDigestApiImpl());注册逻辑也被生成器封装好了,你只需要在模块初始化时调用一次即可。
4.4 集成到 DevEco Studio 工程
生成出来的.ets文件怎么进到鸿蒙工程里?我建议把--harmony_out的路径直接指向 DevEco Studio 的源码目录,比如entry/src/main/ets/pigeon/。这样每次重新生成的时候,文件自动覆盖,不需要手动拖拽。
还有一点值得注意:生成文件的命名如果跟现有文件冲突会很麻烦。我的做法是在生成模板里给每个输出文件加.g.ets后缀,跟手写的.ets文件区分开,避免误改。这算是一个经验之谈——如果生成文件和手写文件放在同一目录且命名规则不清晰,很容易出现“改了生成文件,一重新生成就被覆盖”的憋屈情况。
另一个集成细节是模块导出。生成的 ArkTS 文件里如果引用了其他模块,需要在module.json5里检查是否有对应的权限和依赖声明。比如你用到了文件读写能力,就必须在module.json5里配置对应的权限项,否则运行时会直接报权限错误。
5. 常见问题与排查技巧实录
5.1 类型映射不对导致编译报错
这是我改造过程中遇到最多的问题。Dart 侧定义的一个int字段,生成到 ArkTS 侧默认映射成number。但如果这个字段可能超过Number.MAX_SAFE_INTEGER,比如时间戳和文件大小,ArkTS 侧就会出现精度丢失。
你可能觉得编译不会报错,确实不会。但运行时就出现了:Dart 侧传给鸿蒙的readBytes返回的大小是 562949953421312,到了 ArkTS 侧变成 562949953421312 还是 562949953422000?精度丢失是静默的,排查成本极高。
我的解决方案是在模板里增加一个配置选项,允许你在声明字段时标记@Int64()注解。生成器检测到这个注解后,Dart 侧用字符串传递这个字段,ArkTS 侧收到字符串后再在实现代码里自行解析成BigInt或number。这个方法牺牲了一点传输效率,但换来了确定性的精度保障。
5.2 异步接口在鸿蒙侧出现数据竞争
另一个高频问题是异步接口的实现。Flutter 侧的异步调用会立即返回一个Future,鸿蒙侧实现方法时如果做耗时操作(比如读文件),你不能直接阻塞当前线程。pigeon 的 Android 后端用Result回调来通知结果返回时机,鸿蒙后端也需要等价的机制。
我的做法是在生成模板里为异步方法生成一个回调对象:
export class FileDigestApiImpl { computeDigest(request: DigestRequest, callback: (result: string) => void): void { // 异步实现 someAsyncOperation().then((result) => { callback(result); }); } }这个模式跟鸿蒙侧常见的异步习惯比较一致。如果你生成的代码里没有这个回调参数,说明你的方法声明里可能用了同步返回,但实际实现却走了异步逻辑——这是最容易产生悬空 Future 和回调丢失的场景。
5.3 生成代码在低版本鸿蒙上运行异常
最后聊一个兼容性方面的坑。鸿蒙系统版本跨度比较大,有些低版本机型上的 Flutter 适配层实现不够完整,特别是事件通道相关的 API。我测试的时候发现,同样一份生成代码在旗舰机上跑得很顺,在低端机型上却偶发通道注册失败。
排查思路是先在鸿蒙侧加日志,确认通道注册的时序是否跟 Flutter 侧启动时序冲突。后来定位到问题出在应用从后台恢复到前台时,通道监听器没有重新注册。解决方式是在模板里为事件通道类增加一个onResume方法,业务方生命周期回调里调用它完成重新注册。
这个问题也提醒我,生成器这层虽然只管出代码,但生成的代码应该考虑到常见的生命周期场景。否则你以为生成器把活全干了,实际上生命周期细节还是得手写补。
5.4 排查工具和调试建议
说几个调试时直接能用的工具。鸿蒙侧日志用hilog,查看 Flutter 侧和原生侧的通道消息时,可以在模板里预留一个调试开关,打开后会在通道收发两端各打一条包含通道名、方法名和数据长度的日志。这个开关我用的是环境变量控制,因为日志全开会有一点性能损耗。
实测下来,hilog配合 Flutter 侧的debugPrint输出基本能覆盖 90% 的通道类问题。剩下的 10% 是那种“数据没问题但就是回传不到 Flutter 侧”的问题,这往往是鸿蒙侧回调模型跟 Dart 侧Future完成时机不匹配导致的,需要你对照生成代码里两端的回调次数来判断。
6. 我踩过的坑和最后想说的
这次改造让我印象最深的一个坑是:我一开始以为鸿蒙侧只要保证“生成的代码能编译通过”就算成功,结果实际集成时发现,ArkTS 的模块系统对循环依赖相当敏感,生成的模型文件和通道文件如果互相引用,很容易在运行时报“循环依赖”错误。后来我在模板里强制解耦,让模型文件不依赖任何通道代码,通道文件只引用模型文件,这个报错才彻底消失。
如果你打算在团队里推广这套方案,我还有一个建议:把生成器的接入做成一个脚本命令,嵌入到 Flutter 工程的pubspec.yaml的相关配置或者一个自建的 CLI 工具里。团队里每个人都在本地装一套改造后的 pigeon 不现实,大家直接用统一的生成脚本,版本和参数都被固定住,这才是真正把工具红利落到开发流程里的方式。
最后分享一个小细节,是我在多次打包联调里总结的:鸿蒙侧生成的 ArkTS 代码里,import用的包名一定要跟工程里的oh-package.json5对齐。这个文件是鸿蒙工程的依赖配置,如果你看到“模块未找到”但明明 DevEco Studio 没有报错,八成是这里路径没对上。把这层关系理顺以后,整个生成器链路就算是真正跑通了。