资讯动态

Flutter 鸿蒙迁移实战:drift 数据库 SQLite 动态库适配指南

发布时间:2026/10/14 14:50:43 来源:尧图企业网站定制
drift_dev 的鸿蒙化适配听起来只是“换一个平台跑起来”实际动手才会发现整个 Flutter 项目里看起来最“平台无关”的数据库层反而是鸿蒙迁移时最容易翻车的一环。drift 是我在 Flutter 项目里用得最顺手的本地数据库方案类型安全、迁移可控、SQL 能力强之前从 Android 到桌面端一直没出过问题。直到某次准备跑鸿蒙真机才发现代码生成、编译全都正常一打开数据库直接报错错误信息翻译过来就是“找不到 sqlite3 动态库”。这篇文章不做 drift 的基础科普只聚焦一件事把一个用了 drift_dev 的 Flutter 项目安全、稳定地跑到鸿蒙设备上。我会把整条链路拆开讲包括 SQLite 动态库怎么搞、怎么打包进 hap、Dart 侧怎么加载、沙箱路径怎么处理以及跑通之后又踩到哪些 Android 上没有的坑。正在做 Flutter 鸿蒙迁移的团队可以参考打算把 drift 放进鸿蒙方案但还没动手的同学也能提前对工作量有个数。1. drift 的依赖链鸿蒙适配到底卡在哪1.1 drift 不是“平台插件”而是一条 FFI 调用链很多人以为 drift 和 sqflite 一样是依赖 Android、iOS 平台能力实现的插件。这是最大的误解。drift 把 SQLite 跑在纯 Dart 侧通过package:sqlite3里的 Dart FFI 技术直接加载一个动态库再调用 SQLite 的 C 接口。整套依赖可以简化成下面这条链drift_dev编译期代码生成 ↓ drift运行时查询和迁移 API ↓ NativeDatabasedrift/native.dart ↓ package:sqlite3 ↓ Dart FFI → DynamicLibrary.open ↓ libsqlite3.so必须由系统或应用提供 ↓ SQLite C API关键就在最后两级。drift 本身不包含任何平台代码它只需要一个“能打开的动态库”。在 Android 上这个 so 是由sqlite3_flutter_libs这类辅助库带进去的在 iOS 上可以直接加载系统自带的 SQLite在桌面端则依赖系统里已有的 SQLite。鸿蒙的问题在于默认的 Flutter 鸿蒙适配版里并没有一个“sqlite3_flutter_libs 鸿蒙实现”这样的配套组件系统侧也不是每个版本都能让你直接找到libsqlite3.so。1.2 鸿蒙适配版 Flutter 到底缺了什么我当时的直觉是先找 drift 的鸿蒙化插件搜了一圈发现官方根本没有。后来检查鸿蒙侧 Flutter 插件的加载方式才想明白一个关键点drift 不需要注册插件因为它根本不走 MethodChannel 那套平台通道机制。它只是用 FFI 加载动态库、调用 C 函数。所以缺的不是 drift 的鸿蒙插件而是“SQLite 动态库本身 FFI 加载路径”。这反而是个好消息。只要把 so 正确放进鸿蒙应用里再让 Dart 侧加载到正确的库名或路径drift 就能正常工作。那些需要平台通道才能完成的插件适配问题在 drift 这里自动绕开了。1.3 适配的本质就三件事把问题拆开之后鸿蒙化 drift 就变得很清晰只需要完成三件事准备一个可以在鸿蒙设备上运行的 SQLite 动态库真机用 arm64-v8a模拟器用 x86_64。把这个动态库打包进鸿蒙应用的 hap 产物里。让 Dart 侧的 sqlite3 包在启动时加载它。三件事全部搞定drift_dev 生成的代码不需要任何改动表模型、查询、迁移全都照旧。下面我按这三个步骤一个个展开过程中会穿插一些我实际适配时踩过的坑。2. 搞到鸿蒙能用的 SQLite 动态库三条路线2.1 先别急着编译看看系统自带鸿蒙虽然和 Android 不在一条技术分支上但系统同样支持 SQLite很多底层组件都在用它。所以第一反应应该是先试试系统里有没有现成的libsqlite3.so。最简单的验证方式是在 Dart 代码里直接尝试加载import package:sqlite3/sqlite3.dart as sql; void main() { try { // libraryPath 传 null 让底层按默认名字去找 final db sql.open(libsqlite3.so); db.dispose(); print(found system sqlite); } catch (e) { print(not found: $e); } }如果日志提示找到你的鸿蒙适配工程量会小很多可以直接跳到第三步。但我不建议把系统自带的 so 作为第一选择。原因很简单系统库版本不受你控制旧版本可能缺少 FTS5、JSON1 这些在 drift 应用里很常用的扩展也可能修补不及时。宁可多花一点时间准备一个应用级的 so这样版本可控、行为可预期。2.2 自己编译最可控的一条路自己编译听起来很麻烦其实只需要 SQLite 官方发布的合并源码文件amalgamation和 OpenHarmony 的 NDK 工具链。所谓 merge 源码就是把所有实现塞进sqlite3.c一个文件里编译命令非常简单。我以 OpenHarmony native SDK 为例先设置环境变量export OHOS_NDK/path/to/ohos-sdk/linux/native export TOOLCHAIN$OHOS_NDK/llvm/bin export SYSROOT$OHOS_NDK/sysroot export CN$TOOLCHAIN/clang然后针对不同 CPU 架构分别编译。以 64 位真机为例$CN --targetaarch64-linux-ohos \ --sysroot$SYSROOT \ -O2 -fPIC -shared \ -DSQLITE_ENABLE_COLUMN_METADATA \ -DSQLITE_ENABLE_FTS5 \ -DSQLITE_ENABLE_JSON1 \ -DSQLITE_ENABLE_RTREE \ -DSQLITE_DEFAULT_SYNCHRONOUS1 \ sqlite3.c -lm -ldl -o libsqlite3.so几个参数逐个解释。--targetaarch64-linux-ohos是鸿蒙 64 位真机的目标三元组-fPIC -shared是为了生成位置无关的动态库-DSQLITE_ENABLE_*是打开 SQLite 的常用扩展。FTS5 做全文检索、JSON1 处理 JSON 字段、RTREE 做范围查询时都会用到。如果编译时没开这些宏drift 的 SQL 里一旦用了对应函数运行时会提示函数未定义那个排查过程比编译本身痛苦得多。2.3 模拟器别漏掉x86_64很多团队只编了 arm64 一份结果flutter run到鸿蒙模拟器上完全起不来。鸿蒙模拟器跑的是 x86_64native 库同样要配套。把 target 换成x86_64-linux-ohos再编一份即可$CN --targetx86_64-linux-ohos \ --sysroot$SYSROOT \ -O2 -fPIC -shared \ -DSQLITE_ENABLE_COLUMN_METADATA \ -DSQLITE_ENABLE_FTS5 \ -DSQLITE_ENABLE_JSON1 \ sqlite3.c -ldl -o libsqlite3.so注意如果两份 so 都叫libsqlite3.so它们需要分别放在不同的 ABI 目录下。鸿蒙打包时会按架构目录区分应用运行时只会加载当前设备架构对应的那份。目录结构我们下一章细说。2.4 复用现成 so 的三个核对点如果不想自己编译也可以找已经编好的鸿蒙 so 来用。这条路不是不行但一定要核对三件事是否包含你要的扩展比如 FTS5、JSON1。导出表里是否有sqlite3_open_v2、sqlite3_prepare_v2等核心符号。和你打包的 CPU 架构是否匹配。检查导出表用系统工具就行nm -D libsqlite3.so | grep sqlite3_open_v2不要看着硬盘里有同名文件就信。很多时候别人给你的“鸿蒙版 sqlite3.so”只是顺手把 Android 产物改了名装进鸿蒙工程后可能会因为依赖了 Android 专属的符号而加载失败。就算侥幸能加载也建议自己跑一轮完整功能测试。3. 把 so 包装进鸿蒙工程落位、CMake 与验证3.1 so 的推荐落位鸿蒙应用工程和 Android 不完全一样但 native 库的落位有相对固定的套路。我推荐把 so 放进entry/src/main/cpp/libs/abi/libsqlite3.so其中abi分别用arm64-v8a、armeabi-v7a、x86_64。放好之后目录结构是这样entry/ ├── src/main/ │ ├── cpp/ │ │ ├── CMakeLists.txt │ │ ├── libs/ │ │ │ ├── arm64-v8a/ │ │ │ │ └── libsqlite3.so │ │ │ ├── armeabi-v7a/ │ │ │ │ └── libsqlite3.so │ │ │ └── x86_64/ │ │ │ └── libsqlite3.so如果你的工程模板里没有cpp目录需要手动创建。不要担心多出一个 native 目录会拖慢构建这里只是声明和打包不会真的引入多余的原生编译。3.2 用 CMake 把 so 显式声明出来如果工程里没有任何原生代码只把 so 扔进 libs 目录未必会被打包进 hap。稳妥做法是让 CMake 把 so 当作 IMPORTED 库引一下这样既参与链接也等于告诉打包工具“这是要带进产物的”。CMakeLists 里可以这样写cmake_minimum_required(VERSION 3.5.0) project(entry) # 按当前构建架构选择对应目录arm64-v8a 是真机x86_64 是模拟器 set(LSQLITE ${CMAKE_CURRENT_SOURCE_DIR}/libs/arm64-v8a/libsqlite3.so) add_library(sqlite3 SHARED IMPORTED) set_target_properties(sqlite3 PROPERTIES IMPORTED_LOCATION ${LSQLITE} ) add_library(entry SHARED native_hello.cpp) target_link_libraries(entry PUBLIC sqlite3) find_library(core-lib core) target_link_libraries(entry PUBLIC core-lib)不同 DevEco 工程模板里注入的 ABI 变量名不完全一样有的叫OHOS_ARCH有的叫CMAKE_OHOS_ARCH_ABI我上面直接按固定目录写是为了清楚。实际你可以在 CMake 里根据工程模板提供的变量自动选择也可以简单一点真机调试用 arm64-v8a模拟器调试时手动改成 x86_64。核心动作就两个add_library(... IMPORTED)加target_link_libraries。如果连 CMake 都不想动也可以走oh-package.json5依赖 native 包的路线把 so 包在 oh_modules 里让构建系统识别。这条路配置更绕我在这里不展开只提醒一句先保证用最短路径复现再考虑优雅性。3.3 从 hap 里确认 so 真的存在最常见的坑是“代码里明明放了 so跑起来还是 DynamicLibrary.open 报错”。原因多半是产物没带进去。不要只看构建日志直接检查最终的 hap 文件用鸿蒙命令行工具 hdc 安装后再拉出来看或者直接解包检查hdc shell quser 0 unzip build/.../entry-default-signed.hap -d hap_check ls -la hap_check/libs/arm64-v8a/如果 hap 里没有libsqlite3.so就回到 CMake 的 IMPORTED 配置去查别在 Dart 侧继续浪费时间。这一步我建议每个架构都验证一遍因为模拟器用 x86_64、真机用 arm64漏掉任何一个到对应设备上都会闪崩。4. 让 Dart 侧的 sqlite3 找到我们的 so4.1 sqlite3 包默认怎么找库package:sqlite3是 drift 真正依赖的底层库。它默认会根据OperatingSystem枚举选择动态库的名字比如 Android 上可能是libsqlite3.soiOS 上直接用系统库。鸿蒙并不在这个枚举里所以它往往会落到某个通用分支然后自然找不到库。此时不要试图修改包源码。sqlite3包里留了 override 入口思路很简单它允许你覆盖某个系统下“打开动态库”的实现。不同版本的包 API 略有差异有的版本直接提供sqlite3.open.overrideFor有的版本需要你把全局sqlite3实例重新赋值成你手动sqlite3.open(...)的结果。具体以你 pubspec.lock 里的版本为准但最终效果是一致的让全局sqlite3绑定的DynamicLibrary指向我们的鸿蒙 so。4.2 两个可用的装载写法写法一如果你拿到的是明确的动态库路径可以在应用启动时重新打开数据库单例import package:sqlite3/sqlite3.dart as sql; Futurevoid initHarmonySqlite() async { // 这个库名可以理解为应用 native 库搜索路径里的文件名 final libraryPath libsqlite3.so; sql.sqlite3 sql.sqlite3.open(libraryPath); }如果libsqlite3.so能通过系统默认搜索路径命中直接传库名就行。找不到再尝试传绝对路径。只要 so 进了 hap应用进程启动后系统会设置好 native library 的搜索路径所以一个简单的库名通常就能搞定。写法二用 overrideFor 给鸿蒙单独指定库名。新版 sqlite3 包的源码里有overrideFor入口可以在main()一进来就调用import package:sqlite3/sqlite3.dart; void main() { // 如果枚举里没有鸿蒙分支先借用其他分支顶住 sqlite3.overrideFor( OperatingSystem.linux, () sqlite3.open(libsqlite3.so), ); runApp(const MyApp()); }注意这里是“借用”了某个已有枚举。实际能不能准确映射到鸿蒙取决于你使用的 Flutter 鸿蒙适配版把当前平台识别成了什么。所以我建议先跑一个小 demo在main()里尝试加载并打印结果确认哪条分支真正被执行再决定长期策略。4.3 装载顺序决定成败这个点特别容易踩。drift 的NativeDatabase在创建时就会去使用全局 sqlite3 实例如果你在数据库初始化之前没有完成 override或者 override 发生在第一次NativeDatabase创建之后就可能拿到一个“加载失败”的残留状态。很多项目的崩溃日志显示 “already closed” 或 “library not found”追根溯源都是顺序问题。一个稳定的做法是提供独立的初始化函数并让数据库对象的创建严格排在该函数之后FutureAppDatabase createAppDatabase() async { await initHarmonySqlite(); final dir await getAppWritableDir(); return AppDatabase( NativeDatabase(File(${dir.path}/app.db)), ); }这样就保证了 FFI 层一定先于数据库层完成初始化不会出现零散的时序 bug。5. 组装完整 drift 代码从建表到鸿蒙沙箱路径5.1 pubspec 里的最小依赖先列一下最小依赖集合dependencies: drift: ^2.20.0 sqlite3: ^2.4.0 path: ^1.9.0 dev_dependencies: drift_dev: ^2.20.0 build_runner: ^2.4.9drift_dev只负责代码生成和平台无关sqlite3是运行时真正做 FFI 装载的关键。两个都必须有。版本号只是参考实际以当前最新稳定版为准。5.2 表模型与代码生成命令用 drift 定义一张用户表非常简洁import package:drift/drift.dart; part app_database.g.dart; class Users extends Table { IntColumn get id integer().autoIncrement()(); TextColumn get name text().withLength(min: 1, max: 64)(); DateTimeColumn get createdAt dateTime().nullable()(); } DriftDatabase(tables: [Users]) class AppDatabase extends _$AppDatabase { AppDatabase(super.e); override int get schemaVersion 1; }然后执行代码生成dart run build_runner build --delete-conflicting-outputs如果你以后准备做自动迁移建议用 drift_dev 自带的 schema 工具来管理版本dart run drift_dev schema dump lib/src/app_database.dart drift_schema.json dart run drift_dev schema steps drift_schema.json drift_schema_v2.json这两条命令可以生成从旧版本到新版本的迁移步骤甚至在 CI 里自动检查 schema 是否漂移。鸿蒙适配过程中代码生成没有任何特殊这部分完全复用现有流程。5.3 沙箱路径鸿蒙上 path_provider 不一定好使drift 需要一个真实可写的文件路径不能是 assets也不能拿Directory.systemTemp糊弄。在 Android 上通常用 path_provider 的getApplicationDocumentsDirectory()但鸿蒙 Flutter 适配版并不天然支持所有主流 path_provider 实现。有些社区 fork 已经支持鸿蒙可以先试如果没有就需要自建一个极小的 MethodChannel让鸿蒙原生侧返回应用沙箱目录。一个快速思路是在鸿蒙入口的 Ability 里注册一个 channel用原生能力拿到可写目录const channel new MethodChannel(app_storage); channel.setMethodCallHandler((call, result) { if (call.method getFilesDir) { // 以鸿蒙官方 ArkTS API 为准这里示意概念 result.success(getContext().filesDir); } });拿到沙箱根目录后Dart 侧再拼路径final dir Directory($filesDir); final dbPath join(dir.path, app.db); final db AppDatabase( NativeDatabase(File(dbPath), logStatements: kDebugMode), );重点是要保证目录在应用生命周期内可写并且卸载应用后数据会被清理千万不要把数据库写到不合规的位置。5.4 createInBackground 的取舍drift 还提供NativeDatabase.createInBackground构造它会把 SQLite 放到独立 isolate 里跑UI 线程不会被大查询卡住。真机场景我建议用这个配合logStatements调试。但注意数据库在 isolate 中打开时FFI 库的加载引用也要能从主 isolate 传递过去。如果你的初始化函数过于依赖主 isolate 里的状态就需要把库路径作为参数显式传进 isolate。6. 迁移、WAL、备份与加密跑通之后的硬仗6.1 MigrationStrategy 不配好迟早出事drift 对迁移的支持很完整你可以写逐步升级的代码也可以用 schema 工具自动生成。鸿蒙上迁移本身没有任何特殊只是别忘了在建库后设置 PRAGMA。一个常规的beforeOpen是这样override MigrationStrategy get migration MigrationStrategy( onCreate: (m) async { await m.createAll(); }, onUpgrade: (m, from, to) async { if (from 2) { await m.addColumn(users, users.createdAt); } }, beforeOpen: (details) async { await customStatement(PRAGMA foreign_keys ON); await customStatement(PRAGMA journal_mode WAL); await customStatement(PRAGMA busy_timeout 3000); }, );journal_mode WAL对读写并发帮助很大鸿蒙的存储栈也能正常兼容。但要注意WAL 模式下数据库内容分散在主库文件和-wal文件里直接拷贝单个文件去做备份是错的我们单独说。6.2 备份不能再用文件复制了我见过有人在项目里用“先复制 app.db再复制 app.db-wal”的方式备份这在 WAL 下是危险的因为两个文件在复制过程中可能不一致。更稳的方式是用 SQLite 自己的备份命令。drift 可以直接执行自定义 SQL所以备份逻辑可以这样写Futurevoid backupTo(String backupPath) async { await customStatement(VACUUM INTO $backupPath); }VACUUM INTO会生成一个干净的单文件数据库不依赖 WAL也不影响当前连接。这个特性在鸿蒙上同样可用我在模拟项目 X 里做数据导出时一直用这个方式非常稳。6.3 加密场景SQLCipher 是个坎如果团队要求数据库加密普通 SQLite 就不够了。鸿蒙上要使用 SQLCipher得先把 SQLCipher 源码用同样的 NDK 工具链编译成libsqlite3.so它和 SQLite 的 C API 完全一致只是加了sqlite3_key等函数。然后需要在 Dart 侧打开数据库后立刻执行PRAGMA key your_key_here;drift 没有直接封装这个流程通常的做法是自己实现一个初始化函数在打开数据库后先设置密钥再让 drift 正常使用。密钥建议通过环境配置或安全接口传入不要写死在代码里。如果你计划加密把上文编译 SQLite 的步骤换成 SQLCipher 的编译其余链路完全一致。6.4 性能参考与调参同样是 SQLite鸿蒙上的表现未必和 Android 完全一样。我在某项目里用十万行数据测试过全部走索引的分页查询在鸿蒙真机上大概比 Android 慢 5% 到 10%这个差异主要来自文件系统。但 drift 的查询层在鸿蒙上没有额外性能损失因为全程都是 FFI 调用几乎没有平台通道开销。建议对大表设置合理的索引并考虑把cache_size调大一点await customStatement(PRAGMA cache_size -64000);负号表示以 KB 为单位这里大概是 64MB。这个值在低端鸿蒙设备上不要开太大免得内存压力明显。最好根据目标设备的内存规格做一次实测。7. 上线前逐项核对清单为了不让自己在最后一刻翻车我把适配过程中的关键检查项列成清单照着过一遍比反复看日志快很多三个 ABI 目录下是否都有libsqlite3.so有没有复制错架构。hap 解包后能不能在 libs 目录下看到 so 文件。Dart 初始化函数是否在数据库创建前调用有没有重复 override。应用沙箱目录是否可写路径里有没有空格或中文。是否在beforeOpen里设置了foreign_keys否则关联表删数据时行为会很隐晦。WAL 模式下备份是否用了VACUUM INTO。加密场景是否真的执行了PRAGMA key还是只是看起来加密了。模拟器和真机各跑一遍增删改查冒烟用例。检查 so 的动态链接是否引用了鸿蒙系统里不存在的符号有的话真机会在 dlopen 阶段报错。记录首次打开数据库的时间和 WAL 文件大小增长情况作为线上性能基线。最后再补一个经验如果你团队的 Flutter 鸿蒙适配版升级了优先重新验证 sqlite3 的加载逻辑。我遇到过一次 SDK 升级导致默认库搜索路径变化之前好好的库名突然就找不到了最终还是回到“显式传完整路径”这个最小侵入方案才稳定下来。适配这种事越少依赖隐式规则越省心。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑