☰
pigeon_generator 鸿蒙适配实战:Flutter 插件桥接层设计与迁移指南
2026/10/3 18:06:51 网站建设 项目流程

从把 Flutter 插件往鸿蒙上迁移的那一周开始,我几乎每天都在跟桥接代码较劲。真正让我停下来重新想了三天的,就是 pigeon_generator——准确说,是“pigeon_generator 生成的桥接代码,到底能不能在鸿蒙上用”这件事。如果你也在做 Flutter 的鸿蒙化适配,或者你正打算把一套已经写完的插件业务搬到鸿蒙侧,这篇文章应该能帮你省下不少排查时间。

我先说结论:Pigeon 本身不直接支持鸿蒙,它的价值在别处——它给出了一份非常标准的“API 定义 -> 消息编解码 -> 方法分发”的生成模型。我们要做的,不是重新发明轮子,而是把它的生成端扩展出一个“鸿蒙输出器”,同时把鸿蒙侧的桥接运行时接进 Flutter Engine 的 Channel 链路。底层通信机制、消息协议格式、异步回调约定,这三个东西弄透了,其他都是模板和脚本的事。

这篇文章不堆概念,直接按我实际操作时的顺序来:先讲清楚为什么非得动 pigeon_generator,再拆它的生成机制,然后给鸿蒙侧桥接层的完整设计思路,附上可以抄的接入步骤、踩坑排查链路和性能方面的工程化建议。

1. 为什么要在鸿蒙化里动 pigeon_generator:从一次插件迁移说起

1.1 Flutter 与原生之间的通信困局

做过 Flutter 插件的人都知道,Flutter 侧和原生侧通信,底子上是 Channel 机制。Flutter 通过一个二进制消息通道把 Dart 对象编码后传给原生端,原生端解码、调用原生 API、再把结果编码回传。这套机制本身不难,难的是“每一对参数、每种返回类型”都要手动写编解码代码。

我最早维护的一个插件里有 37 个方法,每个方法 2 到 5 个参数,参数里又套着 Map、数组、二进制数据。手写桥接的结果就是:Dart 侧一个文件,Android 侧一个文件,iOS 侧一个文件,三个文件里全是call.method == "xxx"的分支,再加一堆类型强转。改一个字段名,三端都得同步改,漏一处就是运行期崩溃,还极难定位。

这就是 pigeon_generator 这类桥接代码生成引擎存在的意义:你只需要维护一份 API 定义文件,它自动帮你生成三端的通信样板代码。Dart 侧是抽象接口,原生端是 handler 骨架,消息编解码过程被包进一个叫PigeonCodec的类里,你的业务代码根本感知不到 Channel 的存在。

1.2 Pigeon 的定位:让跨端调用看起来像一个本地调用

Pigeon 最早是 Flutter 官方为了精简插件样板代码搞出来的工具。它把“定义接口”、“序列化参数”、“反序列化结果”这三件事全部自动化。使用体验很像写一个普通 Dart 抽象类:

@HostApi() abstract class DeviceInfoApi { String getPlatformName(); Future<DeviceInfo> getDeviceInfo(); }

然后运行生成命令,Pigeon 会生成 Dart 端、Android 端、iOS 端的代码。Dart 端你直接调用api.getDeviceInfo(),它内部会编码成二进制消息发送到原生侧;原生侧收到后解码、执行真正的业务逻辑、再编码返回。

从开发者的视角看,这就像“本地函数调用”,实际上底层走的还是 BasicMessageChannel。Pigeon 做的最聪明的一件事,是把“消息格式”固化成了一套稳定的约定,不同端只要遵循同一套格式,就能互相通信。

1.3 鸿蒙化为什么不能照搬 Android 方案

很多人第一次做鸿蒙适配时会想:既然 Pigeon 能生成 Android 代码,那鸿蒙侧无非是再写一遍差不多的逻辑。这个想法对了一半。鸿蒙端的 Flutter 运行环境确实会提供 Channel 的接入能力,但有几个问题绕不开:

  • 官方 Pigeon 没有直接的鸿蒙输出器,你拿不到一套开箱即用的 ArkTS 模板,只能自己构建扩展。
  • 鸿蒙侧的语言是 ArkTS,它的类型系统和 Kotlin/Swift 有差异,尤其是Uint8List、嵌套 Map、泛型对象的映射不能完全照搬。
  • 鸿蒙侧的 Flutter Engine 适配层可能并不完整暴露 Android 平台的MethodChannel/BasicMessageChannel全部 API,很多能力要从引擎层自行封装。

所以鸿蒙化适配的核心工作,本质上就是两件事:一是给 pigeon_generator 加一个“鸿蒙端代码输出器”,把单份 API 定义渲染成 ArkTS;二是在鸿蒙侧实现一套生产者/消费者模式的桥接运行时,处理消息解码、方法分发、异步回调。理解了这两点,后面的适配流程就顺了。

2. pigeon_generator 的生成机制拆解:它到底帮我们写了哪些代码

2.1 一份 API 定义文件,如何扩散成三端代码

要自定义 Pigeon 的鸿蒙输出,先得搞清楚它内部的工作流。Pigeon 读取你写好的.dart文件后,会做一次轻量级的解析:识别@HostApi、@FlutterApi、@RegisterApi注解,提取抽象方法、参数类型、泛型、异步返回值、error 类型等信息,然后组织成一个中间结构。

关键点在于:这个中间结构不是只给“Dart 输出器”用的,它是一套独立的元数据模型。Android 输出器、iOS 输出器都是从这个模型去渲染不同的模板。所以鸿蒙化最优雅的做法,不是去魔改 Pigeon 的源码逻辑,而是在它的输出器列表里新增一个ArkTSOutputer,复用同一份元数据,渲染出符合鸿蒙侧调用习惯的代码。

我当时的做法是 fork 了一份 pigeon_generator,增加了一个--arkts_out参数,把中间模型里每个 API 定义、每个自定义数据类型都映射成 ArkTS 文件。如果你的 Pigeon 版本暂时不支持自定义输出器,退而求其次的办法是:先用官方命令生成 Dart 端代码,再写一个基于analyzer包的脚本,解析同一个接口文件生成 ArkTS 桥接层。两种方案各有利弊,前者省心,后者对某些特殊模板的控制力更强。

2.2 消息通道与编解码格式:桥接层的心脏

Pigeon 底层不直接使用MethodChannel,而是基于BasicMessageChannel配合一套自定义 codec。这套 codec 的消息格式继承了 Flutter 标准消息编解码器的设计:一个类型标签加数据载荷。高频类型一般包括空、布尔、整数、浮点数、字符串、字节数组、整数数组、浮点数组、嵌套列表、嵌套映射等。

鸿蒙侧适配时,最不能偷懒的部分就是实现这套 codec 对应的读写器。你可以用 ArkTS 的DataView、Uint8Array做二进制读写,但必须保证每个类型标签的字节序、长度前缀、嵌套方式与 Dart 端完全一致。这里没有捷径,要么自己对照标准格式实现,要么把鸿蒙引擎适配层已有的 codec 抠出来复用。

一个我踩过的坑:Dart 的int在不同长度下会编码成不同标签,而 ArkTS 侧如果一律当BigInt读,小整数场景虽然不出错,但性能明显下降;反之,如果一律当number读,超过 2^53 的大整数就会丢精度。所以类型标签判断必须原样保留,不能做默认值推断。

2.3 适配鸿蒙真正要改的是哪一层

很多人以为把 Pigeon 生成的代码“翻译成 ArkTS”就算适配完了。实际上生成代码只占了桥接工作量的 30%。真正的复杂度在运行时:

  • 生成出来的 ArkTS 桥接对象,怎么跟 Flutter Engine 的 Channel 实例挂钩?
  • 收到消息后,怎么反序列化、分发到对应的原生业务对象?
  • 原生业务异步执行时,怎么把结果异步回传,并且保证回传线程正确?
  • 多个插件模块同时存在时,怎么避免方法名冲突和通道互相抢占?

这些问题,生成器只能帮你搭骨架,承重墙还得自己砌。我的建议是:把鸿蒙侧的桥接运行时单独抽成一个公共库,和生成代码分开维护。生成代码只负责“把某一个 API 接口的调用转换成 Channel 消息”,公共库负责“Channel 消息的解码、分发、回调线程、路由注册”。这样后续每个新插件接入,只跑一次生成器就够了。

3. 鸿蒙侧桥接层设计:从 Channel 到 ArkTS 的调用链

3.1 BasicMessageChannel 在鸿蒙侧的等价实现

鸿蒙侧需要一个能接收来自 Flutter Engine 二进制消息的对象。如果 Flutter 的鸿蒙适配分支已经暴露了BasicMessageChannel的 ArkTS 接口,那直接用就好;如果没暴露,就得在引擎接入层封装一个订阅函数,把底层回调包装成语义等价的对象。

一个最小化的通道封装长这样:

export class PigeonBridgeChannel { private codec: StandardMessageCodec; private messageHandler: ((buffer: ArrayBuffer) => Promise<ArrayBuffer>) | null = null; constructor(private readonly channelName: string) { // 在鸿蒙 Flutter 引擎层注册订阅 this.registerChannel(channelName); } setMessageHandler(handler: (buffer: ArrayBuffer) => Promise<ArrayBuffer>) { this.messageHandler = handler; } }

这里的关键不是类的写法,而是生命周期。Channel 的注册时机必须晚于 Flutter Engine 初始化,早于业务侧第一次调用。我建议把PigeonBridgeChannel的初始化放在鸿蒙页面 onPageShow 里统一管理,而不是像 Android 端那样随 MainActivity 启动。

3.2 注册与分发:把解码后的 payload 映射到 API 实例

收到 Channel 消息后,桥接层首先用 codec 解码。Pigeon 的请求格式通常包含一个方法名、一个参数列表、一个时间段标识,这些字段会按照约定顺序编码。解码完成后进入分发阶段:

async function dispatch(channelName: string, buffer: ArrayBuffer): Promise<ArrayBuffer> { const decoded = PigeonCodec.decodeMessage(buffer); const registry = BridgeRegistry.instance; const handler = registry.lookup(channelName, decoded.method); if (handler == null) { return PigeonCodec.encodeError(new Error(`NotImplemented: ${decoded.method}`)); } try { const result = await handler.invoke(decoded.args); return PigeonCodec.encodeSuccess(result); } catch (err) { return PigeonCodec.encodeError(err); } }

BridgeRegistry是注册中心的角色,它维护一张“channelName + method -> handler”的路由表。生成代码侧只负责把某个 API 实现类注册进来,屏蔽掉底下路由表的细节。这个注册表必须支持按插件名分组,避免不同的插件生成了同名 method 导致覆盖。

3.3 异步结果返回与错误编码

鸿蒙侧的业务方法很可能是异步的,底层是 Promise 或者回调式 API。桥接层需要把异步结果转换成 Channel 的 reply 形式。这里有个容易犯的错:把 Promise 直接当返回值塞给 Channel,结果 Flutter 侧收到一个null。

正确做法是在setMessageHandler回调里await异步结果,再把成功值或错误信息编码回传。同时要注意线程切换:如果原生侧打开的是子线程,回调回来时不能直接操作 ArkTS 的 UI 组件,需要切回 UI 线程。桥接层可以选择统一在 UI 线程回传,Dart 侧再自行处理后续逻辑。

错误编码也要讲究。Pigeon 的约定是错误分为错误代码和错误信息,Dart 侧最终会封装成特定的异常类型。如果鸿蒙侧只随便抛一个字符串,Flutter 侧很可能收不到正确的异常结构,导致调用看起来像“正常返回了 null”。

4. 实操:把 pigeon_generator 加到鸿蒙化工程里

4.1 初始化桥接工程与 pigeon 依赖

第一步还是常规工程改造。在 Flutter 工程的pubspec.yaml里引入 pigeon 依赖。我这里不写死版本号,因为你拉取时以当前稳定版为准。关键是依赖引入后,别急着写接口文件,先跑一次命令确认生成器可用,避免后边花时间排查环境问题。

dev_dependencies: pigeon: any

鸿蒙工程侧需要建一个专门存放桥接代码的目录,我一般用entry/src/main/ets/bridge/,下面再按插件或业务域分目录。这个目录结构会直接影响生成脚本的路径配置,后边维护多个插件时能省不少事。

4.2 定义 API 并调整生成策略

接着定义一个接口文件。这里强烈建议一个业务域一个文件,不要把所有接口塞到一个messages.dart里。Pigeon 生成代码时会扫描整个文件,文件太大、类太多,生成效率会下降,还会让某个模块的改动触发其他模块的重新生成。

@HostApi() abstract class StorageApi { Future<String> write({required String key, required Uint8List data}); Future<Uint8List?> read(String key); Future<bool> delete(String key); }

生成命令我维护成一个 shell 脚本,这样 CI 里也好复用:

dart run pigeon \ --input lib/bridge/storage_api.dart \ --dart_out lib/generated/storage_api.pigeon.dart \ --arkts_out entry/src/main/ets/bridge/storage_api.pigeon.ets

--arkts_out这个参数如果当前的 Pigeon 版本没有,就用我上边说的自定义输出器方案。核心目标是一致的:把一份接口定义同时渲染成 Dart 和 ArkTS,而不是手写两份然后靠人肉保持同步。

4.3 生成代码的目录编排与模板定制

生成代码的编排有一个容易被忽略的细节:生成出来的 Dart 文件应该和手写业务代码严格分层。我见过不少工程把生成文件直接扔进lib/根目录,后续原生侧文件一多,连 Code Review 都分不清哪是生成的、哪是手写的。

我的分层规则是:

  • lib/bridge/只放手写的 API 定义文件,这些是“事实源”。
  • lib/generated/放 Pigeon 生成的 Dart 文件,任何手动修改都会在下次生成时被覆盖,所以必须只允许脚本写入。
  • entry/src/main/ets/bridge/放 Pigeon 生成的 ArkTS 文件,以及少部分手写的运行时注册代码。

模板定制主要针对字段命名和包名。Pigeon 默认生成的 Dart 类名和文件名的关系比较机械,如果你的工程有私有格式要求,优先在模板层替换,不要生成后再批量改代码,否则下次生成又打回原形。

4.4 在鸿蒙侧注册并跑通第一个调用

生成完成之后,鸿蒙侧要做的第一件事不是写业务逻辑,而是把桩代码注册进桥接中心,确保接口能通。

在 ArkTS 入口文件里做类似这样的注册:

BridgeRegistry.instance.register( 'dev.flutter.pigeon.storage_api', new StorageApiImpl(), );

这个StorageApiImpl是业务实现类,先随便实现一个返回固定值的方法,然后从 Flutter 侧调用一次。如果返回值正常,说明通道、编解码、注册中心整条链路都通。此时再开始填真正的业务逻辑,排查成本会低很多。任何一步不通,都先回到“最小可调用”的状态去定位。

我踩过的一个典型教训:一开始就写完整的业务实现,结果调用超时,排查了半天才发现是注册中心把 channelName 写错了一个单词。Pigeon 的 channelName 是根据接口文件路径和类名拼出来的,任何一处跟生成代码不完全一致,都会导致消息发到不存在的通道上,表现为静默超时。

5. 高频踩坑与排查链路:按症状反推根因

5.1 白屏或调用超时:先查通道名与编解码器

桥接层最常见的故障是“Flutter 侧调用方法,没有任何异常,但长时间不返回”。我的排查链一般按下面几条走:

  • 对比生成代码里的channelName和鸿蒙侧注册表里的 key 是否完全一致,一个字符都不能差。
  • 检查消息发送前有没有在 Dart 侧正确传入参数,空参方法而调用方传了null,有些码头解码逻辑会有歧义。
  • 检查鸿蒙侧 codec 读入类型时,是否严格按标签处理。如果 codec 错一个字节,消息流就断了,而由于底层是异步,往往不会立刻抛异常。

我把最常见问题整理成了表格,方便对照:

症状可能根因排查动作
调用毫无响应channelName 不一致打印 Dart 端生成代码里的 channelName,与注册表比对
收到乱码或空白字符串字符串长度前缀读取错误检查 codec 的字符串解码逻辑
小整数返回正常,大整数变浮点int64 类型被当 number 处理按标签区分 int32 与 int64
二进制数据前后字节错位Uint8List 的 offset 处理错误逐字节比对入参和鸿蒙侧收到的数据
偶发性超时回调线程没有切回 UI 线程在回传前统一切线程

5.2 返回值变成了 null:异步回调与结果封装问题

另一个高频症状是:鸿蒙侧明明返回了一个对象,Flutter 侧却拿到null。这种问题十有八九出在异步结果的封装上。

鸿蒙侧实现如果是一个异步函数,你在setMessageHandler里await它的结果再去编码。如果你忘了await,直接把 Promise 对象交给编码器,编码器只会把 Promise 当作一个没有对应标签的嵌套对象处理,结果自然丢失。

再看错误处理。很多鸿蒙框架的 API 错误是通过异常对象抛出的,而不是通过返回值表达。桥接层需要统一捕获异常并编码成 Pigeon 的错误结构。如果异常被吞掉,Flutter 侧会一直等待,最终表现为卡死。所以我强烈建议在桥接层增加一个兜底catch,把任何未捕获异常都转成错误信息回传。

5.3 类型映射缺失:Uint8List、Map、嵌套 List

鸿蒙侧 ArkTS 的类型和 Dart 不是一一对应的,最容易出问题的是容器类:

  • Dart 的Uint8List对应 ArkTS 建议用Uint8Array承载,不要转成普通的Array<number>,否则编码器输出类型标签会变。
  • Dart 的Map<Object?, Object?>对应 ArkTS 的Map<string, Object?>,key 类型不一定兼容,最好在生成代码里按实际键类型做投影。
  • 嵌套 List 里的元素类型不确定,编解码器要递归处理,不能只做一层解码。

这些映射问题,最好在生成器模板里就固化下来。例如我维护的 ArkTS 输出器里,会把 Dart 的Uint8List渲染成Uint8Array,把嵌套泛型渲染成Array<Object?>。这样生成出来的代码天然避开了运行时类型推断的坑,而不是依赖每个人手工加as强转。

5.4 多模块方法冲突与方法名溢出

当工程里同时接入多个 API 类,方法名大概率会撞。比如两个插件都定义了getVersion(),虽然 channelName 不同,但如果你在某个桥接层里只按方法名做路由,就会出现“先注册的覆盖后注册的”。

我的做法是把路由 key 设计成${channelName}#${method},而不是只用 method。这样同一个原生方法名可以出现在不同通道下,互不干扰。另一个隐藏坑是方法名过长,有些底层实现会对 message 的 key 做截断或哈希,导致两端匹配失败。遇到这类情况,可以在生成器模板里给方法名加一层稳定的短映射表。

6. 从能跑通到能上线:桥接层的性能与工程化补齐

6.1 减少消息拷贝与大对象传输

通道通信本身有序列化开销,鸿蒙侧的二进制编解码如果频繁创建临时对象,GC 压力会很大。性能优化我先看两个位置:

  • 编解码时尽量复用DataView和字节缓冲,避免每次收发都new一个大Uint8Array。
  • 大对象(图片、日志块、文件内容)不要直接塞进 Pigeon 参数列表。更好的办法是先用文件或共享内存方式传递对象,Channel 里只传路径或者句柄。

我当时遇到一个真实案例:每次截图上传都要走 Channel 传 4MB 的Uint8List,一来一回接近 20KB 的临时分配,拖动页面时明显掉帧。后来改成把截图落盘,Channel 只传文件路径,耗时从 120ms 降到 30ms 以下。

6.2 线程模型与生命周期管理

鸿蒙侧桥接层如果绑定在页面级组件上,页面销毁后必须注销 Channel 注册,否则下一次重新进页面会重复注册。重复注册轻则告警,重则消息命中了旧实例,造成内存泄漏。

我建议的线程模型是:

  • 所有 Channel 消息统一从引擎线程进入,桥接层解码后,通过独立的调度器切到业务线程或 UI 线程。
  • 业务方法是纯计算型,可以直接在线程池执行;涉及 UI 更新,必须切回 UI 线程。
  • 异步回传统一在 UI 线程做序列化编码。

这套模型听起来简单,但能解决大部分偶发性卡顿和崩溃问题。

6.3 自动化:把生成命令接进 CI

手写跑生成命令迟早会出错。更糟糕的是,接口文件改了但生成代码没重新生成,最后提交的又是旧文件,联调时两边对不上。我把生成命令封装成一个tool/gen_bridges.sh,然后在 CI 的 pre-merge 步骤里强制跑一遍,并检查生成文件是否有 diff。如果有 diff,CI 直接失败,让开发者回头把生成代码提交上来。

这个自动化流程还有一个额外好处:多人协作时,不需要每个人本地装 Pigeon 的特定版本。CI 容器里固定同一个版本,生成结果可复现,不会出现“我本地生成的和你的不一样”的情况。

6.4 后续还能扩展的方向

Pigeon 解决的是双向 RPC 调用,但如果你要做持续事件推流(比如传感器数据、日志回调), Channel 的单次请求-响应模型不够用。此时建议走 EventChannel,或者用一个额外的持续通道协议,不要硬塞进 Pigeon 的 API 定义里。

另外,如果你的鸿蒙业务里混用了 PlatformView,比如嵌入鸿蒙原生地图、播放器,这部分属于原生视图组件通道,Pigeon 不会帮你处理。它更适合管业务数据通路,底层视图的渲染和触摸事件分发,还是得靠引擎层提供的视图工厂。

最后再分享一个选型层面的体会:不要把 Pigeon 生成代码当作“不可变的事实”,它本质上是脚手架。鸿蒙化适配走到后来,真正拉开差距的是你手里的运行时桥接层做得干不干净、扩展性强不强。我目前维护的这套结构,新增一个插件的成本已经从两三天压缩到小半天,大部分时间都花在业务实现上,而不是跟编解码器搏斗。如果你的工程也要长期在鸿蒙上跑,建议尽早把生成链路和桥接运行时独立出来,否则插件一多,样板代码的维护成本会重新把你拉回手写 Channel 的深渊。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询