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

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

一、开发顺序的选择逻辑

菜谱 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

页面用 XxxPageXxxDetail 后缀,一目了然。服务层用 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、测试、元服务和应用上架分发等。

更多推荐