适用读者: 已经读完菜谱系列其他文章、想从"开发者视角"理解项目搭建过程的开发者。本文不重复讲解单个页面的代码细节,而是从数据层搭建、页面开发顺序、关键技术决策三个维度做整体复盘。

完整效果
在这里插入图片描述
在这里插入图片描述

一、开发顺序的选择逻辑

菜谱 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 不存完整 Recipe

let _favs: number[] = []  // 存 id
// 而不是
let _favs: Recipe[] = []  // 存完整对象

在这里插入图片描述

选择理由: 存 id 让 getFavs() 每次都从最新的 RECIPES 里查询,保证数据一致性。如果存完整 Recipe 对象,当菜谱数据更新时(比如修改了菜名或描述),收藏里的数据不会同步。

适用场景: 数据源是静态常量、数据可能被修改的场景。如果数据源是网络 API 且不会在客户端修改,存完整对象更高效(避免每次查询都遍历数据源)。

决策二:采购清单存字符串不存对象

let _shop: string[] = []  // 存字符串
// 而不是
interface ShopItem { name: string; amount: string; checked: boolean }
let _shop: ShopItem[] = []  // 存对象

在这里插入图片描述

选择理由: 采购清单的每项就是一个字符串(“五花肉 500g”),不需要额外的结构。当前没有"已购买"状态、数量等需求,用 string 最简单。

踩坑记录: 最初考虑过用 { name, amount, checked } 对象,但发现 addToShop 的调用方(RecipeDetail)传入的是拼接好的字符串 "五花肉 500g",拆分成对象需要额外的解析逻辑。改成直接存字符串后代码更简洁。

适用场景: 数据结构简单、功能需求明确的场景。如果以后需要"已购买"状态,再改成对象也不迟——当前的简单设计降低了理解成本。

决策三:操作函数返回值的设计

// toggleFav 返回 boolean
export function toggleFav(id: number): boolean { ... }

// addToShop 返回 void
export function addToShop(item: string): void { ... }

选择理由: toggleFav 返回 boolean 让调用方直接赋值 this.fav = toggleFav(id),一行代码完成操作和状态同步。addToShop 不需要返回值——调用方(RecipeDetail)不需要知道添加后的状态,只需要重新查询 getShop() 刷新列表。

设计原则: 如果操作后的状态对调用方有用(比如收藏按钮需要显示 ❤️/🤍),返回新状态;如果调用方只需要刷新列表,返回 void。

三、页面实现的四个关键技术

技术一:@State 刷新的两种模式

菜谱 App 里 @State 刷新归纳为两种模式:

模式 A:操作函数 + 重新查询

// FavoritesPage
addToShop(this.input.trim())
this.refresh()  // 内部调用 getShop() + 复制数组

// ShoppingListPage
clearShop()
this.refresh()

适用于"先修改共享数据、再刷新页面"的场景。操作函数修改模块级变量,refresh 重新查询最新数据赋值给 @State。

模式 B:操作函数返回值赋值

// RecipeDetail
this.fav = toggleFav(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 没有 layoutWeight,Column 只占内容高度,居中效果不明显。

踩坑记录: 最初只用了 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 (let i: number = 0; i < this.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.servings > 1) this.servings-- })
Text('+').onClick(() => { if (this.servings < 8) 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 后缀。这种命名约定让打开文件目录就能知道每个文件的职责。

颜色常量的定义位置

const A: string = '#FF6B6B'   // 每个页面文件顶部各自定义
const T1: string = '#1E1B2E'
// ...

每个页面各自定义一套颜色常量。如果要改主色,需要改 5 个文件。更好的做法是提取到 constants/Colors.ets 统一管理。但在 5 个页面的规模下,各自定义更简单,不需要引入额外的文件层级。

@Builder 的抽取时机

整个项目只有 1 个 Builder(Index 的 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。在评估项目复杂度时,应该同时考虑页面数量和数据流复杂度。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐