ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

鸿蒙Next上Flutter数据库适配:用fluent_query_builder替换sqflite的实践

鸿蒙Next上Flutter数据库适配:用fluent_query_builder替换sqflite的实践 做鸿蒙适配那阵子我卡得最久的地方不是UI布局也不是状态管理而是数据库层。原来App在Android/iOS上一直用sqflite到了鸿蒙Next这套就不灵了Flutter跑在OpenHarmony生态的自研引擎上sqflite那种直接依赖Android SQLite的原生插件没法再用以前的方式直接访问底层数据库。而这个项目里大量查询是动态拼条件、按列排序、带分页的最麻烦的是还要求类型安全——不能因为字段名写错就直接在跑起来的时候崩。后来我把数据访问层重构成fluent_query_builder又给鸿蒙写了一套适配桥才算把这块彻底理顺。这篇文章会把整个适配过程拆开讲。先说清楚鸿蒙Next上Flutter的数据层卡在哪再讲fluent_query_builder的流式SQL和类型安全机制为什么适合做这场改造然后给出一套我实际跑通的技术路线最后把踩过的坑和最终用法一起放出来。如果你也正在做Flutter应用的鸿蒙化尤其是数据库相关的工作这篇应该能帮你少走不少弯路。1. 先说清楚鸿蒙Next上的Flutter数据库这块到底卡在哪1.1 从sqflite依赖链看适配难点不少团队做鸿蒙适配时第一反应是“换个SDK重新编译一遍就行”。UI部分确实可能就这么简单但数据库不行。原因在依赖链上sqflite并不只是Dart代码它底下有sqflite_android、sqflite_ios这些原生实现通过platform channel跟原生侧通信在Android上最终调的是SQLite C库。鸿蒙Next不再兼容AOSP这套原生通道就没法直接用了。社区很快就有人做了OpenHarmony版sqflite的移植但功能覆盖不全。我试过用分支版本跑老代码发现查询接口基本能用可事务行为、批量操作、数据库关闭时机这些细枝末节都有出入。最让人难受的是项目里早就用drift或fluent_query_builder这类抽象层包了一层你原本以为换个底层实现就完事结果drift的底层依赖链更复杂迁移时等于把半套ORM搬过来。这个问题的本质是SQL构建逻辑和SQL执行逻辑没有分开。sqflite同时承担了“拼SQL字符串”和“执行SQL”两件事你换底层时就得一起换。想平滑适配必须先找到一层能把两者隔开的抽象。1.2 fluent_query_builder能继续用的前提fluent_query_builder这个库我一直觉得它被低估了。它本身是纯Dart实现不直接碰任何原生SQLite代码核心工作是把SQL不同子句映射成Dart对象的链式调用。这意味着只要你能提供一个执行接口它就能跑在任意数据库后端上——包括鸿蒙的RDB关系型数据库。它的设计里有一个底层执行者概念通常叫QueryCloser或者类似的闭包/执行器负责真正把SQL文本和参数发给某个引擎。我当时的判断很简单只要能替换这个执行器让SQL落到鸿蒙的native数据库通道上fluent_query_builder整个DSL层就原封不动保留下来。事实也证明这个思路走通了。所以关键问题就变成一个鸿蒙侧如何提供一个能执行SQL并返回结果的稳定通道。提示鸿蒙Next的RDB能力和SQLite高度兼容SQL语法层面基本不用改。真正要处理的是Dart侧到鸿蒙侧的数据通道、类型映射和生命周期控制。2. 流式API和类型安全到底是怎么实现的2.1 DSL设计把SQL的各个子句变成链式调用fluent_query_builder的核心体验是把SQL从字符串变成Dart的流式表达式。比如从user表里查年龄大于18、名字带“张”的用户按年龄倒序并取前20条代码是这么写的final result await db.selectFrom(User) .where(User.name.like(%张%) User.age.greaterThan(18)) .orderBy(User.age.desc) .limit(20) .get();没接触过流式SQL的人第一次看到这个可能不适应但用久了你会发现它有几个比字符串拼接强太多的地方每个SQL子句是一个独立方法天然使用、天然可组合。where里用的是这种逻辑运算符看起来不像编程更像是在描述条件。列定义是对象属性不需要记字段名字符串。复用查询条件时只需要把某个where表达式存成变量在多个方法里拼装。这套设计的底层其实很简单selectFrom返回的是一个查询构建器对象每个方法都返回this或者一个新构建器最后在get()时把整个查询序列化成SQL字符串。你完全可以只看调用链就知道SQL长什么样。2.2 类型安全的来源泛型、列类型与表达式绑定类型安全不是靠运行时检查而是靠Dart的泛型在编译期把事情定死。这个库把表结构定义成强类型类每个字段用text()、integer()、bool()这类方法声明类型class User extends Table { TextColumn get id text()(); TextColumn get name text()(); IntColumn get age integer()(); BoolColumn get isActive boolean()(); }TextColumn和IntColumn是两种完全不同的类型greaterThan只定义在数值/日期等可比较的列上你不可能对name.greaterThan(18)这种写法还能通过编译。同理where里的表达式类型被约束为BooleanExpression你不会意外传一个字符串进去。组合条件也走类型安全路线final expr User.age.greaterThan(18) User.isActive.equals(true);返回的仍然是BooleanExpression可以继续和别的表达式通过|嵌套。这套东西写错了在IDE里就会报红根本等不到运行时。2.3 对比手拼SQL这套设计好在哪手拼SQL常见的悲剧String里写错列名数据库跑起来才报错类型不对数字被套了引号条件组合时少个括号动态条件一多字符串拼接变成灾难。比如下面这种代码在业务代码里非常常见var sql select * from user where 11; if (name ! null) { sql and name \$name\; } if (age 0) { sql and age $age; }且不说SQL注入风险光是这个写法的维护成本就很高。fluent_query_builder把where子句变成一个表达式对象你用ListExpression收集条件、再组合成一个完整条件逻辑清晰得多。类型安全则等于提前把错误拦在你写代码的阶段而不是等到数据库日志里翻字段名。这也是为什么鸿蒙化时我坚持保留这层DSLAPI风格和安全性已经深入人心替换底层只是把“执行器”换掉而已。3. 鸿蒙化适配的完整技术路线不重写只换总线3.1 适配方案对比自研通道 vs 社区分支动手之前我把可选方案列了个表做了一轮简单对比方案优点缺点结论直接等社区sqflite鸿蒙版完善改动最小事务/批量支持不全升级节奏不可控不选换鸿蒙原生RDB 自己写全套sql封装最贴近鸿蒙能力等于推倒重来DSL层全丢不选保留fluent_query_builder DSL自建执行通道改动集中、DSL复用需要写通道桥接有一点开发量选择最后我选了第三套方案。核心思路让fluent_query_builder不再依赖默认的sqflite执行器而是通过一个自定义Executor把SQL和参数通过MethodChannel发给鸿蒙侧由鸿蒙原生RDB执行并返回结果集。这样的好处是所有构建SQL、组合条件、类型映射的代码全部不动只有最底下那一层在执行时换通道。3.2 设计一个CustomExecutor数据库抽象的关键封装fluent_query_builder允许你通过配置传入自己的数据库执行入口。我封装了一个HarmonyDatabaseAdapter它只做一件事接收构建器吐出来的SQL和参数调用鸿蒙通道执行。class HarmonyDatabaseAdapter implements QueryExecutorAdapter { final MethodChannel channel; HarmonyDatabaseAdapter(this.channel); FutureListMapString, Object? executeQuery( String sql, ListObject? parameters, ) async { final result await channel.invokeMethod(executeQuery, { sql: sql, parameters: parameters, }); return (result as List) .map((row) (row as Map).castString, Object?()) .toList(); } Futureint executeInsert( String sql, ListObject? parameters, ) async { final result await channel.invokeMethod(executeInsert, { sql: sql, parameters: parameters, }); return result as int; } Futureint executeUpdateDelete( String sql, ListObject? parameters, ) async { final result await channel.invokeMethod(executeUpdate, { sql: sql, parameters: parameters, }); return result as int; } }实现上executeQuery负责SELECT并返回行列表executeInsert返回自增主键IDexecuteUpdateDelete返回受影响行数。插一条、更新一条、查一批所有业务就归到这三个方法里。3.3 鸿蒙原生侧用RDB Store接收SQL并执行鸿蒙侧用Stage模型的ohos.data.relationalStore创建RDB Store。关键点是要预先建好数据库和表结构查询时把Dart传过来的SQL原样传给rdbStore.executeSql或者rdbStore.querySql。import { relationalStore } from kit.ArkData; let store: relationalStore.RdbStore | null null; export async function initDb(context: Context) { const config: relationalStore.StoreConfig { name: app.db, securityLevel: relationalStore.SecurityLevel.S1, }; store await relationalStore.getRdbStore(context, config); await store.executeSql( CREATE TABLE IF NOT EXISTS user (id TEXT PRIMARY KEY, name TEXT, age INTEGER, is_active BOOLEAN) ); }通道注册这里需要留意MethodChannel需要在Flutter引擎加载完成后再注册不能放到module初始化阶段就等回调否则会丢消息。我是在页面级生命周期或者LoadNative方法里统一初始化注册一次全局复用。let methodChannel pluginChannelManager.registerChannel( com.example.harmony_db, (method, args, callback) { if (method executeQuery) { const params args.parameters as ArrayValueType; const rows store.querySql(args.sql as string, params); const result []; while (rows.goToNextRow()) { result.push(rows.getRow()); } rows.close(); callback(result); } else if (method executeInsert) { store.executeSql(args.sql as string, args.parameters as ArrayValueType); callback(store.lastInsertRowId); } else if (method executeUpdate) { const changed store.executeSql(args.sql as string, args.parameters as ArrayValueType); callback(changed); } } );一个很重要的细节getRow()拿到的是不可预期类型的联合体建议在ArkTS侧就统一转成字符串/数字/布尔/字节数组这些基础类型再通过通道返回给Dart。否则Dart侧拿到不可序列化对象解码时会直接抛异常。4. 改造过程中踩过的真实的坑4.1 通道的异步时序回调竟然会丢第一次联调时查询偶尔返回空偶尔报“method not implemented”。查了很久才发现是两个问题一是通道注册时机太早鸿蒙侧原生模块还没就绪Dart侧的invokeMethod已经发出去了二是我在鸿蒙侧用异步函数处理RDB操作但MethodChannel的callback没有等到异步完成就返回了。解决方式是在Dart侧加一个ready的Future等鸿蒙侧回调“初始化完成”之后再放行业务查询Futurevoid _waitForReady() async { await channel.invokeMethod(ready); }同时鸿蒙侧的所有RDB操作统一用async/await包好再调用callback。这就保证了executeQuery一定是先等RDB执行完才把结果回传。4.2 null值处理与type列冲突鸿蒙RDB的ValueType是联合类型Dart的null在编解码后到ArkTS侧可能变成undefined执行querySql时传参就会类型报错。这个坑非常隐蔽因为SELECT语句不强制要求非空参数只有参数化查询里遇到where id ?且参数为null时才暴露。我的解决方式是在Dart侧统一把null替换成常量标记鸿蒙侧接收后再做还原parameters parameters.map((p) p ?? __NULL__).toList();在ArkTS侧判断参数等于这个标记就转成null。虽然有点朴素但实测很稳定。如果你们内部没有这种特殊字符串冲突的可能完全可以直接用。想做得更正规可以在通道协议里加一层类型标识比如把参数包装成{type: null, value: null}的map但成本略高没必要。4.3 事务边界不能把begin和commit分开传fluent_query_builder默认依赖底层执行器的事务能力如果你只是简单把BEGIN、COMMIT、ROLLBACK转成executeSql发过去事务内部的错误处理很容易变得尴尬中途某条SQL报错时Dart侧可能根本不知道事务已经处于失败状态后续查询还继续走最后提交一个不完整的数据。我最后在HarmonyDatabaseAdapter上暴露出专门的方法Futurevoid transaction(Futurevoid Function() action) async { await channel.invokeMethod(beginTransaction); try { await action(); await channel.invokeMethod(commitTransaction); } catch (e) { await channel.invokeMethod(rollbackTransaction); rethrow; } }鸿蒙侧对应实现里事务的开启、提交、回滚都通过同一个RdbStore实例顺序执行不要每次新建连接。这个顺序保证非常关键因为鸿蒙RDB事务是有连接状态的你另起一个入口去commit事务根本不生效。4.4 ResultSet游标与内存释放鸿蒙的querySql返回的是ResultSet它是一个有状态的游标。如果只把getRow()的数据取走而不close()短时间内看不出来但连续跑大量查询后内存会持续上涨系统会开始报连接泄漏。我最初的写法是遍历完直接返回结果应用在长列表页反复上下滑动后内存悄悄爬了100多MB。后来每条查询路径都确保try/finally里关闭ResultSet问题才消失。这个教训总结成一个习惯在鸿蒙原生侧凡是拿到ResultSet取完数据必须close没有例外。5. 适配后在实际项目里是怎么用的5.1 建表与Model映射鸿蒙侧的RDB Store已经建好了表Dart侧只需要用fluent_query_builder的API操作。建表SQL我建议写在原生侧因为数据库文件的管理和升级由原生负责Dart侧不用关心。Dart侧只定义表和映射关系class User extends Table { TextColumn get id text()(); TextColumn get name text()(); IntColumn get age integer()(); BoolColumn get isActive boolean()(); } class UserModel { final String id; final String name; final int age; final bool isActive; UserModel({required this.id, required this.name, required this.age, required this.isActive}); factory UserModel.fromRow(MapString, Object? row) { return UserModel( id: row[id] as String, name: row[name] as String, age: row[age] as int, isActive: row[is_active] as bool, ); } }注意鸿蒙返回的列名大小写和类型可能与SQLite有差异我在映射层统一做了一次显式转换避免DB层直接暴露数据格式。5.2 基础查询条件组合、排序与分页实际业务里最常用的就是这类查询条件可能来自搜索框、筛选项也可能来自下拉刷新。用fluent_query_builder写出来非常直白FutureListUserModel searchUsers({ String? keyword, int? minAge, int page 1, int pageSize 20, }) async { final conditions BooleanExpression[]; if (keyword ! null keyword.isNotEmpty) { conditions.add(User.name.like(%$keyword%)); } if (minAge ! null) { conditions.add(User.age.greaterOrEqualTo(minAge)); } if (conditions.isEmpty) { // 如果没有任何条件查询全部数据此时不能用where空条件导致SQL语法错误 final list await db.selectFrom(User) .orderBy(User.age.desc) .limit(pageSize) .offset((page - 1) * pageSize) .get(); return list.map(UserModel.fromRow).toList(); } final combined conditions.reduce((a, b) a b); final list await db.selectFrom(User) .where(combined) .orderBy(User.age.desc) .limit(pageSize) .offset((page - 1) * pageSize) .get(); return list.map(UserModel.fromRow).toList(); }这里有一个容易踩的细节where不能传空表达式否则生成的SQL会有语法问题。我上面通过conditions.isEmpty分支处理了跑起来才安心。5.3 关联查询与统计场景fluent_query_builder支持表关联项目里账户和订单是一对多统计订单量时用起来很顺手final result await db .selectFrom(Account) .join(Order, Order.accountId.equals(Account.id)) .where(Order.totalAmount.greaterThan(100)) .get();实际上这套DSL对GROUP BY、COUNT、SUM都有对应写法。比如统计不同年龄段用户分布final ageStats await db .selectFrom(User) .orderBy(User.age.desc) .limit(10) .get();复杂的SQL仍然建议直接写原生SQL通过customSql执行DSL的优势在动态条件和可读性上而不是把所有SQL都硬扭成链式。5.4 数据迁移与升级适配鸿蒙后数据库版本升级逻辑要格外小心。原来Android端用sqflite的onUpgrade做过几轮字段变更鸿蒙侧没有自动继承这套历史。我的处理方式是把迁移脚本也做成表驱动在原生侧维护一个schema_version表每次启动时检查当前版本依次执行增量SQL。final currentVersion await db.customSelect(SELECT value FROM schema_version WHERE name db_version).get(); if (currentVersion.isEmpty) { await db.customExecute(INSERT INTO schema_version(name, value) VALUES(db_version, 1)); await db.customExecute(ALTER TABLE user ADD COLUMN nickname TEXT); }这套做法的好处是Dart侧和鸿蒙侧统一通过同一份version常量控制升级脚本不用再分平台维护。等新版发布后老用户升级时也能保证数据结构一致。6. 我的最终体会适配的关键是找到那层“可替换边界”回头再看这个项目最值钱的不是那几百行桥接代码而是“先找边界再做适配”的思路。fluent_query_builder把SQL生成和执行拆开了这个边界让我能用很小的代价把它搬到鸿蒙RDB上。换成drift就不一定这么好办因为drift底层和sqlite绑定得更深牵扯schema生成和DAO机制移植成本完全是另一回事。实际动手前强烈建议先做三件事盘点当前项目里用了哪些数据库API分别对应读、写、事务、批量这几类是否都能走自定义执行器。检查所有表结构SQL确认鸿蒙RDB是否完全兼容大多数情况下兼容但带AUTOINCREMENT的建表语句要留意。设计一个API稳定性测试在适配早期就覆盖查询、插入、更新、删除、事务回滚、空值查询、批量写入这些常见路径避免后期在业务代码里返工。适配完成后再看代码业务查询层基本没改动底下多了一层黑盒通道。如果你正在用flutter做鸿蒙化改造我建议你也优先看数据层有没有这样的抽象边界。有边界适配就是替换没边界适配就是重写。这两者的工作量差距比大多数人想象的大得多。
返回列表