ArkData + HarmonyOS 离线巡检台账:游标分页与复合索引(连载04/06)【共创稿事节】
巡检记录刚开始只有几十条,列表进入页面就把整个 inspection 表查出来,通常不会立刻暴露问题。等机房、楼层和设备的巡检历史积累到成千上万条后,滚动列表会越来越难管理。更讨厌的是,用户翻到第二页时,后台新增了一条最新巡检记录;如果仍按 LIMIT ... OFFSET ... 查询,第二页的第一条可能刚好是上一页最后一条。这不是数据库把排序搞乱了,而是查询的“位置”随数据插入而移动。
此前已经搭起 inspection_local.db,inspection 表拥有 id、site、note、created_at 和 sync_state 五列;第三阶段又加上了事务批量保存。现在需要给列表一个稳定、可解释的翻页契约:按时间倒序展示,时间相同用 id 再排序,下一页从上一页最后一条之后读取。为此我们采用复合排序键 + 游标分页,并让索引与 SQL 的过滤、排序方向一致。

OFFSET 直观,但不是持续插入场景的天然答案
假设页面大小为三,当前按 created_at DESC 排序得到 R9、R8、R7。页面一的查询是 LIMIT 3 OFFSET 0,页面二用 OFFSET 3。如果翻页前恰好插入新的 R10,原来的 R7 就被推到第四位,第二页会再次读到 R7。反过来,若删掉靠前记录,另一个元素就可能被跳过。对于巡检台账这样的持续追加列表,用户关心的是“从刚刚看到的那条继续往下”,而不是每次都把所有行从头数一遍。
更稳妥的方式是记录上一页最后一行的排序键。例如 R7 的 created_at=1700000003000、id=7,下一页只取时间更早、或时间相同且 id 更小的记录。注意,单凭时间戳不够:多个条目可能在同一毫秒创建,尤其前一阶段的事务批次会使写入时间接近。让 id 作为第二排序键,才能避免相同时间记录在翻页边界发生随机位置变化。

这里并不把游标当作数据库事务快照。分页期间数据库中更新、删除、回填历史数据,仍会改变后续结果;稳定含义是“基于指定排序键继续读取”,不是“永远冻结第一页打开时的全库内容”。如果业务要求一份严格不变的审计导出,应固定查询范围或使用业务快照,而不是用移动分页模拟快照。
让查询条件和索引成为一对
先保留旧表与数据库名称,不重建数据。安装或升级时在仓库初始化并完成既有建表步骤之后执行一次索引 DDL。IF NOT EXISTS 使重复执行不会再创建同名索引;它并不能验证既有索引定义是否与新版本一致,因此真正的结构迁移仍要在下一阶段按 schema 版本管理。当前只增加一个明确的二级索引。
CREATE INDEX IF NOT EXISTS idx_inspection_created_id
ON inspection (created_at DESC, id DESC);
复合索引的左侧列是主要排序键,id 负责同时间戳记录的稳定顺序。与之匹配的第一页查询逻辑如下,后续页面则多一段边界条件。这里的 ? 都是绑定参数,不通过字符串拼接插入用户文字或游标数字。
-- 首次加载
SELECT id, site, note, created_at, sync_state
FROM inspection
ORDER BY created_at DESC, id DESC
LIMIT ?;
-- 从上一页末项继续
SELECT id, site, note, created_at, sync_state
FROM inspection
WHERE created_at < ? OR (created_at = ? AND id < ?)
ORDER BY created_at DESC, id DESC
LIMIT ?;
为什么不把 sync_state 和 site 一起堆进索引?因为本轮列表不限定它们作为等值过滤条件,盲目增加列会扩大索引维护成本,还可能让优化器选择不同查询路径。如果后面需要“只显示待同步记录”或“按设备站点筛选”,应该基于真实查询组合评审另一个索引。每多一个索引,写入或更新相关列时都需要付出维护成本,尤其不能因为列表更快就无限添加。
真实的仓库接口应该怎样返回一页
接入上一阶段 InspectionRepository.ets 时,继续使用它已完成的 init()、openOnce()、requireStore(),不要新建另一份同名数据库。下面把分页的入参、出参和 SQL 操作放在一个完整的方法里;其中 InspectionRecord 保留已有的五个业务字段。PageCursor 使用上一次实际返回的最后一条数据,不使用页面索引作为游标。
// entry/src/main/ets/data/InspectionPaging.ets
import { relationalStore } from '@kit.ArkData';
export interface InspectionRecord {
id: number;
site: string;
note: string;
createdAt: number;
syncState: number;
}
export interface PageCursor {
createdAt: number;
id: number;
}
export interface PageResult {
rows: InspectionRecord[];
nextCursor?: PageCursor;
}
const COLUMNS: string = 'id, site, note, created_at, sync_state';
const FIRST_SQL: string = `SELECT ${COLUMNS} FROM inspection
ORDER BY created_at DESC, id DESC LIMIT ?`;
const NEXT_SQL: string = `SELECT ${COLUMNS} FROM inspection
WHERE created_at < ? OR (created_at = ? AND id < ?)
ORDER BY created_at DESC, id DESC LIMIT ?`;
export class InspectionPaging {
constructor(private store: relationalStore.RdbStore) {}
async ensureIndex(): Promise<void> {
await this.store.executeSql(
'CREATE INDEX IF NOT EXISTS idx_inspection_created_id ' +
'ON inspection (created_at DESC, id DESC)'
);
}
async page(cursor: PageCursor | undefined, pageSize: number = 20): Promise<PageResult> {
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) {
throw new Error('分页大小应为 1~100');
}
const args: Array<relationalStore.ValueType> = cursor === undefined
? [pageSize + 1]
: [cursor.createdAt, cursor.createdAt, cursor.id, pageSize + 1];
const rs: relationalStore.ResultSet = await this.store.querySql(
cursor === undefined ? FIRST_SQL : NEXT_SQL, args
);
const rows: InspectionRecord[] = [];
try {
while (rs.goToNextRow()) {
rows.push({
id: rs.getLong(rs.getColumnIndex('id')),
site: rs.getString(rs.getColumnIndex('site')),
note: rs.getString(rs.getColumnIndex('note')),
createdAt: rs.getLong(rs.getColumnIndex('created_at')),
syncState: rs.getLong(rs.getColumnIndex('sync_state'))
});
}
} finally {
rs.close();
}
const hasMore: boolean = rows.length > pageSize;
const visible: InspectionRecord[] = rows.slice(0, pageSize);
const tail: InspectionRecord | undefined = visible[visible.length - 1];
return {
rows: visible,
nextCursor: hasMore && tail !== undefined
? { createdAt: tail.createdAt, id: tail.id } : undefined
};
}
}
这里多查一条并不是“为了凑整”,而是让 nextCursor 有可靠依据。若直接查满二十条,就无法判断是否真有第二页;多查第 21 条才知道继续按钮是否需要出现。下一页边界必须使用显示给用户的最后一条,不能误用额外探测出来的第 21 条,否则会跳过那条数据。最后一页返回 nextCursor: undefined,界面据此停用“加载更多”。
结果集读取用了 try/finally:即便某列解析出错,也要调用 close() 释放结果集。这里采用 querySql 加绑定参数,保留原生 SQL 复杂过滤表达式的可读性;对静态等值查询也可以使用 RdbPredicates,但不要为了统一风格把双列游标条件拆成容易写错的谓词组合。
页面有“下一页”,也要处理刷新和重复点击
分页不仅是一个 SQL 方法,还包括列表如何与用户操作配合。推荐把 rows、nextCursor、loading 和 error 分开管理。刷新表示从头读取,应该清空旧游标并用第一页替换当前列表;加载更多表示沿旧游标追加,不能在同一个请求完成之前再次触发,否则两个请求都从相同游标出发,结果会重复。
// 可放入现有 ArkUI 页面组件内部,paging 在仓库初始化后注入
@State private rows: InspectionRecord[] = [];
@State private loading: boolean = false;
@State private errorText: string = '';
private nextCursor?: PageCursor;
private paging?: InspectionPaging;
async reload(): Promise<void> {
if (this.loading || this.paging === undefined) { return; }
this.loading = true;
this.errorText = '';
try {
const result: PageResult = await this.paging.page(undefined, 20);
this.rows = result.rows;
this.nextCursor = result.nextCursor;
} catch (e) {
this.errorText = '刷新失败,请检查本地数据库状态后重试';
} finally {
this.loading = false;
}
}
async more(): Promise<void> {
if (this.loading || this.paging === undefined || this.nextCursor === undefined) {
return;
}
const cursor: PageCursor = this.nextCursor;
this.loading = true;
this.errorText = '';
try {
const result: PageResult = await this.paging.page(cursor, 20);
this.rows = [...this.rows, ...result.rows];
this.nextCursor = result.nextCursor;
} catch (e) {
this.errorText = '加载失败,当前列表仍保留,可重试';
} finally {
this.loading = false;
}
}
这一段是供已有页面合并的成员片段,不要在模块顶层直接使用 @State。实际按钮只需按 loading 和游标状态确定是否可点击。若用户在“加载更多”的请求还未完成时启动强制刷新,建议进一步引入前一系列那样的请求代次仲裁,把旧分页响应废弃;此处先使用串行按钮策略,使同一页面不会并发叠加两份数据。

索引是否有效,先看计划再讨论速度
给索引起个名字并不能保证数据库一定使用它。EXPLAIN QUERY PLAN 可以帮助确认是否采用了 idx_inspection_created_id,有无临时排序;但计划会受表大小、统计信息、查询条件影响,小数据集不一定体现该索引的价值。可以在开发样本库中执行下面的 SQL,记录查询计划,再根据目标数据量做后续评估:
EXPLAIN QUERY PLAN
SELECT id, site, note, created_at, sync_state
FROM inspection
WHERE created_at < 1700000003000
OR (created_at = 1700000003000 AND id < 7)
ORDER BY created_at DESC, id DESC
LIMIT 21;
这组常量只用于解释游标条件,不是某次真实巡检数据库的运行数据;不要把查询计划截图或耗时写成已经测出的结果。实际比较时应固定数据库版本、记录数量、设备条件和索引定义。还应覆盖相同毫秒时间戳下多条记录、翻页期间新增记录、按主键删除旧记录、最后一页不足页长、翻页请求重复触发等边界。
还有一种业务陷阱容易被忽略:若应用允许修改旧记录的 created_at,那一行可能跨越游标边界,导致重复或缺失。本例把创建时间当作写入后不变的字段;需要可修改排序依据的业务,则应明确更新后的列表刷新策略,必要时用可单调递增的稳定序列键替代时间字段。时间戳和自增主键都属于当前设备本地表,不能未经设计就拿来充当多设备全局排序依据。
到这一步,离线台账的读路径从“全量读取所有记录”变成了“在稳定双列排序键之后继续读取”;写路径仍沿用上一阶段的事务与队列,表结构也没有突然变形。下一阶段将进入数据库结构迁移:把索引定义、字段新增与旧数据兼容纳入版本管理,让老用户升级之后仍能正确打开历史巡检台账。
更多推荐

所有评论(0)