HarmonyOS菜谱——收藏与采购清单的跨页面状态管理
适用读者正在学习 HarmonyOS ArkTS 开发、对跨页面数据同步有困惑的开发者。本文以菜谱 App 的收藏和采购清单功能为例拆解模块级共享状态的完整实现方案。完整效果一、为什么需要跨页面状态管理菜谱 App 有三个入口页面底部导航栏的菜谱/收藏/清单它们之间存在数据依赖用户在详情页点击❤️ 收藏→ 收藏页必须显示这道菜用户在详情页点击加入采购清单→ 采购清单页必须出现这个食材用户在采购清单页点击✓删除→ 列表必须立即更新如果每个页面各自管理自己的数据这三个同步场景都无法实现。解决方案是在数据层维护一份共享状态所有页面读写同一份数据。二、数据层设计模块级变量 操作函数// RecipeData.ets 新增部分// 收藏数据存储菜谱 id 数组let_favs:number[][]exportfunctiontoggleFav(id:number):boolean{for(leti:number0;i_favs.length;i){if(_favs[i]id){constn:number[][]for(letj:number0;j_favs.length;j){if(j!i)n.push(_favs[j])}_favsnreturnfalse}}_favs.push(id)returntrue}exportfunctiongetFavs():Recipe[]{constr:Recipe[][]for(leti:number0;iRECIPES.length;i){for(letj:number0;j_favs.length;j){if(RECIPES[i].id_favs[j]){r.push(RECIPES[i]);break}}}returnr}exportfunctionisFav(id:number):boolean{for(leti:number0;i_favs.length;i){if(_favs[i]id)returntrue}returnfalse}// 采购清单数据存储字符串数组let_shop:string[][]exportfunctionaddToShop(item:string):void{_shop.push(item)}exportfunctiongetShop():string[]{return_shop}exportfunctionclearShop():void{_shop[]}exportfunctionremoveFromShop(idx:number):void{constn:string[][]for(leti:number0;i_shop.length;i){if(i!idx)n.push(_shop[i])}_shopn}设计决策分析为什么用let而不是export const收藏和采购清单的数据是动态变化的——用户随时会添加或删除。export const保护的是引用不被替换但let让操作函数可以重新赋值_favs n。这里用let是因为操作函数需要替换整个数组来触发 State 刷新后面会详细解释。为什么收藏存 id 不存完整 Recipe_favs存的是number[]菜谱 id 列表不是Recipe[]。原因是如果存完整 Recipe 对象当菜谱数据更新时比如修改了菜名收藏里的数据不会同步。存 id 则每次调用getFavs()都会从最新的 RECIPES 里查询保证数据一致性。为什么采购清单存字符串不存对象采购清单的每项就是一个字符串“五花肉 500g”不需要额外的结构。如果以后需要已购买状态、数量等字段再改成对象也不迟。当前的简单设计降低了理解成本。两个数据层的对比特性收藏 (_favs)采购清单 (_shop)数据类型number[]string[]存储内容菜谱 id食材名称用量查询方式遍历 RECIPES 匹配 id直接返回数组删除方式按 id 过滤重建数组按索引过滤重建数组清空方式无逐个取消收藏clearShop() 一键清空三、FavoritesPage收藏列表的双重刷新Staterecipes:Recipe[][]onPageShow():void{this.refresh()}aboutToAppear():void{this.refresh()}privaterefresh():void{constlatest:Recipe[]getFavs()constcopy:Recipe[][]for(leti:number0;ilatest.length;i){copy.push(latest[i])}this.recipescopy}为什么需要onPageShow和aboutToAppear双重调用aboutToAppear在页面首次创建时触发——用户第一次进入收藏页时调用。onPageShow在页面每次显示时触发——用户从详情页返回收藏页时也会调用。如果只写aboutToAppear用户在详情页取消收藏后返回收藏页列表不会更新——因为页面已经创建过了aboutToAppear不会再执行。加上onPageShow保证每次显示都重新查询最新数据。为什么 refresh 里要复制数组// 不推荐直接赋值引用this.recipesgetFavs()// 推荐复制后赋值constlatest:Recipe[]getFavs()constcopy:Recipe[][]for(leti:number0;ilatest.length;i){copy.push(latest[i])}this.recipescopygetFavs()返回的是一个新数组函数内部r.push(RECIPES[i])创建的理论上直接赋值给 State 也能触发刷新。但复制一遍是防御性编程——如果以后getFavs()的实现改为返回缓存引用复制操作能保证 State 检测到引用变化。在 8 道菜谱的规模下复制的性能开销可以忽略。空状态的条件渲染if(this.recipes.length0){Column(){Text(❤️).fontSize(64).opacity(0.2).margin({bottom:16})Text(还没有收藏).fontSize(16).fontColor(T2)Text(浏览菜谱时点击 ❤️ 即可收藏).fontSize(13).fontColor(T3).margin({top:4})}.width(100%).layoutWeight(1).justifyContent(FlexAlign.Center)}else{Scroll(){Column({space:10}){ForEach(this.recipes,(r:Recipe){/* 菜谱卡片 */})}}}if-else控制两种状态的切换——数组为空时显示空状态提示不为空时显示菜谱列表。空状态用了opacity(0.2)让心形图标变淡避免抢走文字的视觉焦点。layoutWeight(1)让空状态区域占据所有剩余空间内容居中显示。踩坑记录最初空状态没加layoutWeight(1)心形图标和文字贴在导航栏下方视觉上很拥挤。加了layoutWeight(1)后Column 会占据 Scroll 的所有剩余高度配合justifyContent(FlexAlign.Center)实现垂直居中。四、ShoppingListPage输入、添加、删除的完整交互数据输入TextInput onSubmitStateinput:stringRow(){TextInput({placeholder:添加采购项...,text:this.input}).layoutWeight(1).height(40).fontSize(14).backgroundColor(#F5F5F7).borderRadius(20).padding({left:14}).onChange((v:string){this.inputv}).onSubmit((){this.add()})Text(添加).fontSize(13).fontWeight(FontWeight.Bold).fontColor(A).margin({left:8}).onClick((){this.add()})}.onChangevs.onSubmit的区别onChange在每次输入变化时触发——用户打一个字就更新this.input。onSubmit在用户点击键盘的确认/回车键时触发。两个事件配合使用onChange负责实时同步输入内容onSubmit负责提交操作。为什么不直接用TextInput的onSubmit而要加添加按钮因为手机键盘上不是所有输入法都有明显的确认键。加一个添加按钮让交互更明确——用户既能按回车提交也能点击按钮提交。添加操作的防御性检查privateadd():void{if(this.input.trim().length0)returnaddToShop(this.input.trim())this.inputthis.refresh()}.trim().length 0检查——防止添加空项。用户可能误触添加按钮或者输入空格后点击添加。trim()去除首尾空格后再检查长度保证不会添加空白项。this.input 清空输入框。添加成功后必须清空否则用户会看到输入框里还残留着刚才的文字误以为没有添加成功。删除操作按索引过滤Text(✓).fontSize(16).fontWeight(FontWeight.Bold).fontColor(A).onClick((){removeFromShop(idx);this.refresh()})exportfunctionremoveFromShop(idx:number):void{constn:string[][]for(leti:number0;i_shop.length;i){if(i!idx)n.push(_shop[i])}_shopn}删除操作用索引定位——ForEach 的第二个参数idx就是当前项的索引。removeFromShop创建一个新数组跳过指定索引的项然后替换_shop。为什么不用splice_shop.splice(idx, 1)能原地删除元素但不会触发 State 刷新——因为数组引用没变。创建新数组并替换引用是 ArkTS 的标准刷新模式。踩坑记录最初removeFromShop用的是_shop.splice(idx, 1)删除后列表不更新。查了半小时才发现是 State 引用没变化的问题。改成创建新数组 替换引用后正常。这个坑在前面的智能家居项目里也遇到过——RoomPage 的设备列表也是同样的模式。清空操作Text(清空).fontSize(13).fontColor(A).onClick((){clearShop();this.refresh()})exportfunctionclearShop():void{_shop[]}清空操作最简单——把_shop重置为空数组。注意clearShop没有调用this.refresh()因为清空后getShop()返回空数组refresh()会把this.items也设为空数组UI 自动切换到空状态。五、RecipeDetail 的三个交互功能功能一收藏切换Statefav:booleanfalse// 初始化时查询收藏状态aboutToAppear():void{constprouter.getParams()asRecordstring,Objectif(pp[id]){constrid:numberp[id]asnumberthis.recipegetRecipeById(rid)this.favisFav(rid)// 查询是否已收藏}}// 导航栏右侧的心形按钮Text(this.fav?❤️:).fontSize(20).onClick((){if(this.recipe){this.favtoggleFav(this.recipe.id)}})toggleFav的返回值设计——返回 boolean 让调用方直接赋值。toggleFav(id)内部切换收藏状态后返回新的状态值true已收藏false未收藏。调用方直接this.fav toggleFav(...)就能同步 UI不需要额外调用isFav查询。踩坑记录最初toggleFav返回 void调用方需要先调toggleFav再调isFav查询状态两次遍历数组。改成返回 boolean 后减少了一次遍历代码也更简洁。功能二加入采购清单Text( 加入清单).fontSize(12).fontColor(#FF6B6B).fontWeight(FontWeight.Medium).padding({left:10,right:10,top:5,bottom:5}).backgroundColor(#FFF0F0).borderRadius(10).onClick((){if(this.recipe){for(leti:number0;ithis.recipe.ingredients.length;i){addToShop(this.recipe.ingredients[i].name this.recipe.ingredients[i].amount)}}})批量添加——遍历食材数组逐个调用addToShop。一道菜的所有食材一次性加入清单不需要用户逐个点击。拼接格式是名称 用量如五花肉 500g作为一个字符串存入_shop。没有检查重复——当前的简化设计。用户多次点击加入清单会重复添加相同食材。真实 App 需要在addToShop里检查是否已存在或者用 Set 去重。但在原型阶段简化处理降低了代码复杂度。功能三份量调整和计时器Stateservings:number1StatetimerOn:booleanfalseStatetimerMin:number0StatetimerSec:number0// 份量调整Text(−).onClick((){if(this.servings1)this.servings--})Text(this.servings.toString() 人份).fontSize(18).fontWeight(FontWeight.Bold).fontColor(A)Text().onClick((){if(this.servings8)this.servings})// 计时器Text(3分钟).onClick((){this.timerMin3;this.timerSec0;this.timerOntrue})Text(5分钟).onClick((){this.timerMin5;this.timerSec0;this.timerOntrue})Text(10分钟).onClick((){this.timerMin10;this.timerSec0;this.timerOntrue})Text(this.timerOn?⏸ 停止:▶ 开始).onClick((){this.timerOn!this.timerOn})份量调整和计时器都是页面内部的 State——不涉及跨页面同步。份量用if (this.servings 1)和if (this.servings 8)做边界检查防止减到 0 或加到过大。计时器的倒计时逻辑setInterval当前没有实现——点击开始只设置timerOn true没有实际倒计时。这是后续可以完善的功能点。六、Index 底部导航栏的路由设计BuilderNavBtn(label:string,active:boolean){Column({space:2}){Text(label菜谱?:(label收藏?❤️:)).fontSize(18)Text(label).fontSize(9).fontColor(active?#FF6B6B:#C0BFC6)}.onClick((){if(label收藏)router.pushUrl({url:pages/FavoritesPage})elseif(label清单)router.pushUrl({url:pages/ShoppingListPage})})}三元表达式嵌套选择 emoji 图标。label 菜谱 ? : (label 收藏 ? ❤️ : )根据标签名选择对应的图标。这种写法比 if-else 链更紧凑但超过三个分支时可读性会下降。菜谱tab 不跳转——点击首页无反应。因为 Index 本身就是菜谱页面不需要跳转。只有收藏和清单才执行router.pushUrl。这是底部导航栏的标准设计——当前 tab 点击无反应其他 tab 点击跳转。踩坑记录最初菜谱tab 也写了router.pushUrl({ url: pages/Index })点击后页面闪烁——因为 Index 是 Entry 页面重新 push 会重建页面搜索框内容丢失。去掉跳转后问题解决。七、跨页面数据流的完整链路以用户收藏一道菜并查看收藏列表为例1. 用户在 RecipeDetail 点击 ❤️ ↓ 2. this.fav toggleFav(recipe.id) ↓ 3. toggleFav 内部_favs.push(id) 或 _favs 过滤后的新数组 ↓ 4. 用户点击底部导航收藏 ↓ 5. router.pushUrl → FavoritesPage 创建 ↓ 6. aboutToAppear → refresh() → getFavs() ↓ 7. getFavs 遍历 RECIPES匹配 _favs 里的 id返回 Recipe[] ↓ 8. refresh 复制数组 → this.recipes copy ↓ 9. State 触发重新渲染 → ForEach 显示收藏的菜谱关键在第 3 步和第 7 步第 3 步修改的是模块级变量_favs第 7 步读取的也是同一个_favs。因为是同一个变量所以数据一致。如果_favs是页面级变量第 7 步就读不到第 3 步写入的数据。八、适用边界与后续优化当前方案的局限问题原因优化方向数据不持久化_favs和_shop是内存变量App 重启后清空引入 Preferences 本地存储采购清单可重复添加addToShop不检查重复加入 Set 去重或重复提示计时器无实际倒计时只设置了timerOn状态没有 setInterval实现定时器逻辑收藏无批量操作只能逐个取消收藏加入编辑模式批量删除

相关新闻