最近在做 Flutter 应用鸿蒙化改造的时候数据库层把我卡了将近一周。项目里持久化方案用的就是 Drift drift_dev在 Android 和 iOS 上跑了挺久一直很稳结果一迁移到鸿蒙设备光启动崩溃就修了两天。排查到最后问题基本都集中在 sqlite3 动态库加载、沙箱路径和 build_runner 的依赖冲突上。这里先给一个结论drift_dev 本身几乎不是鸿蒙化的瓶颈真正需要动手的是 SQLite 原生库的对接方式以及数据库文件在鸿蒙沙箱里的落位规则。这篇文章就把我整个适配过程中的架构分析、版本锁定、代码改造和排错记录完整分享一下希望对要做 Flutter 鸿蒙 Drift 的人有点帮助。1. 先看清 Drift 的架构再动手鸿蒙化才不至于盲目1.1 别把 drift 和 drift_dev 混为一谈生成器不管运行时很多人第一次接触 Drift 的时候会在 pubspec.yaml 里同时引入drift和drift_dev然后按官方示例跑一遍 build_runner看到生成了一个.g.dart文件就算完事。但这两个包的分工其实特别清晰drift是运行时库负责连接数据库、执行 SQL、管理事务、处理流式查询drift_dev是开发时依赖负责在编译期扫描你的表结构注解生成类型安全的 DAO 代码和数据库类实现。举个例子你在项目里定义一张表class Todos extends Table { IntColumn get id integer().autoIncrement()(); TextColumn get title text().withLength(min: 1, max: 200)(); BoolColumn get done boolean().withDefault(const Constant(false))(); }然后让DriftDatabase(tables: [Todos])修饰数据库类build_runner 就会生成一个_$AppDatabase之类的基类里面包含了建表语句、字段映射、常见 CRUD 的封装。关键在于生成出来的这些代码是纯粹的 Dart 代码不依赖任何平台原生功能。它只和 Dart SDK 版本、drift 运行时版本有关系和你是 Android 还是鸿蒙关系不大。所以drift_dev的代码生成阶段在鸿蒙上完全可以正常工作不需要对生成逻辑做特殊适配。我当时就想当然地以为鸿蒙化要改 build_runner后来调试发现生成的代码一丁点没变。真正的坑是在 runtime 层也就是NativeDatabase如何打开 SQLite 数据库文件。1.2 鸿蒙化适配的真正分界线Dart 代码和 Native 符号Drift 在桌面端和移动端的默认实现是NativeDatabase它内部依赖一个关键包sqlite3。注意这个sqlite3不是 Dart 里调用 SQLite 的抽象封装而是一个通过dart:ffi直接加载系统动态库的绑定包。简单理解Dart 层定义好函数签名然后从.so文件里找符号、绑定到 Dart 函数上最终实现对 SQLite C API 的调用。整个过程大致是Dart 代码调用sqlite3.open(path)绑定层尝试DynamicLibrary.open(libsqlite3.so)从库里找sqlite3_open_v2、sqlite3_prepare_v2、sqlite3_step等函数并建立 FFI 调用之后所有数据库操作都经由这个原生库执行。问题就出在第三步。鸿蒙系统环境里SQLite 库的真实名字不一定是libsqlite3.so。至少在我的目标设备上系统库叫做libsqlite3.z.so而且某些版本/架构下路径还有区别。如果你直接跑原样代码启动时多半会抛“Unable to load library”异常。这才是鸿蒙适配的核心矛盾不是要重写业务逻辑而是要让 sqlite3 绑定包找到正确的原生库。这里建议所有准备做鸿蒙化改造的人先不要急着改业务代码而是花半天时间把依赖链条理清楚。Drift - sqlite3 - DynamicLibrary - libsqlite3.so中间任何一环名字不匹配都跑不起来。理清楚了后面所有问题都是围绕这个主干的细节修补。2. 鸿蒙适配前的环境准备与版本取舍2.1 版本锁定drift、drift_dev 和 Dart SDK 必须对齐版本问题看起来不起眼但实际坑过很多人。Drift 的 runtime 和 drift_dev 虽然职责不同但会通过内置的版本约定检查来保证两者兼容。如果你随便拉一个drift ^2.12.0再配一个drift_dev ^2.10.0运行 build_runner 时大概率直接报错或者在生成代码后出现成员缺失。我们的项目最终锁在dart SDK: 与 Flutter 的鸿蒙适配版绑定建议使用较新的 3.x 版本drift: 2.x 系列drift_dev: 2.x 系列并且和 drift 保持同一个小版本sqlite3: 2.x 系列在pubspec.yaml里我是这么写的dependencies: drift: ^2.12.0 sqlite3: ^2.4.0 dev_dependencies: drift_dev: ^2.12.0 build_runner: ^2.4.0注意drift和drift_dev的版本号前缀一样。不要觉得这样是多余Drift 的注释和生成器版本如果不匹配生成的代码里引用到的一些内部私有方法可能不存在编译期才会暴露出来那可就不好排查了。另外鸿蒙的 Flutter SDK 大多是基于某个 Flutter 版本 fork 的Dart 版本可能比官方版落后一点。建议在项目根目录跑一次flutter doctor -v把 Dart 版本记录下来再去核对 drift 的 SDK 约束。如果 drift 最新版要求的 SDK 太高而鸿蒙 SDK 暂时跟不上那就选择低一个小版本的 drift不要直接升到最新。2.2 处理 sqlite3 依赖来源系统库还是自带 so在鸿蒙上跑 SQLite有两条路可选。第一条路是用鸿蒙系统自带的 SQLite 动态库。好处是不用额外引入 so 文件包体更小而且系统库通常经过平台优化。坏处是库的名称、路径、编译参数不完全由你控制比如启用哪些扩展、是否支持 FTS5、journal_mode 是否允许普通设置等。对于绝大多数业务存储场景系统库完全够用。第二条路是自己把 SQLite 源码编译成鸿蒙可用的 .so然后像 Android 那样塞进工程依赖。好处是完全可控比如你想要 FTS5、RTree、自定义函数扩展都可以编进去坏处是编译链比较长还要考虑 ABI 架构。我当时选了第一条路因为系统库名称只是多了个.z改动成本最小。但要注意使用系统库时不能仅仅依赖sqlite3包默认的加载逻辑因为它写死了libsqlite3.so。你需要做一个动态库加载适配让绑定层尝试多个候选库名。另外sqlite3_flutter_libs这个包在 Android/iOS 上会自动帮你把 SQLite 打包进应用但它的产物里并没有鸿蒙版本的 .so。如果项目之前依赖了这个包来提供 SQLite那么在鸿蒙上要么去掉它、改用系统库要么换一个支持鸿蒙的替代来源。我们项目里是把sqlite3_flutter_libs从依赖里去掉了因为系统库不仅存在而且版本比 Android 上自带的还新没必要重复打包。3. 核心实操drift_dev 生成代码与鸿蒙运行时的对接3.1 用 build_runner 生成 Drift 代码时要注意什么先跑一遍生成命令dart run build_runner build --delete-conflicting-outputs--delete-conflicting-outputs参数很重要。如果项目里同时使用了json_serializable、freezed不同生成器可能会对同一个.dart文件产生多个输出不加这个参数就会因为“输出冲突”直接失败。加了之后 build_runner 会自己删掉旧输出重新生成省得手动清理。如果你的项目结构比较复杂也可以在项目根目录放一个build.yaml对 drift 生成器做微调targets: $default: builders: drift_dev: options: named_parameters: true complex_values: truenamed_parameters控制生成的 DAO 方法是否使用命名参数complex_values控制是否支持复杂数据类型的编码。这些选项官方文档都有说明但建议按团队编码风格统一配置。我在鸿蒙适配过程中没有发现这些选项对运行时有影响它们纯粹是代码生成形态的偏好。生成完成后你会发现多了类似app_database.g.dart的文件。这个文件是纯 Dart 的不涉及dart:ffi或原生库所以鸿蒙编译期基本不存在障碍。如果编译报错多半是 drift 和 drift_dev 版本不一致先检查依赖锁定。另外建议把生成的.g.dart文件提交到 Git而不是在 CI 里临时生成。理由很简单鸿蒙适配阶段可能要反复切换分支如果每个环境都跑一遍 build_runner很容易因为依赖解析顺序不同导致生成结果有微小差异最后查问题都不知道查哪份代码。3.2 自定义 SQLite 加载让 drift 真正跑起来这是整个鸿蒙适配里最关键的一步。先说明一个背景drift提供了一个NativeDatabase.createInBackground的构造函数其中可以传一个databaseOpener这个函数负责根据文件路径返回sqlite3.Database。默认情况下它会使用 sqlite3 包里的sqlite3.open。所以我们只要让sqlite3.open在执行前加载正确的动态库后面的所有东西就都顺了。我当时的做法是给sqlite3包打了一个非常小的 fork在它的 FFI 绑定初始化处增加了一个方法允许外部把DynamicLibrary句柄注入进去。这样就不用改 sqlite3 包源码里一大坨绑定了只需要在应用启动早期调用一次。在lib/sqlite/harmony_sqlite3.dart里核心逻辑大概是import dart:ffi; import package:sqlite3/sqlite3.dart; late DynamicLibrary _sqliteLib; /// 在 App 启动时调用一次完成鸿蒙系统 SQLite 动态库的加载。 void setupSqliteForHarmony() { const candidates [libsqlite3.z.so, libsqlite3.so]; Object? lastError; for (final name in candidates) { try { _sqliteLib DynamicLibrary.open(name); // 注入到 sqlite3 包内部绑定。 // 注意这个方法名来自我们自己的 fork 版本官方版没有。 sqlite3.useDynamicLibrary(_sqliteLib); return; } catch (err) { lastError err; } } throw StateError(无法加载系统 SQLite 动态库$lastError); }为什么写两个候选库名因为有些鸿蒙设备的兼容层可能提供的是标准libsqlite3.so而标准鸿蒙设备上是libsqlite3.z.so。做一次遍历能兼容更多机型代价只是启动时多一次异常捕获对性能几乎无影响。然后在数据库连接处这样写DatabaseConnection openAppDatabase() { // 确保动态库已经注入 setupSqliteForHarmony(); final dbPath _getDatabasePath(); // 这个路径必须落在应用沙箱目录里 return DatabaseConnection( NativeDatabase.createInBackground( File(dbPath), databaseOpener: (path) sqlite3.open(path), ), ); } class AppDatabase extends _$AppDatabase { AppDatabase() : super(openAppDatabase()); override int get schemaVersion 2; }有的朋友可能会有疑问既然databaseOpener里已经调用了sqlite3.open而sqlite3.open用的是注入后的动态库这里是不是多写了一遍并不是。databaseOpener是 drift 留给我们自定义底层数据库打开策略的钩子我们在这里显式调用sqlite3.open并把返回值交给NativeDatabase保证每一步都走的是鸿蒙系统库。如果你不传databaseOpenerdrift 默认也会调用sqlite3.open所以同样是可行的。需要注意的是_getDatabasePath()需要返回一个鸿蒙应用沙箱内的路径。我们不能写死/data/data/包名这种 Android 思维路径也不能用绝对路径。推荐在鸿蒙侧通过 platform channel 把原生层的应用文件目录传进来再用File拼接数据库文件名。代码写法类似FutureString getDatabasePath() async { final appDocDir await getApplicationDocumentsDirectory(); // 如果 path_provider 已适配鸿蒙 final dir Directory(${appDocDir.path}/database); if (!dir.existsSync()) { dir.createSync(recursive: true); } return ${dir.path}/app.db; }如果你的path_provider在鸿蒙上还没适配好那就得自己写一个轻量 MethodChannel 去拿文件目录。这是另一条经验线后面在排错部分单独展开。3.3 数据库迁移与备份数据库资产的“鸿蒙级治理”标题里的“掌控数据库资产”其实就是数据库的版本迁移和备份恢复。Drift 在这块做得比较舒服它提供了一套声明式的MigrationStrategy可以在onCreate、onUpgrade、onDowngrade里定义不同 schema 版本之间的变化。我在项目里只做过一次加表操作但建议把所有迁移逻辑从 App 启动路径里抽出来单独维护。比如这样override MigrationStrategy get migration MigrationStrategy( onCreate: (m) async { await m.createAll(); }, onUpgrade: (m, from, to) async { if (from 2) { await m.createTable(todosMeta); } }, beforeOpen: (details) async { await customStatement(PRAGMA journal_mode WAL); await customStatement(PRAGMA foreign_keys ON); }, );beforeOpen在这里很关键。鸿蒙系统自带的 SQLite 默认是否开启 WAL 和外部键约束不同设备可能不一样。如果你在迁移前依赖外键级联删除强制打开foreign_keys是必要的。WAL 模式能明显降低并发读写时的锁冲突但也带来一个隐患数据库文件旁边会出现.db-wal和.db-shm文件。备份数据库的时候只拷贝.db文件是不完整的必须把-wal和-shm一起拷贝或者先执行PRAGMA wal_checkpoint(TRUNCATE)让变更合入主文件后再备份。这个细节在鸿蒙设备上更容易踩到因为应用被杀或者系统做低内存回收时WAL 文件里的内容可能还在。我当时在验证升级逻辑时只是手动隐藏/恢复主库文件结果好几次数据都回到半小时前后来才发现是 WAL 没处理。如果你在 App 里提供导出备份功能请一定检查备份目录里是否包含了三个文件。另外为了把“数据库资产”看得更清楚drift_dev 还提供了 schema 生成和比较工具。你可以用drift_dev schema dump导出一个 schema 快照和上一个版本做 diff用于审查迁移语句有没有漏掉字段。这个工具是纯 Dart 的鸿蒙环境下也能跑直接在开发机上执行即可不需要连真机。4. 常见故障与排查实录4.1 启动即崩Unable to load library libsqlite3.so这是我们在鸿蒙适配中遇到的第一个崩溃。日志典型长这样Failed to load dynamic library libsqlite3.so: dlopen failed: library libsqlite3.so not found Stack trace: ...看到这个不要慌原因就一句话sqlite3包默认用标准库名而鸿蒙系统里叫libsqlite3.z.so。解决办法就是上面讲的加载适配。如果DynamicLibrary.open(libsqlite3.z.so)在你的设备上也失败了建议先用设备上的文件管理器或者shell看一下系统目录里实际的 SQLite 库文件叫什么。每个系统版本之间命名可能有差异不要照搬别人的名字。另外还有一种情况是库名是对的但动态库依赖的其他符号找不全。比如鸿蒙系统库可能依赖libc或其他系统库FFI 加载时会报dlopen failed。这时候优先确认你用的鸿蒙 SDK 版本是否提供了完整的 SQLite 导出。如果确认是系统库本身缺符号那就必须走自编译 .so 路线了。4.2 build_runner 生成代码时报 conflict生成阶段的另一个高频问题Build failed: 2 outputs created with conflicting contents。表面上看起来是生成文件冲突实际大部分原因是多个生成器同时改动了同一个文件或者不同开发机上的生成顺序不一致。我的处理步骤是先执行dart run build_runner clean手动删除项目根目录下的.dart_tool目录和所有由 drift 生成的.g.dart文件重新执行dart run build_runner build --delete-conflicting-outputs。如果还冲突就检查build.yaml里是否定义了多个 builder 作用于同一组文件或者dependency_overrides里是否强制了某个包版本导致生成器和 runtime 不匹配。在鸿蒙适配场景下还有一个常见的坑本地 fork 的 sqlite3 包版本和 drift 默认依赖的版本不一致导致 Dart 编译器最终解析到了两套 sqlite3 源码。遇到这种问题最好用dependency_overrides把 fork 的包固定在一个版本并在 pubspec.lock 里确认所有依赖都指向同一个路径。4.3 沙箱路径不存在或没有权限数据库文件路径写错了不会崩溃但会在打开数据库时看到类似“cannot open database file”的回报。大部分情况是父目录不存在。Flutter 的path_provider在鸿蒙上如果还没有正式实现返回的目录可能为空或者抛MissingPluginException。我建议不要等第三方适配在鸿蒙工程里新增一个 plugin 通道由原生侧返回应用私有目录// 鸿蒙侧代码示意 let context getContext(this) let filesDir context.filesDir // 返回给 Dart 侧拿到路径后一定要确保数据库文件所在目录已经创建。很多 SQLite 绑定不会自动创建多级目录你要先final dir File(dbPath).parent; if (!dir.existsSync()) { dir.createSync(recursive: true); }另外不要尝试把数据库文件放到应用外部存储或者公共目录。鸿蒙的沙箱机制和 Android 类似但更严格写到共享媒体目录需要申请额外的权限而且漂移也不支持跨沙箱文件。老老实实放在应用私有目录让系统来管理生命周期。4.4 查询慢或数据库打不开先检查 PRAGMA如果前面的坑都避开了库也能打开但某些查询在鸿蒙上特别慢或者并发写入时报database is locked大概率是 PRAGMA 设置不一致。普通场景下建议在beforeOpen里执行几条关键的 PRAGMAPRAGMA journal_mode WALPRAGMA synchronous NORMALPRAGMA busy_timeout 5000PRAGMA foreign_keys ONjournal_mode WAL的好处是读写不互相阻塞尤其适合 Flutter 这种 UI 线程和后台线程都可能访问数据库的场景。busy_timeout是为了避免多 isolate 同时写入时直接抛锁错误。Drift 的NativeDatabase内部有自己的连接池但底层还是 SQLite 原生的锁机制所以这几个参数很有必要。有朋友可能担心 WAL 会带来文件碎片或数据库放大实际可控。不过synchronous NORMAL在 WAL 下允许少量丢失最后一笔事务如果你对数据一致性要求极高可以保守一点保持FULL。这也是为什么我不建议把 PRAGMA 写死在 App 初始化代码里而是放进迁移策略里集中管理。最后再分享一个小技巧适配鸿蒙后一定要做一遍drift_dev生成的 DAO 回归测试尤其是Stream查询。因为 drift 的流查询依赖sqlite3的update_hook而不同系统库对update_hook的支持和触发时机略有差异。我实测下来鸿蒙系统库在普通表上的行为是正常的但如果你用到虚拟表或者 FTS最好写一个自动监听用例跑一遍确认增删改之后流有实时推送。这个不算坑但属于“看着能跑实际可能不刷新”的问题提前发现能省不少事。
阅读完成 · 觉得有帮助?