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

一、开发顺序的选择逻辑
菜谱 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。在评估项目复杂度时,应该同时考虑页面数量和数据流复杂度。
更多推荐


所有评论(0)