HarmonyOS ArkUI 重复点击导致重复入库怎么办:中式美食详情页怎么给按钮加忙碌态和幂等保护
做详情页按钮时,我一开始也容易把事情想简单:收藏就写收藏表,加入购物清单就写购物清单表,按钮点了以后刷新一下页面就行。可中式美食这种真实应用不是玩具页面,用户可能连续点两次,也可能网络慢半拍,还可能从搜索页、收藏页、首页不同入口进详情页。只要按钮没有忙碌态,Repository 没有去重,最后就会出现收藏状态闪一下、购物清单多一份、列表返回后又显示旧状态的问题。
这篇不讲“按钮怎么写好看”,只讲一个很实际的问题:ArkUI 详情页里的按钮动作,怎么避免重复提交和重复入库。
| 项目 | 说明 |
|---|---|
| 应用 | 中式美食 |
| 页面 | 菜谱详情页、收藏入口、购物清单入口 |
| 技术栈 | HarmonyOS、ArkTS、ArkUI、RDB、本地 Repository |
| 主要问题 | 连续点击、慢请求、页面返回刷新后状态不一致 |
| 处理方式 | 页面 busy 锁、请求 key、Repository 幂等写入、列表回读验证 |
先说问题:按钮不是点一下就结束
详情页里最容易出问题的按钮,一般不是“查看步骤”这种只跳页面的按钮,而是会改数据的按钮。
| 按钮动作 | 表面看起来 | 实际风险 |
|---|---|---|
| 收藏菜谱 | 切换一个红心 | 连续点会让收藏状态来回跳,列表看到的是旧值 |
| 加入购物清单 | 把食材写进去 | 同一种食材可能写两份,数量统计也会变乱 |
| 加入最近浏览 | 写一条浏览记录 | 同一道菜刷出多条记录,最近浏览排序不稳定 |
| 保存备注 | 更新一段文本 | 慢请求返回后可能覆盖后一次输入 |
我在中式美食里把这类动作都当成“会改本地状态的写操作”,不会只在按钮上写一个onClick就结束。原因很简单:UI 层只知道用户点了,真正的数据是否已经写成功,要到 Repository 层才能确定。
如果页面和数据层没有分工,问题通常会这样出现:
| 错误写法 | 当时为什么觉得可以 | 后面暴露的问题 |
|---|---|---|
点击后直接改@State | 页面响应快,看起来很顺 | 数据写失败后 UI 不知道回滚 |
只给按钮加enabled(false) | 可以挡住大部分连续点击 | 异步完成前如果页面重建,锁可能丢 |
| Repository 直接 insert | 代码短,逻辑简单 | 同一道菜、同一食材会重复写 |
| 返回列表时不重新查 | 少一次查询 | 列表收藏状态和详情页不一致 |
所以我现在的判断是:页面负责拦住重复意图,Repository 负责保证数据不会重复,返回列表时再用真实数据刷新一次。三层都做一点,才不容易翻车。
页面层先挡住连续点击
ArkUI 里按钮点击是很直接的,但越直接越要小心。详情页按钮一旦绑定异步写入,最好不要让同一个按钮在上一次动作没结束时再进来。
我会在页面里放一个动作锁,不是只有一个全局loading,而是按动作类型区分。收藏和加入购物清单不是同一个动作,不能互相卡死。
@StateprivateactionBusyMap:Record<string,boolean>={};privateisBusy(actionKey:string):boolean{returnthis.actionBusyMap[actionKey]===true;}privatesetBusy(actionKey:string,busy:boolean):void{this.actionBusyMap={...this.actionBusyMap,[actionKey]:busy};}这里没有写成一个isLoading,原因是详情页上可能同时存在多个按钮。比如用户正在收藏菜谱,不应该把“查看步骤”也锁死;但同一个收藏按钮,必须等这次操作结束后才能再点。
按钮上的写法也要直白,正在处理时就展示正在处理,不要让用户以为没点上。
Button(this.isBusy('favorite')?'处理中':(this.isFavorite?'已收藏':'收藏')).enabled(!this.isBusy('favorite')).onClick(()=>{this.toggleFavorite();})这个写法解决的是第一层问题:用户连续点两下时,第二次不会再进入业务逻辑。但这还不够,因为页面状态可能被重建,或者某些动作不是同一个按钮发起的,所以数据层还得兜底。
请求 key 要按业务来定
很多重复问题,根子不是“用户点太快”,而是我们没有想清楚什么叫同一个动作。
在中式美食里,我会把动作 key 设计成业务语义,而不是随手写一个字符串。
| 动作 | 建议 actionKey | 为什么这么定 |
|---|---|---|
| 收藏菜谱 | favorite:${recipeId} | 同一道菜同一时间只能有一个收藏动作 |
| 加入购物清单 | shopping:${recipeId}:${ingredientKey} | 同一道菜同一种食材不能重复写 |
| 最近浏览 | viewed:${recipeId} | 同一道菜应该更新浏览时间,不是插多条 |
| 保存备注 | note:${recipeId} | 同一道菜的备注保存要按最后一次为准 |
用业务 key 的好处是,页面里可以很清楚地知道自己锁的是哪件事,而不是锁住整个页面。
privatebuildShoppingActionKey(recipeId:string,ingredientKey:string):string{return`shopping:${recipeId}:${ingredientKey}`;}privateasyncaddIngredientToShopping(recipeId:string,ingredientKey:string):Promise<void>{constactionKey=this.buildShoppingActionKey(recipeId,ingredientKey);if(this.isBusy(actionKey)){return;}this.setBusy(actionKey,true);try{awaitthis.shoppingRepository.addIngredient(recipeId,ingredientKey);awaitthis.reloadShoppingState(recipeId);}finally{this.setBusy(actionKey,false);}}这段代码里最关键的是finally。不管成功还是失败,忙碌态都要释放。否则一次异常就可能让按钮永远变成不可点。
Repository 层不能相信页面一定没重复
页面层拦住重复点击,只能说明“正常路径下少出错”。但数据层不能相信页面一定靠谱,因为同一份 Repository 以后可能被别的页面、服务卡片、定时任务或者导入逻辑复用。
所以中式美食里我更倾向于把幂等规则放到 Repository:
exportclassShoppingRepository{asyncaddIngredient(recipeId:string,ingredientKey:string):Promise<void>{constexisted=awaitthis.findIngredient(recipeId,ingredientKey);if(existed){awaitthis.increaseIngredientCount(existed.id,1);return;}awaitthis.insertIngredient({recipeId,ingredientKey,count:1,checked:false,createdAt:Date.now(),updatedAt:Date.now()});}}这里的重点不是“先查再插”这四个字,而是它表达了一个业务口径:同一道菜、同一种标准食材,默认应该合并数量,而不是生成两条购物清单记录。
如果以后 RDB 表结构继续完善,还可以把这个规则下沉到唯一索引里。页面层挡一次,Repository 查一次,数据库约束再兜一次,三层不是重复劳动,而是分别防不同场景。
| 层级 | 负责什么 | 解决什么问题 |
|---|---|---|
| ArkUI 页面 | 按钮 busy、动作 key、提示用户 | 防连续点击、防用户误以为没响应 |
| Repository | 查重、合并、更新状态 | 防别的入口绕过页面造成重复数据 |
| RDB 表结构 | 唯一口径、事务、异常兜底 | 防并发写入和脏数据落库 |
收藏状态要等真实结果回来
收藏按钮还有一个坑:为了让页面看起来更快,有些写法会先把红心点亮,再异步写数据库。这个体验是快,但失败时容易尴尬。
我在中式美食里更愿意让按钮进入“处理中”,等 Repository 返回后再刷新状态。
privateasynctoggleFavorite():Promise<void>{constactionKey=`favorite:${this.recipeId}`;if(this.isBusy(actionKey)){return;}this.setBusy(actionKey,true);try{if(this.isFavorite){awaitthis.favoriteRepository.removeFavorite(this.recipeId);}else{awaitthis.favoriteRepository.addFavorite(this.recipeId);}this.isFavorite=awaitthis.favoriteRepository.isFavorite(this.recipeId);}catch(error){this.toastMessage='收藏状态没保存成功,可以稍后再试';}finally{this.setBusy(actionKey,false);}}这段写法会比“先切 UI”慢一点点,但状态更稳。尤其是返回列表页时,列表不是看详情页临时变量,而是重新读收藏状态,这样首页、搜索页、收藏页看到的是同一个结果。
返回列表时再校验一次
如果详情页改了收藏状态,返回列表页后列表不刷新,用户会觉得“刚才明明收藏了,怎么这里还是没收藏”。这不是按钮问题,而是页面之间状态同步的问题。
我的做法是:详情页完成写入后,不要求它直接修改列表里的每一个卡片;列表页在onPageShow或返回后刷新关键状态。
privateasyncrefreshFavoriteFlags():Promise<void>{constrecipeIds=this.recipes.map(item=>item.id);constfavoriteIdSet=awaitthis.favoriteRepository.findFavoriteIds(recipeIds);this.recipes=this.recipes.map(item=>({...item,isFavorite:favoriteIdSet.has(item.id)}));}这样做的好处是边界很清楚:详情页只负责把数据写对,列表页只负责把自己显示的数据读对。不要让详情页越过边界直接改列表页内部状态,否则入口一多,后面很难维护。
| 场景 | 刷新方式 | 注意点 |
|---|---|---|
| 从首页进详情再返回 | 首页刷新收藏标记 | 不必重新加载全部菜谱内容 |
| 从搜索页进详情再返回 | 搜索结果刷新状态 | 保留关键词和排序,不要清空搜索 |
| 从收藏页取消收藏 | 收藏页重新读取列表 | 被取消的菜谱要从收藏列表移除 |
| 从购物清单返回 | 购物清单刷新数量 | 不要只刷新 checked 状态 |
错误提示不要吓人
重复提交处理好了,还要处理失败提示。失败提示不是把异常原样丢给用户,也不是一律弹“系统错误”。中式美食这类应用更适合说清楚当前动作有没有成功。
| 动作 | 失败提示 |
|---|---|
| 收藏失败 | 收藏状态没保存成功,可以稍后再试 |
| 加入购物清单失败 | 这份食材暂时没加入清单,稍后可以再点一次 |
| 最近浏览失败 | 不影响看菜谱,只是最近浏览可能不会更新 |
| 备注保存失败 | 备注没保存上,先别退出页面 |
这类提示看起来小,但它能避免用户误判。比如加入购物清单失败,不应该说“页面异常”,因为菜谱详情本身还能继续看;最近浏览失败,也不应该打断用户,因为它只是一个辅助记录。
我现在会怎么验收
写完这套逻辑后,我不会只点一次按钮就算结束。重复点击问题最怕“偶尔正常”,所以要按场景验收。
| 验收项 | 预期结果 |
|---|---|
| 连续点收藏按钮 5 次 | 只执行一次有效写入,最终状态和数据库一致 |
| 网络慢时再次点击 | 按钮处于处理中,不再触发第二次请求 |
| 加入同一种食材两次 | 不新增重复行,只合并数量或保持同一条记录 |
| 从详情页返回列表 | 列表收藏状态重新读取,和详情页一致 |
| Repository 被别的入口调用 | 仍然不会写出重复购物清单记录 |
最后总结一下
重复点击这类问题,表面看是按钮问题,实际是状态边界问题。页面要挡住连续操作,Repository 要认得什么叫同一条业务数据,RDB 要尽量有自己的兜底口径。
中式美食详情页里,我现在不会把“收藏”“加入购物清单”当成简单点击事件处理,而是当成一次本地写操作处理:页面先进入忙碌态,业务 key 拦住重复请求,Repository 再按recipeId、ingredientKey这类字段做幂等处理。这样用户点得快、页面返回快、列表刷新慢,都不容易把数据搞乱。
