ArkTS 状态机实战:中式美食列表页怎么区分加载中、空结果和错误态

中式美食的列表页如果只写recipes.length === 0就显示空态,早晚会出问题。第一次进入页面时数组是空的,搜索没有结果时数组也是空的,数据库读取失败时数组还是空的。用户看到同一个空页面,根本分不清是正在加载、真的没菜谱,还是系统出错了。

我现在更倾向于让 ViewModel 明确告诉页面当前是什么状态。页面不要猜,也不要把业务判断写在build()里。加载中、空结果、错误态、正常列表、刷新中都应该是不同状态,用户看到的反馈也应该不一样。

环境和边界

项目说明
应用中式美食
技术栈HarmonyOS / ArkTS / ArkUI
主要页面首页菜谱列表、分类列表、搜索结果列表
主要模块RecipeListPageRecipeListViewModelRecipeRepositoryRecipeListState
核心目标页面不靠数组长度猜状态,而是消费明确状态

这篇只处理列表状态,不展开讲分页加载、复杂缓存和推荐排序。先把状态边界稳住,后面加筛选、分页、收藏同步时才不容易乱。

为什么数组长度不够用

场景数组长度用户应该看到
首次进入列表0加载中或骨架屏
搜索没有结果0“没有找到相关菜谱”,带清空搜索入口
分类下面暂无菜谱0分类空态,引导换分类
数据库读取失败0错误提示和重试按钮
正常列表大于 0菜谱卡片列表

如果这些场景都用一个空态处理,用户会误解。比如数据库失败时显示“暂无菜谱”,用户会以为应用里真的没有内容;首次加载慢一点时显示空态,用户会觉得数据丢了。

状态模型先写清楚

我会把列表状态写成一个联合类型。这样页面不会同时出现loading=trueerror=truerecipes=[]这种互相打架的情况。

typeRecipeListState=|{type:'initialLoading'}|{type:'refreshing';recipes:RecipeCardModel[]}|{type:'empty';reason:EmptyReason;keyword?:string;categoryName?:string}|{type:'error';message:string;retryable:boolean}|{type:'ready';recipes:RecipeCardModel[]}typeEmptyReason='search'|'category'|'favorite'|'all'

refreshing里保留旧列表,是为了刷新时不让页面突然白掉。用户已经看到一批菜谱了,刷新过程中保留旧内容,再给一个轻提示,比整页切成 loading 更舒服。

Repository 只返回数据结果

Repository 不需要知道页面怎么展示,它只负责把数据读出来。错误就抛错,空结果就返回空数组,不要在 Repository 里拼 UI 文案。

interfaceRecipeQuery{keyword?:stringcategoryId?:stringfavoriteOnly?:boolean}interfaceRecipeQueryResult{items:RecipeCardModel[]query:RecipeQuery}classRecipeRepository{asyncqueryRecipes(query:RecipeQuery):Promise<RecipeQueryResult>{constitems=awaitrecipeDao.queryCards({keyword:query.keyword,categoryId:query.categoryId,favoriteOnly:query.favoriteOnly??false})return{items,query}}}

Repository 的边界越干净,ViewModel 越好写。不要让数据层判断“这是搜索空态还是分类空态”,因为这属于页面语义。

ViewModel 负责翻译成页面状态

ViewModel 拿到 Repository 结果以后,再决定页面应该显示什么。这里才知道用户当前是搜索、分类、收藏还是全部列表。

classRecipeListViewModel{privatestate:RecipeListState={type:'initialLoading'}constructor(privaterecipeRepository:RecipeRepository){}asyncload(query:RecipeQuery):Promise<void>{this.state={type:'initialLoading'}try{constresult=awaitthis.recipeRepository.queryRecipes(query)this.state=this.toState(result)}catch(error){this.state={type:'error',message:'菜谱列表加载失败,可以稍后再试',retryable:true}}}asyncrefresh(query:RecipeQuery):Promise<void>{constoldRecipes=this.state.type==='ready'?this.state.recipes:[]this.state={type:'refreshing',recipes:oldRecipes}try{constresult=awaitthis.recipeRepository.queryRecipes(query)this.state=this.toState(result)}catch(error){this.state=oldRecipes.length>0?{type:'ready',recipes:oldRecipes}:{type:'error',message:'刷新失败,请重试',retryable:true}}}getState():RecipeListState{returnthis.state}}

刷新失败时我不一定切到整页错误态。如果旧列表还在,就保留旧列表,给一个轻提示会更合适。整页错误态更适合首次加载失败。

空态原因要分开

空结果也不是一种。搜索无结果、分类暂无菜谱、收藏为空,文案和按钮都不一样。

privatetoState(result:RecipeQueryResult):RecipeListState{if(result.items.length>0){return{type:'ready',recipes:result.items}}if(result.query.keyword){return{type:'empty',reason:'search',keyword:result.query.keyword}}if(result.query.categoryId){return{type:'empty',reason:'category',categoryName:this.findCategoryName(result.query.categoryId)}}if(result.query.favoriteOnly){return{type:'empty',reason:'favorite'}}return{type:'empty',reason:'all'}}

这里的重点不是代码多,而是让空态能说人话。用户搜“红烧”没结果,应该提示换关键词;收藏为空,应该引导去浏览和收藏菜谱。

ArkUI 页面只按状态渲染

页面层不要再判断recipes.length。它只根据state.type渲染对应组件。

@Componentstruct RecipeListPage{@StateprivatelistState:RecipeListState={type:'initialLoading'}privateviewModel=newRecipeListViewModel(recipeRepository)privatecurrentQuery:RecipeQuery={}aboutToAppear():void{this.viewModel.load(this.currentQuery).then(()=>{this.listState=this.viewModel.getState()})}build(){Column(){if(this.listState.type==='initialLoading'){RecipeListSkeleton()}elseif(this.listState.type==='error'){ErrorStateView({message:this.listState.message,showRetry:this.listState.retryable,onRetry:()=>this.reload()})}elseif(this.listState.type==='empty'){RecipeEmptyView({reason:this.listState.reason,keyword:this.listState.keyword,categoryName:this.listState.categoryName,onClear:()=>this.clearFilter()})}else{RecipeCardList({recipes:this.listState.recipes})}}}privatereload():void{this.viewModel.load(this.currentQuery).then(()=>{this.listState=this.viewModel.getState()})}}

这段页面代码会比一堆布尔变量更好维护。以后加分页、筛选、推荐入口,仍然是先改状态,再让页面渲染状态。

空态文案也要跟场景走

空态原因文案方向推荐动作
搜索无结果没找到包含这个关键词的菜谱清空搜索、换关键词
分类暂无当前分类还没有菜谱切换分类、查看全部
收藏为空还没有收藏菜谱去首页浏览
全部为空本地还没有菜谱数据导入或刷新数据

空态文案不要统一写“暂无数据”。这句话对开发者省事,对用户没有帮助。

验收时我会这样测

场景操作期望结果
首次进入打开首页列表先显示骨架屏,再显示菜谱
搜索无结果搜一个不存在的词显示搜索空态和清空入口
分类为空切到没有菜谱的分类显示分类空态,不说系统错误
Repository 抛错模拟数据库读取失败显示错误态和重试按钮
重试成功错误态点击重试恢复正常列表
刷新失败有旧列表时刷新失败保留旧列表,不整页变空

这里我特别看重“刷新失败”。很多应用第一次加载没问题,但刷新失败时会把旧内容清掉,用户就以为内容没了。对中式美食这种列表浏览应用来说,旧内容能保留就尽量保留。

失败用例提前兜住

失败用例可能后果处理方式
把 loading 和 empty 混用首屏闪一下“暂无数据”initialLoading单独表示
搜索失败显示空态用户误以为真没结果Repository 抛错时进入error
刷新失败清空旧列表用户以为数据丢失refreshing保留旧列表
空态文案统一用户不知道下一步做什么EmptyReason给动作
页面直接改数组ViewModel 状态失控页面只触发动作,不直接拼状态

这些失败用例不是为了把代码写复杂,而是为了避免用户看到错误反馈。状态拆清楚以后,页面和数据层都会少一点猜测。

这次拆完后的判断

列表页状态机不是为了“架构好看”,而是为了让用户知道当前到底发生了什么。中式美食后面会继续加搜索、分类、收藏、推荐和分页,如果一开始只靠数组长度判断,后面每个入口都会出现奇怪空态。

我现在的做法是:Repository 只返回数据,ViewModel 把数据结果翻译成页面状态,ArkUI 页面只按状态渲染。这个边界一旦稳住,后面加筛选和推荐时,页面不会变成一堆猜测条件。