巡检记录刚开始只有几十条,列表进入页面就把整个 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 作为第二排序键,才能避免相同时间记录在翻页边界发生随机位置变化。

OFFSET 位移与游标续读的区别

这里并不把游标当作数据库事务快照。分页期间数据库中更新、删除、回填历史数据,仍会改变后续结果;稳定含义是“基于指定排序键继续读取”,不是“永远冻结第一页打开时的全库内容”。如果业务要求一份严格不变的审计导出,应固定查询范围或使用业务快照,而不是用移动分页模拟快照。

让查询条件和索引成为一对

先保留旧表与数据库名称,不重建数据。安装或升级时在仓库初始化并完成既有建表步骤之后执行一次索引 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,那一行可能跨越游标边界,导致重复或缺失。本例把创建时间当作写入后不变的字段;需要可修改排序依据的业务,则应明确更新后的列表刷新策略,必要时用可单调递增的稳定序列键替代时间字段。时间戳和自增主键都属于当前设备本地表,不能未经设计就拿来充当多设备全局排序依据。

到这一步,离线台账的读路径从“全量读取所有记录”变成了“在稳定双列排序键之后继续读取”;写路径仍沿用上一阶段的事务与队列,表结构也没有突然变形。下一阶段将进入数据库结构迁移:把索引定义、字段新增与旧数据兼容纳入版本管理,让老用户升级之后仍能正确打开历史巡检台账。

参考资料:华为 ArkData API 版本差异、ResultSet 读取与资源释放、ArkData 文档入口。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐