做跨端应用的人应该都体会过,数据库这块到了鸿蒙上总会卡一卡。明明在 Android 和 iOS 上跑得好好的 ORM 方案,一换到鸿蒙设备,要么是三方插件没有对应实现,要么是生成的代码里还留着旧平台的调用通道。我今天想聊的是怎么把 Flutter 生态里非常常用的 SQLite ORM 代码生成器 floor_generator 接到鸿蒙持久化体系上,形成一条能落地的适配路径。这套方案适合正在做 Flutter 项目向鸿蒙设备迁移的团队,也适合那些不愿意手写 SQL、想继续保留 ORM 治理能力的同学。我会从 floor_generator 的生成机制讲起,到运行时如何接管数据库打开流程,最后把踩过的坑完整列一遍。
1. 先把问题看明白:floor_generator 为什么会在鸿蒙上“水土不服”
1.1 floor 与 floor_generator 各管哪一段
很多人在接触 floor 的时候,容易把它当成一个普通的 ORM 框架,但实际上它是双层结构:
- floor 运行时库:负责提供
@Entity、@Dao、@Database注解,以及FloorDatabase、Executor、Migration这些运行期基类。 - floor_generator 生成器:基于 Dart 的分析器(
analyzer)读取源码的抽象语法树,把实体类、DAO 接口翻译成可执行的 SQL 映射代码,再由build_runner驱动生成.g.dart文件。
这种设计在国内团队里很讨喜:实体和 DAO 只需要写一次,CRUD 的基础 SQL 不用手动维护,生成出来的代码是普通 Dart 文件,业务层完全无感。而且 floor 的生成逻辑层面是纯 Dart 的,理论上只要能跑 Dart VM 就能执行生成命令。
问题恰恰就出在“生成逻辑”和“生成产物”之间的落差上。floor_generator 不负责真正操作 SQLite,它的产物最终会调用 sqflite 这个插件,而 sqflite 在鸿蒙平台上没有对应的原生实现。所以当你在鸿蒙上执行 build_runner build 时,往往能顺利拿到生成文件;可一旦 app 运行起来,只要数据库一打开,马上就崩在找不到原生通道。这也是我当时排查时最迷惑的地方:生成成功并不代表适配完成,编译不报错也不代表能落库。
1.2 鸿蒙化真正卡住的是运行时依赖,不是代码生成本身
如果把数据库类比成一栋楼,floor_generator 只负责出设计图纸,真正把楼盖起来的是 sqflite 这个“施工队”。在 Android/iOS 上施工队认识本地 SQLite,而在鸿蒙上施工队既没有入场证,也没有对应的建材接口。
sqflite 的默认实现是这样的:
dart复制Future<Database> openDatabase(...) {
return databaseFactory.openDatabase(path, options);
}
databaseFactory 是一个全局对象,默认指向通过 MethodChannel 与原生侧通信的 sqfliteDatabaseFactoryDefault。MethodChannel 这个名字本身就是写给原生平台看的,Android 和 iOS 分别注册了同名 handler,鸿蒙侧如果没有这套注册,调用自然会失败。
你只要重新捋一遍这个链,就会找到一个很好的“劫持点”:floor_generator 生成的代码调用的是 sqflite.openDatabase 这个顶层函数,而这个函数本身只是对全局 databaseFactory 的一层转发。换句话说,只要我们在运行时把 databaseFactory 换成一套能对接鸿蒙关系型数据库的工厂,floor_generator 的生成代码一行都不用改。
1.3 兼容边界:哪些 floor 能力可以完整保留
开始动手前,建议先建一张“能力保留清单”,避免适配到一半才发现某个常用功能根本没法支撑。我整理过一次,大致是这样的:
| floor 能力 | 鸿蒙适配后能否保留 | 说明 |
|---|---|---|
| 实体映射与类型安全 | 能 | 生成器只做 AST 变换,与平台无关 |
| 自动 CRUD SQL | 能 | SQL 由生成器产出,鸿蒙 SQLite 直接兼容 |
| 事务与外键约束 | 基本能 | 需要自研数据库工厂里手动触发 PRAGMA |
| schema 版本迁移 | 能 | 依赖 SQLite 版本机制,鸿蒙底层兼容 |
| sqflite 原生能力 | 不能 | 需要切换到鸿蒙关系型数据库 API |
| 查询日志与调试输出 | 能 | 可以在自定义工厂里用 logStatements 透传 |
这张表做完之后,你会发现 floor_generator 最值钱的部分其实都是跨平台的,被卡住的只有最底层那个连接器。所以不需要大改生成器,也不需要换掉整个 ORM,只要在连接器上做文章就够了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 适配方案的整体设计:绕过生成器,接管数据库工厂
2.1 核心原则:不改 floor_generator 源码
网上有一种做法是 fork 一份 floor_generator,然后在模板里把 sqflite 的 import 替换成自研的鸿蒙数据库包。这种思路看起来直接,但维护成本大到离谱:每次升级 floor 版本都要手动 merge 上游模板,还得保证 AST 版本解析逻辑一致,否则生成出来的代码签名对不上,编译期间就爆炸。
我的原则是尽量不改生成器,只改运行时的“依赖注入点”。floor_generator 已经把代码生成这层做到足够好了,我们没必要去破坏它。鸿蒙适配的本质是给一张已经画好的图纸匹配一个能施工的队伍,而不是重新画一张图纸。
所以,我选择用 databaseFactory 这个全局入口来做接管。它在 sqflite 里是稳定 API,floor 也是通过它走的数据库打开流程。替换成自定义工厂之后,生成的代码、实体映射、DAO 分发逻辑全部保持原样,唯一变化的是底层的 SQLite 连接由鸿蒙原生关系型数据库提供。
2.2 链路拆解:从注解到鸿蒙数据库,数据流过哪些层
把整条链路画出来更好理解:
实体类 + DAO 接口 → floor_generator 解析注解 → 生成 Executor 与 DAO 实现 → 业务层调用 databaseBuilder.build() → floor 内部 SqfliteDatabaseConnection.open() → 调用 sqflite.openDatabase() → 转发到全局 databaseFactory.openDatabase() → 自定义鸿蒙工厂 → 鸿蒙关系型数据库存储引擎 → SQLite 文件落盘。
这段链路里,前五步都是纯 Dart 的,到了全局 databaseFactory 才算是跨平台分叉点。Android/iOS 下它会走进原生插件,鸿蒙下我们就让它走进自研工厂。这样做的好处是一旦出现诡异问题,排查范围可以迅速收敛到自研工厂内部,而不是把整个 ORM 生成体系翻一遍。
另外还有一层要顺手处理掉:databaseFactory 除了 openDatabase,还包含了 deleteDatabase、databaseExists、getDatabasesPath 这些操作。如果你只重写了 openDatabase,后面做数据库清理或路径判断时会发现仍然走的是原生插件,一样会崩。我在第一版适配时就吃过这个亏,只替换了主流程,结果 deleteDatabase 在测试销毁数据库时又把原生通道拉了回来。
2.3 两套替换策略的取舍
除了改 databaseFactory,还有一条更彻底的路:用 dependency_overrides 把 sqflite 整个包指向本地 fork。两条路各有适用场景,我做个对比:
| 策略 | 侵入性 | 维护成本 | 适用范围 |
|---|---|---|---|
| 运行时替换 databaseFactory | 低 | 中 | 大多数场景都够用 |
| dependency_overrides 覆盖 sqflite 包 | 高 | 高 | 需要同时替换 getDatabasesPath 等顶层函数时 |
实际项目里我建议先走第一条,把数据库打开、事务、迁移跑通之后,再评估是否需要覆盖整个包。有些团队喜欢一上来就把 sqflite 换成自研同名包,结果发现自研包还得维持 sqflite 对外开放的大量符号,纯属给自己挖坑。
当然,如果 floor 未来的版本调整了数据库连接实现,不再依赖顶层 databaseFactory,而是直接实例化内部类,那就只能考虑 fork 或者用 dependency_overrides 做类替换。但从目前稳定版本来看,全局工厂这条路是最平滑的。
3. 实操:把 floor_generator 的产物接到鸿蒙持久化层
3.1 环境准备:先确认鸿蒙 Flutter SDK 能跑通 build_runner
动手之前先把基础环境确认好。首先是鸿蒙 Flutter SDK 要正确安装,flutter doctor 能识别到鸿蒙设备或模拟器。这一步看似废话,但在真正做版本适配时很关键,因为不同版本的鸿蒙 Flutter SDK 对应的 Dart 版本可能不一样,而 floor_generator 对 analyzer 版本很敏感。
建议先建一个空壳工程,把依赖加进去:
yaml复制dependencies:
floor: ^1.4.2
dev_dependencies:
floor_generator: ^1.4.2
build_runner: ^2.4.8
然后跑一次 dart run build_runner build,看能不能在空模板上正常生成。如果报版本冲突,比如 analyzer 与 build_runner 相互嫌弃,优先检查 Dart 版本是否被鸿蒙 SDK 锁得太旧。我遇到过一次因为 Dart 版本低于 3.0 导致 floor_generator 完全无法解析新语法的情况,最后只能调整 SDK 分支解决。
另外建议从一开始就加上 --delete-conflicting-outputs 参数,避免后续接入其他代码生成器(比如 JSON 序列化)的时候,因为输出文件冲突导致反复清理缓存。命令是这样的:
bash复制dart run build_runner build --delete-conflicting-outputs
3.2 用 floor_generator 生成标准产物
这里用一个简单的待办事项表做示例。先定义实体:
dart复制// lib/db/entity/todo_entity.dart
import 'package:floor/floor.dart';
@Entity(tableName: 'todo')
class TodoEntity {
@PrimaryKey(autoGenerate: true)
final int? id;
final String title;
final bool completed;
TodoEntity({this.id, required this.title, required this.completed});
}
再定义 DAO:
dart复制// lib/db/dao/todo_dao.dart
import 'package:floor/floor.dart';
import '../entity/todo_entity.dart';
@dao
abstract class TodoDao {
@Query('SELECT * FROM todo WHERE completed = :completed')
Future<List<TodoEntity>> findTodosByStatus(bool completed);
@insert
Future<int> insertTodo(TodoEntity entity);
@delete
Future<int> deleteTodo(TodoEntity entity);
}
然后是数据库入口类:
dart复制// lib/db/app_database.dart
import 'package:floor/floor.dart';
import 'dao/todo_dao.dart';
import 'entity/todo_entity.dart';
@Database(
version: 1,
entities: [TodoEntity],
exportSchema: true,
)
abstract class AppDatabase extends FloorDatabase {
TodoDao get todoDao;
}
跑完 build_runner 后,工作目录里会多出 app_database.g.dart。先别急着改它,先打开看两个关键位置:
$FloorAppDatabase类的databaseBuilder如何构造数据库实例;- 内部有没有直接调用
sqflite.openDatabase以及getDatabasesPath。
这一步是理解鸿蒙适配的核心前提。生成代码里对 sqflite 的 import 大多数是 package:sqflite/sqflite.dart,它暴露出来的顶层函数会在运行时通过全局工厂转发。所谓适配,就是在业务代码初始化时把全局工厂换成鸿蒙实现。
3.3 实现鸿蒙数据库工厂,这是最核心的适配层
databaseFactory 的类型来自 sqflite 的 DatabaseFactory 接口。我们需要实现几个方法:openDatabase、deleteDatabase、databaseExists、getDatabasesPath、setDatabasesPath。其中最重要也最复杂的自然是 openDatabase。
下面是一个裁剪过的自定义工厂骨架,用来展示关键逻辑:
dart复制// lib/ohos/ohos_database_factory.dart
import 'package:sqflite/sqflite.dart';
import 'package:sqflite/sqlite_api.dart';
class OhosDatabaseFactory extends DatabaseFactory {
@override
Future<Database> openDatabase(String path, DatabaseFactoryOptions options) async {
// 1. 通过 MethodChannel 或者自定义原生桥接层打开鸿蒙关系型数据库
final nativeStore = await OhosRelationalStore.open(path);
// 2. 用原生句柄包装成 Dart 侧的 Database 对象
final database = OhosDatabase._(nativeStore, path);
// 3. 调用 onConfigure,确保 PRAGMA 在事务外生效
await options.onConfigure?.call(database);
// 4. 根据版本号判断是否需要执行 onCreate / onUpgrade / onDowngrade
final currentVersion = await database.getVersion();
if (currentVersion == 0) {
await options.onCreate?.call(database, options.version);
await database.setVersion(options.version);
} else if (currentVersion < options.version) {
await options.onUpgrade?.call(database, currentVersion, options.version);
await database.setVersion(options.version);
} else if (currentVersion > options.version) {
await options.onDowngrade?.call(database, currentVersion, options.version);
await database.setVersion(options.version);
}
return database;
}
@override
Future<bool> databaseExists(String path) {
return OhosRelationalStore.exists(path);
}
@override
Future<void> deleteDatabase(String path) {
return OhosRelationalStore.delete(path);
}
@override
Future<String> getDatabasesPath() {
return OhosPathProvider.getDatabasePath();
}
@override
Future<void> setDatabasesPath(String path) async {
await OhosPathProvider.setDatabasePath(path);
}
}
这里最容易踩坑的是回调顺序。sqflite 的语义是:onConfigure 在事务外面执行,适合放 PRAGMA foreign_keys = ON、PRAGMA journal_mode = WAL;onCreate 和 onUpgrade 则默认在事务里面执行,保证迁移中断时不会留下半套表结构。自定义工厂如果顺序搞反了,外键约束会在建表之后才被关闭掉,一开始看起来正常,后面删除父表记录时会冒出诡异的外键异常。
OhosDatabase 这个类需要实现 Database 接口中的 query、insert、update、delete、execute、transaction、close 等方法。这些方法本质上是把 floor 生成的 SQL 字符串原样发给鸿蒙关系型数据库执行。鸿蒙的关系型数据库 API 支持 SQL 字符串执行,所以 floor 生成的标准 SQL 基本不用做转换,只有少量类型映射需要留意,比如 Dart 的 bool 在 SQLite 里存的是整数 0/1,读取出来后要转回 Dart 的 bool。
3.4 初始化引导:把全局工厂注入到应用启动流程
自定义工厂写完之后,还需要一个专门的入口负责“抢跑”。因为 databaseFactory 是全局状态,必须在任何数据库操作之前完成赋值。最稳妥的位置是在 main() 开头:
dart复制// lib/main.dart
import 'package:sqflite/sqflite.dart';
import 'ohos/ohos_database_factory.dart';
void main() {
databaseFactory = ohosDatabaseFactory;
runApp(MyApp());
}
注意一点:如果工程里同时兼容 Android/iOS 和鸿蒙两套平台,不能无脑把 databaseFactory 覆盖成鸿蒙工厂。建议加一个环境判断,只有跑在鸿蒙设备上才覆盖:
dart复制import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
import 'ohos/ohos_database_factory.dart';
void bootstrapDatabase() {
if (defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS) {
return;
}
databaseFactory = ohosDatabaseFactory;
}
这样老平台的逻辑完全不动,鸿蒙走新工厂。业务层不需要关心自己跑在哪套数据库上,floor 生成的 DAO 和实体映射代码通通不用分叉维护。
3.5 让数据库 schema 成为可审计的资产
floor_generator 有一个很容易被忽略的能力:@Database(exportSchema: true)。这个开关会在每次生成时,把数据库当前版本的 schema 给导出一份 JSON 文件。这份 JSON 应该提交到版本库里,作为数据库资产的一等公民对待。
你需要在 build.yaml 里指定 schema 的导出位置:
yaml复制targets:
$default:
builders:
floor_generator:
options:
generated_migrations: true
schema_location: lib/db/schema
导出的 schema 文件可以用于后续版本升级时的对比,也能让审查数据库变更变得更直观。我习惯在每次改动实体字段后,先跑一遍生成器,再看 schema diff,确认是否多了不该出现的列。
有了 schema 文件和 migration 机制,数据库版本治理才算闭环。比如从版本 1 升到版本 2,新增一个 due_at 字段:
dart复制// lib/db/migration.dart
import 'package:floor/floor.dart';
class Migration1To2 extends Migration {
@override
Future<void> migrate(ExecutableDatabase database) async {
await database.execute('ALTER TABLE todo ADD COLUMN due_at INTEGER');
}
}
在创建数据库时注册它:
dart复制final database = await $FloorAppDatabase
.databaseBuilder('app.db')
.addMigrations([Migration1To2()])
.build();
这套迁移逻辑与平台无关,鸿蒙适配不会影响迁移流程。有了完全可控的 schema 导出和迁移脚本,持久化层才真正变成了可以被审查、被回滚的资产。
4. 常见问题排查与避坑实录
4.1 运行时提示 MethodChannel 找不到,或者数据库打开直接崩溃
这是最典型的问题,原因基本集中在全局工厂没有被正确替换。排查顺序如下:
- 检查
main()里是否在runApp之前执行了databaseFactory = ohosDatabaseFactory。 - 确认有没有多个入口,比如某些自动化测试或 isolate 里绕过了初始化。
- 如果用了自定义依赖注入容器,检查数据库实例的创建时机是否在赋值之后。
我在一个模拟器上遇到过一种情况:系统会恢复上一次进程状态,导致 runApp 被触发两次,第二次执行时全局工厂被某个缓存模块重置回默认值。这种问题很难从代码里一眼看出来,建议做一个启动日志,把当前 databaseFactory 的 runtimeType 打印出来,能省不少排查时间。
4.2 事务回滚和外键约束表现不一致
sqflite 的 transaction API 对嵌套事务做了很完善的保护,内层事务失败时不会直接破坏外层。鸿蒙关系型数据库原生接口虽然在绝大多数场景兼容 SQLite 语义,但在事务嵌套和保存点的处理上有差异。自定义 OhosDatabase.transaction 时,如果只是简单地把 SQL 往底层一丢,很可能会遇到内层失败后外层也被异常状态污染的情况。
我的实现思路是维护一个 _transactionLevel 计数。第一次进入事务时,发起 BEGIN IMMEDIATE;嵌套进入时,只做计数加一,不真的开启新事务。内层执行失败时,把错误记录到一个挂起标记里,等最外层收到错误后才统一 ROLLBACK。这套模仿 sqflite 行为的方案实测下来最稳。
外键约束方面,务必在 onConfigure 里执行:
dart复制await database.execute('PRAGMA foreign_keys = ON');
而且要在自定义工厂里确保这个回调执行后,任何建表语句才被发起。如果你把 onConfigure 放到了 onCreate 后面,外键约束只对之后的操作生效,已经建好的表不会因为 PRAGMA 改变而重新校验数据。
4.3 build_runner 与其他代码生成器冲突
floor_generator 不是工程里唯一的生成器,一旦和其他代码生成器共存,经常会听到 Conflicting outputs 的报错。不要慌,这通常是 build.yaml 里没有做 target 隔离。最简单的处理是在 build_runner 命令后面加 --delete-conflicting-outputs,但我更推荐对每个生成器定义独立的 target:
yaml复制targets:
$default:
builders:
floor_generator:
generate_for:
- lib/db/**/*.dart
这样 floor_generator 只扫描数据库模块相关的源码,JSON 序列化生成器则负责它自己的目录,两者不会抢同一份输出文件。这个调整看起来是工程规范问题,但对鸿蒙移植尤其重要,因为你本来就多了一套自研工厂和桥接代码在工程里,生成器的扫描范围变大后,出错的概率也直线上升。
4.4 schema 升级导致数据被清掉
floor 在版本号提升且没有注册对应 Migration 时,不同版本的处理策略不一样。有些版本直接抛异常,有些版本则会走 drop 重建的逻辑。无论哪种,对线上数据都是灾难级的。所以我的习惯是“版本号必须和 Migration 一一对应”,宁可注册一个空 Migration,也绝不跳版本。
在鸿蒙适配阶段,由于需要调试自研工厂,我一度频繁改版本号测试迁移流程。某一次升级时忘写 Migration,结果测试设备里的本地数据在重新打开数据库后只剩空表,损失了一天的测试数据。从那以后我改动了实体结构的第一件事,就是去 lib/db/schema 目录里确认新 JSON 文件已经生成,而不是闷头继续写业务。
4.5 查询日志与慢 SQL 定位
floor_generator 默认不会打印 SQL 日志,但在自研工厂里可以打开。在 openDatabase 方法中,如果 options.marker 或者 logStatements 为 true,就把每次 query 的 SQL 通过 debugPrint 输出。鸿蒙侧的关系型数据库也允许记录耗时,可以把超过 200ms 的查询单独标记出来。这个能力在排查“为什么页面打开变慢了”的时候极其有用。
5. 这次适配做下来,我最大的几点体会
第一点:能不改生成器就尽量不要改生成器。floor_generator 的价值在于它和数据库实现解耦,只负责生成抽象映射代码。只要你不去破坏这层抽象,鸿蒙适配就永远可控。后来我再看整个适配工时,发现大头根本不是代码生成,而是自定义数据库工厂里的回调顺序、事务语义、类型映射这些细节。
第二点:鸿蒙侧的持久化不是简单翻译一遍 API。你仍然需要把 SQLite 的表、索引、迁移脚本当成正经资产来治理,而 floor_generator 恰好提供了 schema 导出和 migration 框架。相关能力在鸿蒙上一样适用,所以数据库资产治理的流程从 Android/iOS 迁移到鸿蒙,几乎没有折损。
第三点:初始化顺序是鸿蒙适配里最容易被低估的问题。只要 databaseFactory 赋值晚于数据库实例创建,一切都会变得不可控。我后来直接写了一个启动自检,在 debug 模式下断言数据库构建前 databaseFactory 已经是指定类型,配合日志输出,把这类问题挡在了早期。
最后再分享一个小技巧:在完全跑通之前,别急着把自研工厂里的所有方法都实现完整。先只实现 openDatabase 和 databaseExists,让它能建库、能查表,跑通一个 CRUD 闭环,再逐步补齐 deleteDatabase、getDatabasesPath 这些冷门路径。这样排障范围最小,也最能快速反馈 floor_generator 生成的 SQL 在鸿蒙底层是否真的兼容。
