
中式美食的列表页如果只写recipes.length 0就显示空态早晚会出问题。第一次进入页面时数组是空的搜索没有结果时数组也是空的数据库读取失败时数组还是空的。用户看到同一个空页面根本分不清是正在加载、真的没菜谱还是系统出错了。我现在更倾向于让 ViewModel 明确告诉页面当前是什么状态。页面不要猜也不要把业务判断写在build()里。加载中、空结果、错误态、正常列表、刷新中都应该是不同状态用户看到的反馈也应该不一样。环境和边界项目说明应用中式美食技术栈HarmonyOS / ArkTS / ArkUI主要页面首页菜谱列表、分类列表、搜索结果列表主要模块RecipeListPage、RecipeListViewModel、RecipeRepository、RecipeListState核心目标页面不靠数组长度猜状态而是消费明确状态这篇只处理列表状态不展开讲分页加载、复杂缓存和推荐排序。先把状态边界稳住后面加筛选、分页、收藏同步时才不容易乱。为什么数组长度不够用场景数组长度用户应该看到首次进入列表0加载中或骨架屏搜索没有结果0“没有找到相关菜谱”带清空搜索入口分类下面暂无菜谱0分类空态引导换分类数据库读取失败0错误提示和重试按钮正常列表大于 0菜谱卡片列表如果这些场景都用一个空态处理用户会误解。比如数据库失败时显示“暂无菜谱”用户会以为应用里真的没有内容首次加载慢一点时显示空态用户会觉得数据丢了。状态模型先写清楚我会把列表状态写成一个联合类型。这样页面不会同时出现loadingtrue、errortrue、recipes[]这种互相打架的情况。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[]}typeEmptyReasonsearch|category|favorite|allrefreshing里保留旧列表是为了刷新时不让页面突然白掉。用户已经看到一批菜谱了刷新过程中保留旧内容再给一个轻提示比整页切成 loading 更舒服。Repository 只返回数据结果Repository 不需要知道页面怎么展示它只负责把数据读出来。错误就抛错空结果就返回空数组不要在 Repository 里拼 UI 文案。interfaceRecipeQuery{keyword?:stringcategoryId?:stringfavoriteOnly?:boolean}interfaceRecipeQueryResult{items:RecipeCardModel[]query:RecipeQuery}classRecipeRepository{asyncqueryRecipes(query:RecipeQuery):PromiseRecipeQueryResult{constitemsawaitrecipeDao.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):Promisevoid{this.state{type:initialLoading}try{constresultawaitthis.recipeRepository.queryRecipes(query)this.statethis.toState(result)}catch(error){this.state{type:error,message:菜谱列表加载失败可以稍后再试,retryable:true}}}asyncrefresh(query:RecipeQuery):Promisevoid{constoldRecipesthis.state.typeready?this.state.recipes:[]this.state{type:refreshing,recipes:oldRecipes}try{constresultawaitthis.recipeRepository.queryRecipes(query)this.statethis.toState(result)}catch(error){this.stateoldRecipes.length0?{type:ready,recipes:oldRecipes}:{type:error,message:刷新失败请重试,retryable:true}}}getState():RecipeListState{returnthis.state}}刷新失败时我不一定切到整页错误态。如果旧列表还在就保留旧列表给一个轻提示会更合适。整页错误态更适合首次加载失败。空态原因要分开空结果也不是一种。搜索无结果、分类暂无菜谱、收藏为空文案和按钮都不一样。privatetoState(result:RecipeQueryResult):RecipeListState{if(result.items.length0){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}privateviewModelnewRecipeListViewModel(recipeRepository)privatecurrentQuery:RecipeQuery{}aboutToAppear():void{this.viewModel.load(this.currentQuery).then((){this.listStatethis.viewModel.getState()})}build(){Column(){if(this.listState.typeinitialLoading){RecipeListSkeleton()}elseif(this.listState.typeerror){ErrorStateView({message:this.listState.message,showRetry:this.listState.retryable,onRetry:()this.reload()})}elseif(this.listState.typeempty){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.listStatethis.viewModel.getState()})}}这段页面代码会比一堆布尔变量更好维护。以后加分页、筛选、推荐入口仍然是先改状态再让页面渲染状态。空态文案也要跟场景走空态原因文案方向推荐动作搜索无结果没找到包含这个关键词的菜谱清空搜索、换关键词分类暂无当前分类还没有菜谱切换分类、查看全部收藏为空还没有收藏菜谱去首页浏览全部为空本地还没有菜谱数据导入或刷新数据空态文案不要统一写“暂无数据”。这句话对开发者省事对用户没有帮助。验收时我会这样测场景操作期望结果首次进入打开首页列表先显示骨架屏再显示菜谱搜索无结果搜一个不存在的词显示搜索空态和清空入口分类为空切到没有菜谱的分类显示分类空态不说系统错误Repository 抛错模拟数据库读取失败显示错误态和重试按钮重试成功错误态点击重试恢复正常列表刷新失败有旧列表时刷新失败保留旧列表不整页变空这里我特别看重“刷新失败”。很多应用第一次加载没问题但刷新失败时会把旧内容清掉用户就以为内容没了。对中式美食这种列表浏览应用来说旧内容能保留就尽量保留。失败用例提前兜住失败用例可能后果处理方式把 loading 和 empty 混用首屏闪一下“暂无数据”用initialLoading单独表示搜索失败显示空态用户误以为真没结果Repository 抛错时进入error刷新失败清空旧列表用户以为数据丢失refreshing保留旧列表空态文案统一用户不知道下一步做什么按EmptyReason给动作页面直接改数组ViewModel 状态失控页面只触发动作不直接拼状态这些失败用例不是为了把代码写复杂而是为了避免用户看到错误反馈。状态拆清楚以后页面和数据层都会少一点猜测。这次拆完后的判断列表页状态机不是为了“架构好看”而是为了让用户知道当前到底发生了什么。中式美食后面会继续加搜索、分类、收藏、推荐和分页如果一开始只靠数组长度判断后面每个入口都会出现奇怪空态。我现在的做法是Repository 只返回数据ViewModel 把数据结果翻译成页面状态ArkUI 页面只按状态渲染。这个边界一旦稳住后面加筛选和推荐时页面不会变成一堆猜测条件。