ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter鸿蒙化适配:fluent_query_builder数据库层实践

Flutter鸿蒙化适配:fluent_query_builder数据库层实践 做 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 大于 18status 等于 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 这套查询构建逻辑是跨平台通用的我们只需要换掉它的执行引擎业务层那些复杂的查询条件、排序、分页逻辑就可以原封不动地复用。鸿蒙侧的数据库能力其实非常完整它内置的关系型数据库 RDBRelationalStore底层就是 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 的选项有两个主流路线我分别踩过说一下差异。路线一是直接用鸿蒙自带的 RDBRelationalStore。它集成在系统里无需额外引入 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 { FutureListMapString, dynamic rawQuery(String sql, Listdynamic args); Futureint rawInsert(String sql, Listdynamic args); Futureint rawUpdate(String sql, Listdynamic args); Futureint rawDelete(String sql, Listdynamic args); FutureT transactionT(FutureT Function() action); }然后写一个鸿蒙实现内部通过MethodChannel调用鸿蒙侧封装的 RDB 能力。这里我贴一个精简版的示例class OhosQueryExecutor implements QueryExecutor { static const _channel MethodChannel(com.example.rdb_executor); final _sqlContext SqlContext.sqlite(); override FutureListMapString, dynamic rawQuery( String sql, Listdynamic args) async { final result await _channel.invokeMethod(query, { sql: sql, args: args, }); return (result as List).castMapString, dynamic(); } // 实际执行时把 QueryBuilder 的输出转成 sql args FutureListMapString, 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 类型说明intINTEGER直接绑定无特殊处理doubleREAL直接绑定StringTEXT直接绑定boolINTEGER (0/1)需要手动转换不能用 true/false 直接绑定DateTimeINTEGER (时间戳) 或 TEXT建议统一存毫秒时间戳排序和比较都方便nullNULL绑定为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(MapString, 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是完整可落地的版本重点看代码里的类型规范化方法_normalizeArgsclass OhosRdbAdapter implements QueryExecutor { static const _channel MethodChannel(com.example.rdb_executor); static const _sqlContext SqlContext.sqlite(); bool _inTransaction false; Listdynamic _normalizeArgs(Listdynamic args) { return args.map((arg) { if (arg is bool) return arg ? 1 : 0; if (arg is DateTime) return arg.millisecondsSinceEpoch; return arg; }).toList(); } override FutureListMapString, dynamic rawQuery( String sql, Listdynamic args) async { final normalizedArgs _normalizeArgs(args); final result await _channel.invokeMethod(query, { sql: sql, args: normalizedArgs, }); return (result as List).castMapString, dynamic(); } override Futureint rawInsert(String sql, Listdynamic args) async { final normalizedArgs _normalizeArgs(args); return await _channel.invokeMethod(insert, { sql: sql, args: normalizedArgs, }) as int; } override Futureint rawUpdate(String sql, Listdynamic args) async { final normalizedArgs _normalizeArgs(args); return await _channel.invokeMethod(update, { sql: sql, args: normalizedArgs, }) as int; } override Futureint rawDelete(String sql, Listdynamic args) async { final normalizedArgs _normalizeArgs(args); return await _channel.invokeMethod(delete, { sql: sql, args: normalizedArgs, }) as int; } override FutureT transactionT(FutureT 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; } } FutureListMapString, dynamic executeQuery(QueryBuilder query) { return rawQuery(query.build(_sqlContext), query.getValues()); } }鸿蒙侧对应的 ArkTS 实现也不复杂核心就是拿到relationalStore的RdbStore实例后执行querySql。注意鸿蒙的通道参数识别Dart 侧传过来的List在 ArkTS 侧拿到的是ArrayObject遍历绑定即可。如果说这段代码里有什么值得收藏的经验那就是_normalizeArgs这一步。千万别省我吃过亏一开始没做 bool 转换鸿蒙侧一直报参数类型错误排查了两个小时才发现是true这个值导致的。4.3 流式查询的落地用法增删改查一次走通适配器就位后业务层的体验就完全回到熟悉的节奏了。下面一组示例代码覆盖了常见操作条件查询 排序 分页final adapter OhosRdbAdapter(); FutureListUser 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(); }金额范围筛选 状态匹配FutureListMapString, 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); }插入数据Futureint 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()); }更新数据Futureint 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()); }删除数据Futureint 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 文本每次改动构建逻辑都能第一时间发现回归。鸿蒙化适配不是一次性的工作后续升级依赖、换引擎都可能再来一遍把这些调试工具固化下来下次能省不少事。
返回列表