在这里插入图片描述

实例:商品分页列表(Product)|技术:商品字段、批量种子生成、ProductDao

一、业务需求分析:当数据量变大,列表需要分页

前面的实例里,列表都是「一次加载全部」——几十条数据毫秒级加载,全量没问题。但电商商品列表的数据量是千、万、十万级:一次把 10 万件商品全部查出来塞进内存,App 直接卡死。分页(Pagination)就是解决这个问题的标准方案:每次只加载一页(如 10 条),滚动到底部再加载下一页,让无限数据流在有限内存里流畅呈现。

实例 8 的核心需求:

  1. 分页查询LIMIT pageSize OFFSET offset——每页 10 条,按销量排序,翻页加载;
  2. 懒加载:滚动到底部自动加载下一页(onReachEnd 触发);
  3. 总条数统计:头部显示「共 N 件商品」,让用户知道总量(COUNT(*));
  4. 分类筛选:按分类过滤后再分页(组合条件 + 分页);
  5. 双列瀑布流:两列网格展示商品卡片(List 的 lanes 多列布局)。

这五个需求组合,就是电商列表的完整形态。数据层核心是 LIMIT/OFFSET 分页——本实例的技术主线。

二、字段设计:电商商品表

商品表 goods

字段名 类型 约束 说明
id INTEGER PRIMARY KEY AUTOINCREMENT 自增主键
name TEXT NOT NULL 商品名称
price REAL NOT NULL 现价(元)
original_price REAL NOT NULL DEFAULT 0 划线价(原价)
sales INTEGER NOT NULL DEFAULT 0 销量
image TEXT DEFAULT ‘📦’ 占位图 emoji
tag TEXT DEFAULT ‘’ 标签(热卖/新品/清仓)
category TEXT DEFAULT ‘其他’ 分类
created_time INTEGER NOT NULL 上架时间戳

设计要点

1. original_price 划线价。电商标配——现价 + 划线原价制造「折扣感」(¥99.00 ¥138.60)。原价通常 > 现价,UI 上原价加删除线。

2. image 用 emoji 占位。真实电商存图片 URL,Demo 用 emoji(📱👟🍫)做占位图——零资源依赖,页面也直观。生产环境替换为 image_url TEXT 即可。

3. sales 销量字段。排序依据——电商列表通常按销量/热度排序(ORDER BY sales DESC),让爆款在前。销量是「运营指标」,每次下单 +1,本实例由种子数据指定。

4. tag 标签。热卖/新品/清仓,页面用红色小徽标展示。可空(DEFAULT ''),无标签的商品不显示徽标。

5. created_time 上架时间。排序备选字段(按新品排序),本实例未用但保留——字段设计为「可能用到的查询」预留

三、建表 SQL 与索引

CREATE TABLE IF NOT EXISTS goods (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  price REAL NOT NULL,
  original_price REAL NOT NULL DEFAULT 0,
  sales INTEGER NOT NULL DEFAULT 0,
  image TEXT DEFAULT '📦',
  tag TEXT DEFAULT '',
  category TEXT DEFAULT '其他',
  created_time INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_goods_sales ON goods (sales);

idx_goods_sales 索引的意义:分页查询的核心排序是 ORDER BY sales DESC——没有索引,SQLite 每次都要全表排序;有索引,直接走索引倒序遍历,配合 LIMIT 只取前 N 条,性能提升显著。分页 + 排序的组合必须建索引,这是分页查询的第一性能法则。

四、ProductDao 封装:分页查询的两种形态

数据层核心 ProductDao。分页查询有「纯分页」和「分类过滤分页」两种形态:

形态一:纯分页(按销量倒序)

static async queryPage(context: common.Context, limit: number, offset: number): Promise<Goods[]> {
  const store = await ProductDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(ProductDao.TABLE);
  predicates.orderByDesc('sales').limitAs(limit).offsetAs(offset);
  const result = await store.query(predicates);
  return ProductDao.collect(result);
}

limitAs(limit).offsetAs(offset) 是 RdbPredicates 的分页 API,等价于 SQL 的 LIMIT ${limit} OFFSET ${offset}——取「从第 offset 条开始的 limit 条」。

形态二:分类过滤分页

static async queryPageByCategory(context: common.Context, limit: number, offset: number, category: string): Promise<Goods[]> {
  const store = await ProductDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(ProductDao.TABLE);
  if (category !== '全部') {
    predicates.equalTo('category', category);
  }
  predicates.orderByDesc('sales').limitAs(limit).offsetAs(offset);
  const result = await store.query(predicates);
  return ProductDao.collect(result);
}

关键细节:category !== '全部' 时才加过滤条件——「全部」是页面的特殊筛选值,语义是「不过滤」。把「全部」的语义判断放在 DAO 层(而非页面拼接 SQL),让 DAO 接口保持「分类参数直达」的干净。

五、OFFSET 的语义与翻页计算

理解 OFFSET 是分页的关键。假设 pageSize=10,页码从 0 开始:

页码 page OFFSET 计算 取出的记录
0 0 × 10 = 0 第 1~10 条
1 1 × 10 = 10 第 11~20 条
2 2 × 10 = 20 第 21~30 条
n n × 10 第 10n+1 ~ 10n+10 条

页面层的翻页状态管理(ProductPage):

@State pageSize: number = 10;
@State page: number = 0;
@State goods: Goods[] = [];
@State loading: boolean = false;
@State finished: boolean = false;

async loadMore(): Promise<void> {
  if (this.loading || this.finished) {
    return;  // 加载中或已加载完,忽略
  }
  this.loading = true;
  const offset = this.page * this.pageSize;
  const list = await ProductDao.queryPageByCategory(this.context, this.pageSize, offset, this.category);
  if (list.length > 0) {
    this.goods = this.goods.concat(list);  // 追加到已有列表
    this.page++;
  }
  if (list.length < this.pageSize) {
    this.finished = true;  // 返回不足一页 → 没有更多了
  }
  this.loading = false;
}

翻页三要素

  1. page 自增:每次成功加载 this.page++,OFFSET = page × pageSize 递进;
  2. concat 追加this.goods.concat(list) 把新页追加到已有列表,而不是替换——列表持续增长,形成「瀑布流」;
  3. finished 判定:返回条数 < pageSize 说明最后一页了(或数据不足一页),置 finished 停止加载——这是分页终止的经典判断

防重入if (this.loading || this.finished) return——防止快速滚动时 onReachEnd 连续触发导致重复加载同一页。loading 标志位是懒加载的防抖闸门。

六、总条数统计与分类计数

总条数(头部显示)

static async count(context: common.Context): Promise<number> {
  const store = await ProductDao.getStore(context);
  const result = await store.querySql(`SELECT COUNT(*) AS c FROM ${ProductDao.TABLE}`);
  let total = 0;
  if (result.goToNextRow()) {
    total = result.getLong(result.getColumnIndex('c'));
  }
  result.close();
  return total;
}

分类计数(筛选条徽标)

static async categoryCounts(context: common.Context): Promise<Record<string, number>> {
  const store = await ProductDao.getStore(context);
  const result = await store.querySql(
    `SELECT category, COUNT(*) AS cnt FROM ${ProductDao.TABLE} GROUP BY category`
  );
  const map: Record<string, number> = {};
  while (result.goToNextRow()) {
    map[result.getString(result.getColumnIndex('category'))] = result.getLong(result.getColumnIndex('cnt'));
  }
  result.close();
  return map;
}

GROUP BY category 一次统计出每个分类的商品数——页面筛选条用 Object.keys 拿到分类列表,同时知道每类数量。分类筛选条的数据源就是这条 GROUP BY

七、技术要点对照表

技术点 实现方式 生产价值
分页 limitAs + offsetAs 每次只取一页
排序 orderByDesc(‘sales’) 爆款在前
分页索引 idx_goods_sales 排序走索引
翻页状态 page 自增 + concat 瀑布流增长
终止判断 返回 < pageSize 最后一页检测
防重入 loading 标志 懒加载防抖
总条数 COUNT(*) 头部总量展示

八、文章小结

商品分页的数据层核心是 LIMIT/OFFSET + 销量索引 + 翻页状态机:limitAs/offsetAs 取页、idx_goods_sales 保排序性能、page/concat/finished 三要素驱动翻页、loading 防重入。这是所有「大数据量列表」应用(电商、信息流、通讯录分页)的地基。分类过滤分页展示了「条件 + 分页」的组合写法,是分页的高级形态。

下一篇(8-2)展示双列瀑布流 UI——触底加载动画 + 总数卡 + 分类筛选条。

九、字段设计详解:价格 REAL、库存 INTEGER 与销量排序的取舍

上一节的字段表一笔带过,这一节把「每个字段为什么选这个类型」讲透——类型选择不是随意,背后是数据库存储与查询的权衡。

9.1 price 用 REAL:小数价格直存,不做分转换

商品价格是小数(¥9.9、¥138.60),SQLite 的 REAL 是 8 字节浮点数,直接存小数、直接读出,代码零转换成本。另一种方案是「以分为单位存 INTEGER」:¥9.90 存 990,精度绝对可靠,但每次读写都要乘除 100,且容易忘记转换出 bug。Demo 用 REAL 图直观,生产环境金额建议 INTEGER 分存储——金融数据对浮点误差零容忍。

方案 存储 精度 代码成本 适用
REAL 直存 9.9 有浮点误差 零转换 Demo、展示类
INTEGER 分 990 绝对精确 乘除 100 生产、支付场景

9.2 stock 库存用 INTEGER:整数语义 + 原子扣减

库存是「件」——离散整数,不存在 3.5 件。INTEGER 4 字节(-21 亿~21 亿)存库存绰绰有余,且整数语义让「扣库存」可以写成原子 SQL:

UPDATE goods SET stock = stock - 1 WHERE id = ? AND stock > 0;

AND stock > 0 保证库存不为负——并发下单时这个条件就是防超卖的第一道闸门。如果用 REAL 存库存,stock - 1 的语义和比较都会变得含混。

9.3 sales 销量:冗余聚合字段,为排序而生

销量本质是「订单数的聚合结果」,理论上可以 COUNT(*) 现算。但每次列表展示都要 JOIN 统计,成本高;所以电商表普遍冗余一个 sales 字段,下单时 +1,查询时直接 ORDER BY sales DESC——用写入时的微小代价换查询时的高性能,这是「读多写少」场景的经典折中,也是分页排序的直接数据来源。

9.4 字段类型对照总表

字段 类型 设计理由
price / original_price REAL 小数价格直存
stock INTEGER 库存整数 + 原子扣减
sales INTEGER 销量聚合冗余,排序依据
name / tag / category / image TEXT 变长字符串
created_time INTEGER 毫秒时间戳

十、LIMIT/OFFSET 原理:数据库怎么「翻页」

10.1 SQL 语义:先排序、再跳过、后截断

SELECT * FROM goods ORDER BY sales DESC LIMIT 10 OFFSET 20;

执行分三步:① 按 sales 排序全表;② 跳过前 20 条(OFFSET);③ 取接下来的 10 条(LIMIT)返回。注意 OFFSET 的语义是「跳过多少条」,不是「第几页」——第 2 页 = 跳过第一页的 10 条。

10.2 RdbPredicates 的 limit/offset 写法

RdbPredicates 把 LIMIT/OFFSET 封装成链式方法,新旧两套 API 并存

// 旧写法:limit() / offset()
predicates.orderByDesc('sales').limit(10).offset(20);

// 当前推荐:limitAs() / offsetAs()(API 9 起)
predicates.orderByDesc('sales').limitAs(10).offsetAs(20);

两者语义完全一致,limitAs/offsetAs 是官方演进后的推荐写法(本实例全程用后者)。链式调用时书写顺序无关——predicates 是命令构建器,最终拼出的 SQL 恒为 LIMIT n OFFSET m

十一、分页参数设计:页大小与页码公式

11.1 页大小 pageSize 怎么定

pageSize 首屏加载 翻页频率 适用
10 最快 商品瀑布流
20 通用列表
50 较慢 表格、后台

电商列表取 10~20:首屏秒开 + 触底加载节奏合适。pageSize 是页面层常量,DAO 层只接收参数不写死——翻页接口保持可复用。

11.2 页码 → OFFSET 公式

SQL 行号从 0 起,所以页码也从 0 起,公式 offset = page × pageSize

page pageSize=10 取出的行
0 offset=0 第 1~10 条
1 offset=10 第 11~20 条
2 offset=20 第 21~30 条

若 UI 页码从 1 显示,公式变为 offset = (page - 1) × pageSize——页面显示层与 SQL 偏移层差 1,是分页最常见的 off-by-one 坑。本实例页面层直接管理 0 起的 page,与 OFFSET 天然对齐,绕开了这个坑。

11.3 最后一页的边界:不足 pageSize 即终止

「返回条数 < pageSize」意味着要么是最后一页、要么总数据不足一页——两种情况都该停止翻页(置 finished)。这一判定已写进 loadMore,是 OFFSET 分页的通用收尾逻辑。

十二、索引对排序的作用:idx_goods_sales 为什么关键

12.1 无索引:全表排序 O(n log n)

没有索引时,ORDER BY sales DESC 迫使 SQLite 把全表(假设 6 万行)读进内存建临时排序树,复杂度 O(n log n),且每次翻页都重复这一过程——数据量上十万后开始卡顿。

12.2 有索引:倒序遍历 O(pageSize)

B-tree 索引的叶子节点天然有序。ORDER BY sales DESC 直接从索引最右端向左遍历,配合 LIMIT 只读前 10 个叶子节点就返回——开销与数据总量无关,只与 pageSize 相关。这也是「分页 + 排序必须建索引」的原理依据。

12.3 索引失效与代价

  • 函数包裹会失效ORDER BY LOWER(sales) 之类对列做运算,索引无法命中;
  • 写放大:每条 INSERT/UPDATE 都要同步维护索引树,所以只给高频排序字段(sales)建,不为每个字段都建;
  • 深分页仍慢:OFFSET 到第 590 条时,索引也得先「跳过」590 个节点——数据百万级时应换游标分页(8-3 已对比三种方案)。

十三、FAQ

问题 解答
LIMIT 和 OFFSET 能互换吗 SQL 语法固定 LIMIT n OFFSET m;RdbPredicates 链式调用顺序无关,最终拼出固定顺序
页码从 0 还是 1 SQL OFFSET 从 0;UI 从 1 时 offset = (page - 1) × pageSize,注意 off-by-one
limitAs 和 limit 有什么区别 同一能力的两代 API,limitAs/offsetAs 为当前推荐写法
为什么 LIMIT 10 还是很慢 通常慢在 ORDER BY 未走索引,或 OFFSET 过大(深分页)
索引建了为什么没生效 检查是否对列做函数运算、WHERE 与 ORDER BY 字段是否一致、是否走了其他索引
分页结果会重复或漏数据吗 OFFSET 分页在翻页期间若数据变动会偏移;本实例静态种子数据无此问题,动态数据需游标分页
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐