做 Flutter 鸿蒙化适配,真正磨人的往往不是 Flutter 引擎本身,而是藏在依赖树底层的那些三方库。这次要处理的是 relic_core —— 一个典型的代码生成式持久化库,负责把内存缓存和 SQLite 落盘统一收敛到一套模型下面。在 HarmonyOS NEXT 环境下,relic_core 没法像普通纯 Dart 包那样直接跑通,因为它底层链路里有文件路径、数据库驱动、异步调度这些需要对接原生系统的逻辑。
这篇文章不打算给你一份“照着敲就能编译通过”的假教程,而是把我从依赖排查、方案选型,到数据库驱动替换、路径适配、序列化迁移,再到真机验证的完整过程拆开讲。适合两种人看:一种是手里已经有一个 Flutter 项目、正准备往鸿蒙迁移的团队;另一种是纯粹想搞懂“三方库平台适配”到底在调什么的人。看完你会发现,真正决定适配工作量的不是包名,而是它最底层那几条原生依赖的耦合深度。
1. 先看懂 relic_core 的架构,再决定从哪里下手
1.1 relic_core 到底在帮你干什么
先说结论:relic_core 不是一个单独的存储库,它更像一个“数据访问层生成框架”。实际工程里,你会定义一批数据模型和查询接口,然后用代码生成器生成一堆仓库类。这些仓库类自动管理内存缓存和数据库落盘,调用方只需要面向接口写业务逻辑,不用关心数据到底存在内存里还是磁盘上。
举个例子可能更好理解。没引入它之前,你写一个用户信息的存取,大概要自己维护一份 Map 缓存,再手动拼 SQL,缓存失效时删掉重查,还得处理异步回调。用了 relic_core 之后,你要做的只是声明“我要一张 user 表、按 id 查询”这一类语义,生成的代码会把缓存命中和数据库读写串起来。从架构上讲,它把数据流拆成了两层:上层是业务拿到的 Repository,下层是真正执行 IO 的执行器。这套设计本身不依赖任何平台,问题全出在下层执行器的默认实现上。
很多团队选 relic_core,主要看中它能省掉相当一部分样板代码,同时把缓存策略收敛到一个地方。我对这套思路的评价是:生成代码的方式确实能减少重复劳动,团队规范也更好统一。但代价也很明显——它在底层必然绑定一个具体数据库驱动,这个驱动一旦没有鸿蒙版本,整个库就跑不了。这也是为什么适配之前,一定要先把架构分清楚:Dart 侧生成代码大概率不用大动,真正要解决的是执行器这一层在鸿蒙系统上的“接地点”。
1.2 鸿蒙化最大的劫点不在 Dart,在三处原生耦合
我这次适配前前后后遇到的问题,归一下类,最后都指向三处。
第一处是文件路径。relic_core 要确定 SQLite 库文件放在哪,最常见的做法是调用 path_provider 这类插件拿到应用支持目录或文档目录。Android 上没问题,因为 path_provider 的 Android 实现是现成的;HarmonyOS NEXT 的目录体系和 Android 完全不同,拿不到路径,后续一切白搭。
第二处是数据库驱动。原来用的驱动如果是 sqflite,背后走的是一条 MethodChannel,原生端用 Android 的 SQLite API 执行 SQL。这条通道在鸿蒙上注册都注册不进去,更别说执行查询。要么找一个把 SQLite 能力桥接出来的鸿蒙适配插件,要么改成 ffi 方式直接加载鸿蒙环境里的 sqlite 库,基本没有第三条路。
第三处是异步模型。Flutter 侧数据库操作一般要放到 isolate 里跑,避开 UI 线程阻塞;到了鸿蒙上,任务调度、线程能力和平台通道的时序都跟 Android 不完全一致。表现出的问题五花八门:有时候是首次启动数据库文件还没建好就开始查,有时候是并发读写把数据库锁死。
这三处问题在 Android 上基本被生态磨平了,但 HarmonyOS NEXT 目前还是三方库适配的空白区,每一处都要自己确认。我认为这就是适配工作真正的价值:不是把代码重新编译一次,而是把 Dart 世界和鸿蒙原生世界之间断掉的连接重新接上。
1.3 三种适配路线:先别拍脑袋,按调用面选型
不同团队遇到 relic_core 跑不起来,选的路差别很大。我梳理下来,基本是三种。
第一种是最小侵入。relic_core 的生成代码不动,只把它依赖的驱动替换成支持 ohos 的版本,同时补上路径获取和序列化兼容。好处是工作量小、风险可控,尤其适合版本不太激进、数据库还停留在基础增删改查的工程;坏处是依赖社区有没有对应适配包,如果项目里还用了别的原生插件,可能牵一发动全身。
第二种是自建适配层。绕开原驱动的 API,自己在中间写一个执行器。听起来工作量最大,但本质上只是一层薄封装:把原来的数据库调用翻译成鸿蒙侧能懂的语义。这个方案对团队架构设计能力要求高一些,但自由度最高,以后换数据库驱动也不怕。
第三种是换掉 relic_core。如果业务里用得本来就不深,干脆改成已经支持鸿蒙的持久化方案,比如 drift 的ohos分支,或者最朴素的 sqlite3 封装。这种选择最保守,长期看最稳,但历史数据迁移和代码改造成本取决于业务里嵌了多少深水用法。
拿我个人这次来说,选的是第二种思路切入、按第一种路径落地。先判断原有代码和驱动耦合得紧不紧,如果强耦合就做适配层,如果只是几个调用点就直接换驱动。你实际做的时候也可以按这个优先级判断:
| 路线 | 改动范围 | 推荐场景 | 主要风险 |
|---|---|---|---|
| 替换驱动 | 只动依赖配置和初始化 | 存量代码对 relic_core 的 API 使用比较浅 | 社区适配包不成熟 |
| 自建适配层 | 增加一个执行层 | 数据库操作集中、需长期维护 | 工程量大、需测试兜底 |
| 替换整个存储方案 | 涉及业务层重写 | relic_core 使用深度不高 | 历史数据迁移成本不可忽略 |
这个表不是让你对号入座,而是提醒你:先花半天把代码里 relic_core 的真实调用面摸清楚,比闷头改代码要快得多。
2. 动手前的依赖体检和鸿蒙工程准备
2.1 用 flutter pub deps 把依赖树拉出来
我在接手这个工程的时候,第一件事不是立刻建 ohos 分支,而是跑两条命令,把依赖树的底裤看了一遍。
flutter pub deps --style=compact flutter pub deps --style=listcompact 版本能让你大致知道每个包引了谁;list 版本能看出有没有重复的、存在平台冲突的包。看的时候重点留意几类东西:第一,relic_core 下面挂的是哪个数据库驱动,是 sqflite 还是 sqlite3 + sqlite3_flutter_libs;第二,有没有间接依赖 path_provider、shared_preferences 这类带原生实现的包;第三,有没有包已经悄悄带上了 ohos 相关依赖。这条命令在鸿蒙化适配里算是最低成本的信息搜集手段。
看过依赖树之后,我习惯再人工过一遍 pubspec.lock 里锁住的版本。锁文件能看到具体版本和依赖来源,如果某个包指到了本地路径或者 git 源,那它很可能就是有人已经做过渡层或补丁的版本。宁可让依赖树复杂一点,也比编译到一半才发现某个包没有 ohos 实现要好。
2.2 先把鸿蒙 Flutter 工程跑起来,再谈适配
适配三方库之前,一定要先确认基础环境是通的。目前做 HarmonyOS NEXT 的 Flutter 适配,通常是使用社区维护中的鸿蒙分支 Flutter SDK,配合 DevEco Studio 创建应用工程。Flutter 工程和 ohos 工程的关系,可以理解成一个 Flutter module 被嵌进了一个原生鸿蒙壳里,Flutter 代码编译出的产物最终作为 hap 包的一部分被带进去。
我的习惯是先不引入任何三方库,建一个空 Flutter 工程,让它能在鸿蒙真机上弹出界面。这一步跑通,说明引擎、构建链、签名、设备连接都没问题。然后再把项目的 pubspec 整体搬进来,逐步解除对 Android 专用插件的依赖。
这里要提醒一下:不要一开始就照搬 Android 工程的构建命令。鸿蒙侧的命令行、hdc 设备调试工具、资源目录结构都不一样。以我已经接触到的几个版本来讲,构建产物关键词是 hap,调试工具用 hdc 代替 adb。你本地装的具体版本可能略有差别,以该分支的 README 为准。跑通一次构建之后,把命令记进团队文档,后面所有排查都有了可靠基线。
2.3 先写一个不依赖 relic_core 的最小冒烟用例
环境通了之后,不要急着把 relic_core 接进去。先写一个最小冒烟用例:一个页面、一个按钮,点击后往普通数据库表里插一行数据,再读出来。这个用例的目的是把“鸿蒙环境本身”和“relic_core 适配”这两个变量分开。如果普通数据库操作在鸿蒙上都跑不通,说明问题在驱动,不在 relic_core;如果普通操作没问题,加了 relic_core 才挂,那问题大概率在生成代码或缓存调度。
我把这个用例放在一个单独的 sample 目录里,只引入数据库驱动和路径插件,不涉及业务模型。这一步耗时大概一个下午,但它能帮你省后面至少三天的排查时间。因为你在确认问题时可以直接对别人说:“普通 SQLite 在鸿蒙上没问题,是 relic_core 封装后才出现的问题”,问题的边界立刻就清晰了。
3. 核心适配实操:把驱动、路径、序列化三件事焊死
3.1 数据库驱动替换:把生成代码脚下的地基换掉
relic_core 这类库,通常在初始化时需要你传入数据库实例或者执行器的创建方式。原来的代码可能是这样写的(示意):
final database = MyDatabase(); // 生成类 final repo = MyRepository(database: database);到鸿蒙这边,数据库实例怎么来的,就是替换的关键。如果你能找到现成的 ohos 适配驱动,直接替换依赖并在初始化时换成对应工厂方法;如果找不到,就需要用 ffi 或自建轻量通道,让 Dart 侧能执行鸿蒙环境里的 SQLite。
以常见的 sqlite3 封装为例,鸿蒙侧同样走 ffi 的思路。你需要在 ohos 工程里确保 sqlite 的动态库能被加载。这里有两条路径要盯:一是 so 文件是否真的被打进了 hap,二是 Dart 侧加载时用的是不是和实际架构匹配的库。64 位真机上只打 32 位 so,运行时就会出现找不到符号或者根本无法加载的诡异报错。
驱动换完之后,务必要做一次“老代码是否还调用旧 API”的静态检查。搜索代码里所有 import 了原驱动包的地方,一旦有遗漏,运行时就会出现 MissingPluginException 之类的问题。这一步看起来很基础,但我在实际项目里见过太多次“明明换了驱动,老包名还在代码里”的情况了。
3.2 路径获取:别让数据库文件写丢
数据库文件放在哪,看似简单,坑却特别多。原来 Android 上可以通过 path_provider 拿到应用文档目录,鸿蒙上不能再用这一套,需要拿到鸿蒙应用自己的文件目录。
推荐的做法是用鸿蒙适配的路径插件,或者在适配层里自己通过鸿蒙应用上下文获取目录。拿到目录后在日志里把返回的完整路径打出来,确认它落在应用沙盒目录内。
这里有个我踩过的坑:过早调用路径获取。在 main() 里 Flutter 引擎还没完全挂好平台通道时就发起异步请求,有概率返回空或者抛异常。解决办法是等第一帧渲染完成之后,再初始化数据库。这不算 relic_core 本身的问题,但适配时很容易误判成 relic_core 的 bug。
路径拿到之后要拼接完整文件名。一个比较稳的写法是:
final dir = await getApplicationSupportDirectory(); final dbPath = '${dir.path}/relic_core_cache.db';实际用哪个插件,API 名称可能略有差异,以你拿到的版本为准。关键是记住一件事:数据库文件路径一定要可查、可验证。真机上跑完,用 hdc shell 找到对应目录,看到 .db 文件体积在增长,才说明路径这关真的过了。
3.3 数据格式与迁移:把表结构当接口来管
换驱动之后,最容易翻车的是历史数据。relic_core 生成代码时对模型字段有一套自己的序列化规则,日期、枚举、嵌套对象这类复杂类型往往会转成 JSON 字符串或自定义编码。如果新驱动的实现和旧驱动对 SQLite 的类型亲和性处理不一致,老数据可能读出来类型丢失,或者日期格式对不上。
我在迁移时建议做三件事。第一,在数据库层面保留版本号。relic_core 如果没有显式建表版本管理,也要自己在路径旁维护一个 schema-version 文件。第二,日期和时间统一成毫秒时间戳或者 ISO8601 字符串,不要在代码里混用两种格式。第三,老库迁移最稳的方式是“先用新驱动把数据读出来,再清理写入”,而不是在 SQL 层做花式类型转换。
如果是开发阶段,数据丢了也不可惜,那就简单粗暴一点:直接删除旧库文件重建表。省掉复杂迁移逻辑,把精力放在驱动正确性验证上。生产环境绝对不能这么干,生产环境必须走 schema-version 升级链路,否则用户升级 App 的时候会出现数据被清空的事故。
3.4 构建验证与冒烟测试:让数据真正落盘才算过
所有代码改完,接下来是验证环节。我习惯按这个顺序做冒烟测试:
第一步,构建出 hap 包,装到模拟器或真机上,确认应用能启动、页面不白屏。 第二步,调用一次写入,然后调用一次查询,把查询结果渲染到界面上,确认读写通路已经打通。 第三步,杀掉应用进程重启,再去查同一份数据,确认数据确实落盘,而不是只存在内存缓存里。 第四步,连续重启三次并各自写入一条数据,确认没有出现数据库损坏或者文件路径漂移。
第四步相当重要。很多适配问题不是第一次能暴露出来的,是文件路径在重启后变了、或者数据库没有正确关闭,才在第二次启动时报错。我见过最迷惑的现场就是:第一次跑通,第二次启动报 database is locked,原因就是上一次进程没有释放文件锁。
命令层面,鸿蒙设备用 hdc 连接。不过我不太建议过度依赖命令行去翻沙盒文件,因为权限隔离会把操作复杂化。直接在应用内做一个“导出版本、表数量、文件大小”的调试页,比任何命令都直观。
4. 高频问题排查与独家的几条避坑心得
4.1 高频报错速查表
这一节直接上表,都是我和周边团队在同类适配里真实踩过的:
| 现象 | 最可能的原因 | 处理方向 |
|---|---|---|
| MissingPluginException | 某插件没有 ohos 平台实现 | 换成适配包或在 ohos 工程注册实现 |
| sqlite3 library not found | so 未打进 hap 或架构不匹配 | 检查动态库打包配置和 CPU 架构 |
| getApplicationDocumentsDirectory 返回空 | path_provider 未适配 | 用鸿蒙路径插件替换 |
| database is locked | 上轮进程未释放文件锁 | 确保数据库正常关闭,访问串行化 |
| 首帧后 IO 卡顿 | 主 isolate 直接跑重 SQL | 移到后台 isolate,用事务批处理 |
| 编译期 Can not find module | 依赖名写错或没加 ohos 依赖源 | 核对 pubspec 和 lock 文件 |
这个表解决的是“报错一眼能对上”的场景。真正难的是那些不报错、但数据明显不对的问题,比如写入成功、查询却是旧值,那就要检查缓存策略和失效逻辑。relic_core 的内存缓存可能带 TTL 机制,适配过程里如果时钟或调度行为发生变化,缓存表现也会受影响。这属于隐蔽问题,排查时要尤其警惕。
4.2 一套能减小误判的排查流程
遇到问题不要急着改代码,建议按这个顺序走一遍。
先抓日志。鸿蒙上用 hdc 连接设备,过滤 Flutter 和业务自己的标签。Dart 侧异常通常带 [ERROR:flutter] 前缀,原生侧异常会在 hilog 里出现对应系统标签。两边日志对齐,能快速判断问题到底在 Dart 层还是原生层。
再还原最小复现。把业务代码收进 sample,逐步注释掉无关逻辑,直到剩下一条插入链路还能复现问题。这个方法听着笨,但对“偶发”“切换页面才触发”“重启后出现”这类问题特别有效。
然后是二分定位。把问题切成三段:初始化段、查询段、落盘段。分别在每段前后打时间戳和日志,看哪一段耗时异常或者中断。我调试过一次“首次启动后查询返回空”的问题,最后定位到是初始化还没完成、业务代码已经开始查询,属于典型的竞态问题。加一个初始化完成的 Future 句柄就能解决。但如果一开始就往缓存策略上猜,可能要白白折腾好几天。
4.3 性能和包体积的几条实测经验
最后分享几条性能和包体积上的实测心得。
第一,数据库 IO 一定要移到后台 isolate。relic_core 的操作如果默认走平台通道,鸿蒙端的通道调用和主线程耦合比想象中要紧,大量增删改查时掉帧明显。Future 的 then 回调里尽量只放轻量操作,真正重的读写交给 isolate,在小内存设备上效果立竿见影。
第二,批量写永远比逐条写快一个数量级。不管原工程代码风格是什么样,适配阶段给写操作统一加事务,会让数据一致性测试更稳定,也更容易定位性能瓶颈。
第三,控制 so 的体积。如果你通过 ffi 把 sqlite 带进来了,别忘了裁剪原生库。只保留 arm64-v8a 一个架构,能省下一大截包体积;如果还要照顾模拟器,再单独出一个 x86_64 的包,不要把全架构的 so 都打进 release。
其实做完整套之后,我心里最深的感受是:鸿蒙化适配的成败,七八成在动手之前就已经决定了。依赖树看得够不够细、每一步验证做没做扎实,比写一万行适配代码都重要。relic_core 只是一个典型样本,这套“先拆架构、再定方案、最后看数据落盘”的思路,拿去适配别的三方库一样能打。