适用读者已经读完菜谱系列其他文章、想从开发者视角理解项目搭建过程的开发者。本文不重复讲解单个页面的代码细节而是从数据层搭建、页面开发顺序、关键技术决策三个维度做整体复盘。完整效果一、开发顺序的选择逻辑菜谱 App 的推荐开发顺序是1. RecipeData.ets 数据层接口 静态数据 2. Index.ets 首页入口 分类导航 菜谱列表 3. CategoryPage.ets 分类页筛选逻辑 4. RecipeDetail.ets 详情页食材/步骤/交互 5. FavoritesPage.ets 收藏页共享状态读取 6. ShoppingListPage 采购清单输入/删除/清空为什么先做数据层RecipeData 是所有页面的基础——没有接口定义和 mock 数据页面无法渲染任何内容。先做数据层有两个好处第一接口定义就是页面的骨架。Recipe 的 12 个字段直接决定了详情页要展示什么、列表页要显示哪些信息。先写接口再做页面能避免页面写好了发现数据结构不合理的返工。第二mock 数据让预览效果更真实。8 道菜谱的真实数据红烧肉、番茄鸡蛋面等比菜谱1菜谱2的占位数据更有代入感。在开发过程中频繁预览时真实数据能帮你更好地判断 UI 效果。为什么首页第二做首页是 App 的入口——用户打开 App 第一眼看到的就是首页。先做首页能快速看到整体效果验证颜色搭配、布局间距、卡片样式是否合适。如果先做详情页再做首页可能会发现详情页的风格和首页不协调需要返工。为什么收藏和采购清单最后做收藏和采购清单依赖共享状态——需要先有 RecipeDetail 的收藏按钮和加入清单按钮才能测试跨页面同步是否正常。如果先做收藏页getFavs()返回空数组页面永远是空状态无法验证功能是否正确。二、数据层的三个设计决策决策一收藏存 id 不存完整 Recipelet_favs:number[][]// 存 id// 而不是let_favs:Recipe[][]// 存完整对象选择理由存 id 让getFavs()每次都从最新的 RECIPES 里查询保证数据一致性。如果存完整 Recipe 对象当菜谱数据更新时比如修改了菜名或描述收藏里的数据不会同步。适用场景数据源是静态常量、数据可能被修改的场景。如果数据源是网络 API 且不会在客户端修改存完整对象更高效避免每次查询都遍历数据源。决策二采购清单存字符串不存对象let_shop:string[][]// 存字符串// 而不是interfaceShopItem{name:string;amount:string;checked:boolean}let_shop:ShopItem[][]// 存对象选择理由采购清单的每项就是一个字符串“五花肉 500g”不需要额外的结构。当前没有已购买状态、数量等需求用 string 最简单。踩坑记录最初考虑过用{ name, amount, checked }对象但发现addToShop的调用方RecipeDetail传入的是拼接好的字符串五花肉 500g拆分成对象需要额外的解析逻辑。改成直接存字符串后代码更简洁。适用场景数据结构简单、功能需求明确的场景。如果以后需要已购买状态再改成对象也不迟——当前的简单设计降低了理解成本。决策三操作函数返回值的设计// toggleFav 返回 booleanexportfunctiontoggleFav(id:number):boolean{...}// addToShop 返回 voidexportfunctionaddToShop(item:string):void{...}选择理由toggleFav返回 boolean 让调用方直接赋值this.fav toggleFav(id)一行代码完成操作和状态同步。addToShop不需要返回值——调用方RecipeDetail不需要知道添加后的状态只需要重新查询getShop()刷新列表。设计原则如果操作后的状态对调用方有用比如收藏按钮需要显示 ❤️/返回新状态如果调用方只需要刷新列表返回 void。三、页面实现的四个关键技术技术一State 刷新的两种模式菜谱 App 里 State 刷新归纳为两种模式模式 A操作函数 重新查询// FavoritesPageaddToShop(this.input.trim())this.refresh()// 内部调用 getShop() 复制数组// ShoppingListPageclearShop()this.refresh()适用于先修改共享数据、再刷新页面的场景。操作函数修改模块级变量refresh 重新查询最新数据赋值给 State。模式 B操作函数返回值赋值// RecipeDetailthis.favtoggleFav(this.recipe.id)适用于操作函数返回新状态的场景。toggleFav返回 boolean直接赋值给 State不需要额外查询。两种模式的选择标准如果操作后的完整列表需要展示收藏列表、采购清单用模式 A 重新查询如果只需要一个状态值收藏按钮的 ❤️/用模式 B 直接赋值。技术二导航栏的 layoutWeight 弹性分配Text(title).fontSize(20).fontWeight(FontWeight.Bold).fontColor(T1).margin({left:10}).layoutWeight(1)layoutWeight(1)让标题占据导航栏的所有剩余空间。无论右侧有没有内容收藏按钮、清空文字、数量显示标题都不会被挤压。这个属性在四个页面的导航栏里都用到了——它是固定两侧、中间弹性布局的核心。和 Blank 的区别Blank()也是弹性空白但它在 Row 里独占一个位置。layoutWeight(1)是附加在现有组件上的属性不需要额外的空白组件。在导航栏这种标题需要文字显示的场景下layoutWeight 比 Blank 更合适。技术三空状态的 layoutWeight justifyContent 组合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)layoutWeight(1)让空状态 Column 占据所有剩余空间justifyContent(FlexAlign.Center)让内容垂直居中。这两个属性必须配合使用——只有 layoutWeight 没有 justifyContent内容会贴顶只有 justifyContent 没有 layoutWeightColumn 只占内容高度居中效果不明显。踩坑记录最初只用了 justifyContent 没有 layoutWeight空状态内容贴在导航栏下方。查了文档才发现 Column 的 justifyContent 只在有剩余空间时生效——没有 layoutWeight 分配空间Column 的高度就是内容高度没有剩余空间可言。技术四条件渲染避免 undefined 报错if(this.recipe){Scroll(){Text(this.recipe.name)// this.recipe 肯定不是 undefined}}this.recipe可能是 undefined传入的 id 无效时直接访问this.recipe.name会报错。if (this.recipe)保证只在数据存在时才渲染内容。为什么不用this.recipe?.name可选链虽然也能避免 undefined 错误但会让整个 build 方法变成一长串可选链调用。if (this.recipe)把数据是否加载成功的判断集中在一个地方代码结构更清晰——看到if (this.recipe)就知道下面的所有代码都假设数据存在。四、交互设计的三个细节细节一加入清单按钮的批量添加.onClick((){if(this.recipe){for(leti:number0;ithis.recipe.ingredients.length;i){addToShop(this.recipe.ingredients[i].name this.recipe.ingredients[i].amount)}}})点击一次加入清单把所有食材一次性添加。不需要用户逐个点击每个食材——一道菜的食材通常是一起买的批量添加更符合使用场景。没有检查重复的简化设计。多次点击会重复添加相同食材。真实 App 需要在addToShop里检查_shop.indexOf(item) -1或者用 Set 去重。但在原型阶段简化处理降低了代码复杂度。细节二份量调整的边界检查Text(−).onClick((){if(this.servings1)this.servings--})Text().onClick((){if(this.servings8)this.servings})最小 1 人份、最大 8 人份。边界检查防止用户减到 0 或无限加。上限 8 的业务理由mock 数据里最大的 servings 是6-8人戚风蛋糕8 是合理上限。为什么不用 disabled 状态ArkTS 的原生 Button 有 disabled 属性但 Text 组件没有。用if判断比实现自定义 disabled 样式更简单。如果用 Button 组件可以加disabled(this.servings 1)让减号按钮变灰不可点但 Text 的实现更轻量。细节三计时器按钮的状态切换Text(this.timerOn?⏸ 停止:▶ 开始).fontColor(#FFFFFF).backgroundColor(this.timerOn?#FF6B6B:A).onClick((){this.timerOn!this.timerOn})按钮的文字和颜色同时根据timerOn切换。未启动时是主色背景视觉突出引导用户点击运行中是红色背景暗示正在运行点击停止。踩坑记录最初开始按钮用灰色背景#F5F5FA和预设时间按钮一样。用户分不清哪个是开始哪个是预设。改成主色后开始/停止按钮在视觉上最突出。五、页面间数据同步的完整链路链路一收藏同步RecipeDetail 点击 ❤️ → toggleFav(id) 修改 _favs → this.fav 返回值true/false → UI 更新❤️ ↔ 用户点击底部收藏 tab → FavoritesPage 创建 → aboutToAppear → refresh() → getFavs() → getFavs 遍历 RECIPES匹配 _favs 里的 id → 复制数组 → this.recipes copy → ForEach 渲染收藏的菜谱链路二采购清单同步RecipeDetail 点击加入清单 → addToShop(item) 修改 _shop → 无 UI 变化用户看不到反馈 用户点击底部清单 tab → ShoppingListPage 创建 → aboutToAppear → refresh() → getShop() → 复制数组 → this.items copy → ForEach 渲染采购清单两条链路的区别收藏同步有即时反馈❤️ 切换采购清单没有即时反馈需要切换到清单页才能看到。这是设计选择——收藏是轻量操作用户期望即时反馈加入清单是批量操作用户知道食材已经加进去了不需要即时展示。六、代码组织的经验文件命名规范pages/ Index.ets 入口页面用 Index CategoryPage.ets 功能页面用 XxxPage RecipeDetail.ets 详情页面用 XxxDetail FavoritesPage.ets 列表页面用 XxxPage ShoppingListPage.ets 功能页面用 XxxPage页面用XxxPage或XxxDetail后缀一目了然。服务层用XxxData后缀。这种命名约定让打开文件目录就能知道每个文件的职责。颜色常量的定义位置constA:string#FF6B6B// 每个页面文件顶部各自定义constT1:string#1E1B2E// ...每个页面各自定义一套颜色常量。如果要改主色需要改 5 个文件。更好的做法是提取到constants/Colors.ets统一管理。但在 5 个页面的规模下各自定义更简单不需要引入额外的文件层级。Builder 的抽取时机整个项目只有 1 个 BuilderIndex 的 NavBtn。其他页面没有抽取 Builder原因页面重复结构为什么没抽取菜谱卡片3 个页面重复结构有微小差异第三行信息不同导航栏4 个页面重复右侧内容各不相同空状态2 个页面重复emoji 和文案不同抽取 Builder 的判断标准出现 3 次以上且结构完全一致时才抽取。菜谱卡片出现 3 次但结构有差异导航栏出现 4 次但右侧内容不同都不满足完全一致条件。七、当前版本的已知问题问题位置影响修复方案搜索框无过滤逻辑Index输入文字后列表不筛选实现 getRecipesBySearch()采购清单可重复添加ShoppingListPage同一食材出现多次addToShop 里检查 indexOf计时器无倒计时RecipeDetail点击开始后时间不动实现 setInterval 逻辑份量调整无实际计算RecipeDetail只改显示数字食材用量不变根据倍数重新计算 amounts数据不持久化RecipeData重启后收藏和清单清空引入 Preferences 存储导航栏标题未动态化CategoryPage标题是分类名不是固定值已实现this.cat优先级排序搜索功能 数据持久化 计时器 份量联动 去重。搜索是用户最直接感知的功能缺失数据持久化影响用户体验重启后收藏丢失计时器和份量联动是锦上添花。八、项目复杂度的真实衡量菜谱 App 只有 5 个页面但操作函数有 8 个——比页面数还多。这说明页面数量不是衡量项目复杂度的唯一指标。数据流的复杂度同样重要复杂度维度菜谱 App 的表现数据层4 个接口 2 组共享状态 8 个操作函数页面间依赖收藏和采购清单需要跨页面同步条件渲染两个页面有空状态一个页面有数据加载判断交互功能收藏切换、加入清单、份量调整、计时器一个 5 页面的 App 如果有复杂的跨页面数据同步其开发难度可能超过一个 10 页面但数据独立的 App。在评估项目复杂度时应该同时考虑页面数量和数据流复杂度。