1. 为什么我要折腾“toor 鸿蒙化”,以及这件事到底解决什么问题
今年年初,我接手的一个跨平台项目要往鸿蒙系统上迁移。第一轮跑起来后,页面倒是能显示了,但点进详情页就崩溃,而且报错位置五花八门——有的说实例没注册,有的说资源文件找不到,最诡异的是同一个服务对象被创建了两次。排查到最后,问题全部指向一个被我们忽略的底层模块:项目里的依赖注入库 toor。
toor 是我们内部长期在使用的一个 Flutter 三方依赖库,核心能力是单例注入、工厂注册、作用域管理和资产加载封装。它在原生的 Flutter 环境里一直很稳定,但到了鸿蒙容器里,它那套基于 Dart 层生命周期假设的机制就失灵了。所谓“鸿蒙化适配”,并不是把代码复制过去改几个编译错误那么简单,而是要重新理解:同一个 toor 库,在不同宿主环境下,哪些能力是通用的,哪些能力是必须跟着容器生命周期走的。
这篇文章不打算讲鸿蒙环境的搭建步骤,也不讨论 Flutter 引擎怎么编译,只聚焦 toor 这个库在鸿蒙化过程中被我们拆解、改造、验证的完整过程。如果你手头也有一个依赖注入类三方库要迁到鸿蒙,或者你正在规划一个 Flutter 跨端项目的鸿蒙版本,这篇指南里遇到的问题和解决思路应该能直接复用到你的场景里。
先说结论:鸿蒙化适配的难点从来不在 Dart 语法层,而在“生命周期对齐”和“资源定位”这两件事。把这两件事想透,toor 的适配就完成了一大半。
2. 单例注入的鸿蒙适配:一场关于“实例到底应该活多久”的思考
2.1 Dart 里的单例,为什么到了鸿蒙会变成“双例”
我们先从最基础的场景说起。toor 的容器在 Dart 层维护了一个注册表,通过 registerSingleton 注册的实例,在进程存活期间理论上只有一份。这个假设在原生 Flutter 环境里基本成立,因为 Flutter 引擎生命周期和页面栈的绑定关系比较简单。
但鸿蒙的落地场景不一样。一个鸿蒙应用可能同时存在多个 UIAbility 的页面栈,页面之间可能还夹杂着原生控件。当用户从 Flutter 页面退回原生页面再重新进入时,Flutter 引擎不一定继续持有原来的 Dart 运行时;在某些系统场景下,引擎会因为内存压力被回收,之后重新创建。对 toor 来说,这等于“进程没有死,但 Dart 世界被重启了”,注册表里的所有单例全部作废,而原生侧有些资源还活着,就会出现两边状态对不上的情况。
我们项目里遇到的崩溃就是这么来的:一个登录态服务在 Flutter 引擎重启后被 toor 重新创建了一份,但原生侧缓存里还握着旧实例的引用,两边各改各的。等到某个接口回包走回调时,旧实例的上下文已经废弃,直接抛了空指针。用命令行日志去看,对象哈希值都对不上,排查越深入越迷惑。
2.2 把 toor 的单例生命周期对齐鸿蒙容器:组合根与宿主绑定
解决这个问题,思路不是“让单例永远单例”,而是“让单例的存活边界和 Flutter 引擎的创建边界保持同步”。也就是说,引擎被重建,单例就应该跟着重建;引擎还在,单例就必须在内存里保持一致。
做法上,我们放弃了在 toor 内部单独维护静态注册表的方案,改为在组合根里显式绑定生命周期。大致分三步:
第一步,把原有散落在各模块的 registerSingleton 调用全部收拢到一个顶层方法里,这个方法只在引擎初始化完成后执行一次。第二步,给 toor 增加一个 reset 语义,允许在宿主重新创建时清空所有单例并重新注册。第三步,把 reset 动作挂钩到鸿蒙容器的生命周期回调上,确保“引擎创建”和“注册表重建”是同一个时机。
简化后的 Dart 代码大致长这样:
class AppRegistrar { static final toor = ToorContainer(); static void rebuild() { // 清空旧注册表,避免上次引擎残留的实例继续存活 toor.reset(); // 同步型单例:应用级服务,生命周期等于 Dart 运行时 toor.registerSingleton<SessionService>(SessionService()); toor.registerSingleton<ApiClient>( ApiClient(session: toor.get<SessionService>()), ); // 异步工厂型单例:无法在注册时立刻创建,但要保证全生命周期只存在一份 toor.registerSingletonAsync<PrefsService>(() async {}); } }在鸿蒙侧,只需要在 Flutter 容器创建完成的通知里调用 AppRegistrar.rebuild(),不需要关心具体页面走的是原生栈还是 Flutter 栈。这样做的收益是:无论 Flutter 引擎被系统重建多少次,toor 里的实例始终与引擎同生共死,不存在“半新半旧”的状态。
提示:不要把 reset 放到 onPageShow 或 onResume 这类回调里,那会让注册表在频繁切前后台时反复重建,反而引发新问题。
2.3 为什么组合根收拢是关键,而不是在 toor 里加一个“全局锁”
一开始有同事提出更“省事”的方案:直接把 toor 容器改成进程级静态变量,加锁保证访问安全。但实测下来问题更多:静态容器里的旧实例无法感知引擎重建,脏数据一直留着;而且也不是每个单例都适合跨引擎存活,有些服务自身持有了页面绑定的 StreamController,旧容器不清空,监听永远不会释放,内存泄漏一串接一串。
组合根收拢的方式牺牲了一点“随时随地都能注入”的便利,换来的是生命周期边界清晰。调试时打开布局检查工具,只需要看一个入口方法,就能知道当前运行时的依赖全貌,定位问题的效率高了很多。
这里还有一个我在鸿蒙上实测到的细节:如果项目里同时启用了热重载开发模式,Dart 层的 isolate 会被重启,但鸿蒙原生侧的 Activity 不一定走销毁流程。这种阶段非常容易复现“双例”,我的建议是开发阶段把 toor 的 reset 调用同时挂在两个时机上——一个是 Flutter 引擎创建回调,另一个是热重载后首帧渲染完成时。虽然这会让注册表多执行一次,但能有效避免开发时反复撞上脏状态。
3. 资产工厂实战:让 toor 把“找资源”这件事也管理起来
3.1 鸿蒙的资产路径,和 Flutter 默认的那套完全不是一个逻辑
依赖注入库通常只管对象实例,但 toor 的定位比较特殊,它还带了一个资产工厂能力:允许把“加载某个资源”也封装成一个注入服务。这样业务层代码不必到处写 rootBundle.load,只需要向容器要一个 AssetLoader 实例就行。
这个能力在原生 Flutter 上几乎不需要适配,因为资产路径几乎都是统一的 flutter_assets 前缀。但鸿蒙打包后的资源走的是 rawfile 体系,资源在应用包里的存放路径和编译方式都不一样。最直观的表现是:同一个资源文件名,在 Flutter 默认环境能加载,到了鸿蒙上却经常提示找不到;或者加载到了,但读取出来的二进制内容和预期不一样。
我们项目里最典型的一个案例是 JSON 配置文件。原来用 rootBundle.loadString('assets/config/app.json') 在 Android 和 iOS 上都正常,到鸿蒙真机上直接抛异常。后来定位到是资源路径映射规则不同,鸿蒙会对 rawfile 目录做统一归类,某些带嵌套目录的路径会被展平,子目录信息丢失。
3.2 资产工厂的分层设计:路径归一化、加载缓存、失败兜底
为了不让业务代码感知到平台差异,我在 toor 的资产工厂里做了三层封装。
第一层是路径归一化:注册资产时允许业务层传“逻辑路径”,工厂内部维护一张映射表,把逻辑路径翻译成当前平台的物理路径。鸿蒙环境下自动加上 rawfile 前缀规则,Flutter 环境则保持原样。
第二层是加载缓存:对同一个逻辑路径的加载结果做内存缓存。这个缓存必须和第一节提到的单例生命周期绑定,引擎重建时要一并清空,否则会出现“逻辑路径更新了,但缓存的旧资源还在”的隐患。
第三层是失败兜底:加载失败时,工厂会根据错误码决定是重试、降级到内置默认资源,还是直接抛异常。
对应的工厂代码大致是这样:
typedef AssetFactory = Future<ByteData> Function(String logicalPath); class ToorAssetFactory { final Map<String, String> pathMappings; final Map<String, ByteData> _cache = {}; Future<ByteData> load(String logicalPath) { final cached = _cache[logicalPath]; if (cached != null) return Future.value(cached); final physicalPath = normalize(logicalPath); return rootBundle.load(physicalPath).then((data) { _cache[logicalPath] = data; return data; }); } String normalize(String logical) { // 这里是鸿蒙适配的关键差异点,由平台侧 hook 返回实际路径 return pathMappings[logical] ?? logical; } void resetCache() => _cache.clear(); }业务层不再感知 rootBundle 或 rawfile,只面向逻辑路径编程。后续鸿蒙的打包规则再变化,需要改的也只有映射表,不会波及几十处调用点。
3.3 一个必须处理的“隐藏坑”:异步工厂和多 Bundle 并存
toor 有一个使用频率很高的能力是异步工厂注册:允许注册一个返回 Future 的 Provider,首次调用时创建一个可被后续复用的实例。这个能力在资源加载场景里很好用——某种资源只需要初始化一次,但第一次加载很慢。
在鸿蒙环境下,这个 usage 会碰到一个“多 Bundle”问题:如果你的应用拆分了多个 HAP/HSP,资源文件分散在不同的 Bundle 里,那么 rootBundle 默认只会去找主 Bundle 的路径。异步工厂注册的资源如果落在子模块里,初始化出来的实例数据就是空的,但 toor 照样会把它缓存起来,后续所有调用都拿到空数据。
处理办法也很粗暴但有效:在 resetCache 时同时检查异步工厂实例的来源标记,凡是标记为子模块资产的,直接放弃缓存、强制走一次重新加载。本质上就是拿“每次多读一次文件”换“永远不拿到脏数据”。这个取舍我在项目里测下来是值得的,尤其 JSON 配置这种低频加载资源,性能损失可以忽略不计。
4. 鸿蒙级精密依赖管理:作用域、销毁与组合根的三个关键决策
4.1 为什么说“精密依赖管理”不是营销词,而是可追踪的注册链
“鸿蒙级精密依赖专家”这个说法听起来像包装,但落到工程上,它指的是一个非常具体的能力:容器里的每个依赖,都能回答三个问题——谁创建了它、它在哪个作用域存活、销毁时由谁负责释放。
toor 在原生 Flutter 里已经把这三个问题管理得不错了,但鸿蒙化之后,有一个变量被放大:原生页面和 Flutter 页面的混编。一个 Flutter 页面上的业务对象,可能被一个原生页面的回调持有;原生页面销毁后,Flutter 侧的对象还认为自己在生命周期内。精密管理要做的,就是让这种跨栈的依赖不至于变成悬空引用。
我们的做法是给 toor 增加“源标记”字段。每次注册实例时,除了常规的 factory 参数,还要显式声明这个实例的来源域:是 Flutter 容器域、页面作用域,还是原生桥接域。消费方注入时,如果发现自己所需要的实例来源域和当前调用链不匹配,容器会在 debug 模式直接抛出警告,release 模式改用空对象兜底。
这个设计本质上把“依赖管理”从一个纯内存问题,提升到了“跨运行时的协调”问题。它不改变 toor 的注入语法,但让每一个依赖的实际存活边界都变得可观测。
4.2 作用域注入与销毁钩子:在鸿蒙页面栈里管理生命周期
作用域注入是 toor 的核心特性之一:同一个类型可以在不同作用域注册不同的实例。典型场景是列表页进入详情页时,详情页需要自己的临时状态服务,页面销毁后这些状态一并释放。
鸿蒙化之后,这个机制的实现方式没变,但触发时机要重新设计。在原生 Flutter 里,页面销毁通常意味着 build 流程清理,可以依赖 State dispose;在鸿蒙的混编栈里,Flutter 页面被压入后台不代表销毁,需要等到原生栈的 onPageHide 或返回手势结束才会真正释放。
所以我把作用域销毁钩子的触发点从“Flutter 控制器的 dispose 回调”扩展成了“页面不可见且栈内确认移除”两个条件。简单说:
scope.onPageHidden((scopeId) { // 不立即销毁,只标记为可回收 scope.markCollectable(scopeId); }); scope.onPageRemoved((scopeId) { // 栈内确认移除,真正执行 dispose scope.disposeAll(scopeId); });这个两段式设计避免了误杀。曾经我把销毁钩子直接绑在 onPageHide 上,结果是用户从详情页返回列表页时,详情页的对象被提前销毁,列表页里还没消费完的异步回调瞬间全部失效,白屏问题反反复复。后来改成“先标记、后销毁”才彻底解决。
4.3 循环依赖检测在鸿蒙化后出现的“误报”真相
toor 自带循环依赖检测,A 依赖 B、B 又依赖 A 时,容器应该抛出明确的循环引用错误。鸿蒙化之后,这个检测却频繁触发误报,一度让我怀疑是不是容器代码有 bug。
排查后发现,误报的根因不在 toor,而在注入时机。项目里有一些服务类的构造函数很重,会同步调用其他服务的方法;在鸿蒙引擎首帧阶段,Flutter 容器尚未完全准备好,部分原生桥接服务处于“已注册但未初始化完成”的状态,这个时候 A 去取 B,B 又不甘示弱地回头取 A,两边都拿不到,于是容器判定为循环依赖。
严格来说这不是循环依赖,是“初始化顺序依赖”。我在适配层加了一个分级初始化机制:先无依赖的基础服务,再依赖基础服务的业务服务,所有需要原生桥接的服务统一放到第三批。这样既绕开了误报,也让首帧启动速度有了肉眼可感的提升。
5. 完整适配流程复盘:一份可以照着执行的依赖登记与验证清单
5.1 适配前必须明确的依赖能力清单
动手之前,我先给 toor 的全部能力做了一次分类盘点,确定哪些是纯 Dart 逻辑、哪些依赖平台能力。分类方式直接影响后续工作量:
| toor 能力项 | 是否依赖平台 | 鸿蒙化适配重点 |
|---|---|---|
| 单例注册与获取 | 不依赖,但受引擎生命周期影响 | 对齐组合根的 reset 时机 |
| 工厂注册(含异步) | 不依赖 | 注意异步初始化与缓存清理 |
| 作用域注入 | 不依赖,但受页面栈状态影响 | 两段式销毁钩子设计 |
| 资产加载 | 强依赖 | rawfile 路径归一化与多 Bundle 映射 |
| 循环依赖检测 | 不依赖 | 分级初始化,避免误报 |
| 依赖日志输出 | 不依赖 | 可在鸿蒙侧桥接到原生日志系统 |
这张表做完,我心里就有底了:真正的平台强绑定只有资产加载一项,其余都是生命周期对齐的问题。把时间花在刀尖上,不要把自己埋进无休止的编译错误里。
5.2 六步走完,从空壳工程到真机验证
第一步,搭一个最小鸿蒙壳工程,里面只有一套空页面和一个空的 toor 容器。第二步,把项目里所有 registerSingleton 调用原样搬进来,不做任何改造,跑通一遍真机,观察崩溃点。第三步,按第二节的方法引入组合根收拢和 reset 机制,再跑一遍,重点看引擎重建场景。第四步,加入资产工厂,先用单资源场景验证路径归一化,再逐步覆盖多 Bundle。第五步,引入作用域注入和销毁钩子,用长列表反复进出的场景压测内存。第六步,开启 release 模式完整回归,重点验证热重载时不会串状态的场景。
这六步每一步都留了回滚空间,任何一步验证不过,都能明确知道是哪个层次出了问题,不会东一榔头西一棒子。
5.3 我排过的错,都在这个查问题表里
结合这段时间的调试记录,我整理了一份高频问题速查表。遇到同类的现象,直接按表里的根因去查,基本一次命中:
| 症状 | 根因 | 解决办法 |
|---|---|---|
| 同名单例对象在两个页面拿到不同哈希 | 引擎被重建,注册表重新注册 | 统一走组合根 reset,不要手动重复注册 |
| 资源在模拟器正常、真机报找不到 | rawfile 路径展平,子目录丢失 | 维护逻辑路径到物理路径的映射表 |
| 页面返回后异步回调大量失效 | 销毁钩子绑在了页面隐藏事件上 | 改成“隐藏标记 + 栈内移除销毁”两段式 |
| 容器频繁提示循环依赖 | 原生桥接服务初始化顺序不一致 | 按依赖层级做分级初始化 |
| 首帧白屏且日志没有任何报错 | 单例工厂里同步加载了慢资源 | 改为异步工厂,并放置到第三批注册 |
这个表不是一次性做完就丢的,我在项目每次迭代后都会更新一条。依赖注入的坑往往藏在多层对象的缝隙里,一个表查不到,就顺着调用链一步步打印实例来源标记。
6. 最后分享一个我用下来的小技巧:用“适配层”而不是“补丁”消化平台差异
在做 toor 鸿蒙化的整个过程中,我最深的体会是:迁移到鸿蒙的库,最怕的不是功能缺失,而是“为了能跑起来”在业务代码里撒满平台判断,到处写 if HarmonyOS 分支。那样短期内确实能编译过,但后续每一次版本升级都会变成噩梦。
我的做法是额外加了一个极薄的适配层,所有差异都收敛在这层里。业务侧拿到的 toor API 和原来保持一致:还是 registerSingleton、还是 get,还是异步工厂。只是底层实现会根据宿主环境选择不同的路径归一化规则、生命周期回调绑定逻辑。这样做的直接好处是,当鸿蒙侧 Flutter 容器升级,或者 toor 库发布新版本时,需要改动的只有适配层,几十处业务调用点基本不动。
另一个值得记录的小技巧是:给 toor 的每个实例加一个“出生时间戳”标记,在 debug 模式下打印对象哈希时带上这个时间戳。这看起来是笨办法,但排查“两个实例到底哪个是旧的”时,时间戳一秒就能定位,比对着日志猜快得多。这个习惯我从这个项目保留到了后续所有依赖注入相关的调试里,屡试不爽。