鸿蒙 Flutter 适配实战:改造 js_wrapping 实现 Dart-JS 类型安全互操作
做鸿蒙 Flutter 适配最头疼的不是 UI 组件找不到替换而是那些和 JavaScript 深度绑定的三方库。js_wrapping 就是这么一类库——它的存在就是为了在 Dart 侧优雅地包装 JS 对象让你用强类型的 Dart 代码去操作 GC 里的同名对象、属性、方法。刚接手这个适配需求时我一度觉得好多坑鸿蒙的 Flutter 环境里dart:js_interop的行为和 Web 端不完全一致package:js的注解到 ArkTS 运行时能不能跑通也要验证。这篇文章就把我这段时间从原理调研到代码改造、从回调封装到属性映射的完整过程写下来希望能给做同类适配的开发者省点踩坑时间。1. js_wrapping 到底做了什么先把原理讲透1.1 为什么会有“包装 JavaScript 对象”这种需求做 Flutter 开发的同学应该都有这个经历你的 App 里要接一个第三方 SDK——比如某个地图服务、埋点统计、推送通道对方只提供网页端 JavaScript SDK。在 Flutter Web 场景下Dart 编译成 JavaScript 之后理论上可以直接通过dart:html或者dart:js_interop去访问window上的全局对象问题在于这些 API 用起来非常原始。你拿到的不是“这个东西是什么”而是一堆getProperty、callMethod、convertToDart之类的底层操作。每调一个方法都要手写一大段样板判断类型、转参数、拿返回值。更难受的是JS 是动态类型语言反射、原型链、函数一等公民这些特性到了 Dart 这种强类型静态语言里能用的 API 突然变成了“魔法字符串”IDE 补全和编译期检查全部失效。js_wrapping 要解决的就是这个问题。它基于package:js的注解机制让你用纯 Dart 的 class 去“声明”一个 JS 对象的形状——有哪些属性、哪些方法、方法签名是什么然后通过代码生成器自动产出那些枯燥的底层互操作代码。你在业务代码里只需要像操作普通 Dart 对象一样调用即可背后发生了什么一般人不用关心。一句话总结它为 Dart 与 JS 的互操作提供了一个“类型安全的外壳”这个外壳不改变 JS 侧的真实对象行为只是让 Dart 侧的使用体验变得符合直觉。1.2 js_wrapping 的底层机制拆解js_wrapping 的核心依赖是package:js里那套注解体系主要包含三个关键部分。第一部分是JS()注解。它用来标记一个 Dart 类声明它对应的是哪个 JavaScript 全局对象。比如你注册了一个window.MySDK那么在 Dart 侧定义JS(window.MySDK)就可以让这个类绑定到那个对象上。类内部的方法和属性也靠注解来映射真实名称比如JS(doSomething)标记一个 Dart 方法让它调用的是 JS 对象上的doSomething方法。第二部分是JS()函数。这是package:js提供的最底层互操作入口类似Reflect.apply的角色。它可以接受方法名、接收者以及参数列表动态调用 JS 函数。js_wrapping 生成的代码里大量充斥着这种模式构造函数里拿到 JS 对象引用方法调用时通过JS函数转发。第三部分是代码生成器。js_wrapping 本身依赖build_runner来扫描你定义的包装类生成对应的互操作实现。生成的代码不会改变你的包装类而是生成一个_$Impl之类的实现类或者通过扩展方法补全底层调用。这个设计让用户代码非常干净——你只需要维护注解驱动的“接口形状”样板由机器产出。这种机制的好处很明显如果你拿到了一个新的 JS SDK只需要照着它的文档写一个 Dart 包装类几十个方法可能只需要小半天就能全部接完剩下的交给代码生成。但对于鸿蒙适配来说问题恰恰出现在这套机制依赖的底层假设上。1.3 鸿蒙环境下它为什么会失效我在项目初期最困惑的部分就在这里。package:js这套东西在 Flutter Web 上运行得好好的怎么到了鸿蒙就不行了关键差异在运行时。Flutter Web 是把 Dart 编译成 JavaScript直接跑在浏览器渲染引擎里所以dart:js_interop能直接操作页面上下文里的对象。而鸿蒙上的 Flutter 是原生应用业务代码通过 Flutter 引擎跑在原生线程上并不是浏览器环境。虽然 ArkWeb 组件可以加载 Web 页面但在原生 Flutter 和系统 WebView 之间并没有一个默认的“全局 JS 对象直接可访问”的桥接通道。更要命的是鸿蒙的 Flutter 实现中dart:js_interop的底层行为在不同版本上也有差异。我测试时发现部分接口能正常拿到对象部分会直接抛异常还有一部分是静默失败的——返回值变成 null不报错、不给日志排查起来非常折磨。这就是适配工作必须动手改源码、而不是指望原库“开箱即用”的原因。2. 鸿蒙化适配的整体方案设计2.1 适配范围与目标动手之前先明确边界。我这次要适配的 js_wrapping 版本只涉及核心运行时部分也就是js_wrapping这个包自身的逻辑不包括它依赖的那些业务包装类。业务包装类每个项目各写各的适配时只需要保证底层能力一致上层那些JS(...)注解类不需要逐个改动。所以适配的交付目标可以拆成三条在鸿蒙 Flutter 环境中js_wrapping 的核心 API 都能正常调用不抛UnimplementedError。属性读写读 JS 对象属性、往 JS 对象写属性行为与 Web 端保持语义一致。强类型回调Dart 函数传给 JS 侧JS 侧之后调用回来能正常触发并且回调参数能正确地从 JS 类型转换回 Dart 类型。这三条每一项都涉及底层差异但它们是 js_wrapping 最核心的能力不做完没法交付。2.2 环境与前置条件适配需要一套完整的鸿蒙 Flutter 开发环境我这边使用的版本组合供参考Flutter 的鸿蒙分支基于 Flutter 官方开源版本同步、DevEco Studio 以及对应的 OpenHarmony SDK。具体版本号会因为发布时间不同而更新建议以你当前项目锁定的版本为准我这篇文章不写死版本号因为写死了过不了多久就对不上。另外还需要在pubspec.yaml里引入build_runner和js_wrapping。如果 js_wrapping 被鸿蒙化改造过就改用本地路径依赖如果是原始库需要先确认它能在鸿蒙环境编译通过。编译这块最容易栽跟头的地方是dart:js老库和新库的导入路径差异详见下一章的改造说明。2.3 方案选型改造原库还是另起炉灶当时摆在面前的路线有三条说说我为什么选了改造而不是重写。第一条路不改造直接用原始 js_wrapping碰运气。结果编译都过不去因为原始包里的某些文件直接导入了dart:js这个库只存在于 Web 平台原生鸿蒙 Flutter 编译时直接报错。第二条路另写一套新的互操作层完全不依赖 js_wrapping。这看着干净实际上工作量爆炸——你得重新实现代码生成、注解解析、类型转换还要保证和现有业务包装类的兼容。而且这东西没有独立价值纯粹的重复造轮子。第三条路也是我最终选择的在保留 js_wrapping 对外 API 和代码生成机制的前提下替换它的底层互操作实现。核心思路是把原来直接依赖dart:js的那部分操作抽出来换成一层适配层在鸿蒙环境里通过平台通道转发到 ArkTS 运行时去执行。这个思路有点像“依赖倒置”js_wrapping 不直接绑死某个具体运行时而是定义JsBridge这个抽象Web 环境有一套基于dart:js_interop的实现鸿蒙环境有一套基于事件通道的实现。这样原有业务代码的注解和包装类完全不用改代码生成器也可以复用只有运行时的“桥”被替换了。3. 核心改造实操强类型回调与属性映射3.1 改造 JS 对象包装基类js_wrapping 里所有包装类的根基是一个基类它持有底层 JS 对象的引用并提供属性读写、方法调用、类型转换这些基础能力。我做的第一件事就是把这个基类里的dart:js依赖剥掉换成自己实现的抽象桥。// 适配前的概念示意不是具体库源码仅说明依赖方向 import dart:js as js; // 这是问题根源鸿蒙 Flutter 编译不过 // 适配后的抽象桥设计 abstract class JsBridge { dynamic callMethod(String method, Listdynamic args); dynamic getProperty(String name); void setProperty(String name, dynamic value); void registerCallback(String name, Function callback); }这个JsBridge接口是整个适配的关键抽象。它彻底隔离了“JS 对象”这个概念在不同平台上的实现差异。在 Web 端实现类里调用dart:js_interop的callMethod在鸿蒙端实现类里把方法名和参数打包成消息通过MethodChannel发给原生侧原生侧拿到后到 ArkTS 运行时里执行再把返回值传递回来。可能你会问为什么不直接用 Flutter 官方的MethodChannel而是自己套一层原因很简单——MethodChannel是异步的而 JS 对象调用很多场景需要同步返回结果。Flutter 与原生通信天然异步这里需要额外处理。我的做法是鸿蒙原生侧在收到 invoke 消息后把结果以同步方式返回对于确实无法同步的场景比如回调触发单独走一条异步回调通道。这个细节在 3.3 小节展开。3.2 属性映射的精修从“名字对得上”到“类型也对得上”属性映射是 js_wrapping 的看家本领之一。原始机制很好理解你在 Dart 包装类里写一个external int get height代码生成器就会生成getProperty(height)的调用。但鸿蒙化之后问题变成了“拿到值之后怎么转成 Dart 类型”。ArkTS 侧的数据类型和 Dart 侧不是一码事。原生传来的是一个数字在 Dart 里可能自动变成int也可能是double取决于它是不是小数。这件事在 Web 端相对简单——dart:js_interop有一套完整的类型转换规则数值会按照 Dart 的 int/double 规则自动转换但通过消息通道传过来时中间的序列化和反序列化会把类型信息弄丢一部分。我的方案是在JsBridge的实现里加一个显式类型标注协议。在注册属性读取时业务层可以告诉桥“这个属性我期望它是个double”桥在拿到消息后先做类型检查再按目标类型做一次收敛转换。// 示意属性读取时带上期望类型 final value await bridge.getProperty(width, expectedType: JsNumberType.double); return value as double;这种做法的代价是包装类要稍微多写一点声明但换来的是稳定。否则你会在鸿蒙上遇到一个很经典的问题JS 里0.1 0.2传来传去到 Dart 侧变成了0或者被截断成整数调试起来完全摸不着头脑。3.3 强类型回调跨桥之后它还“强”吗强类型回调是这次适配里最需要谨慎处理的模块。原始 js_wrapping 支持 Dart 函数作为回调传给 JS 侧JS 侧将来用参数调用它。Web 端这很容易Dart 函数本身就是一等对象JS 侧拿到函数引用直接调用即可。鸿蒙端不行Dart 函数没法被 ArkTS 运行时直接持有必须注册到一张全局映射表里给 JS 侧一个唯一的回调 IDJS 侧调用时带着 ID 走异步通道回来Dart 侧再根据 ID 找到函数执行。听起来不难但有几个容易翻车的细节。第一个是回调的回收。如果业务层注册了一批回调但从不注销映射表会无限膨胀。我参照了 Vue 事件监听的清理思路在包装类析构或者页面销毁时提供一个统一的releaseAllCallbacks()方法桥底层会把这个对象关联的所有回调 ID 清空。第二个是参数类型逆转换。JS 侧调用回调时传过来的参数经过消息通道到 Dart 侧后可能是 Map、List、num 这些通用类型。如果包装类声明回调签名为void Function(UserInfo info)那就必须把 Map 手工转换成UserInfo实例。我实现了一个callbackArgConverter注册点允许业务层为不同类型的回调参数提供转换函数。// 示意定义带强类型参数的回调注册 void registerTypedCallback( String callbackName, Function callback, { Listdynamic Function(Listdynamic rawArgs)? argConverter, })第三个是时序问题。JS 侧触发回调时可能 Dart 侧正处于某个异步任务的中间状态。这时候如果直接同步执行回调可能会打乱业务流程。我的经验是回调统一通过scheduleMicrotask放入微任务队列保证它在当前同步代码段结束后再执行避免在消息返回的瞬间插入执行导致的顺序错乱。3.4 异步方法与事件监听的兼容处理除了“调用方法 回调”这个基本模式js_wrapping 的包装类里还有相当一部分方法本身返回的是Future比如 JS SDK 里常见的初始化、登录、请求数据等。这些方法和同步方法本质不同Web 端你可以直接通过返回 Promise 对象的 JS 操作配合await等待鸿蒙端经过消息通道后Promise 的语义天然会变成异步所以适配时反而省事——只需要把桥的通道设计成支持Future返回值。但事件监听就得自己搭一层了。某些 SDK 是事件驱动的你给 JS 对象注册一个on(message, handler)JS 侧之后会反复调用每次调用都有不同的参数。这和 3.3 里的单次回调还不一样它是长期持久的监听关系。我的实现思路是在桥的注册表里区分“一次性回调”和“持久监听器”。一次性回调执行完即删除持久监听器一直保留直到业务层显式注销。和底层打交道时注册方法名也分成两种registerCallback和addEventListener。这个设计虽然简单但非常实用因为很多业务包装类里方法名和事件名是混在一起的你没有这张表根本不知道谁是一次性的谁是长期的就会发生“回调触发了但找不到函数”或者“函数一直被引用导致内存泄漏”的问题。4. 测试、构建与踩坑实录4.1 单元测试没有原生环境也能验证核心逻辑鸿蒙 Flutter 的调试比较复杂不是每次代码改动都能立刻跑到模拟器里验证。我采取的策略是分层测试对JsBridge抽象层的逻辑比如回调 ID 管理、类型转换、注销清理用纯 Dart 单元测试覆盖通过注入一个内存实现的 fake 桥来验证这部分不需要真实环境跑得飞快。我刚才提到的三个核心问题——回调不重复、参数逆转换正确、监听器正确释放——都写了对应的测试用例。比如回调 ID 管理测试里会模拟 JS 侧多次调用同一个回调 ID断言它稳定映射到同一个函数再比如注销逻辑注册 100 个回调到对象里然后调用releaseAllCallbacks断言注册表为空。这层测试在适配中后期尤其重要。因为鸿蒙侧联调往往要等构建产物一对一的验证周期长很多低级逻辑错误其实可以靠测试提前发现。我印象最深的一个 bug回调注册时 ID 生成用了DateTime.now().microsecondsSinceEpoch当两个回调在同一微秒内先后注册时ID 重复导致第二个回调覆盖了第一个。这种问题在真实联调时极难复现但在单元测试里用循环注册几千次立刻暴露。test(回调注册不重复, () { final bridge InMemoryJsBridge(); final callbacks String, Function{}; for (var i 0; i 1000; i) { final id bridge.registerCallback(evt_$i, () {}); callbacks[id] () {}; } expect(callbacks.keys.toSet().length, 1000); });4.2 联调必踩的坑清单下面这份坑清单完全来自我的实操记录如果你也在做这类适配大概率能对上几条。问题现象根本原因我的解决方式编译报错找不到dart:js原库直接依赖dart:js库该库不支持鸿蒙 Flutter实现JsBridge抽象层替换所有对dart:js的直接引用属性读回整数消息通道反序列化时丢掉了类型细节桥层引入期望类型标注按声明的类型做收敛转换回调注册后偶发失效回调 ID 重复改用自增原子整数做 ID替代时间戳方案事件监听泄漏持久监听器未区分于一次性回调桥层增加addEventListener/removeEventListener接口业务层显式管理异步方法结果错乱多个并发请求共用同一个消息通道返回端口为每个调用生成自增 callId通道返回时按 callId 路由这里我想特别强调一下并发请求那个坑。刚开始我以为MethodChannel天然支持并发后来实测发现当我在很短的时间内连续调用三四个异步 SDK 方法时返回结果会错乱——第二个调用的结果跑到了第一个调用里。排查后确认是消息通道的返回端口没有区分调用来源。加一个 callId 之后问题立刻消失。这个教训建议所有做通道桥接的开发者谨记不管是不是鸿蒙平台都适用。4.3 性能与线程别让桥接变成卡顿瓶颈桥接层经常被忽略的一个问题是线程模型。Dart 侧 UI 线程和 ArkTS 侧的执行环境不是同一个线程每次跨桥调用都有通信开销。如果包装类的某个方法被高频调用比如每帧都读取某个 JS 属性性能会非常难看。我的优化思路有两个层面。第一是高频读属性的缓存业务层通过一个开关告诉桥“这个属性变化不频繁请缓存它的值”桥会在首次读取后缓存后续读取直接返回缓存值除非显式调用invalidateCache。第二是批量调用把多个互不依赖的方法调用合并成一次桥接消息减少通道往返次数。这两个优化在 Web 端可能多余但在鸿蒙端实测下来收益很大高频场景耗时能下降约一个数量级。当然缓存方案也有风险。如果 JS 侧属性真的变了但没通知 Dart 侧读到的就是旧值。所以我给缓存加了一个最低生命周期限制只缓存标记为immutable的属性而那些 SDK 文档里注明会动态变化的属性一律不缓存。这个区分一定要由业务包装层的作者来负责桥本身不做猜测。4.4 与官方 Java/ArkTS 插件协作的边界适配过程中我一度钻进了一个误区想当然地以为鸿蒙 Flutter 项目里只能通过 Dart 侧写桥接。后来发现鸿蒙原生侧本身也有一套 Flutter 插件机制允许你在 ArkTS 代码里注册自定义 MethodChannel 处理器。两者结合的正确姿势是桥接层Dart 侧只负责把 js_wrapping 的操作转发成标准消息而消息的最终执行者是你在 ArkTS 侧注册的一个全局对象管理器。这个管理器维护着一个字符串到 JS 对象的映射表。Dart 侧把对象 ID 和操作发过来管理器根据 ID 找到 JS 对象执行属性和方法操作。这样设计的好处是你可以在原生侧同时管理多个 JS 对象而不需要在 Dart 侧保留大量引用同时JS 对象的生命周期也由原生侧托管Dart 侧只是持有一个轻量的引用句柄。如果原生侧已经存在一个比较成熟的 JS 对象管理模块这个边界就更值得讲究不要试图在 Dart 侧重建一套管理逻辑直接复用它通过消息委托给它执行即可。5. 适配之后的维护建议5.1 定期回归Web 端行为不能坏适配工作完成不代表可以高枕无忧。js_wrapping 本身是跨平台的库你改完底层桥之后最怕的就是为了适配鸿蒙把 Web 端的正常行为弄坏。比如我在属性缓存优化中加的标记逻辑在 Web 端就没有必要一旦误用可能会导致读到过期值。所以我这边的项目会维护一套跨环境回归用例同一套测试代码分别跑在 Flutter Web 环境和鸿蒙模拟器环境断言结果一致。这套用例的密度不需要很高但核心场景必须覆盖到尤其是属性读写、回调触发、异步方法返回这三条主链路。5.2 聊一下“要不要把改造合并回上游”完成适配之后我身边有同行问过你的改造要不要提个 PR 给 js_wrapping 官方这个问题要一分为二看。如果官方愿意接受一个平台无关的抽象层改造那自然是好事未来所有使用者都能受益。但现实是上游维护者未必了解鸿蒙平台的特殊约束强行提交可能会引入他们难以维护的分支。我的个人倾向是如果上游明确表态支持就做一次优雅的抽象层改造把差异隔离在小体积的适配文件里如果上游态度不明确就作为内部维护分支在每次升级原始库版本后手动合并一次变更。从目前的生态现状来看后面这种情况更常见。5.3 给后来者的三个建议兜底收集了这么多最后总结几条供参考。第一动手适配前先把原始库的代码结构和依赖关系完整梳理一遍重点标注哪些文件直接依赖 Web 专用库大方向错误的改造越早发现损失越小。第二建立一个能快速运行的鸿蒙模拟器测试链路哪怕它构建慢也比“凭感觉写完、一次上机看到一堆错”强得多。第三无论多简单的桥接逻辑都要写单元测试尤其是回调管理这种看起来“几十行代码不值得测”的部分——我踩的大型 bug恰恰都是从这种“不值得测”的角落里冒出来的。这次适配 js_wrapping 的经验最核心的一句话是平台差异并不可怕可怕的是代码把所有平台假设寄生于底层库的某条固定链路上。用抽象层把链路切开你就有机会让一个 Web 生态的老库在鸿蒙园区重新焕发生机。