做 Flutter 的时候,最怕的不是 UI 写不完,而是业务做到一半被数据库层卡住。尤其这两年鸿蒙设备多起来,老板一句"App 要上鸿蒙",你就得把所有依赖重新过一遍,其中数据库这一层,几乎每次都是重灾区。我这次要聊的fluent_query_builder鸿蒙化,就是把我踩过的坑、试过的路、最后沉淀下来的适配方案完整拆开来讲,里面涉及流式 SQL 构建、类型安全查询、鸿蒙侧数据库抽象这几个关键点,适合正在做 Flutter 跨端应用、或者刚接到鸿蒙适配任务的开发同学参考。
先说结论:fluent_query_builder本身是纯 Dart 实现的 SQL 构建器,它不直接操作数据库,而是帮你把查询"拼"出来——生成 SQL 字符串和参数列表。这意味着鸿蒙化真正的难点不在 Dart 层,而在"用谁来执行这条 SQL"以及"Dart 类型怎么和鸿蒙数据库类型对照"这两件事上。把这条主线想清楚,适配工作其实可以拆得很细,每一步都走得踏实。
1. 为什么要把 fluent_query_builder 带到鸿蒙:需求拆解与收益分析
1.1 流式 SQL 到底解决了什么痛点
先回顾一下不用 fluent_query_builder 的时候,Flutter 里写数据库查询是什么样子的。最朴素的做法是直接用sqflite或者sqlite3包的rawQuery,把 SQL 写成字符串拼接:
final result = await db.rawQuery( 'SELECT * FROM users WHERE age > ${age} AND status = "${status}" ORDER BY created_at DESC' );这种写法在原型阶段很爽,但一旦表结构复杂、查询条件多,问题全冒出来了。首先是最经典的 SQL 注入风险,用户输入直接拼进字符串,哪怕你在团队内部定了"永远不要直接拼用户输入"的规矩,代码评审时也总会漏掉一两个地方。其次是可读性,一个 10 行以上的 SQL 字符串里面全是换行和转义,后面接手的人看着就头疼。最难受的是改动成本,今天要加一个条件、明天要换一个排序字段,你都得小心翼翼地改字符串中间的位置,一个引号没配对,整个查询就崩了。
流式 SQL 构建器解决的就是这个"拼接地狱"问题。它把 SQL 的每一个组成部分都抽象成方法调用,查询条件、排序、分页、联表全部变成链式调用:
final query = QueryBuilder() .select() .from('users') .where('age').greaterThan(18) .andWhere('status').equals('active') .orderBy('created_at', desc: true) .limit(20);这段代码读起来几乎是自然语言:从 users 表里查,age 大于 18,status 等于 active,按 created_at 倒序,取前 20 条。每加一个条件就是在链上多接一环,不需要去动已经写好的部分,代码结构天然地跟业务逻辑对齐。我在实际项目中用下来最大的感受是:写查询变成了搭积木,而不是绣花。
1.2 类型安全查询带来的长期价值
fluent_query_builder 的第二个核心卖点是类型安全。这不是说它能在编译期帮你把 SQL 的每个字段都校验一遍——它毕竟是一个运行时构建 SQL 的库,做不到像编译型 ORM 那样完全静态检查——而是说它在"查询构建"这个环节引入了类型约束,让参数传递的规则变得清晰可控。
用字符串拼接 SQL 时,所有参数都变成了字符串的一部分,int、double、bool、DateTime 这些类型全靠格式化。而 fluent_query_builder 的做法是让参数走独立的getValues()通道,构建 SQL 时用?占位符,运行时再把 Dart 值原样传给数据库驱动。这样数字就是数字、字符串就是字符串,数据库端做参数绑定时类型是明确的,不会有"字符串里混进了奇怪字符"的问题。
我在一个实际的用户管理模块里对比过:用老办法写条件筛选,光是"int 类型的 age 传成字符串会不会出问题"这件事就够排查半天的;换成 fluent_query_builder 之后,同样的逻辑写出来,类型边界一眼就能看出来。加上 IDE 补全、代码重构的加持,长期维护成本确实低不少。
1.3 鸿蒙化适配的整体思路和收益
那么问题来了,为什么非要让它跑在鸿蒙上?原因很直接:Flutter 应用做鸿蒙适配时,UI 层 Work 已经很多了,如果数据库层再换一套 API,那等于业务代码全部重写。flutter_query_builder 这套查询构建逻辑是跨平台通用的,我们只需要换掉它的"执行引擎",业务层那些复杂的查询条件、排序、分页逻辑就可以原封不动地复用。
鸿蒙侧的数据库能力其实非常完整,它内置的关系型数据库 RDB(RelationalStore)底层就是 SQLite,支持标准的 SQL 语法和事务。因此适配思路可以概括成一句话:保留 fluent_query_builder 的 SQL 生成层和 Dart 类型体系,把它的输出(SQL 字符串 + 参数列表)通过适配器送进鸿蒙的 RDB 执行。这样业务代码写一次,Android、iOS、鸿蒙三端都能跑,这是做跨端应用最理想的状态。
2. fluent_query_builder 核心机制拆解:一条流式查询是怎么拼出来的
2.1 骨架:QueryBuilder 与 SqlContext 的分工
在动手写适配器之前,得先搞清楚这个库内部是怎么运作的。fluent_query_builder 的架构可以分成两个核心角色:QueryBuilder负责收集查询条件、管理构建状态,SqlContext负责把状态翻译成具体方言的 SQL 文本。这俩是彻底分离的。
QueryBuilder本质上是一个状态机。你调用where()、join()、orderBy()时,它并没有立刻生成 SQL,只是把"意图"记录在内部的对象结构里。等到真正需要执行的时候,再调用build()方法,传入一个SqlContext,由它来决定这些意图最终长成什么样子的 SQL。
SqlContext的作用是屏蔽方言差异。比如 SQLite 的LIMIT直接用数字,MySQL 的LIMIT也差不多,但 PostgreSQL 可能更推荐OFFSET ... FETCH写法;又比如带引号的标识符,SQLite 用双引号,MySQL 用反引号,H2 数据库又不一样。SqlContext就是这些差异的收敛点。
final sqlContext = SqlContext.sqlite(); final sql = query.build(sqlContext); final values = query.getValues();这个分工对我们鸿蒙化特别有利。鸿蒙 RDB 兼容 SQLite 方言,所以适配时选SqlContext.sqlite()就够了,不需要自己实现一个新的方言处理器。
2.2 表达式体系:Where、Join、OrderBy 的组装方式
fluent_query_builder 的核心是它的表达式体系。where()方法接收字段名后,会返回一个用于描述条件的中间对象,然后你可以通过equals()、greaterThan()、lessThan()、inList()、like()等方法来定义具体的比较逻辑。
final query = QueryBuilder() .select() .from('users') .where('age').greaterThanOrEquals(18) .andWhere('age').lessThan(30) .orWhere('vip_level').equals(3) .orderBy('id');这段代码构建的逻辑是年龄在 18 到 29 之间,或者 VIP 等级等于 3 的用户。组合条件的关键点是andWhere和orWhere这两个方法,它们决定了条件之间的逻辑关系。实际业务里最常见的坑也是在这里:AND和OR混用时,如果没有括号约束,生成的 SQL 可能会和业务预期的逻辑不一致。
join的使用也很直观。它设计成语义化操作,你可以显式指定LEFT JOIN或INNER JOIN,然后通过on()来描述关联条件。对于鸿蒙适配来说,join这种表达方式有一个额外的好处:RDB 的RdbPredicates处理简单的相等条件还行,遇到多表关联就力不从心了,而 fluent_query_builder 生成的 SQL 是可以直接喂给 RDB 执行的。
2.3 参数化与方言适配:SQL 和值列表分离
fluent_query_builder 有一个内部约定值得单独拿出来讲:构建 SQL 时,所有的值都不会直接嵌入 SQL 文本,而是统一用?占位符,真实的值收集在getValues()返回的列表里。
-- query.build(SqlContext.sqlite()) 的输出 SELECT * FROM users WHERE age > ? AND status = ? ORDER BY created_at DESC LIMIT ? -- query.getValues() 的输出 [18, 'active', 20]这个设计的好处有两层。第一层是安全:参数永远走绑定通道,不管用户输入什么内容,都只会被当作值处理,不可能被解释成 SQL 结构。第二层是方便适配:鸿蒙 RDB 执行 SQL 时本身就支持这种参数绑定模式,我们只需要把 SQL 字符串和values原样传递过去就行,不需要做任何转换。
做适配时我特别强调一点:很多刚接触这个库的同事会犯一个错误,直接调用query.toString()或者把build()的输出打印出来看,发现 SQL 里是?而不是真实值,就以为库有问题。其实这是它的特性,SQL 文本只负责表达结构,值在另一个通道里,两者在数据库执行时才合体。
2.4 表结构定义:字段类型与实体映射
fluent_query_builder 里可以用Table定义表结构,给字段标注类型。这样写的好处是可以在构建查询时做基础的类型提示和约束,也有助于统一字段名的拼写。
final usersTable = Table('users', const { 'id': IntegerType(), 'name': StringType(), 'age': IntegerType(), 'email': StringType(), 'is_active': IntegerType(), 'created_at': StringType(), });不过在实际适配中,我倾向于把表结构定义当成"文档约定"而不是强制约束。因为鸿蒙 RDB 建表时用的是它自己的 SQL 语句,Table这个对象主要是给 Dart 侧查询构建提供参照。真正重要的是 Dart 类型和数据库类型之间的映射表,这个我在第三章会详细讲。
3. 鸿蒙化适配的关键路径:从 Dart 到鸿蒙 RDB 的桥接
3.1 整体架构:纯 Dart 层复用与执行引擎替换
鸿蒙化适配最忌讳的事情是"为了适配而适配",把本来能复用的逻辑推倒重来。我建议把整个工程分成两层来看:
顶层是"查询构建层",也就是业务代码使用的 fluent_query_builder 的QueryBuilder、Table这些 API。这一层是纯 Dart 的,跟平台无关,理论上在 Android、iOS、鸿蒙上行为完全一致,不需要做任何改动。
底层是"执行层",负责把构建好的 SQL 真正跑起来。在 Android/iOS 上,这个角色通常是sqflite或者sqlite3包;在鸿蒙上,就需要切换到鸿蒙自己的 RDB 能力。两层之间定义一个适配器接口,把query(sql, values)、execute(sql, values)、transaction(fn)这几个统一操作暴露给上层。
这种"上层不变、下层替换"的架构,核心价值在于业务代码迁移成本趋近于零。我当时在项目里做适配,数据库相关的 Service 层代码一个字没改,只替换了底层的执行器实现,跑通一遍测试后,心里那块石头才算放下来。
3.2 鸿蒙数据库引擎选型:RDB 还是第三方 SQLite
鸿蒙上执行 SQL 的选项有两个主流路线,我分别踩过,说一下差异。
路线一是直接用鸿蒙自带的 RDB(RelationalStore)。它集成在系统里,无需额外引入 so 文件,功能上覆盖了 SQLite 的核心能力,包括 SQL 执行、事务、谓词查询等。对于 Flutter 插件来说,需要通过 MethodChannel 或者鸿蒙侧的 platform channel 调用,相当于在 Dart 和 ArkTS 之间做一层桥接。
路线二是用社区提供的sqlite3包 + 鸿蒙的 SQLite 动态库。这个方案的好处是 API 和标准 SQLite 完全一致,sqlite3包在 Flutter 生态已经非常成熟;麻烦在于鸿蒙上需要找到或者编译对应的.so文件,集成步骤稍多。
我最终选的是路线一(RDB + MethodChannel)。一个重要的原因是鸿蒙应用如果完全走系统能力和安全管控,RDB 的权限模型、数据库文件管理都是系统统一管理的,后续上架、审核、云备份这些环节更省心。而且鸿蒙 RDB 的 SQL 语法和 SQLite 高度一致,SqlContext.sqlite()生成的语句能直接执行。
提示:如果只是做纯内部工具类应用、对系统能力依赖不敏感,路线二也可以接受。但如果是做面向消费者市场的正式应用,建议优先考虑鸿蒙 RDB。
3.3 适配器接口设计:把 QueryBuilder 输出转成 RdbStore 调用
有了整体架构,下面要做的就是定义一个"执行器"接口,屏蔽平台差异。我用 Dart 抽象类来定义这个契约:
abstract class QueryExecutor { Future<List<Map<String, dynamic>>> rawQuery(String sql, List<dynamic> args); Future<int> rawInsert(String sql, List<dynamic> args); Future<int> rawUpdate(String sql, List<dynamic> args); Future<int> rawDelete(String sql, List<dynamic> args); Future<T> transaction<T>(Future<T> Function() action); }然后写一个鸿蒙实现,内部通过MethodChannel调用鸿蒙侧封装的 RDB 能力。这里我贴一个精简版的示例:
class OhosQueryExecutor implements QueryExecutor { static const _channel = MethodChannel('com.example.rdb_executor'); final _sqlContext = SqlContext.sqlite(); @override Future<List<Map<String, dynamic>>> rawQuery( String sql, List<dynamic> args) async { final result = await _channel.invokeMethod('query', { 'sql': sql, 'args': args, }); return (result as List).cast<Map<String, dynamic>>(); } // 实际执行时,把 QueryBuilder 的输出转成 sql + args Future<List<Map<String, dynamic>>> executeQuery(QueryBuilder query) { final sql = query.build(_sqlContext); final args = query.getValues(); return rawQuery(sql, args); } }鸿蒙侧用 ArkTS 实现query方法时,内部调用rdbStore.querySql(sql, args)即可。这里需要特别注意:鸿蒙 RDB 的querySql接口对参数类型有要求,不能直接传 Dart 的dynamic过去,需要在通道层做一次类型规整,这个放到第三章第四节细说。
3.4 类型映射与参数绑定:Dart 类型到原生 RDB 类型
类型映射是整个适配过程中最容易踩坑的环节。Dart 是动态类型,鸿蒙 RDB 的参数绑定接口却期望明确的类型。我在适配时整理了一张对照表,团队里做数据库开发的同学都贴在自己工位上:
| Dart 类型 | SQLite / RDB 类型 | 说明 |
|---|---|---|
int | INTEGER | 直接绑定,无特殊处理 |
double | REAL | 直接绑定 |
String | TEXT | 直接绑定 |
bool | INTEGER (0/1) | 需要手动转换,不能用 true/false 直接绑定 |
DateTime | INTEGER (时间戳) 或 TEXT | 建议统一存毫秒时间戳,排序和比较都方便 |
null | NULL | 绑定为null即可,SQL 中用IS NULL判断 |
我在鸿蒙适配时遇到的第一个实际问题就是bool。Dart 里true在数据库这一侧是个"非法类型",鸿蒙 RDB 的参数绑定不接受布尔值,必须转成 0 或 1。后来我们直接在适配器的rawQuery里做递归规范化:遍历参数列表,把bool转成int,把DateTime转成int(毫秒时间戳),其他类型原样透传。
这里也顺带说一句字段读取侧的映射。rawQuery返回的Map里,SQLite 的 INTEGER 到 Dart 侧可能是int,但鸿蒙 RDB 通过 channel 返回时,数字可能会变成num,如果你在 Dart 侧强依赖int类型,建议拿到结果后做一次显式转换。这个细节看起来小,但真出问题时能把人折腾够呛。
4. 实战:一个完整的鸿蒙化适配示例
4.1 定义业务表和实体
空谈架构没有说服力,我拿一个"用户订单查询"的场景走一遍完整流程。假设我们有两张表:用户表users和订单表orders,需要支持的条件包括:按用户活跃状态过滤、按订单金额范围筛选、按创建时间排序、分页查询。
先用Table定义表结构(给查询构建做参照):
final usersTable = Table('users', const { 'id': IntegerType(), 'name': StringType(), 'age': IntegerType(), 'is_active': IntegerType(), 'created_at': IntegerType(), }); final ordersTable = Table('orders', const { 'id': IntegerType(), 'user_id': IntegerType(), 'amount': DoubleType(), 'status': StringType(), 'created_at': IntegerType(), });对应的实体类用最简单的手写 Model 就可以:
class User { final int id; final String name; final int age; final bool isActive; final DateTime createdAt; User({required this.id, required this.name, required this.age, required this.isActive, required this.createdAt}); factory User.fromMap(Map<String, dynamic> map) { return User( id: map['id'] as int, name: map['name'] as String, age: map['age'] as int, isActive: (map['is_active'] as num) == 1, createdAt: DateTime.fromMillisecondsSinceEpoch(map['created_at'] as int), ); } }4.2 编写鸿蒙数据库适配器(MethodChannel 桥接)
有了表定义和实体,接下来实现适配器。我把通道层的调用细节封装得厚一点,业务代码就不用关心平台差异。下面这个OhosRdbAdapter是完整可落地的版本,重点看代码里的类型规范化方法_normalizeArgs:
class OhosRdbAdapter implements QueryExecutor { static const _channel = MethodChannel('com.example.rdb_executor'); static const _sqlContext = SqlContext.sqlite(); bool _inTransaction = false; List<dynamic> _normalizeArgs(List<dynamic> args) { return args.map((arg) { if (arg is bool) return arg ? 1 : 0; if (arg is DateTime) return arg.millisecondsSinceEpoch; return arg; }).toList(); } @override Future<List<Map<String, dynamic>>> rawQuery( String sql, List<dynamic> args) async { final normalizedArgs = _normalizeArgs(args); final result = await _channel.invokeMethod('query', { 'sql': sql, 'args': normalizedArgs, }); return (result as List).cast<Map<String, dynamic>>(); } @override Future<int> rawInsert(String sql, List<dynamic> args) async { final normalizedArgs = _normalizeArgs(args); return await _channel.invokeMethod('insert', { 'sql': sql, 'args': normalizedArgs, }) as int; } @override Future<int> rawUpdate(String sql, List<dynamic> args) async { final normalizedArgs = _normalizeArgs(args); return await _channel.invokeMethod('update', { 'sql': sql, 'args': normalizedArgs, }) as int; } @override Future<int> rawDelete(String sql, List<dynamic> args) async { final normalizedArgs = _normalizeArgs(args); return await _channel.invokeMethod('delete', { 'sql': sql, 'args': normalizedArgs, }) as int; } @override Future<T> transaction<T>(Future<T> Function() action) async { if (_inTransaction) return action(); _inTransaction = true; await _channel.invokeMethod('beginTransaction'); try { final result = await action(); await _channel.invokeMethod('commit'); return result; } catch (e) { await _channel.invokeMethod('rollback'); rethrow; } finally { _inTransaction = false; } } Future<List<Map<String, dynamic>>> executeQuery(QueryBuilder query) { return rawQuery(query.build(_sqlContext), query.getValues()); } }鸿蒙侧对应的 ArkTS 实现也不复杂,核心就是拿到relationalStore的RdbStore实例后执行querySql。注意鸿蒙的通道参数识别,Dart 侧传过来的List在 ArkTS 侧拿到的是Array<Object>,遍历绑定即可。
如果说这段代码里有什么值得收藏的经验,那就是_normalizeArgs这一步。千万别省,我吃过亏:一开始没做 bool 转换,鸿蒙侧一直报参数类型错误,排查了两个小时才发现是true这个值导致的。
4.3 流式查询的落地用法:增删改查一次走通
适配器就位后,业务层的体验就完全回到熟悉的节奏了。下面一组示例代码覆盖了常见操作:
条件查询 + 排序 + 分页
final adapter = OhosRdbAdapter(); Future<List<User>> fetchActiveUsers({int page = 1, int pageSize = 20}) async { final query = QueryBuilder() .select() .from('users') .where('is_active').equals(true) .orderBy('created_at', desc: true) .limit(pageSize) .offset((page - 1) * pageSize); final rows = await adapter.executeQuery(query); return rows.map(User.fromMap).toList(); }金额范围筛选 + 状态匹配
Future<List<Map<String, dynamic>>> fetchOrdersByAmountRange( {required double minAmount, required double maxAmount}) async { final query = QueryBuilder() .select() .from('orders') .where('amount').greaterThanOrEquals(minAmount) .andWhere('amount').lessThanOrEquals(maxAmount) .andWhere('status').equals('paid') .orderBy('created_at', desc: true); return await adapter.executeQuery(query); }插入数据
Future<int> addUser(String name, int age, {required bool isActive}) async { final query = QueryBuilder() .insert('users') .values({ 'name': name, 'age': age, 'is_active': isActive, 'created_at': DateTime.now(), }); return await adapter.rawInsert(query.build(_sqlContext), query.getValues()); }更新数据
Future<int> updateUserStatus(int userId, {required bool isActive}) async { final query = QueryBuilder() .update('users') .set({'is_active': isActive}) .where('id').equals(userId); return await adapter.rawUpdate(query.build(_sqlContext), query.getValues()); }删除数据
Future<int> deleteUser(int userId) async { final query = QueryBuilder() .delete() .from('users') .where('id').equals(userId); return await adapter.rawDelete(query.build(_sqlContext), query.getValues()); }这五个操作跑通,适配工作基本上就完成 80% 了。剩下的主要在复杂场景的打磨,比如联表查询和聚合统计,这些 fluent_query_builder 也支持,思路完全一致。
4.4 联表查询与事务处理的特殊考量
联表查询是业务里绕不开的场景。fluent_query_builder 的写法很干净,关键是你要能把它生成的 SQL 正确送往 RDB。看一个订单和用户联表的例子:
final query = QueryBuilder() .select() .from('orders') .join('users', 'users.id').equals('orders.user_id') .where('orders.status').equals('paid') .orderBy('orders.created_at', desc: true) .limit(50); final rows = await adapter.executeQuery(query);这种写法生成的 SQL 大概是SELECT * FROM orders INNER JOIN users ON users.id = orders.user_id WHERE orders.status = ? ORDER BY orders.created_at DESC LIMIT ?,鸿蒙 RDB 原生支持,跑起来没有任何问题。需要注意的是如果两张表有同名字段,查询结果里的Mapkey 会发生覆盖,建议在select()时显式指定字段别名,这是很多踩坑现场的高发区。
事务方面的处理,我上面的适配器里已经给了基础实现。这里补充一个经验:在鸿蒙上做事务时,不要在事务闭包里做太多的异步等待,尤其是不要穿插网络请求或者长时间计算。鸿蒙 RDB 的事务机制对持锁时间比较敏感,事务开太久容易超时或者引发死锁。我当时做过一个批量导入功能,一开始把网络请求放事务里,结果 crash 率肉眼可见地涨,后来把所有 IO 和预处理挪到事务外,只在事务里做纯 SQL 操作,问题立刻消失。
事务的用法:
await adapter.transaction(() async { await adapter.rawDelete( QueryBuilder().delete().from('orders').build(_sqlContext), [], ); await adapter.rawInsert( QueryBuilder().insert('orders').values({'user_id': 1, 'amount': 99.9, 'status': 'unpaid'}).build(_sqlContext), [], ); });5. 适配过程中的常见坑与排查技巧实录
5.1 通道不通:MethodChannel 注册与调用时机问题
鸿蒙上做 Flutter 插件适配,第一个遇到的坑基本都出在通道上。Dart 侧的MethodChannel名字必须和 ArkTS 侧注册的完全一致,大小写都不能差。其次,鸿蒙侧的通道注册时机要放在 ability 的onCreate或者onWindowStageCreated生命周期里,如果注册太晚,Dart 早期发起的调用会被直接丢弃或者报MissingPluginException。
排查这类问题有个快捷方式:在鸿蒙 DevEco Studio 的日志里过滤关键字channel或MethodChannel,能直接看到通道注册是否成功。还有一个保险做法是在 Dart 侧给通道调用加上超时保护,避免因注册异常导致无限期挂起。
5.2 类型不符:参数绑定时的隐性问题
鸿蒙 RDB 的参数绑定比大家想象中严格。Dart 的int跨过通道后,在 ArkTS 侧可能是number类型,但如果 Dart 侧用了int之外的类型比如double的整数值,比如18.0,在某些鸿蒙版本上会被当作REAL处理,跟 INTEGER 字段做比较时可能触发隐式转换问题。
一个很实用的做法是把_normalizeArgs做成递归的,对double类型做一次整数判断,如果是18.0这样的值,直接转换成18。这能在参数绑定这一层把绝大多数类型问题挡在外面。这个细节我是被线上问题教育出来的,后来成了团队里的固定写法。
5.3 并发问题:RDB 的单写多读限制
鸿蒙 RDB 对写入操作有较严格的约束,同一时间通常只允许一个写事务。如果你的应用是 Flutter 多 isolate 架构,每个 isolate 各自持有适配器实例,它们同时写数据库就可能触发 "database is locked" 之类的错误。
我在实际项目中给出的方案是:全局只保留一个数据库执行入口,所有 isolate 的写操作都通过同一个入口排队执行。具体实现可以借助 Dart 的synchronized包或者自己维护一个 Future 队列,把rawInsert、rawUpdate、rawDelete串行化。读操作可以放开并发,RDB 对并发读的支持还是不错的。
5.4 方言差异:LIMIT 与参数顺序的细节
虽然鸿蒙 RDB 和 SQLite 语法高度一致,但有个细节要留意:LIMIT ? OFFSET ?的参数顺序。SqlContext.sqlite()生成的 SQL 遵循 SQLite 的标准顺序,先LIMIT后OFFSET,参数列表先放 limit 再放 offset,这个顺序和 MySQL 的LIMIT offset, count完全不同。如果你之前写习惯了 MySQL,在鸿蒙上调试分页时很容易搞反。
还有一点,fluent_query_builder 的orderBy默认是升序,要在字段后加desc: true才走降序。这个 API 设计和很多 SQL 拼装库不一样,如果从别的库迁移过来,很容易漏掉这个参数,导致排序结果跟预期反着来。建议在适配完跑一轮针对性的排序分页测试用例,一次性把这些细节都校准。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
MissingPluginException | 通道未注册或名称不一致 | 检查 ArkTS 侧的通道注册时机和名称 |
| 参数绑定报错 | bool/DateTime 未转换 | 检查适配器的_normalizeArgs |
| 查询结果字段丢失 | 联表同名字段覆盖 | 在select()中显式指定列名或别名 |
| 写操作报 database locked | 多 isolate 并发写 | 全局串行化写操作 |
| 分页数据错乱 | LIMIT/OFFSET 顺序或参数位置错误 | 核对 SQL 文本和参数列表的顺序 |
| 排序结果相反 | orderBy忘记传desc: true | 检查构建查询时的排序参数 |
最后再分享一个我在这个项目上特别受益的习惯:适配完成后,把 fluent_query_builder 的输出(SQL 文本和参数列表)用debugPrint打印出来,和数据库实际执行的语句对照检查。这个库的build()产物是纯粹的文本,非常适合做断言测试。我后来直接在测试用例里断言 SQL 文本,每次改动构建逻辑都能第一时间发现回归。鸿蒙化适配不是一次性的工作,后续升级依赖、换引擎都可能再来一遍,把这些调试工具固化下来,下次能省不少事。