当前位置: 首页 > news >正文

懒加载的秘密:ArkTS 实现鸿蒙商品分页与总条数统计

实例:商品分页列表(Product)|技术:LIMIT/OFFSET 分页、总条数统计、懒加载

一、分页的三种方案对比

在深入实现之前,先建立分页方案的整体认知。数据库分页有三种主流方案:

方案SQL 形式优点缺点
OFFSET 分页LIMIT 10 OFFSET 20实现简单,任意跳页深分页性能差(OFFSET 越大越慢)
游标分页WHERE id > lastId LIMIT 10深分页性能稳定无法任意跳页,需记录游标
Keyset 分页WHERE (sort, id) > (v, id) LIMIT 10复合排序下最优复杂

本实例采用OFFSET 分页——因为它实现最简单(limitAs + offsetAs两个 API 搞定)、支持任意跳页,且 Demo 数据量(60 条)下 OFFSET 深分页的性能问题完全无感。方案选型看数据量:几百条用 OFFSET 完全够,百万级才需要考虑游标。读者理解三种方案的适用边界即可。

二、OFFSET 分页的完整实现

8-1 文章展示过queryPagequeryPageByCategory,这里深入分页参数的语义:

staticasyncqueryPageByCategory(context:common.Context,limit:number,offset:number,category:string):Promise<Goods[]>{conststore=awaitProductDao.getStore(context);constpredicates=newrelationalStore.RdbPredicates(ProductDao.TABLE);if(category!=='全部'){predicates.equalTo('category',category);}predicates.orderByDesc('sales').limitAs(limit).offsetAs(offset);constresult=awaitstore.query(predicates);returnProductDao.collect(result);}

生成的 SQL:

SELECT*FROMgoodsWHEREcategory='数码'ORDERBYsalesDESCLIMIT10OFFSET20;

语义拆解:先按 category 过滤,再按销量倒序,然后跳过前 20 条(OFFSET 20)、取接下来 10 条(LIMIT 10)——即第 3 页。执行顺序:WHERE → ORDER BY → LIMIT/OFFSET,SQLite 按这个顺序处理,理解它有助于排查分页边界问题。

三、深分页的性能陷阱

OFFSET 分页有一个众所周知的性能问题:OFFSET 越大,查询越慢

原因:SQLite 处理LIMIT 10 OFFSET 20000时,必须先扫描前 20010 条、跳过前 20000 条、只返回最后 10 条——前面 20000 条白白扫描。数据量小(几百条)无感,但十万级数据翻到 10000 页时,OFFSET 巨大,每次查询都扫描全量,性能灾难。

生产级缓解方案(理解即可,本实例不实现):

// 游标分页:记住上一页最后一条的 idstaticasyncqueryPageByCursor(context:common.Context,limit:number,lastId:number):Promise<Goods[]>{constpredicates=newrelationalStore.RdbPredicates(ProductDao.TABLE);if(lastId>0){predicates.greaterThan('id',lastId);// 只取 id > 上页末尾的}predicates.orderByAsc('id').limitAs(limit);// ...}

WHERE id > lastId LIMIT 10每次都从上次位置往后取,不重复扫描——深分页性能恒定。代价是不能跳页(只能下一页)。

本实例为什么用 OFFSET?60 条数据分 6 页,OFFSET 最大 50,性能完全无感;实现简洁(2 个 API);演示意图清晰(教材要展示 LIMIT/OFFSET 语法本身)。教材选最直白的方案,生产按数据量升级

四、总条数统计与分页的配合

头部「共 N 件商品」的 N 来自COUNT(*)

staticasynccount(context:common.Context):Promise<number>{conststore=awaitProductDao.getStore(context);constresult=awaitstore.querySql(`SELECT COUNT(*) AS c FROM${ProductDao.TABLE}`);lettotal=0;if(result.goToNextRow()){total=result.getLong(result.getColumnIndex('c'));}result.close();returntotal;}

COUNT 与分页的配合:COUNT 在首次加载时查询一次(总量),之后分页查询只关心「取哪一页」,两者独立。页面用total显示总量、用goods.length显示已加载——「已加载 30 / 共 60 件」的进度感。

COUNT 的性能COUNT(*)走全表扫描(或索引扫描),60 条毫秒级。生产环境百万级数据,COUNT 也是开销点——常用方案是缓存总量(定期刷新)或用近似值。本实例不做优化,理解即可。

五、懒加载状态机:页面层的核心

懒加载的「翻页状态机」在 ProductPage 里,是分页体验的灵魂。完整状态流转:

@Stategoods:Goods[]=[];@StatepageSize:number=10;@Statepage:number=0;@Stateloading:boolean=false;@Statefinished:boolean=false;asyncloadMore():Promise<void>{if(this.loading||this.finished){return;// 状态守卫:加载中/已完成则忽略}this.loading=true;constoffset=this.page*this.pageSize;constlist=awaitProductDao.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=0, goods=[], finished=false
加载中loadMore 开始loading=true,忽略重复触发
追加返回 > 0 条concat + page++
完成返回 < pageSizefinished=true,停止加载

为什么list.length < this.pageSize判完成:如果请求 10 条但只返回 5 条,说明数据库里只剩 5 条了——这就是最后一页。**「不足一页 = 没有更多」**是分页终止的经典判据,比「list.length === 0」更早触发(恰好整除的情况不会多查一次空页)。

防重入的两种机制

  1. loading标志:onReachEnd 连续触发时,第一次已置 loading=true,后续直接 return;
  2. finished标志:加载完成后不再触发。

这两个守卫保证了「快速滚动到底」时不会重复请求同一页。

六、分类切换与分页的重置

切换分类时,分页状态必须整体重置(8-2 文章讲过),这里深入原因:

asyncswitchCategory(cat:string):Promise<void>{this.category=cat;this.page=0;// 页码归零this.goods=[];// 列表清空this.finished=false;// 完成标志复位awaitthis.loadMore();// 从新分类的第一页开始}

如果不重置会怎样?从「数码」第 3 页切到「服饰」,page 还是 2、goods 还是数码的 30 条——loadMore 会用 OFFSET=20 去查服饰,且列表尾部残留数码商品,数据完全错乱。分类变化 = 新的分页序列,所有分页状态必须重置。这是一个容易漏、漏了必出 bug 的细节。

七、技术要点对照表

技术点实现方式生产价值
分页limitAs + offsetAs一页一取
排序索引idx_goods_salesORDER BY 走索引
深分页OFFSET 大时慢 → 游标方案大数据量升级路径
总量COUNT(*)头部进度信息
终止判定list.length < pageSize最后一页检测
防重入loading + finished 守卫懒加载正确性
分类重置page/goods/finished 归零切换不错乱

八、常见问题 FAQ

Q1:page 从 0 开始还是从 1 开始?
A:本实例从 0 开始(page=0→ OFFSET=0 第一页)。数组下标习惯 0 基,页码习惯 1 基,两者都可,关键是页面内保持一致。OFFSET 计算公式page * pageSize在 0 基下最简洁。

Q2:切换分类后 total(总条数)需要更新吗?
A:本实例的 total 是「全部商品数」(COUNT 全表),切换分类不变。如果产品要「每个分类的总数」,需要按分类 COUNT——categoryCounts()的 GROUP BY 结果里已经有每类数量,页面可以取categories[cat]显示分类总量。

Q3:加载失败怎么办?
A:当前实现未处理 loadMore 的失败(异常会冒泡到控制台)。生产版应 try-catch,失败时 loading=false 允许重试,并 Toast 提示「加载失败」。懒加载的失败重试是生产必选项,读者可自行补充。

Q4:为什么用 concat 而不是 push?
A:concat返回新数组(不可变更新),push原地修改。ArkUI 的 @State 响应式要求引用替换才能触发 UI 刷新——this.goods.concat(list)产生新数组赋值,刷新生效;this.goods.push(...)原地改不触发。这与 5-2 文章 Map 整体赋值的原理一致。

Q5:OFFSET 分页在删除数据后页码会乱吗?
A:会。如果删了第 1 页的商品,第 2 页的 OFFSET=10 取到的内容会「前移错位」。本实例无删除功能(商品只读),无此问题。生产环境删除频繁时应考虑游标分页。

Q6:双列瀑布流的加载顺序和单列一样吗?
A:一样。lanes 只是布局分列,数据流仍是「数组顺序 → 分列渲染」——加载的 10 条依次填入两列。瀑布流的高度差异不影响加载逻辑。

九、文章小结

本篇文章深入讲解了商品分页的数据层与状态机:limitAs/offsetAs 分页 + idx_goods_sales 索引 + COUNT 总量 + 懒加载四态状态机(加载中/追加/完成/防重入)。核心心法是「不足一页即完成」的终止判据和「分类切换必重置」的状态管理。OFFSET 分页的实现最简,深分页的性能演进(游标方案)也做了铺垫——分页是数据量思维的第一课

下一篇(8-4)展示 60 条商品种子数据如何铺满三页,验证分页与懒加载在真实数据下的效果。

十、分页查询核心实现逐行解读

前文展示了 loadMore 的整体结构,这里把「页码 → OFFSET」的换算与「是否还有更多」的判断逐行拆解。先看最核心的两行:

constoffset=this.page*this.pageSize;// ① 页码 × 每页条数 = 跳过条数constlist=awaitProductDao.queryPageByCategory(this.context,this.pageSize,offset,this.category);// ② LIMIT 10 OFFSET 0/10/20…

limit 与 offset 的换算表(pageSize = 10):

页码 pageoffset = page × pageSize命中的记录页面语义
00第 1~10 条第 1 页
110第 11~20 条第 2 页
220第 21~30 条第 3 页
550第 51~60 条第 6 页(最后)

页码状态 page 的三个关键点

  1. 0 基计数:page 从 0 开始,offset 直接等于 page × pageSize,公式最简;若从 1 开始则要写(page - 1) * pageSize,多一层减法;
  2. 先取后加:loadMore 中「先用当前 page 算 offset 取数,成功后才 page++」——保证下一次触发时 page 已指向下一页,顺序颠倒会重复取同一页;
  3. 内外解耦:内部状态 0 基,UI 展示用page + 1(如「第 3 页 / 共 6 页」),显示层与状态层互不污染。

「是否有更多」的判断逐行解读

if(list.length<this.pageSize){this.finished=true;// 请求 10 条,返回不足 10 条 = 没有更多}
  • 返回 10 条 → 下一页还有货,继续;
  • 返回 5 条(库中只剩 5 条)→ 最后一页,置 finished,不再触发;
  • 恰好整除的边界:60 条最后一批恰好 10 条,此时不置 finished,会多触发一次 loadMore 查第 7 页返回 0 条——0 < 10成立,同样置 finished。多查一次空页,换代码极简,60 条数据无感;生产环境如在意,可先 COUNT 再决定是否查。

十一、排序切换:综合/销量/价格 DESC 的谓词写法

排序条三档「综合 / 销量 / 价格」,本质只改 ORDER BY 子句。推荐用配置表映射排序串,替代散落的 if-else

constSORT_OPTIONS:Record<string,string>={'综合':'id ASC',// 默认:按插入顺序'销量':'sales DESC',// 销量从高到低'价格':'price DESC, id ASC',// 价格降序,同价按 id 兜底};predicates.orderBy(SORT_OPTIONS[this.sortKey]??'id ASC').limitAs(this.pageSize).offsetAs(offset);

三个细节

  1. orderBy()直接接收完整排序串,比orderByDesc('sales')更灵活——切换排序时只换字符串,谓词对象无需重建;若当前代码用orderByDesc,把排序条件提取成参数即可平滑升级;
  2. 价格排序必须带唯一键兜底:多条商品同价时,ORDER BY price DESC的返回顺序 SQLite 不保证稳定,翻页时第 1 页末尾与第 2 页开头可能「重复或漏条」。追加, id ASC后同价商品按 id 定序,分页才能不重不漏;
  3. 切换排序 = 新的结果序列,必须走与分类切换相同的重置流程(见十三节),否则旧排序数据与新排序数据混列。

生成的 SQL:

SELECT*FROMgoodsORDERBYpriceDESC,idASCLIMIT10OFFSET0;

十二、总数 COUNT 与总页数计算

第四节展示了 COUNT 查询总量,这里补上「总页数」的推导——它是分页条显示「共 N 页」的依据:

consttotalPages=Math.ceil(this.total/this.pageSize);// 向上取整
总条数 totalpageSizetotalPages最后一页条数
6010610(满页)
551065(不足一页)
211031
0100

为什么要向上取整:55 ÷ 10 = 5.5,第 6 页只有 5 条——只要有余数就得多一页Math.ceil精确表达「最后不满一页也算一页」。它与懒加载的list.length < pageSize判断互为印证:totalPages 回答「最多有几页」,finished 回答「实际到没到最后一页」,两者数值对得上说明分页状态一致。实际使用中 totalPages 还可用于「下一页按钮置灰」(当前页 ≥ totalPages 时禁用),是分页体验的收尾细节。

十三、下拉刷新:重置分页逻辑

下拉刷新(Refresh 组件的 onRefresh)与分类切换本质相同——刷新 = 从第一页重新开始

asynconRefresh():Promise<void>{this.page=0;// ① 页码回到第一页this.goods=[];// ② 旧列表清空this.finished=false;// ③ 完成标志复位this.total=awaitProductDao.count(this.context);// ④ 总量一并刷新awaitthis.loadMore();// ⑤ 重新加载第一页}

为什么三步重置缺一不可

  • 只清 goods、不归零 page:loadMore 会用旧 OFFSET(如 20)取「第 3 页」拼到空列表上,列表直接缺前两页;
  • 不复位 finished:loadMore 一进来就被状态守卫if (this.finished) return拦下,刷新完全失效;
  • 不刷新 total:删除/新增商品后头部总数停留在旧值。

与其他入口的统一:分类切换、排序切换、下拉刷新共用同一套「归零 → 重载」模板,只是触发源不同——把三处逻辑收敛为一个resetAndLoad()方法,可消除重复代码:

asyncresetAndLoad():Promise<void>{this.page=0;this.goods=[];this.finished=false;awaitthis.loadMore();}

刷新完成后收起刷新动画并提示「已刷新」,懒加载从新序列第一页继续。

十四、分页细节 FAQ

Q1:切换排序后必须重置分页吗?
A:必须。排序改变整个结果序列,「第 3 页」不再是同一批数据。与分类切换同理:page 归零 + goods 清空 + finished 复位,再按新排序加载第一页;漏了会出现「综合排序的旧数据 + 价格排序的新数据」混杂。

Q2:为什么价格排序要带id ASC兜底?
A:SQL 规范不保证 ORDER BY 相等值的返回顺序稳定。同价商品两次查询顺序可能不同,翻页会重复或漏条。追加唯一键后顺序完全确定——ORDER BY 带唯一键兜底是分页排序的通用最佳实践

Q3:totalPages 在 total=0 时是 0,UI 怎么处理?
A:头部显示「共 0 件商品」,分页条隐藏或显示「暂无数据」;懒加载第一次 loadMore 因0 < pageSize置 finished,不会死循环请求。

Q4:下拉刷新与触底加载同时触发会冲突吗?
A:不会。两者共用 loading 守卫:刷新开始先置 loading=true,触底事件被拦;刷新结束 loading=false,触底恢复。所有加载入口共用一把锁,状态机才不乱。

Q5:OFFSET 与 LIMIT 可以省略吗?
A:可以。LIMIT单独使用表示「最多返回 N 条」(如排行榜 Top10);OFFSET单独使用不合法(需配合 LIMIT)。分页必须成对出现,顺序固定LIMIT n OFFSET m

http://www.jsqmd.com/news/1401885/

相关文章:

  • OpenClaw新手必备:10个高效技能包快速搭建AI助手
  • 论文尾巴降AI按字怎么弄,助研君最省心
  • Prism框架区域与导航机制:构建模块化WPF/Xamarin.Forms应用的核心
  • Windows安全中心页面不可用?从服务检查到注册表修复的完整解决方案
  • 2026 年新消息:浦北口碑好的公路防撞护栏生产厂家哪家靠谱,被忽略的公路保命装置,居然还有这么多不为人知的细节?-煜翎丝网 - 行业推荐官[官方】--
  • 工业软件授权分发技术解析与实践
  • 黑客攻防实战:从渗透测试到APT攻击防御
  • Mac 上跑通 minazapper 狗叫分类器:从“假 100%“到真实可用的踩坑指南
  • 构建多智能体协作系统:从协议设计到工程实践
  • 【C++】CSP-J复赛模拟赛2
  • Keil MDK 安装配置全攻略:从零搭建 ARM Cortex-M 开发环境
  • Photoshop抠图实战:快速选择、钢笔与通道三大核心技法详解
  • 广东东莞玻璃收纳盒铜槽生产厂家实力测评,本地采购避坑指南 - myqiye
  • 2026 年至今,磐安靠谱的液化气罐水切割加工厂怎么联系,原来能用来切割的东西,不只见过的那几样 - 企业信息推荐-2
  • GS8552-SR精密运放芯片选型、应用与实测指南
  • 深度 | DeepSeek V4 跑上国产芯片:推理闭环成立,训练侧还在用英伟达?
  • x32dbg/x64dbg逆向之反汇编while分析
  • 2026年8月山东省日照市联通单宽带怎么选_新手避坑指南 - 找卡家园
  • 2026全球股市API市场分析:核心需求与选型指南
  • 2026 年至今,上甘岭知名的短视频获客制造企业哪家好,以为靠短视频涨粉就能引流?错过这招,半年都难挖到精准客户。-抖盈技术 - 行业推荐官[官方】--
  • 从炫技项目到工程思维:三层模型构建可复用自动化流程
  • TAPD与企微/飞书集成实战:OpenClaw架构设计与效能提升
  • 电子设计竞赛实战:基于STM32与NRF24L01的无线图像传输系统全解析
  • 反复修改 Prompt 仍不稳定,任务该沉淀成 Skill
  • Python openpyxl.chart 自动化生成Excel图表:从入门到实战
  • OpenClaw开源智能体平台:架构解析与商业化部署实战
  • AI在餐厅效果图设计中的应用与优化
  • GBase8a数据库单机版部署实战:从环境准备到连接验证完整指南
  • 嵌入式无线图像传输实战:从硬件匹配到稳定通信的调试指南
  • 2026年8月山东省临沂市电信单宽带避坑指南!小白怎么选_ - 找卡家园