在鸿蒙设备上跑 Flutter 工程,最让我头疼的从来不是 UI 适配,而是本地数据这一层。一开始图省事,全用 SharedPreferences 存 JSON,数据量一上来,启动分析和列表加载肉眼可见地卡。后来决定引入 Realm,但手写模型映射和维护字段同步又成了新的体力活。折腾了一轮之后,我用 realm_generator 把整个数据层从“手写维护”变成了“声明模型后自动生成”,并在鸿蒙端完成了插件集成和数据架构治理。这篇文章就把这套实战过程拆开讲,包括选型逻辑、工程配置、生成器原理、鸿蒙集成的坑,以及最后沉淀下来的数据层架构,给同样在 Flutter + 鸿蒙 这条路上做落地的人一个可以直接抄作业的参考。
1. 项目拆解与选型思路
1.1 为什么在鸿蒙场景下选择 Realm
在鸿蒙出现之前,Flutter 的本地数据库基本是老三样:SharedPreferences、Hive、sqflite。SharedPreferences 适合小量键值,Hive 性能不错但类型安全弱一些,sqflite 功能强但要从 SQL 语句写起,映射到 Dart 对象靠自己折腾。Realm 之所以在这个项目里胜出,不是因为它比所有人都快,而是因为它在 Flutter 跨端场景里提供了一条比较顺的“对象映射 + 自动生成”路径。
我在做选型对比时,重点看四个维度:编写效率、查询能力、鸿蒙适配成本、团队上手难度。Realm 的 Flutter 绑定在这几个维度上相对均衡。查询可以直接用类似realm.query<Task>('priority > $0', [8])的方式,生成的 Query 对象是惰性求值,排序和过滤都延迟到真正迭代时执行。这一点对处理大列表很友好,不会在查询语句构造时就把全部数据加载进内存。
更重要的是,Realm 的 Flutter 版本把模型对象和数据库字段直接绑定,配合代码生成器,模型类声明完,增删改查的底层字段名全部自动生成。鸿蒙侧虽然需要额外接入原生适配包,但整体接入路径比我想象中短,后面会详细讲。
1.2 realm_generator 在工程里的真实定位
很多同事第一次听到 realm_generator,以为是另一个数据库框架,其实不是。它只是 Realm Flutter 里的一个编译期代码生成器,底层还是 Realm 存储引擎,只是它帮我们把“模型类声明”到“底层字段操作”之间的样板代码全部自动生成出来。
打个比方,手动写 Realm 模型,就像你买了个智能家居中枢,但每个开关都要自己去接线。而 realm_generator 的作用是,你只需要告诉它房间里有什么灯、什么窗帘、什么插座,它自动把控制面板帮你接好。字段名、类型转换、主键映射、索引标记,这些容易写错又繁琐的工作,全部交给生成器处理。
它在工程里的位置大概是:Dart 模型类源码 -> build_runner 执行生成 -> 生成对应的.realm.dart文件 -> 打开数据库时注册 schema。也就是说,它是链接“业务模型”和“存储引擎”的桥梁,一方面保证强类型,另一方面减少手工映射带来的低级错误。
1.3 手写、半生成、全自动的取舍
我一开始见过不少项目是“半生成”状态:模型类自己写,查询和写入时却还是手写字符串拼条件。比如realm.objects('Task').where('ownerName == $0', name),一旦字段改名,这个字符串条件不会在编译时给你任何提醒,跑起来才报错。这种半生成状态其实最危险,因为你以为自己有类型保护,实际还是裸奔。
用生成器之后,查询条件里的字段、排序字段、索引字段都可以由生成的代码提供强类型引用。改字段名时,编译直接提示错误。这个体验差异在我看来是决定性的。当然,全自动也不是没有代价,生成代码的可读性一般,遇到要 debug 底层字段映射时你得打开生成的.realm.dart看一遍,但总比自己手工维护所有映射成本低得多。
2. 工程准备与鸿蒙侧集成
2.1 前置条件与工程结构确认
开始之前,先把基础环境确认好。Flutter 版本建议用稳定渠道的较新版本,鸿蒙工程目录已经存在,并且 FlutterEngine 能在真机上跑起来,这样我们才把重点放到数据库层。如果工程连 Flutter 页面都还跑不通,先解决工具链的问题,否则后面排查数据库层问题时会混入环境噪声。
鸿蒙工程的本地数据库通常落在应用沙箱的 data 目录下,最好在开发阶段把 Realm 文件的 path 固定到一个可预期的位置,比如{getApplicationContext files 下的子目录}/db/xxx.realm,方便后续用工具看数据。我在调试时吃过“找不到数据库文件”的亏,后来发现是路径用了临时目录,系统清理后文件就没了。
2.2 pubspec 依赖与 build.yaml 配置
在 pubspec.yaml 里,realm 作为主依赖,realm_generator 放在 dev_dependencies,build_runner 也放在 dev_dependencies。注意 generator 千万不要放 dependencies,否则业务包体积会变大。示例配置如下:
dependencies: realm: ^3.0.0 dev_dependencies: realm_generator: ^3.0.0 build_runner: ^2.4.0如果你的工程只有一个默认入口,不写 build.yaml 也能直接跑。但我建议还是显式加一个 build.yaml,把生成范围收敛到lib/models目录,避免以后模块多了 generator 全工程扫描,拉长构建时间,也避免误生成一些不该生成的内部类。
targets: $default: builders: realm_generator|realm: generate_for: - lib/models/这里有个小坑:不同版本的 generator 对 generate_for 的写法有细微差异,有的版本用目录结尾的/会识别异常。遇到这种情况,可以去掉末尾斜杠或者指定具体的.dart文件。
2.3 鸿蒙插件的落地方式
鸿蒙侧最麻烦的一步是原生适配包的集成。因为 Flutter 的 realm 包在 Android 上默认依赖 Android 原生模块,鸿蒙并不直接兼容,所以需要找到对应的鸿蒙适配包,把它以源码或 .har 的方式集成进entry工程。
我采用的方式是先在entry/oh-package.json5里声明本地依赖,指向放在entry/libs下的 .har 文件。大致结构如下:
{ "dependencies": { "harmony_realm": "file:./libs/harmony_realm.har" } }这里有个需要特别注意的地方:不要把它加进 Android 的依赖体系里,也不要天真的以为 Flutter 插件会自动传递鸿蒙实现。鸿蒙的 Flutter 插件机制需要在原生工程侧显式声明,且必须使用鸿蒙的包管理方式。集成完成后,先跑一个最简单的Realm(config)打开动作,确认没有MissingPluginException,再继续往下做模型生成。
注意:如果你发现
MissingPluginException,优先检查 .har 是否正确打进包体,以及oh-package.json5的依赖声明是否生效,而不是去改模型代码。
2.4 hvigor 构建与缓存问题
鸿蒙工程构建用的是 hvigor,它有自己的构建缓存。每次改了原生依赖或者新增 .har,最好先清理一次缓存再构建。我遇到过几次奇怪的问题:代码看起来没问题,但运行起来还是旧版行为,清理缓存后立即恢复正常。
具体操作就是在工程根目录执行 hvigor 的清理任务:
hvigor --clear-cache hvigor assembleHap如果你的 IDE 是 DevEco Studio,直接在构建菜单里选择 Clean 即可。这个动作虽然简单,但能省下大量定位假问题的的时间。
3. 模型定义与代码生成实操
3.1 模型类的正确写法
Realm 的模型类必须继承RealmObject,字段类型也有限制,不能直接把自定义普通类塞进去,必须是 Realm 支持的类型:bool、int、double、String、DateTime、ObjectId、Uint8List 以及 RealmList、RealmSet、RealmDictionary 等集合类型。
写模型类时要注意几个点:
- 字段不要用
final,生成器需要对字段做映射。 - 主键字段用
@PrimaryKey()标记,如果主键是自增的 ObjectId,在构造函数里赋值最方便。 - 高频查询字段用
@Indexed(),但不要给所有字段都加索引,因为写事务会有额外开销。 - 不需要持久化的字段用
@Ignore()标记。
一个比较标准的模型类大概是这样的:
import 'package:realm/realm.dart'; part 'task.realm.dart'; class Task extends RealmObject { Task({ ObjectId? id, String title = '', int priority = 0, DateTime? dueAt, }) : _id = id ?? ObjectId(), _title = title, _priority = priority, _dueAt = dueAt; @PrimaryKey() late ObjectId _id; late String _title; @Indexed() late int _priority; late DateTime? _dueAt; @Ignore() bool isExpired = false; }注意头部必须写part 'task.realm.dart';,这是生成器的目标文件。如果忘记这一行,生成器不会报错,但也不会生成对应的映射代码,后续打开数据库时 schema 会缺失。
3.2 运行生成命令与产物解读
模型定义好之后,在工程根目录跑:
dart run build_runner build --delete-conflicting-outputs第一次跑可以把整个生成过程看一遍,尤其注意有没有绿色输出显示生成了哪个文件。如果没生成.realm.dart,先检查 part 指令和文件目录是否匹配。
生成的.realm.dart文件内容一般很长,核心做的事情可以归纳为:为每个字段生成底层 setter/getter,注册字段元信息,生成 schema 描述。这些逻辑普通开发者不需要逐行改,但建议至少扫一眼,知道字段名是怎么映射的,这样以后出现“字段读出来是 null”这类问题时,你能快速定位是生成文件旧了,还是打开数据库时 schema 配置漏了。
打开数据库时,要把模型类的 schema 传进去:
final config = Configuration.local([Task.schema], path: 'db/xxx.realm'); final realm = Realm(config);如果没有传 schema,Realm 会尝试读取现有数据库文件,如果文件不存在通常能自动创建,但如果文件已经存在且 schema 不一致,就会报 misconfigured schema 的错误。
3.3 生成前后的功能对照
生成器带来的收益,列个表更直观:
| 功能点 | 手写代码 | 生成器代码 |
|---|---|---|
| 字段映射 | 每次新增字段要手写映射 | 模型声明后自动完成 |
| 主键冲突检测 | 自己处理异常 | 生成器统一管理元信息 |
| 查询字段名 | 字符串拼接,容易拼错 | 强类型引用,编译期检查 |
| schema 版本管理 | 手动迁移容易漏 | 生成代码携带 schema 信息,减少遗漏 |
| 索引维护 | 需要手动关联 | 注解驱动,自动注册 |
这并不意味着生成器能解决所有问题。它只解决“模型定义和底层存储之间的映射”,不解决业务逻辑,也不替代迁移策略。真正要保证数据层稳定,还是得靠架构。
4. 强类型模型与持久化架构治理
4.1 强类型模型解决了什么
我用一段实际经历说明。之前手写 Map 做缓存时,有一次把字段priority误写成了priorityLevel,JSON 序列化不报错,前端取出来的数据全是默认值。这种问题在编译期完全暴露不出来,只能靠运行时打印日志,配合多个端联调才能发现。
用强类型模型之后,这种低级错误基本绝迹。字段定义在模型类里,生成器自动生成对应的读写方法,任何引用都走编译期校验。哪怕只是迁移数据库时少了新字段,代码都编译不过。
这里要提醒一点:不要在一个模型类里同时混用“业务显示字段”和“存储字段”而不做任何隔离。比如 UI 上需要一个“是否过期”的临时判断,我在模型里用@Ignore()忽略掉,这个字段不会持久化,但可以在模型类里保留计算逻辑。这种做法能让项目快速迭代,但注意不要往模型里塞太多 UI 状态,否则数据层会越来越像一堆散乱的静态变量。
4.2 查询与写入性能调优
Realm 推荐一次写事务处理多个对象,而不是循环单个 add。实际测试时,循环写入的表现特别差,每次事务提交都要等待磁盘同步,几十条数据可能就慢到明显卡顿。正确的做法是:
realm.write(() { for (final item in items) { realm.add(RealmModel.fromMap(item)); } });读操作方面,查询本身是惰性的,使用.sort()、.filter()不会立即执行。但如果你在.watch()流里频繁重建查询,或者把每个列表项都做一次新的realm.find,同样会引发性能问题。更好的方式是数据进入内存或 ViewModel 后,尽量复用同一个查询结果。
索引配置也要克制。索引能加速读,但写入时要多维护一层索引结构。低频筛选字段不要加索引,真正的高频条件字段才值得加。
4.3 事务、监听与 UI 响应式架构
Realm 提供.watch(),可以对单个对象或集合变化做订阅。这个能力很适合 Flutter 的响应式 UI,但使用不当也会成为性能杀手。
我的建议是:UI 层尽量不要直接持有 RealmObject。正确的做法是,数据层把 RealmObject 转化为轻量 DTO 或领域对象,提供给 ViewModel,再由页面渲染。这样做有几个好处:RealmObject 在事务提交后自动刷新,如果 UI 层持有的是旧引用,可能读到的数据与预期不符;另外,领域对象更容易做单元测试和 mock。
监听订阅记得在页面销毁时取消,否则StreamSubscription泄漏会导致页面回收后仍在更新数据。真机上比较常见的现象就是退出列表页再进来,内存不降反升,多半是订阅没清理。
5. 鸿蒙适配中踩过的坑与排查实录
5.1 编译期的坑
我在鸿蒙工程里遇到的最大编译坑,是 Flutter 侧添加 realm 依赖后,原生插件没有被正确打包进 HAP。报错信息是MissingPluginException,但问题实际发生在构建链路上。原因是我把 .har 文件放错了目录,导致 hvigor 没有把它当作运行时依赖打包。
排查顺序很重要:
- 先确认模型文件有没有生成成功,排除 Dart 侧问题。
- 再看鸿蒙原生侧依赖是否加载,最简单的验证是把数据库路径改成明显错误的位置,看能否正常抛出 Realm 相关的路径异常。
- 如果异常是
MissingPluginException,多半是插件没走到鸿蒙的实现,回头检查 oh-package.json5。
还有一次,构建成功但打开数据库时报“unknown table”,一看生成的.realm.dart文件日期还是几天以前的,说明 build_runner 没有真正重新执行,清理构建缓存后重新生成就恢复了。
5.2 运行期的坑
列表页因为查询构造不当变卡的案例也很典型。起初是在每个 item 的 build 方法里写realm.all<Task>().where(...),导致每次滚动都在创建新查询,内存和 CPU 双双报警。改成在 ViewModel 里先获取RealmResults,再对结果做分页切片,性能立刻好了很多。
另一个常见问题是数据库本地文件路径下写到错误的位置,尤其是在鸿蒙文件系统沙箱限制下。如果路径指向了缓存目录,系统可能随时清理掉文件,表现为应用重启后数据丢失。日志里可能什么错误都没有,数据就这么消失了。我把 path 改成应用数据目录后,这个问题再没出现过。
5.3 快速定位问题的方法论
复盘下来,数据库类问题最怕黑盒排查。我建立一个固定流程,先确认生成文件版本,再确认数据库 schema 版本,最后才是看业务代码逻辑。日志要分两层:Flutter 侧打 dart 日志,鸿蒙侧用 DevEco 过滤原生插件日志,两边的错误码对上之后,才定位到具体是查询错误还是 SQLite 层的问题。
| 现象 | 常见原因 | 处理办法 |
|---|---|---|
| 打开数据库报 schema 错误 | 生成文件过期或 schema 列表漏传 | 重新 build_runner,确认传入全部模型 |
| 数据写入后列表不刷新 | watch 订阅未正确注册 | 检查订阅流,页面销毁时取消 |
| 列表卡顿 | 每行重建查询 | 查询结果缓存或提升到 ViewModel |
| 缺少插件异常 | .har 未打包或路径不对 | 检查 oh-package.json5 与入口目录 |
| 应用重启数据消失 | 数据库 path 指向缓存目录 | 改为应用数据目录,保证沙箱持久性 |
6. 工程实践建议与后续扩展
6.1 多模块项目的模型目录规划
项目变大后,不要把所有模型塞在一个文件里。我现在的做法是,lib/models下按业务域划分子目录,比如task/、user/、setting/。每个域里的模型文件独立生成自己的.realm.dart,打开数据库时把所有 schema 汇总。
这样拆分的好处是,团队并行开发时不太会发生文件冲突。每个域模型的字段变化都集中在自己的目录里,review 起来也直观。如果项目更大,可以进一步把models、repositories、providers分别放进各自模块,用 publish 的方式在模块间共享。
6.2 数据迁移策略
Realm 的数据迁移是在Configuration.local()里通过schemaVersion和migrationCallback显式控制的。最开始项目里没有迁移概念,一旦模型增加必填字段,老用户数据库打不开,只能清空重建。后来我补上了迁移策略:
final config = Configuration.local( [Task.schema], schemaVersion: 2, migrationCallback: (migration, oldSchemaVersion) { if (oldSchemaVersion < 2) { // 给已有对象补充默认值,或者重命名旧字段 } }, );迁移回调里最怕直接访问新版本才添加的对象,应该在 migration 提供的入口上操作历史 schema。这种问题跑了之后才明白,迁移不是“改完模型自动就行的”,必须提前设计好版本号规则。
6.3 自动化流水线建议
我最后把生成和迁移检查放进了 CI。每次代码合并前,CI 会执行一次dart run build_runner build并检查 git diff 中是否包含预期的.realm.dart文件变化。这样即使有同事忘了重新生成,也会在流程上被卡住。
另外,release 构建时开的混淆需要小心。RealmObject 子类的字段名要保持安全,否则混淆后的随机字段名会让生成的映射关系错乱。鸿蒙 release 模式尤其要检查 keep 规则。
6.4 后续扩展方向
这套架构稳定之后,后续可以继续扩展的方向包括:把数据库操作抽象成统一的 Repository 接口,方便以后替换或测试;把 Realm 的 schema 版本号纳入应用版本发布检查;把本地数据上传逻辑改造成通过事务队列发布,避免网络异常时数据堆积。每一步都不难,但前提是先把模型生成和鸿蒙集成的基础打好。
我个人的体会是,realm_generator 最核心的价值不是省那些代码量,而是让数据层的变更在编译期就暴露问题。只要模型声明写对,生成代码就是可信的。最后再分享一个小技巧:每次改完模型,先把旧的生成文件删掉再执行 build_runner,能避免很多奇怪的缓存冲突,尤其在鸿蒙这种构建链路比较复杂的工程里,这个习惯很管用。