ArkData + HarmonyOS 离线巡检:谓词分页与稳定排序(连载05/06)【共创稿事节】
离线巡检做到第四篇时,写入链路已经有了事务、同实例写队列以及 request_key 去重凭据。再往前走,新的瓶颈不一定是写不进去,而是恢复某条历史记录时,需要从几万条草稿里翻出正确的一条。如果每次进入列表都调用 all(),把整张表读入内存,再使用 JavaScript 的 sort()、slice() 做翻页,确实容易得到一个能滚动的页面,但随着记录增加,数据库本可以做的过滤排序被转移到了 UI 线程,页面稳定性也很难维护。
本篇不改变既有 inspection_draft.db。第3版结构仍是 inspection_draft(id, title, body, updated_at, priority) 和 inspection_submission(request_key, payload_json, row_count, created_at);其中后者承担上一阶段的重放凭据职责,不应被当作巡检历史列表的一部分。我们新增的是查询契约:关键词与优先级过滤、确定性的顺序、有限行数和下一页游标。读路径与写路径分离,但继续共用同一份已完成迁移的数据库连接。

先分清三种查询:列表、详情、提交确认
列表只需要 id、title、priority、updated_at 等摘要字段;详情需要 body;提交确认按 request_key 查询凭据。把它们都塞进 all() 的问题不仅是浪费内存,还可能让“某条数据暂时不在当前列表页”被误解释为“这条数据不存在”。页面列表的可见页是一种视图切片,完整记录的身份仍以主键 id 为准。
排序也不能只写 orderByDesc('updated_at') 就结束。两条巡检记录可能在同一毫秒内写入,时间戳完全相同;如果数据库在这些并列项之间任意改变顺序,翻页就会出现重复和遗漏。必须再加一个稳定的次级键 id DESC,建立严格的二元排序 (updated_at DESC, id DESC)。时间相同的记录由主键决定先后,才有可靠的页面边界。对于同一设备上的离线数据库,这个规则比“最新的排最前面”更具体也更可测试。

用 RdbPredicates 在数据库层完成过滤和取数
在之前 DraftRepository 类内增加查询方法,沿用 ready(context) 的打开和迁移机制,沿用已存在的 DraftRecord 字段语义。以下使用 @kit.ArkData 的 relationalStore.RdbPredicates:contains 负责匹配标题中的文本,equalTo 负责优先级,两个 orderByDesc 定义确定顺序,limitAs 控制单次返回上限。所有过滤值都作为谓词参数传入,不要靠字符串拼接 SQL 条件。
// entry/src/main/ets/model/DraftRepository.ets 的增量代码
import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
export interface DraftPageFilter {
keyword: string;
priority: number | null;
}
export interface DraftCursor { updatedAt: number; id: number; }
export interface DraftPage {
items: DraftRecord[];
next: DraftCursor | null;
}
// 以下方法放入上一阶段 DraftRepository 类中,ready() 沿用已有实现。
async queryPage(context: common.UIAbilityContext,
filter: DraftPageFilter, cursor: DraftCursor | null,
size: number = 20): Promise<DraftPage> {
if (!Number.isInteger(size) || size < 1 || size > 100) {
throw new Error('分页大小只允许1至100');
}
const db: relationalStore.RdbStore = await this.ready(context);
const pred = new relationalStore.RdbPredicates('inspection_draft');
const word: string = filter.keyword.trim();
if (word.length > 0) pred.contains('title', word);
if (filter.priority !== null) {
if (!Number.isInteger(filter.priority) ||
filter.priority < 0 || filter.priority > 3) {
throw new Error('priority只接受0至3');
}
pred.equalTo('priority', filter.priority);
}
if (cursor !== null) {
// 排序序列下一页:旧时间,或同时间下更小的主键。
pred.beginWrap()
.lessThan('updated_at', cursor.updatedAt)
.or()
.beginWrap()
.equalTo('updated_at', cursor.updatedAt)
.lessThan('id', cursor.id)
.endWrap()
.endWrap();
}
pred.orderByDesc('updated_at').orderByDesc('id');
pred.limitAs(size + 1);
const result: relationalStore.ResultSet = await db.query(pred,
['id', 'title', 'body', 'updated_at', 'priority']);
const items: DraftRecord[] = [];
try {
const idCol = result.getColumnIndex('id');
const titleCol = result.getColumnIndex('title');
const bodyCol = result.getColumnIndex('body');
const timeCol = result.getColumnIndex('updated_at');
const priCol = result.getColumnIndex('priority');
while (result.goToNextRow()) {
items.push({
id: result.getLong(idCol),
title: result.getString(titleCol),
body: result.getString(bodyCol),
updatedAt: result.getLong(timeCol),
priority: result.getLong(priCol)
});
}
} finally {
result.close();
}
const hasMore: boolean = items.length > size;
if (hasMore) items.pop();
const last: DraftRecord | undefined = items[items.length - 1];
return {
items,
next: hasMore && last ? { updatedAt: last.updatedAt, id: last.id } : null
};
}
这里特意查询 size + 1 条:多取一条仅用于判断是否还有下一页,不把它加入返回的 items。如果连一条都没有,返回空数组和空游标;如果恰好只剩 size 条,next=null,页面不必再发无意义的下一页请求。这个设计既没有人为猜测“数据库总共有多少条”,也没有额外调用整表计数。对经常新增记录的离线工具,列表页是否还有下一页,通常比每次刷新一次精确总数更重要。
上述方法继续从 ready(context) 获取迁移完的 RdbStore。打开失败、Schema 高于支持版本、磁盘异常、查询本身失败均让异常传到页面边界;结果集无论正常还是异常都在 finally 中关闭,避免长期保持游标资源。和前一篇的写队列不同,这里不对只读查询额外套串行写队列,以免读取被不必要地阻塞;如果业务要求严格的同一快照视图,再根据 SDK 支持与数据库事务策略建立单独的读一致性方案。
为什么“游标页”比简单 offset 更稳
传统翻页往往是按 offset = pageIndex * pageSize 跳过前面若干行。假设第一页按时间降序显示 id: 9, 8, 7,翻到第二页之前有新记录 id: 10 插入,第一页现在变成 10, 9, 8。再跳过前3条,下一页第一条就可能再次出现 7。无论页面渲染是不是用了 LazyForEach,数据库偏移位置已经改变了。offset 翻页适合相对静态、可接受重新定位的集合;对持续新增的巡检历史,基于最后一条排序键的游标更明确。
使用 (updated_at, id) 作为游标并不需要增加数据库列;它只要求这两项保持不可混淆的排序含义,且每次翻页保持相同关键词和优先级。若切换了过滤条件,必须把 cursor 置空并清空已有列表,不能把旧查询的游标直接带到新查询。若正在浏览的某条记录被修改,其 updated_at 可能前移,此时游标保证的是基于当前排序条件的后继查询,不是跨所有并发修改的全量快照。对于必须保证严格快照的审核导出,应建立固定时间边界或数据库快照,而不是假装普通分页就有这一能力。

给 UI 加一道“旧请求不能覆盖新筛选”的门禁
页面里还有一个容易忽略的异步竞态:用户先搜索“泵”,请求 A 尚未返回,又立即改成“阀”,请求 B 更快返回;如果 A 最后返回再把列表覆盖,界面就会显示错误条件的数据。解决方式是在页面维护递增版本号 generation。每次改变筛选条件,先清空游标和现有列表,增加代数;异步结果只允许在请求开始时的代数仍然有效时写入状态。这样不要求底层数据库接口具有取消能力,也不会把过期结果当成最新查询。
// 页面控制器中的增量逻辑,使用 queryPage() 的强类型结果
private generation: number = 0;
private next: DraftCursor | null = null;
private hasNext: boolean = true;
private loadingGeneration: number = -1;
@State private drafts: DraftRecord[] = [];
async replaceFilter(context: common.UIAbilityContext,
filter: DraftPageFilter): Promise<void> {
this.generation++;
this.next = null;
this.hasNext = true;
this.drafts = [];
await this.loadMore(context, filter);
}
async loadMore(context: common.UIAbilityContext,
filter: DraftPageFilter): Promise<void> {
if (!this.hasNext || this.loadingGeneration === this.generation) return;
const requestVersion: number = this.generation;
this.loadingGeneration = requestVersion;
try {
const page = await this.repository.queryPage(context, filter, this.next, 20);
if (requestVersion !== this.generation) return;
this.drafts = this.drafts.concat(page.items);
this.next = page.next;
this.hasNext = page.next !== null;
} finally {
if (this.loadingGeneration === requestVersion) {
this.loadingGeneration = -1;
}
}
}
这里的 loadingGeneration 只限制当前代的重复请求:上一代请求尚未返回时,新筛选条件也能发起新查询;旧请求完成后不会把新代的加载状态误清空。页面仍应限制连续滚动触底造成的并发取页次数,并在请求错误时展示明确的重试入口。最简单的搜索场景可以在用户按下提交按钮时才发查询,而不是每输入一个字就无限制地发起 I/O。代数校验负责防止旧结果覆盖,不能代替输入节流与重试策略。
索引、查询范围与提交凭据各司其职
查询语义确定后,再考虑索引。按照 updated_at DESC, id DESC 排序频繁读取的场景,可以评估在 inspection_draft 建立复合索引,例如 CREATE INDEX IF NOT EXISTS idx_draft_time_id ON inspection_draft(updated_at DESC, id DESC)。索引创建应纳入版本迁移和事务审查;本篇为了保持 v3 模型不变,仅给出索引规划,不在已发布版本上悄悄修改 db.version。只有掌握实际的数据规模、写入频率、读分页耗时和执行计划,才能判断索引是否值得增加写入维护成本。contains(title, keyword) 的子串搜索并不当然享受到普通 B-tree 排序索引的加速,这又是独立的检索优化课题。

更重要的是保留读写职责边界。用户为了确认上一次提交是否成功,应按 request_key 检索 inspection_submission 凭据,不要用标题和最新时间猜测是否重复保存;用户点击列表详情则按 inspection_draft.id 精准获取,不应依赖当前分页里刚好存在这条记录。草稿数据一旦进入同步与归档流程,还需要区分“本地未同步”“已提交待确认”“可清理”的不同生命周期,不能仅靠分页删除旧数据。
验收时可建立小规模确定性数据集:给几条记录故意使用相同 updated_at、不同 id,检验分页组合顺序;在翻下一页之前插入一条更新的记录,观察是否出现重复 ID;改筛选词后检查游标清零;读取到最后一页时确认 next=null;制造查询异常确认 ResultSet 正常关闭;最后用同一个 request_key 重试提交,确保新查询方法没有破坏去重链路。这些都是可复现测试设计,不需要预填任何“实测毫秒数”。
本篇将“记录写得进去”推进到“记录找得准、翻页不容易错位”。数据库负责有限集合与排序,页面负责筛选生命周期,业务仓储负责记录身份与幂等凭据。第六篇会沿着这三条边界完成离线数据的生命周期、安全处理与整个系列的工程收尾,而不是用一份庞大的 all() 方法继续掩盖职责。
更多推荐

所有评论(0)