HarmonyOS菜谱——架构总览与数据层演进分析 适用读者已经读完前面几篇菜谱系列、想从全局理解项目架构的开发者。本文不重复讲解单个页面的实现细节而是从数据怎么流动和代码怎么组织两个维度做整体分析。完整效果一、项目文件结构与职责划分entry/src/main/ets/ ├── pages/ │ ├── Index.ets 首页搜索 分类导航 菜谱列表 底部导航 │ ├── CategoryPage.ets 分类页按分类筛选菜谱列表 │ ├── RecipeDetail.ets 详情页食材/步骤/贴士/份量/计时器/收藏/加入清单 │ ├── FavoritesPage.ets 收藏页已收藏菜谱列表 │ └── ShoppingListPage.ets 采购清单食材输入/删除/清空 └── services/ └── RecipeData.ets 数据层接口定义 静态数据 查询函数 共享状态操作5 个页面 1 个服务层。和前面两个 App 对比项目页面数服务层核心数据智能家居5DeviceData17 个设备4 个操作函数旅行探索10TravelData UserDataService6 个城市2 个服务菜谱5RecipeData8 道菜谱8 个操作函数菜谱 App 的页面数量和智能家居一样但数据层更复杂——RecipeData 同时承担了静态数据、查询函数、共享状态三种职责。这是项目规模决定的5 个页面共享收藏和采购清单状态需要在数据层统一管理。二、数据层的三层结构RecipeData.ets 的代码可以分为三层第一层接口定义exportinterfaceRecipe{id:number;name:string;icon:string;category:string;time:string;difficulty:stringservings:string;desc:string;ingredients:Ingredient[];steps:string[];tips:string}exportinterfaceIngredient{name:string;amount:string}exportinterfaceCategory{name:string;icon:string;color:string}三个接口定义了三种数据结构。Recipe是核心——12 个字段涵盖了菜谱的所有信息。Ingredient和Category是辅助结构分别服务于食材列表和分类导航。接口的设计原则是字段只存展示数据不存行为。Recipe 没有isFavorite或cookTime倒计时字段——这些是运行时状态应该在页面的 State 里管理不应该污染数据结构。第二层静态数据exportconstCATS:Category[][/* 6 个分类 */]exportconstRECIPES:Recipe[][/* 8 道菜谱 */]两个export const数组是整个 App 的数据源。它们是只读的——没有操作函数会修改 CATS 或 RECIPES 的内容。这和智能家居的 DeviceData 不同——DeviceData 的 DEVICES 数组会被toggleDevice修改。静态数据的设计决策为什么不用本地 JSON 文件或网络 API因为这是 demo 阶段硬编码在代码里最简单。真实 App 应该从后端 API 加载数据但在学习和原型阶段mock 数据降低了开发成本。第三层共享状态 操作函数// 收藏状态let_favs:number[][]exportfunctiontoggleFav(id:number):boolean{...}exportfunctiongetFavs():Recipe[]{...}exportfunctionisFav(id:number):boolean{...}// 采购清单状态let_shop:string[][]exportfunctionaddToShop(item:string):void{...}exportfunctiongetShop():string[]{...}exportfunctionclearShop():void{...}exportfunctionremoveFromShop(idx:number):void{...}两组操作函数分别管理收藏和采购清单。它们操作的是模块级变量let _favs和let _shop不是 State——State 是页面级的模块级变量是全局的。三、数据流的三种模式模式一静态数据直读无状态页面Index、CategoryPage数据CATS、RECIPES操作只读不修改Index.ets → import { CATS, RECIPES } → ForEach 直接遍历渲染 CategoryPage.ets → import { getRecipesByCat } → 查询后 State 渲染这两个页面读取静态数据不需要在页面内维护状态副本。Index 用ForEach(CATS, ...)和ForEach(RECIPES, ...)直接遍历原始数组。CategoryPage 用getRecipesByCat(cat)查询子集后赋值给 State。为什么 Index 不需要 State 存菜谱列表因为首页显示的是全部菜谱不需要筛选、排序、分页。直接遍历 RECIPES 就够了。如果以后加搜索过滤功能就需要State filteredRecipes: Recipe[]来存储过滤结果。模式二查询函数 State 副本页面CategoryPage、FavoritesPage数据getRecipesByCat、getFavs操作查询后赋值 State// CategoryPagethis.recipesgetRecipesByCat(this.cat)// FavoritesPageprivaterefresh():void{constlatest:Recipe[]getFavs()constcopy:Recipe[][]for(leti:number0;ilatest.length;i){copy.push(latest[i])}this.recipescopy}这两个页面都需要根据条件筛选数据——CategoryPage 按分类筛选FavoritesPage 按收藏状态筛选。筛选结果赋值给 StateUI 根据 State 渲染。FavoritesPage 的 refresh 为什么要复制数组getFavs()返回的是新数组函数内部r.push(RECIPES[i])创建的理论上直接赋值也能触发 State 刷新。但复制是防御性编程——保证 State 的引用一定和上次不同。这个模式在前面的智能家居和旅行探索项目里也反复出现。模式三共享状态 操作函数页面RecipeDetail、FavoritesPage、ShoppingListPage数据_favs、_shop操作读写同一份模块级变量RecipeDetail → toggleFav(id) → 修改 _favs FavoritesPage → getFavs() → 读取 _favs RecipeDetail → addToShop(item) → 修改 _shop ShoppingListPage → getShop() → 读取 _shop这是跨页面数据同步的核心模式——一个页面修改数据另一个页面读取数据通过共享的模块级变量实现一致性。三种模式的判断标准条件模式例子数据只读、不需要筛选模式一直读Index 读 CATS/RECIPES数据需要按条件筛选模式二查询StateCategoryPage 按分类筛选数据需要跨页面同步模式三共享状态收藏/采购清单四、操作函数的设计模式8 个操作函数可以分为三类查询类getFavs、getRecipesByCat、getRecipeByIdexportfunctiongetFavs():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}查询函数的共同特点遍历数组、条件过滤、返回新数组。不修改原始数据只读取。返回新数组而不是引用——保证调用方修改返回值不会影响原始数据。getFavs有双重遍历——外层遍历 RECIPES内层遍历 _favs。时间复杂度 O(n×m)n 是菜谱数m 是收藏数。在 8 道菜谱的规模下完全不是问题但如果数据量增大可以用 Set 优化// 优化版本用 Set 降低时间复杂度到 O(nm)exportfunctiongetFavs():Recipe[]{constfavSetnewSet(_favs)constr:Recipe[][]for(leti:number0;iRECIPES.length;i){if(favSet.has(RECIPES[i].id))r.push(RECIPES[i])}returnr}但 ArkTS 对 Set 的支持不如 TypeScript 完善当前用双重遍历更稳妥。修改类toggleFav、addToShop、clearShopexportfunctiontoggleFav(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}修改函数的共同特点先判断当前状态再决定添加或删除返回新状态。toggleFav返回 boolean让调用方直接赋值this.fav toggleFav(id)不需要额外查询。为什么不用splice_favs.splice(i, 1)会原地修改数组不改变引用。如果页面用this.recipes getFavs()刷新getFavs()返回新数组State 能检测到变化。但如果以后有页面直接读取_favssplice 不会触发任何通知。创建新数组并替换引用是最安全的做法。删除类removeFromShopexportfunctionremoveFromShop(idx:number):void{constn:string[][]for(leti:number0;i_shop.length;i){if(i!idx)n.push(_shop[i])}_shopn}删除函数用索引过滤——遍历数组跳过指定索引创建新数组。这和toggleFav的id 过滤思路一样只是定位方式不同索引 vs id。五、State 刷新的完整分类整个项目里 State 的刷新可以归纳为四种模式模式 A重新查询赋值// CategoryPagethis.recipesgetRecipesByCat(this.cat)// FavoritesPagethis.recipescopy// getFavs() 复制适用于需要按条件筛选的数组。查询函数返回新数组赋值给 State 触发刷新。模式 B操作函数 重新查询// FavoritesPagethis.refresh()// 内部调用 getFavs() 复制// ShoppingListPageaddToShop(this.input.trim())this.refresh()// 内部调用 getShop() 复制适用于先修改共享数据、再刷新页面的场景。操作函数修改模块级变量refresh 重新查询最新数据赋值给 State。模式 C操作函数 返回值赋值// RecipeDetailthis.favtoggleFav(this.recipe.id)适用于操作函数返回新状态的场景。toggleFav返回 boolean直接赋值给 State不需要额外查询。模式 D直接赋值// RecipeDetailthis.servings--this.servingsthis.timerOn!this.timerOnthis.timerMin3;this.timerSec0;this.timerOntrue// ShoppingListPagethis.input适用于基本类型number、boolean、string的 State。直接赋值就能触发刷新不需要复制数组或重新查询。四种模式的选择标准数据类型修改方式模式例子数组需要筛选查询函数ACategoryPage 的 recipes数组跨页面同步操作函数重新查询BFavoritesPage 的 recipes布尔跨页面同步操作函数返回值CRecipeDetail 的 fav基本类型页面内直接赋值Dservings/timerOn/input六、导航与路由的设计底部导航栏Index 的三个 tabIndex (首页/菜谱) ├→ 菜谱 tab不跳转当前页 ├→ 收藏 tab → FavoritesPage └→ 清单 tab → ShoppingListPage底部导航栏用Builder NavBtn实现通过router.pushUrl跳转。“菜谱” tab 不跳转——因为 Index 本身就是菜谱页面。踩坑记录最初菜谱 tab 也写了跳转router.pushUrl({ url: pages/Index })点击后页面重建搜索框内容丢失。修复方式是去掉菜谱 tab 的跳转逻辑。这和智能家居首页的问题一样——Entry 页面重复 push 会导致页面重建。页面间跳转的参数设计跳转参数类型接收方式Index → CategoryPagecat: string分类名router.getParams()Index → RecipeDetailid: number菜谱 idrouter.getParams()CategoryPage → RecipeDetailid: number菜谱 idrouter.getParams()FavoritesPage → RecipeDetailid: number菜谱 idrouter.getParams()Index → FavoritesPage无——Index → ShoppingListPage无——只有两种参数模式传分类名字符串和传菜谱 id数字。收藏页和采购清单页不需要参数——它们的数据来自共享状态不依赖入口参数。和前面 App 的路由对比菜谱 App 的路由比旅行探索简单得多——旅行探索有 10 个页面、多种参数模式城市 id、工具页直接跳转等。菜谱只有 5 个页面路由关系清晰。简单项目的路由不需要复杂设计——router.pushUrl params就够了。七、Builder 的使用策略整个项目只有 1 个 BuilderBuilderNavBtn(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})})}只在首页的底部导航栏用了一个 Builder。其他页面没有抽取 Builder——因为菜谱卡片虽然在 Index、CategoryPage、FavoritesPage 三个页面重复出现但每个页面的卡片结构有微小差异首页第三行用三个独立 Text其他页面用拼接字符串强行统一需要处理这些差异。Builder 抽取的判断标准出现 3 次以上且结构完全一致时才抽取。菜谱卡片出现 3 次但结构有差异不满足完全一致条件。在 5 个页面的规模下重复代码的维护成本可以接受。八、颜色系统constA:string#FF6B6B// 主色红色代表食欲、热情constBG:string#F8F7FC// 背景极浅灰紫constCW:string#FFFFFF// 卡片白色constT1:string#1E1B2E// 标题深色constT2:string#888888// 副文字灰色constT3:string#BBBBBB// 辅助浅灰主色选择的逻辑菜谱 App 选了 #FF6B6B红色智能家居选了 #6C5CE7紫色旅行探索选了 #FF6B35橙色。三个 App 的主色选择不是随意的——红色让人联想到食物和食欲菜谱紫色给人科技和智能感智能家居橙色代表旅行和活力旅行探索。功能颜色的使用规律颜色值使用场景主色 A#FF6B6B强调按钮、选中状态、食材数量、计时器绿色#00B894凉菜分类间接使用橙色#FF9F43汤粥分类、渐变头像灰色#F5F5FA按钮背景、emoji 底座功能颜色在菜谱 App 里用得比智能家居少——因为菜谱的内容更偏向展示而不是状态。智能家居需要用绿色/红色编码安全/警告状态菜谱没有这种需求。九、后续可优化的方向方向当前状态优化方案数据持久化内存数据重启清空引入 Preferences 本地存储搜索功能搜索框有 UI 无逻辑实现 getRecipesBySearch(text)计时器只有状态切换实现 setInterval 倒计时采购清单去重可重复添加addToShop 里检查重复份量联动只改显示数字根据份数重新计算食材用量网络数据硬编码 mock从后端 API 加载菜谱这六个方向的优先级搜索功能 数据持久化 计时器 份量联动 去重 网络数据。搜索是用户最直接感知的功能缺失数据持久化影响用户体验重启后收藏丢失计时器和份量联动是锦上添花网络数据是产品化的最后一步。