1. 项目背景:为什么盯上 relic_core 的鸿蒙化
1.1 relic_core 到底解决什么问题
relic_core 是 Flutter 生态里一个定位特别“重”的三方库——它不画界面、不搞动画,专心管数据。核心职责是把上层业务的持久化需求收敛到一个统一抽象里:实体注册、数据库连接管理、事务控制、查询构建、版本迁移,全部在 core 这一层搞定。业务方只需要声明数据模型,通过RelicDatabase拿到Dao或者Repository,就能完成增删改查,完全不用关心底层到底跑的是什么引擎。
我第一次接触这个库是在一个数据量比较大的工具类 App 上,本地仓库、聊天记录、离线缓存全都要落盘。当时对比过 Hive、Isar、Drift 和 relic_core,最后选它是因为两点:第一,查询能力够强,支持联表、聚合、条件分页,Hive 那种 KV 存储根本比不了;第二,它的平台层做了一个叫DatabaseDelegate的接口,所有原生操作都通过这个接口派发出去。当时只觉得是常规抽象,没想到后面做鸿蒙适配时,这个设计直接成了救命稻草。
1.2 鸿蒙适配到底在适配什么
说到这得先解释清楚一个常被问的问题:Flutter 应用不是跨平台吗,为什么上了鸿蒙还要适配三方库?
答案在原生依赖上。Flutter 本身确实是跨平台的,Dart 代码不需要改,但 relic_core 底层依赖 sqflite 的 Android 实现,而 sqflite 在 Android 上又靠的是sqlite3的 Native.so。鸿蒙 App 跑在鸿蒙 Next 和开源鸿蒙上,不走 Android 兼容层,原有的.so压根加载不了,数据层直接瘫痪。
这带来的连锁反应是:登录态丢了、本地缓存全没、离线数据读不出来、埋点上报断档。所以鸿蒙化适配,不是把 Dart 代码翻译一遍,而是把“平台的腿”重新接上——保留 relic_core 上层的查询 API 和业务模型不动,把底下负责跟系统数据库打交道的那一层,从 Android sqflite 换成鸿蒙原生能力。
这个思路想清楚了,后面的工作就变得非常机械:接口梳理、平台通道替换、原生实现重写、逐条验证。难的不是技术单一突破,而是细节多、坑碎。
2. 架构拆解:先搞清楚 relic_core 的“骨架”
2.1 分层设计:从 Dart API 到原生 SQLite
动手适配前,我花了一整天把 relic_core 的源码从头捋了一遍,这个时间非常值得。整个库的分层大概是这样的:
- API 层:对外暴露
RelicDatabase、RelicTable、RelicQuery,只管声明式用法和类型安全。 - 核心层:连接池管理、事务边界控制、SQL 语句生成、迁移执行,不依赖任何平台 API。
- 委托层:
RelicDatabaseDelegate接口,定义了open、execute、query、close等最小集。 - 平台层:Android 走 sqflite 的
getDatabasesPath和SQLiteDatabase,iOS 走sqflite的Sqlite3。
适配的核心锚点就在委托层。relic_core 的设计者刻意把平台差异压缩到一个极小的接口面里,上层永远不知道底层数据库是 SQLite 还是别的。这意味着一件事:理论上我们可以实现一个OhosDatabaseDelegate,把原生调用切到鸿蒙的relationalStore,Dart 业务代码一行都不用动。
2.2 三个核心模块:连接管理、查询构建、迁移
深入源码后,relic_core 里真正复杂的是这三个模块。
连接管理这一块,它支持多数据库实例,用RelicDatabaseConfig区分不同业务库。每个连接内部维护了一个事务栈,嵌套事务通过SAVEPOINT实现。适配时最需要注意的是连接生命周期——鸿蒙侧relationalStore.getRdbStore拿到的RdbStore对象是有状态的原生资源,如果 Dart 侧反复open/close,容易触发底层 fd 泄漏。
查询构建是另一个坎。relic_core 的RelicQueryBuilder不是简单拼字符串,它会根据RelicTable的字段类型生成带参数占位符的 SQL,再配合List<Object?>参数列表统一传给原生层。这种参数化设计在鸿蒙适配时必须原样保留,否则一旦开始拼字符串,SQL 注入和类型转换问题就会全冒出来。
迁移模块相对简单,但也最容易出事故。relic_core 会在RelicMigration里记录版本号,每次启动比对PRAGMA user_version,按序执行onUpgrade。鸿蒙的relationalStore也有版本管理,但两边的版本语义必须提前对齐——我第一次实操就吃了这个亏,Dart 侧版本号已经到 5,鸿蒙侧建库还是初版,直接触发了全表重建。
2.3 适配的本质是替换“平台腿”
把架构看清楚后,你会发现鸿蒙适配的本质就一句话:替换平台腿,保留大脑。
这正是我和团队最早达成的共识。项目里很多人一开始担心鸿蒙化要重写整套数据层,但我们用两周时间验证下来,真正要动的只有 delegate 实现、插件注册、原生数据库调用三部分。其余像查询语法、事务 API、迁移 DSL,全都是 Dart 层的东西,鸿蒙不鸿蒙都一样。
所以后来的所有工作,都围绕一个目标推进:让OhosDatabaseDelegate在行为上尽量等同原有 Android delegate,把差异封装在内部,对上层透明。
3. 鸿蒙化适配的完整实操流程
3.1 环境准备:DevEco Studio 与 OHOS Flutter SDK
先说环境,这块的坑比预想的多得多。
鸿蒙上的 Flutter 目前走的是开源鸿蒙 SIG 维护的分支,不是 Chrome 上直接flutter create就能跑。我当时用的组合是:DevEco Studio 5.0 配套版本的 CLI 工具,配合 gitee 上 openharmony-sig 的flutter_flutter和flutter_packages仓库。版本锁定非常关键,建议直接锁定 release tag,不要用master分支,否则插件的Native接口签名一变,编译报错能查一整天。
编译链上还有一个容易忽略的点:鸿蒙 Flutter 应用最终打包成 HAP,插件要同时打进 HAP 的libs目录。默认模板里不会自动处理三方库的.so,需要在build-profile.json5里手动加依赖声明。我第一次构建时漏配了 native 库路径,运行阶段一直报dlopen failed,说起来都是泪。
3.2 依赖替换:把 sqflite 的 shadow 彻底摘掉
依赖替换是 Mac 上第一个大动作。原依赖树里sqflite_common不能直接使用,因为它的 Android 实现里硬编码了io.flutter.plugins.sqflite的通道名,切到鸿蒙后通道注册会冲突。
我的做法是在 relic_core 的下层加了一个适配包装包,叫relic_core_ohos。这个包做的事情很简单:把RelicDatabaseDelegate的默认工厂从 sqflite 换成自研的OhosDatabaseDelegate,同时重写getDatabasesPath()的路径来源。这样最外层业务代码尽管直接调用RelicDatabase.open(),内部走的已经是鸿蒙通路。
依赖方向: 业务层 → relic_core(Dart API) → relic_core_ohos(实现 DatabaseDelegate) → flutter_ohos_plugin(原生插件) → ohos.relationalStore注意一个细节:pubspec.yaml里别把relic_core_ohos写成普通依赖,建议用dependency_overrides锁定版本,防止回编译时被解析回 Android 通路。
3.3 平台通道改造:MethodChannel 降级问题
relic_core 原有的 Android 通道是基于老式MethodChannel写的,在鸿蒙 Flutter 引擎上会遇到一个问题:通道接口存在,但BinaryMessenger的底层实现不是原来那套,方法调用会走不通。
这里我给的建议是升级到新版Pigeon或者直接用鸿蒙插件的Plugin机制重写。鸿蒙侧插件的基类会提供onMethodCall回调,参数通过 JSON 字符串传递。我们实际使用的是它,Dart 侧负责把 SQL 操作序列化为请求体,原生侧解析后执行。
ArkTS 侧伪代码大概是这样的:
import { Plugin, PluginContext, BusinessError } from '@ohos/flutter_ohos/plugin'; export class RelicOhosPlugin extends Plugin { private storeMap: Map<string, relationalStore.RdbStore> = new Map(); onCreate(ctx: PluginContext): void { // 可在此处初始化线程池或预加载 } onMethodCall(ctx: PluginContext, method: string, param: string, data: BusinessError): void { const req = JSON.parse(param); try { switch (method) { case 'openDatabase': this.handleOpenDatabase(req, data); break; case 'execute': this.handleExecute(req, data); break; case 'query': this.handleQuery(req, data); break; case 'closeDatabase': this.handleCloseDatabase(req, data); break; } } catch (e) { data.reply({ code: 500, message: `${e}` }); } } }通道改造时最容易踩的坑是返回值格式。原生侧回传的Map里不能直接塞Uint8List,需要先把二进制数据转成 Base64 字符串,Dart 侧再解析成Set<Uint8List>,否则类型断言的 exception 会在Result上炸开。
3.4 数据库引擎接入:换成鸿蒙的 relationalStore
数据库引擎的选择上,本来考虑过两条路:一是直接编译 sqlite3 的 OHOS 版本,二是用鸿蒙官方的@ohos.data.relationalStore。
实测下来我更推荐后者,原因很实际:relationalStore是系统级能力,华为把 WAL、加密存储、并发策略都封装好了,不需要自己维护 sqlite 的编译产物。而且从 API 12 开始,relationalStore的接口已经稳定,支持批量插入、事务、谓词查询,足够承载 relic_core 生成的 SQL。
建库的核心代码是拿到 Context 和数据库配置:
import relationalStore from '@ohos.data.relationalStore'; import { common } from '@kit.AbilityKit'; async function openStore(context: common.UIAbilityContext, dbName: string, version: number) { const config: relationalStore.StoreConfig = { name: dbName, securityLevel: relationalStore.SecurityLevel.S1, encrypt: false, }; return relationalStore.getRdbStore(context, config); }要注意的是securityLevel参数。数据敏感度不高可以用S1,如果涉及账号、支付信息,至少提到S2。这个参数直接影响底层是否启用文件级加密,别无所谓地乱填。
拿到RdbStore之后,执行 SQL 就非常直接:
const sql = 'INSERT INTO user (id, name, age) VALUES (?, ?, ?)'; const args: relationalStore.ValueType[] = [1, '张三', 28]; await store.executeSql(sql, args);查询方面,可以把 SQL 字符串用store.querySql(sql, args)跑通,返回的ResultSet再逐行转成 JSON 数组回传。但我更建议高频查询改用RdbPredicates做,性能差距在大量数据下非常明显。
3.5 路径与上下文获取:getDatabasesPath 的正确姿势
relic_core 打开数据库前会先通过getDatabasesPath()拿目录。Android 上这个路径是/data/data/<包名>/databases,鸿蒙上完全不同。
鸿蒙推荐的做法是从插件侧拿到 UIAbility 的 Context,合成数据库目录:
import common from '@ohos.app.ability.common'; function getDatabaseDir(context: common.UIAbilityContext): string { const baseDir = context.databaseDir; return baseDir; }databaseDir在鸿蒙上默认指向应用沙箱内的数据库目录,不需要自己建,也不建议手写绝对路径。沙箱路径每次安装可能变化,硬编码必炸。
Dart 侧我在OhosDatabaseDelegate里做了一层缓存,通过一次getDatabasesPath调用把路径存到静态变量,后续所有数据库实例共用。实测可以减少大约 20% 的插件通道调用次数,对启动速度有一点帮助。
3.6 线程模型与并发:把 Dart isolate 映射到鸿蒙线程
这是整个适配里最容易翻车的地方。
原本在 Android 上,sqflite 默认把 SQL 操作放到后台线程池执行,Dart isolate 不用管线程切换。鸿蒙relationalStore虽然也支持异步调用,但如果你所有操作都压在主线程的 EventLoop 上,大量写操作时会让 UI 掉帧,跑性能压测时现象非常明显。
我的解决方案是给OhosDatabaseDelegate加一个专门的 SQL 执行队列:
let taskQueue = new taskpool.TaskPool();实际操作中没用taskpool,而是用@ohos.worker起了一个专用 Worker,所有executeSql和querySql通过postMessage投递,原生侧按顺序执行,再把结果投回 Dart isolate。这里要特别留意:Dart 侧和原生侧必须保证调用顺序一致,因为 shelf_core 的事务依赖严格串行。乱开并发会导致SQLITE_BUSY频繁出现。
4. 数据存储实战:迁移、查询与性能
4.1 建表与版本迁移:从零开始的完整链路
环境通了之后,最紧要的就是把迁移链路跑通。
relic_core 的迁移模型是版本驱动的。适配后我先把一个空库从版本 1 一路升级到当前版本 5,完整验证了一次迁移代码。迁移 SQL 在 Dart 侧定义,通过 delegate 层执行,所以鸿蒙侧不需要知道业务表结构。
版本 1 到 2 的升级示例:
migration.onUpgrade((db, oldVersion, newVersion) async { if (oldVersion < 2) { await db.execute( 'CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER NOT NULL)', ); } if (oldVersion < 3) { await db.execute('ALTER TABLE user ADD COLUMN avatar TEXT'); } });这里有个实测发现的坑:relationalStore的executeSql对ALTER TABLE ADD COLUMN的支持是正常的,但如果你在同一批次里执行多条 DDL,必须在每条之间显式调用beginTransaction()/commit(),不能依赖batch自动包装。relic_core 的迁移模块本身也是逐条执行的,问题不大,但如果你后续手写批量脚本,务必注意。
4.2 事务操作:嵌套事务的鸿蒙实现
relic_core 对业务层屏蔽了连接细节,业务可以直接调database.transaction(() async { ... })。Dart 层实际是通过BEGIN IMMEDIATE和SAVEPOINT实现的。
鸿蒙relationalStore自带事务 API:
await store.beginTransaction(); try { await store.executeSql('INSERT ...'); await store.commit(); } catch (e) { await store.rollback(); throw e; }但嵌套事务就需要注意了:鸿蒙的beginTransaction不支持嵌套,第二次调用会直接抛异常。我在OhosDatabaseDelegate里用 Dart 侧的计数器模拟了SAVEPOINT语义:最外层走beginTransaction,内层改为直接执行SAVEPOINT sp_1/RELEASE sp_1。这样上层代码完全不用改,嵌套事务也能正常工作。
4.3 缓存与索引:大数据量查询的加速实践
适配完后,性能调优才是重头戏。relic_core 本身有一个可选的RelicCache模块,基于内存 LRU 缓存查询结果。它在 Android 上表现不错,但鸿蒙上首轮实测发现缓存命中率并不高——原因是查询条件五花八门,缓存 key 不好统一。
后来我改了一版:在OhosDatabaseDelegate里加了一个轻量 SQL 指纹缓存,用 SQL 语句加参数列表的哈希做 key,直接把原生ResultSet的行映射结果缓存住,TTL 设 30 秒。效果立竿见影,首页列表的重复查询延迟从 80ms 降到 5ms 以内。
索引也值得单独说。原来的表结构只有主键索引,加了一个idx_user_name和idx_user_age之后,按名字查询的耗时从 300ms 掉到 20ms。说实话,这个性能提升比一堆缓存策略都有用,强烈建议在迁移脚本里直接建好常用查询索引。
4.4 加密与敏感数据保护
如果你要存 token、密钥这一类敏感数据,靠relic_core本身的明文存储肯定不行。Android 侧原方案通常是 sqlcipher 加密库,鸿蒙这边有两条路:
- 使用
relationalStore的encrypt: true,由系统层做文件加密; - 在 Dart 层对敏感字段做 AES-GCM 加密,落库前加密,读出后解密。
我两条都试了,最终选择后者。原因是系统级加密一旦开启,数据库的读写性能下降比较明显,而且备份迁移时会遇到密钥绑定问题。Dart 层的字段级加密更灵活,业务只需对TokenModel的token字段做处理即可。实现时用cryptography包的 AES-GCM,密钥从鸿蒙@ohos.huks生成并保管,避免硬编码。
5. 踩坑记录与排查速查
5.1 常见错误速查表
适配过程中,我们把团队遇到的问题都记在一张表里,后面接手的人对照就能排查:
| 错误现象 | 可能原因 | 解决方案 |
|---|---|---|
dlopen failed/ 找不到 so | build-profile.json5缺 native 依赖 | 检查 HAP 的libs目录是否包含插件.so |
| 数据库打开后是空表 | relationalStore版本号与迁移脚本不一致 | 核对StoreConfig.version与PRAGMA user_version |
| 事务嵌套报错 | 鸿蒙beginTransaction不支持嵌套 | 用 Dart 计数器模拟SAVEPOINT |
| 查询返回字段类型错乱 | ResultSet的getDouble/getString类型判断不当 | 使用getByType并根据列类型分支处理 |
| 通道调用超时 | 原生侧长时间执行 SQL 未异步化 | SQL 操作全部改到 Worker 线程 |
| 写入频繁时 UI 掉帧 | 任务压在主线程 EventLoop | 使用独立 Worker 串行执行 SQL |
SQLITE_BUSY频繁 | 多连接并发写 | 全局只保留一个RdbStore连接 |
| 安装后数据库丢失 | databaseDir路径被硬编码 | 每次从 Context 动态获取 |
5.2 三个最头疼的问题实战复盘
第一个是版本号不一致。一开始我把relationalStore的版本设成 1,Dart 侧迁移已经跑到 5,启动时鸿蒙侧发现版本不符,直接抛异常。这个问题排查了两天才发现,因为异常信息在插件通道里被包了一层,不会直接显示在 Flutter 控制台。
第二个是ResultSet 遍历太慢。鸿蒙ResultSet的getString是按列取值,如果循环里频繁调用,性能会非常差。我改成先把整行copyRow()取出来,再组装成对象,速度提升数倍。大查询场景下,建议直接拉全量行再在内存里构建,否则逐行跨线程投递的开销巨大。
第三个是并发写锁。测试并发任务时经常报SQLITE_BUSY,后来定位到是因为/data里有多个连接同时写。relationalStore虽然内部做了锁,但跨插件通道的多连接调用还是会互相抢占。我的做法是全局单例,只保留一个RdbStore实例,所有操作串行执行。
5.3 性能与稳定性验证清单
收尾前跑一轮完整的验证,我列了一份自查清单,基本上每个适配场景都能直接抄:
- 启动阶段:连续打开 5 个业务库,确认无
dlopen和版本冲突报错; - 读写延迟:单条插入、批量插入(1000 条)、按索引查询,分别记录耗时;
- 事务压力:连续 100 次嵌套事务 + 回滚,确认不会抛
SQLITE_BUSY; - 迁移测试:空库直升最新版本、版本回退、迁移中途中断,三种情况都必须验证;
- 线程验证:所有原生 SQL 操作在 Worker 侧执行,UI 线程无
block日志; - 通道稳定性:高频调用 10 万次查询,确认无内存泄漏和回调泄漏;
- 真机兼容:至少跑两台不同性能的鸿蒙设备,确认低端机无崩溃。
这套验证跑完后,我们的relic_core_ohos适配包基本稳定,后续业务迭代再也没碰过数据层。
最后说一点个人实操体会。三方库鸿蒙化的重点从来不是把 API 抄一遍,而是把架构边界搞清楚。relic_core 的 delegate 抽象这次帮了大忙,也让团队明白一个道理:写 Flutter 业务时多留一层平台抽象,将来面对新的系统平台,你手里就永远有牌可打。适配期间踩过的版本号、通道类型、并发锁这些坑,其实都不是什么高深问题,全是细节——但细节一旦疏忽,排查成本是成倍增长的。