HarmonyOS RDB 查询顺序不稳定怎么办:中式美食收藏页怎么用排序字段和兜底排序
收藏页最怕的不是查不到数据,而是查到了以后顺序飘。用户刚收藏的一道菜,返回列表后应该排在前面;如果刷新一下跑到中间,用户会怀疑收藏没成功。这个问题看起来像 UI 列表问题,真正原因通常是数据查询没有明确排序口径。我的处理原则很直接:页面先把用户看到的问题挡住,数据层再把真正的边界兜住,最后用可重复的操作去验收。这样写出来的逻辑不一定最炫,但上线后更稳。
这个问题的原因通常不是某一行代码写错,而是页面状态、业务口径和数据回读没有分开。只要原因判断错了,后面修起来就会变成哪里闪修哪里,最后越修越乱。
| 项目 | 说明 |
|---|---|
| 应用 | 中式美食 |
| 页面 | 收藏页 |
| 技术栈 | HarmonyOS、ArkUI、ArkTS、本地数据 |
| 主要问题 | 状态口径不清楚,页面显示和本地数据对不上 |
| 处理目标 | 用户重复操作、返回页面、刷新列表时结果保持一致 |
| 环境 | 版本/口径 |
|---|---|
| DevEco Studio | 6.1.0 Release |
| HarmonyOS SDK | API 18 |
| 语言 | ArkTS |
| 数据层 | RDB、Repository 封装 |
| 验收设备 | 手机模拟器 + 本地真机页面回退路径 |
为什么这个问题会出现
很多页面刚开始能跑,是因为测试路径太顺了:点一次、等结果、再看页面。但真实使用里,用户不会按我们的节奏来。用户会连续点,会返回再进来,会改条件后又取消,也会在网络慢的时候以为自己没点上。
| 常见写法 | 当时看起来没问题 | 后面的问题 |
|---|---|---|
| 页面变量直接改 | 代码短,马上能看到效果 | Repository 失败后页面不知道怎么回滚 |
| 不记录动作来源 | 所有入口走同一段逻辑 | 返回列表时不知道该刷新哪一块 |
| 不做兜底校验 | 单次测试能通过 | 重复点击或旧数据会把状态弄乱 |
| 只看 UI 不看数据 | 页面像是对了 | 下次进入又变成旧状态 |
我现在写这类功能,会先问自己一个问题:这个状态到底是谁负责?页面负责展示,Repository 负责数据口径,路由负责入口来源。只要这三件事混在一起,后面必然难排查。
页面层先把状态说清楚
在 收藏页 里,我不会让一个布尔值承担所有含义。页面里至少要区分“正在处理”“当前展示结果”“已经确认的数据”。这样出问题时能知道到底是哪一步错了。
typePageStatus='idle'|'loading'|'saving'|'error';@StateprivatepageStatus:PageStatus='idle';@StateprivatecurrentItems:RecipeItem[]=[];@StateprivateerrorMessage:string='';privatecanTriggerAction():boolean{returnthis.pageStatus!=='loading'&&this.pageStatus!=='saving';}这段代码不是为了显得复杂,而是为了避免页面状态互相抢。比如正在保存时,不应该再触发同一个写动作;加载失败时,也不能直接展示空列表,否则用户会以为真的没有数据。
业务 key 要按真实含义来定
查询顺序不稳定怎么办? 不能靠一个全局变量糊过去。中式美食里我会给关键动作一个业务 key,这个 key 必须能看懂它锁住的是哪件事。
privatebuildActionKey(recipeId:string):string{return`favoriteAt:${recipeId}`;}privateasyncrunWithActionLock(recipeId:string,task:()=>Promise<void>):Promise<void>{constkey=this.buildActionKey(recipeId);if(this.runningKeys.has(key)){return;}this.runningKeys.add(key);try{awaittask();}finally{this.runningKeys.delete(key);}}这里的重点是finally。只要是异步动作,就一定要考虑失败和异常。否则按钮锁住以后不释放,用户只能退出页面重进。
RDB 查询里先给出第一排序
收藏页最直接的排序口径是收藏时间。用户刚收藏的菜谱排在前面,这个规则最好在 RDB 查询时就说清楚,不要等数据查出来以后完全靠页面数组排序。
asyncqueryFavoriteRecipes():Promise<FavoriteRecipeRecord[]>{constpredicates=newrelationalStore.RdbPredicates('favorite_recipe');predicates.equalTo('deleted',0);predicates.orderByDesc('favoriteAt');predicates.orderByAsc('recipeName');constresultSet=awaitthis.rdbStore.query(predicates,['recipeId','recipeName','favoriteAt','score','coverResource']);returnthis.mapFavoriteResultSet(resultSet);}这里用了两层排序:先按favoriteAt倒序,再按recipeName升序。这样做的原因是收藏时间相同的时候,页面仍然有一个稳定结果。否则同一批导入的数据可能因为插入顺序不同而前后变化。
| 字段 | 排序方式 | 解决的问题 |
|---|---|---|
favoriteAt | 倒序 | 新收藏的内容排在前面 |
recipeName | 升序 | 同一时间收藏时顺序不飘 |
recipeId | 兜底比较 | 老数据字段缺失时仍然稳定 |
ArkTS 层再做一次兜底排序
RDB 的排序能解决大部分问题,但老数据迁移、空字段、同一时间戳这些情况还是要兜底。中式美食里我不会让页面直接相信数据库结果完全干净,而是进入页面数组前再做一次轻量排序。
privatesortFavoriteRecipes(list:FavoriteRecipeItem[]):FavoriteRecipeItem[]{return[...list].sort((left,right)=>{consttimeDiff=Number(right.favoriteAt||0)-Number(left.favoriteAt||0);if(timeDiff!==0){returntimeDiff;}constnameDiff=String(left.recipeName||'').localeCompare(String(right.recipeName||''));if(nameDiff!==0){returnnameDiff;}returnString(left.recipeId).localeCompare(String(right.recipeId));});}这段代码看起来普通,但它解决了一个很烦的问题:同一组数据多次刷新后,卡片顺序不再随机跳。尤其是收藏页这种用户会反复看的页面,顺序稳定比“偶尔看起来对”更重要。
ResultSet 映射时就补默认值
有些顺序问题不是排序函数错了,而是映射阶段把空值带到了页面。比如旧数据没有favoriteAt,页面排序时拿到undefined,不同写法会排出不同结果。
privatemapFavoriteResultSet(resultSet:relationalStore.ResultSet):FavoriteRecipeRecord[]{constrecords:FavoriteRecipeRecord[]=[];while(resultSet.goToNextRow()){records.push({recipeId:resultSet.getString(resultSet.getColumnIndex('recipeId')),recipeName:resultSet.getString(resultSet.getColumnIndex('recipeName'))||'未命名菜谱',favoriteAt:resultSet.getLong(resultSet.getColumnIndex('favoriteAt'))||0,score:resultSet.getDouble(resultSet.getColumnIndex('score'))||0,coverResource:resultSet.getString(resultSet.getColumnIndex('coverResource'))||''});}resultSet.close();returnrecords;}我会在这里把默认值补上,而不是把判断散落到每一个 UI 组件里。这样列表卡片只负责展示,不用到处写||和三元判断。
Repository 层再兜一次
页面层能挡住大部分误操作,但它不是最后防线。以后功能多了,同一段 Repository 可能被详情页、列表页、服务卡片、导入逻辑一起调用。如果 Repository 自己不判断,别的入口一样能写出脏数据。
exportclassFavoriteRepository{asyncsave(recipeId:string,payload:Record<string,string>):Promise<void>{constoldRecord=awaitthis.findByRecipeId(recipeId);if(oldRecord){awaitthis.update(oldRecord.id,{...payload,updatedAt:Date.now()});return;}awaitthis.insert({recipeId,...payload,createdAt:Date.now(),updatedAt:Date.now()});}}这段逻辑表达的是一个简单口径:同一道菜相关的状态,先看有没有旧记录。有就更新,没有再插入。不要每次都新增,否则数据会越来越脏。
| 层级 | 负责内容 | 为什么不能省 |
|---|---|---|
| ArkUI 页面 | 按钮、弹窗、列表展示 | 让用户知道当前动作有没有进行中 |
| ViewModel | 组织页面状态和动作 key | 避免一个变量承担太多含义 |
| Repository | 查重、更新、插入 | 防止其他入口绕过页面写脏数据 |
| 验收用例 | 重复点、返回、刷新、异常 | 确认不是单次路径刚好成功 |
返回页面后要读真实数据
很多状态问题不是发生在当前页面,而是发生在返回后。比如详情页里保存成功了,列表页如果还拿旧数组显示,用户看到的就是错的。
asynconPageShow():Promise<void>{awaitthis.reloadVisibleState();}privateasyncreloadVisibleState():Promise<void>{constlatest=awaitthis.repository.queryCurrentState();this.currentItems=this.mergeState(this.currentItems,latest);}我不建议让详情页直接去改列表页内部数组。短期看省事,长期看会让入口越来越乱。更稳的做法是:写操作完成后,相关页面自己回读一次需要展示的状态。
为什么这样能解决问题
这个方案能解决问题,不是因为代码多写了几行,而是因为每一层都有自己的责任。页面层解决的是“用户现在能不能继续点”的问题,Repository 解决的是“这条业务数据到底应该新增还是更新”的问题,页面返回后重新读取解决的是“别的页面看到的是不是最新结果”的问题。
如果只做页面层,重复点击少了,但别的入口仍然可能写脏数据。如果只做 Repository,数据是干净了,但用户点按钮时没有反馈,会以为应用卡住。如果只做返回刷新,页面看起来会恢复,但中间写错的数据已经进库了。三层合起来,才是一个比较稳的处理方式。
| 只做一层 | 还能留下什么坑 | 现在怎么避免 |
|---|---|---|
| 只禁用按钮 | 页面重建或其他入口可能绕过 | Repository 再查重和更新 |
| 只查重入库 | 用户连续点时没有反馈 | ArkUI 层给 busy 状态 |
| 只返回刷新 | 数据写错后只是重新显示错数据 | 写入前先定业务口径 |
| 只靠人工测试 | 慢请求和旧数据覆盖测不出来 | 固定重复点击和返回刷新用例 |
以后怎么避免同类问题
后面再写中式美食的页面,我会先把动作分成两类:只影响展示的动作,和会改本地数据的动作。只影响展示的动作可以轻一点,比如切换 tab、展开说明;会改本地数据的动作就必须按上面的方式处理,不能只写一个onClick。
我的检查清单也会固定下来:
| 检查项 | 不通过时会出现什么 |
|---|---|
| 是否有业务 actionKey | 连续操作时不知道该拦哪一个动作 |
| 是否有 finally 释放状态 | 异常后按钮可能一直不可用 |
| Repository 是否查重 | 别的入口会写出重复数据 |
| 返回页面是否回读 | 列表状态和详情页状态可能不一致 |
| 失败提示是否具体 | 用户不知道是保存失败还是页面失败 |
这样做的好处是,问题以后再出现时,我不用从整页代码里乱找。先看 actionKey,再看 Repository 口径,再看返回刷新,基本就能定位到是哪一层没有守住。
还有一个容易忽略的小点:不要把这类状态只写在注释里。真正有用的是把口径落到变量名、方法名和测试动作里。比如reloadVisibleState()比refresh()更清楚,buildActionKey()比getKey()更清楚,save()里面先findByRecipeId()再insert()也比直接写一串数据库调用更容易看懂。
代码写清楚以后,后面自己回来排查也快。看到列表乱了,就先查 query 的排序;看到按钮乱了,就先查 actionKey;看到数据重复了,就先查 Repository 的查重逻辑。这个顺序固定下来,排查效率会高很多。
privateasyncverifyAfterAction(recipeId:string):Promise<void>{constpageState=this.currentItems.find(item=>item.id===recipeId);constdbState=awaitthis.repository.findByRecipeId(recipeId);if(!pageState||!dbState){this.errorMessage='页面状态和本地数据没有对上,需要重新加载';awaitthis.reloadVisibleState();}}这段验收代码不一定要原样放到生产逻辑里,但它提醒我:每个会改数据的动作,都要能回答“页面看到的”和“本地查到的”是不是同一件事。
我会怎么验证
只看一次正常点击没有意义,必须按容易出错的路径测。
| 验收动作 | 预期结果 |
|---|---|
| 快速连续触发同一个动作 | 只有一次有效写入 |
| 写入过程中返回再进入 | 页面从 Repository 读到真实状态 |
| 取消操作或失败重试 | 临时状态不会污染正式结果 |
| 旧数据缺少字段 | 兜底排序或默认值不让页面崩 |
| 刷新三次列表 | 展示顺序和状态保持一致 |
最后说一句
查询顺序不稳定怎么办? 表面是页面问题,实际是状态边界问题。中式美食这类内容应用,页面入口多、状态也多,如果只靠一次点击后的临时变量,后面一定会遇到列表刷新不对、按钮状态不对、数据重复的问题。
我的做法就是把事情拆开:页面负责当前交互,ViewModel 负责动作节流和状态整理,Repository 负责真实数据口径。这样以后功能继续加,问题也能沿着这条线查,不会变成到处猜。
