ArkUI 列表加载实战:中式美食怎么避免闪白、误判空结果和旧列表被清空 中式美食列表页最容易被忽略的体验问题是短暂空白。读 RDB 慢一点、图片兜底慢一点、筛选条件重新计算慢一点页面就可能先空一下。开发者知道这是加载用户不知道。用户看到的只是刚才还有菜谱点了一下筛选怎么没了图 1 - 中式美食列表加载状态骨架屏不是装饰而是告诉用户这里马上会出现菜谱卡片。栏目内容应用名称中式美食工程环境HarmonyOS、ArkTS、ArkUI、Stage 模型本文主题列表加载、骨架屏、旧列表保留、空态误判、错误态核心文件RecipeListPage.ets、RecipeListViewModel.ets、RecipeCard.ets、RecipeListSkeleton.ets验收入口冷启动列表、搜索筛选、分类切换、断网或读取失败读完能带走什么怎么让 ArkUI 列表页在加载、空结果、错误和刷新之间不乱跳本章导读这次不讲怎么画几条灰色占位线而是拆列表加载里的几个真实问题为什么不能用items.length 0判断空态。首次进入和二次刷新为什么不应该用同一种 loading UI。骨架屏尺寸为什么要贴近真实菜谱卡片。为什么刷新失败时不应该立刻清掉旧列表。验收时怎么检查骨架屏有没有造成布局跳动。当前验证环境本文对应的是中式美食列表页加载体验页面由 ArkUI 绘制状态由 ViewModel 输出。数据可能来自本地 RDB也可能来自内存缓存图片可能来自本地资源或兜底图。项目说明技术栈HarmonyOS、ArkTS、ArkUI页面菜谱列表页、搜索结果页、分类结果页状态来源RecipeListViewModel卡片组件RecipeCard骨架组件RecipeListSkeleton重点检查闪白、误判空态、旧列表被清空、错误态无法退出列表加载不是 UI 小细节。对内容型应用来说用户会不会继续往下看很多时候就取决于第一次加载和筛选切换有没有稳定感。一、为什么不能只用空白页空白页对开发者来说很好理解数据还没回来所以什么都不显示。但用户不会这么想。用户只会觉得页面是不是坏了。方案用户看到什么问题空白页什么都没有像卡住或崩了转圈知道在等不知道等出来什么骨架屏看到即将出现的卡片结构需要和真实卡片对齐中式美食列表页最后选骨架屏是因为它能提前告诉用户这里会出现一组菜谱卡片而不是一片未知空白。二、列表状态不能靠数组长度反推很多列表页一开始会写成这样if(items.length0){EmptyView()}else{RecipeList(items)}这段逻辑看起来没问题但实际会把几种完全不同的情况混在一起。情况items.length用户应该看到首次加载0骨架屏搜索无结果0搜索空态和换词建议分类下没有菜0分类空态读取失败0 或旧数据错误提示或旧列表保留所以我会先把状态模型拆出来让页面按状态渲染而不是靠数组长度猜。exporttypeRecipeListStatus|{type:initialLoading}|{type:refreshing;previous:RecipeCardState[]}|{type:ready;items:RecipeCardState[]}|{type:empty;reason:search|category|all}|{type:error;message:string;previous?:RecipeCardState[]}这个状态模型多了两个关键点refreshing带旧列表error也可以带旧列表。这样就不会每次加载都把页面清空。三、首次加载和刷新加载要分开首次进入列表时页面没有旧内容显示骨架屏合理。可用户已经看到列表后再切换排序或筛选就不一定要把旧列表清掉。exportclassRecipeListViewModel{state:RecipeListStatus{type:initialLoading}asyncload(filter:RecipeFilter):Promisevoid{constpreviousthis.currentItems()this.stateprevious.length0?{type:refreshing,previous}:{type:initialLoading}try{constitemsawaitthis.repository.queryRecipes(filter)this.stateitems.length0?{type:ready,items}:{type:empty,reason:filter.keyword?search:all}}catch(_e){this.stateprevious.length0?{type:error,message:列表刷新失败,previous}:{type:error,message:菜谱列表读取失败}}}privatecurrentItems():RecipeCardState[]{returnthis.state.typeready?this.state.items:[]}}这里真正解决的是旧列表被清空的问题。用户切换筛选时如果旧内容还可用就先保留旧内容再给一个轻量加载提示。这样比瞬间白屏稳得多。四、骨架屏尺寸要和真实卡片一致骨架屏如果尺寸随便写数据回来时页面会跳。中式美食列表卡片有封面、标题、标签和摘要骨架屏也应该按这个结构占位。exportconstRecipeCardMetrics{height:112,coverWidth:104,coverHeight:80,radius:10,padding:12}asconst骨架屏直接复用这组尺寸。BuilderfunctionRecipeCardSkeleton(){Row(){Blank().width(RecipeCardMetrics.coverWidth).height(RecipeCardMetrics.coverHeight).borderRadius(RecipeCardMetrics.radius).backgroundColor(#F0F1F3)Column({space:8}){Blank().width(68%).height(18).borderRadius(4).backgroundColor(#E8EAED)Blank().width(42%).height(14).borderRadius(4).backgroundColor(#EEF0F2)Blank().width(90%).height(12).borderRadius(4).backgroundColor(#F1F2F4)}.layoutWeight(1).padding({left:12})}.height(RecipeCardMetrics.height).padding(RecipeCardMetrics.padding)}这段代码不是为了漂亮而是为了不跳。真实卡片多高骨架卡片就占多高真实封面多大骨架封面就占多大。五、页面分支要干净页面只负责按状态画不负责猜业务。这样后面排错时很快能判断到底是 ViewModel 状态错了还是 UI 分支画错了。BuilderfunctionRecipeListContent(state:RecipeListStatus){if(state.typeinitialLoading){RecipeListSkeleton()return}if(state.typerefreshing){Stack(){RecipeList(items:state.previous)LoadingBar()}return}if(state.typeready){RecipeList(items:state.items)return}if(state.typeempty){EmptyRecipeView(reason:state.reason)return}ErrorRecipeView(message:state.message)}这里的分支要克制不要把太多判断塞进 ArkUI 组件里。页面一旦开始到处判断items.length、keyword.length、errorMessage.length后面状态一定会乱。六、错误态不要把旧列表直接清掉刷新失败时保留旧列表很重要。比如用户已经看到了 20 道菜切换排序时读取失败如果页面直接清空用户会以为菜谱没了。更好的做法是保留旧列表同时提示刷新失败。BuilderfunctionErrorWithPrevious(state:ExtractRecipeListStatus,{type:error}){if(state.previousstate.previous.length0){Stack(){RecipeList(items:state.previous)ToastBar(text:state.message)}return}ErrorRecipeView(message:state.message)}这也是为什么error状态里允许带previous。错误态不是只有一种首次加载失败和刷新失败应该区别对待。七、为什么不用一个全局 loading全局 loading 写起来简单但对列表页不够细。首次加载、刷新、分页加载、图片加载体验不一样。加载类型推荐 UI原因首次加载骨架屏没有旧内容可保留筛选刷新旧列表 顶部轻提示避免闪白加载更多底部 loading不影响已读内容图片加载图片区域占位卡片高度不能变如果所有情况都用一个全局 loading页面看起来统一实际体验很粗糙。用户已经看到内容时就不要轻易把内容盖掉。工程验收记录骨架屏和加载状态必须跑真实操作不只看预览图。验收项操作方式通过标准首次进入冷启动进入列表页先显示骨架屏不出现空白页搜索筛选输入关键词并触发刷新旧列表不突然清空分类切换从家常菜切到汤羹顶部筛选区域不抖动无结果搜一个不存在的词显示搜索空态不显示骨架屏读取失败模拟仓储异常有旧列表时保留旧列表无旧列表时显示错误态图片慢加载延迟图片解析卡片高度不跳我会重点看切换筛选和读取失败。很多列表页首次加载没问题但二次刷新时会暴露闪白和旧内容丢失。常见问题复盘1. 为什么不直接用转圈转圈只能说明正在等不能告诉用户等出来什么。列表页用骨架屏更明确这里马上会出现菜谱卡片。2. 为什么刷新时要保留旧列表因为旧列表比空白页有价值。刷新失败时旧内容至少还能看直接清空会让用户以为内容丢了。3. 为什么骨架屏要复用卡片尺寸为了避免数据回来时布局跳动。骨架屏和真实卡片尺寸不一致用户会看到页面上下抖。4. 为什么不用items.length 0判断空态因为空数组可能代表加载中、无结果、读取失败也可能代表还没开始查。状态含义不同UI 就应该不同。本章小结中式美食列表页不能用空白页硬等首次加载要用贴近真实卡片的骨架屏。列表状态要由 ViewModel 明确输出不能靠数组长度反推。刷新和错误时尽量保留旧列表避免用户误以为内容被清掉。