中式美食列表页最容易被忽略的体验问题,是短暂空白。读 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.length===0){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):Promise<void>{constprevious=this.currentItems()this.state=previous.length>0?{type:'refreshing',previous}:{type:'initialLoading'}try{constitems=awaitthis.repository.queryRecipes(filter)this.state=items.length>0?{type:'ready',items}:{type:'empty',reason:filter.keyword?'search':'all'}}catch(_e){this.state=previous.length>0?{type:'error',message:'列表刷新失败',previous}:{type:'error',message:'菜谱列表读取失败'}}}privatecurrentItems():RecipeCardState[]{returnthis.state.type==='ready'?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.type==='initialLoading'){RecipeListSkeleton()return}if(state.type==='refreshing'){Stack(){RecipeList(items:state.previous)LoadingBar()}return}if(state.type==='ready'){RecipeList(items:state.items)return}if(state.type==='empty'){EmptyRecipeView(reason:state.reason)return}ErrorRecipeView(message:state.message)}这里的分支要克制,不要把太多判断塞进 ArkUI 组件里。页面一旦开始到处判断items.length、keyword.length、errorMessage.length,后面状态一定会乱。
六、错误态不要把旧列表直接清掉
刷新失败时保留旧列表很重要。比如用户已经看到了 20 道菜,切换排序时读取失败,如果页面直接清空,用户会以为菜谱没了。更好的做法是保留旧列表,同时提示刷新失败。
@BuilderfunctionErrorWithPrevious(state:Extract<RecipeListStatus,{type:'error'}>){if(state.previous&&state.previous.length>0){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 明确输出,不能靠数组长度反推。
- 刷新和错误时尽量保留旧列表,避免用户误以为内容被清掉。