分页查询的基石:ArkTS 为鸿蒙商品列表设计 LIMIT/OFFSET 的表

实例:商品分页列表(Product)|技术:商品字段、批量种子生成、ProductDao
一、业务需求分析:当数据量变大,列表需要分页
前面的实例里,列表都是「一次加载全部」——几十条数据毫秒级加载,全量没问题。但电商商品列表的数据量是千、万、十万级:一次把 10 万件商品全部查出来塞进内存,App 直接卡死。分页(Pagination)就是解决这个问题的标准方案:每次只加载一页(如 10 条),滚动到底部再加载下一页,让无限数据流在有限内存里流畅呈现。
实例 8 的核心需求:
- 分页查询:
LIMIT pageSize OFFSET offset——每页 10 条,按销量排序,翻页加载; - 懒加载:滚动到底部自动加载下一页(
onReachEnd触发); - 总条数统计:头部显示「共 N 件商品」,让用户知道总量(
COUNT(*)); - 分类筛选:按分类过滤后再分页(组合条件 + 分页);
- 双列瀑布流:两列网格展示商品卡片(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;
}
翻页三要素:
- page 自增:每次成功加载
this.page++,OFFSET = page × pageSize 递进; - concat 追加:
this.goods.concat(list)把新页追加到已有列表,而不是替换——列表持续增长,形成「瀑布流」; - 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 分页在翻页期间若数据变动会偏移;本实例静态种子数据无此问题,动态数据需游标分页 |
更多推荐




所有评论(0)