同一份借用流水,第一页显示正常,翻到第二页却又看见刚刚读过的记录,这不一定是列表组件复用了错误的节点。只要分页使用偏移量,而两次查询之间又插入了新记录,后面的行号就可能整体移动。器材簿已经有可靠的借还事务,本篇把观察对象换成查询边界:在数据持续变化时,究竟怎样定义“下一页”,以及一轮浏览应该包含哪些记录。

复合游标组织借用流水的技术封面

一、用一个小实验分开三种稳定性

继续使用 API 14 和第三篇的数据版本二,索引为 LOAN_TIME_IDX(CREATED_AT DESC, ID DESC)。比较分页方案之前,先区分排序稳定、成员稳定与内容稳定。排序稳定要求同一批记录有确定次序;成员稳定要求一轮浏览不会混入后来新增的记录;内容稳定则要求已存在记录的字段也保持同一时刻的值。一个游标并不会自动同时解决这三件事。

只按创建时间排序不够,因为同一毫秒可以出现多笔借用。我们给时间相同的记录增加流水编号降序作为第二排序键,形成唯一、确定的顺序。游标保存最后一条已显示记录的时间和编号,下一页寻找时间更早,或者时间相同但编号更小的记录。把边界写成小于号,是为了不把最后一条再次读回来。

成员稳定采用一个额外的上界:第一页开始时读取当前最大流水编号,后续页面都限制 ID <= ceiling。本系列流水编号自动递增且不被复用,因此刷新之前的新记录不会混入本轮浏览。编号上界不是总条数,也不是页码;删除一条记录后,编号中间可以出现空洞,这不会改变它表达的时间边界。

这个设计还要求流水身份不可被随意改写。恢复历史备份、导入另一份数据库或重建主键,会改变编号所代表的数据集合,届时所有页面游标都应失效。游标是某一份数据库在特定规则下的阅读位置,不是可以跨任意数据库迁移的永久地址。第五篇恢复完成后要求刷新页面,原因也包括这里的查询边界。

内容稳定没有在本篇承诺。别人归还了器材,后续查到的那笔流水可以显示新的归还状态,而此前已经显示的行仍可能保留旧状态。要获得整轮浏览的严格快照,需要更明确的快照生命周期或结果物化策略,不能长期占用一个数据库事务等用户慢慢翻页。器材簿选择刷新重新开始一轮浏览,以适应轻量查询页面。

二、把边界变成一个明确的数据结构

以下接口放在 data/GearRepository.ets 的类定义之前,pageLoans() 加入同一个类,复用前文的 store 与 serial()。查询固定使用参数绑定,页大小固定为二十。一次多读取一条,只用来判断是否还有下一页;真正交给界面的仍然最多二十条。

export interface LoanRow {
  id: number;
  gearName: string;
  borrower: string;
  createdAt: number;
  returnedAt: number;
}
export interface LoanCursor { time: number; id: number; }
export interface LoanPage {
  rows: LoanRow[];
  next: LoanCursor | undefined;
  hasMore: boolean;
  ceiling: number;
}

// 加入 GearRepository 类。
pageLoans(cursor: LoanCursor | undefined, ceiling: number): Promise<LoanPage> {
  return this.serial(async () => {
    let upper = ceiling;
    if (cursor === undefined) {
      const max = await this.store.querySql('SELECT COALESCE(MAX(ID), 0) FROM LOAN');
      try { if (max.goToNextRow()) { upper = max.getLong(0); } }
      finally { max.close(); }
    }
    let sql = 'SELECT L.ID, G.NAME, L.BORROWER, L.CREATED_AT, L.RETURNED_AT ' +
      'FROM LOAN L JOIN GEAR G ON G.ID = L.GEAR_ID WHERE L.ID <= ?';
    const args: relationalStore.ValueType[] = [upper];
    if (cursor !== undefined) {
      sql += ' AND (L.CREATED_AT < ? OR (L.CREATED_AT = ? AND L.ID < ?))';
      args.push(cursor.time, cursor.time, cursor.id);
    }
    sql += ' ORDER BY L.CREATED_AT DESC, L.ID DESC LIMIT ?';
    args.push(21);
    const rs = await this.store.querySql(sql, args);
    const rows: LoanRow[] = [];
    try {
      while (rs.goToNextRow()) {
        rows.push({ id: rs.getLong(0), gearName: rs.getString(1),
          borrower: rs.getString(2), createdAt: rs.getLong(3), returnedAt: rs.getLong(4) });
      }
    } finally { rs.close(); }
    const more = rows.length > 20;
    if (more) { rows.pop(); }
    let next: LoanCursor | undefined = undefined;
    if (rows.length > 0) {
      const last = rows[rows.length - 1];
      next = { time: last.createdAt, id: last.id };
    }
    return { rows: rows, next: next, hasMore: more, ceiling: upper };
  });
}

额外读取的一条不能成为下一页游标。假设本页展示二十条,却把第二十一条作为边界,下一页会从它后面开始,这条记录就永远没有被展示。代码先移除探测行,再从真正返回数组的末尾生成游标,保证“已经读取”和“已经显示”之间的语义不混淆。

同样,不要在每一页重新计算最大编号。上界一旦变化,整轮浏览的成员范围就变了,新记录可能在不同位置进入结果。只有用户主动刷新时,才丢弃旧游标与上界,重新定义一轮浏览。把这两个值作为一组保存,能够减少“游标更新了,上界还是上一次”的状态组合错误。

连接器材表的目的是显示名称,关联方向由流水中的器材编号决定。前两篇已经约定保留存在历史流水的器材,因此这里可以使用内连接。如果未来允许删除器材,就应重新选择软删除、保存名称快照或左连接等方案;否则被删除器材对应的历史借用会从列表里消失,页面看似分页正常,实际上数据语义已经改变。

结果集在返回 LoanPage 前就关闭了,页面拿到的是普通对象。因为一次只转换有限行,资源占用与本页大小直接相关,而不是让界面持有一个覆盖所有流水的长寿命游标。如果以后需要更大的页大小,应同时观察对象创建、列表渲染和数据库查询,不能只看 SQL 本身。

三、让翻页与刷新拥有不同的动作

下面 pages/Index.ets 展示完整调用。翻页在原有数组后追加;刷新则清空当前记录、游标和上界。忙碌时停用两个按钮,让最小页面一次只执行一个查询。列表使用流水编号作为键,不能使用数组位置,因为刷新、插入和归还都可能改变行的位置或内容。

import { common } from '@kit.AbilityKit';
import { GearRepository, LoanRow, LoanCursor } from '../data/GearRepository';

@Entry
@Component
struct Index {
  @State rows: LoanRow[] = [];
  @State busy: boolean = false;
  @State more: boolean = true;
  @State status: string = '按创建时间倒序';
  private cursor: LoanCursor | undefined = undefined;
  private ceiling: number = 0;

  aboutToAppear(): void { this.load(false); }

  async load(reset: boolean): Promise<void> {
    if (this.busy || (!reset && !this.more)) { return; }
    this.busy = true;
    if (reset) {
      this.cursor = undefined; this.ceiling = 0;
      this.rows = []; this.more = true;
    }
    try {
      const repository = await GearRepository.open(getContext(this) as common.UIAbilityContext);
      const page = await repository.pageLoans(this.cursor, this.ceiling);
      this.rows = this.rows.concat(page.rows);
      this.cursor = page.next;
      this.ceiling = page.ceiling;
      this.more = page.hasMore;
      this.status = this.rows.length === 0 ? '暂无借用记录' : `已显示 ${this.rows.length} 笔`;
    } catch (error) {
      this.status = '加载失败,可重试当前页';
    } finally { this.busy = false; }
  }

  build() {
    Column({ space: 16 }) {
      Text('借用流水').fontSize(28)
      Text(this.status).fontColor('#64748B')
      List({ space: 12 }) {
        ForEach(this.rows, (row: LoanRow) => {
          ListItem() {
            Column({ space: 8 }) {
              Text(`${row.borrower} · ${row.gearName}`).fontSize(18)
              Text(row.returnedAt === 0 ? '借用中' : '已归还').fontColor('#0F766E')
            }.alignItems(HorizontalAlign.Start).width('100%').padding(18)
              .backgroundColor(Color.White).borderRadius(14)
          }
        }, (row: LoanRow) => row.id.toString())
      }.layoutWeight(1)
      Button(this.more ? '加载下一页' : '没有更多记录').enabled(!this.busy && this.more)
        .onClick(() => { this.load(false); })
      Button('刷新流水').enabled(!this.busy).onClick(() => { this.load(true); })
    }.padding(24).width('100%').height('100%').backgroundColor('#F3F7F9')
  }
}

页面效果可以使用三笔明确的数据观察:在初始化后的数据库中,依次为周可借用无线麦克风、陈禾借用三脚架、许宁借用补光灯,分别使用不同的借用单号。进入流水页后,预期显示三笔“借用中”,状态为“已显示 3 笔”。代码仍然通过第二篇的 borrow() 建立记录,不绕过库存更新直接塞入流水。

借用流水的竖屏页面效果

加载失败时,游标没有提前推进,因此再次点击仍然重试同一页。错误处理中如果先修改游标,再尝试拼接数组,就可能丢失这一页;如果数组已经拼接却没有保存游标,重试又会重复展示。把一次成功查询的结果整体应用到页面,是比到处去重更直接的做法。

四、比较方案时记录条件,不先写结论

先构造超过两页的数据,并保证至少几笔流水拥有相同的创建时间。这个条件检验复合排序键是否真正生效;只有时间各不相同的数据,无法揭示缺少第二排序键的问题。接着读取第一页,新增一笔流水,再请求第二页,观察本轮成员是否仍受原上界限制。最后主动刷新,确认新记录进入新一轮列表。

构造数据时可以反复调用第二篇的借出与归还方法,每次更换借用单号,避免因为库存耗尽而把后续请求全部变成失败。时间相同的边界案例应放在单独测试库中,通过受控数据准备固定几笔时间戳;正式业务仍然不允许编辑创建时间。测试准备与产品功能要分清,否则为了方便造数据而开放的入口可能改变生产约束。

还要覆盖整页边界:零条、少于二十条、恰好二十条以及二十一条。恰好二十条时没有探测行,按钮应显示没有更多记录;二十一条时第一页保留二十条,第二页返回一条。这个检查比只观察很多条数据更直接,能够发现额外读取、末尾游标和按钮状态之间的偏差。

偏移分页和游标分页的差别可以用同一组数据解释。偏移量描述跳过多少行,前面的记录发生变化后,后续行号会改变;游标描述最后一个已展示对象的位置,查询直接从这个边界继续。两种方式各有适用条件:需要随机跳到任意页时,偏移分页更直观;连续向后浏览、记录持续追加时,游标更容易维护边界。

首次查询、保存上界与继续翻页的业务流程

性能分析应进一步检查查询计划。复合索引的顺序与查询排序相同,是一个合理的设计前提,但带连接、上界和析取条件的查询,最终仍由优化器选择计划。使用官方数据库调试能力查看实际语句与计划,再分别记录查询耗时和页面追加耗时。没有这些观察时,不能仅凭“加了索引”就写出提升百分比。

还要考虑时间被修改的情况。本系列不开放编辑 CREATED_AT,因为一条记录一旦跨过当前游标边界,就可能重复出现或被跳过。若业务真的允许修正发生时间,应在修正后提示刷新,或为排序另设不可变序列。显示时间与排序身份可以不是同一个字段,但应该在模型里明确区分。

五、游标也有需要结束的生命周期

搜索条件、器材筛选和借用状态一旦变化,旧游标就不再对应同一结果集合。以后增加筛选时,应把筛选条件、上界和游标同时重置,而不是只换一段 SQL。游标如果需要持久化,也必须保存对应的排序规则与筛选条件;版本升级改变排序含义时,旧游标应作废重新开始。

此外,游标分页并不会自动限制页面累计内存。每次查二十条,连续追加很多页之后,界面仍然可能持有大量对象。数据量增长时可以采用窗口化缓存、返回顶部刷新或分页浏览等产品策略;数据库分页解决的是单次读取边界,列表回收与对象保留仍属于界面层的工作。

器材簿现在拥有可以持续浏览的流水页:排序由时间与编号共同确定,本轮成员受编号上界控制,结果集及时关闭,失败时不推进游标。下一篇回到更长的生命周期,讨论备份何时可用、恢复如何预检,以及为什么“复制数据库文件”不能直接代替受控备份。

官方参考:ResultSet 生命周期与访问接口、RdbStore.querySql、SQLite 查询规划与多列索引。

Logo

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

更多推荐