【健康菜谱助手|02】HarmonyOS ArkTS 烹饪步骤实战:把分步操作、完成状态和返回恢复做稳
菜谱详情页解决的是“做什么”,烹饪步骤页解决的则是“现在做到哪里、下一步是什么、退出后还能不能接着做”。对用户来说,切菜时锁屏、接电话后返回、误触上一页、切到步骤总览再回来都是正常行为;对工程来说,这些行为会同时触发路由、页面生命周期、状态恢复和语音资源管理。一旦只用一个 currentStep 临时变量支撑全部流程,页面看上去能翻页,却很难称为可靠的做饭模式。
本文基于“健康菜谱助手”1.0.5 的真实 HarmonyOS ArkTS 工程展开。项目在 CookingStepsData.ets 中为 60 道菜准备了 60 组步骤,共 369 条 CookingStep;CookingStepsPage.ets 已实现上一项、下一项、总览跳转和中文语音朗读。但当前步骤只保存在页面的 @State 中,离开后会回到第 1 步;最后点击“完成”仅执行 router.back(),也没有把完成结果作为一个清晰事件交回业务层。
烹饪步骤页的核心不是把 currentStep 加一,而是让步骤位置、语音状态、完成语义和页面生命周期围绕同一个 CookingSession 演进。
HEALTHY-RECIPE-ONE7-COOKING-SESSION-20260730

本文会完成以下工作:
- 还原
CookingStepsPage的真实路由、步骤与语音链路。 - 用有限状态机约束上一步、下一步、跳转和完成。
- 为返回恢复设计可持久化的
CookingSession,避免每次从头开始。 - 把“进入做饭模式”和“完成做饭”拆成两个不同业务事件。
- 解决切换步骤时语音仍在朗读上一条的问题。
- 给出空步骤、非法 recipeId、重复点击和页面重建的验证方法。
- 分析手机、平板和 2in1 下步骤页的布局边界。
一、先盘点真实工程,不从理想代码出发
当前页面的核心状态非常精简:
@State recipeName: string = ''
@State steps: CookingStep[] = []
@State currentStep: number = 0
@State isSpeaking: boolean = false
@State speechStatus: string = '做饭模式:可朗读当前步骤'
@StorageLink('appThemeColor') appThemeColor: string = '#4CAF50'
private speechEngine?: textToSpeech.TextToSpeechEngine
这段代码说明页面已经区分了步骤内容、当前位置和语音状态,但还没有保存“本次烹饪会话”。currentStep 的生命周期与组件实例相同:组件销毁后它也消失。页面再次创建时,字段初始化为 0,用户自然回到第一步。
数据层的覆盖情况则相对完整。getCookingStepsById() 对 recipeId 1 到 60 都有明确分支,并保留 STEPS_DEFAULT 作为兜底:
export function getCookingStepsById(recipeId: number): CookingStep[] {
switch (recipeId) {
case 1: return STEPS_1
case 2: return STEPS_2
// ...一直覆盖到 60
case 60: return STEPS_60
default: return STEPS_DEFAULT
}
}
统计真实源码可得到:
| 项目 | 数量 | 含义 |
|---|---|---|
| 菜谱步骤数组 | 60 组 | 与 60 道菜的 recipeId 对应 |
CookingStep 记录 | 369 条 | 每道菜约 5 至 7 步 |
| 页面当前位置 | 1 个 currentStep | 仅在当前组件内存有效 |
| 完成回调 | 0 个 | 最后一步直接返回 |
因此,本篇不是补齐步骤文案,而是把已有数据变成可恢复、可验证的操作流程。
二、当前页面的运行链路
页面出现时读取路由参数,并据此加载菜名和步骤:
aboutToAppear(): void {
const params = router.getParams() as StepsRouterParams
let recipeId = 1
if (params && params.recipeId) {
recipeId = params.recipeId
}
const recipe = getRecipeById(recipeId)
this.recipeName = recipe ? recipe.name : '菜谱'
this.steps = getCookingStepsById(recipeId)
}
随后页面用三条交互路径修改 currentStep:
- 点击“上一步”执行
currentStep--。 - 点击“下一步”执行
currentStep++。 - 点击“全部步骤”中的任意行,把
currentStep直接设为对应索引。
最后一项显示“完成”,点击后释放语音引擎并返回:
if (this.currentStep < this.steps.length - 1) {
this.stopSpeech()
this.currentStep++
} else {
this.releaseSpeechEngine()
router.back()
}
这条链路在一次连续操作中成立,但把几个不同概念混在了一起:
currentStep既是展示索引,也是进度。- 到达最后一步不等于已经完成,点击完成才是业务确认。
- 返回详情页可能是暂停,也可能是放弃,不能自动推断。
- 页面消失会释放语音,却不会保存步骤位置。
- 非法路由被默认改成 recipeId 1,用户可能在不知情时看到另一道菜。

三、先定义 CookingSession,而不是继续堆页面字段
步骤页需要一个只描述会话的模型。它不复制菜谱文案,只记录用户进行到哪里:
export type CookingSessionStatus =
'idle' | 'in_progress' | 'paused' | 'completed'
export interface CookingSession {
recipeId: number
currentStepIndex: number
status: CookingSessionStatus
startedAt: number
updatedAt: number
completedAt?: number
}
字段设计有几个有意的限制:
- 保存索引而不是整个
CookingStep,避免菜谱内容升级后持久化旧文案。 - 保存
recipeId,用于与数据源重新关联。 - 用
status区分进行中、暂停和完成,而不是用“是不是最后一步”猜测。 updatedAt可用于判断是否继续上一次会话,也能辅助排查异常恢复。completedAt只有用户确认完成后才存在。
如果产品允许同一时间只做一道菜,Preferences 保存一个活动会话即可;如果要保留多道菜的进度,可以按 recipeId 存成数组或使用 RDB。当前应用的用户数据规模很小,使用 Preferences 足够,但存储操作应放在服务层,不能让页面直接拼 JSON。
四、建立明确的步骤状态机
“上一项”和“下一项”不是两个孤立按钮,它们共同构成状态机。有效状态范围必须始终满足:
0 <= currentStepIndex < steps.length
可以先把边界计算封装成只读方法:
private hasPrevious(): boolean {
return this.currentStep > 0
}
private hasNext(): boolean {
return this.steps.length > 0
&& this.currentStep < this.steps.length - 1
}
private isLastStep(): boolean {
return this.steps.length > 0
&& this.currentStep === this.steps.length - 1
}
然后只允许一个入口改变步骤:
private async moveToStep(target: number): Promise<void> {
if (this.steps.length === 0) {
return
}
const safeIndex = Math.max(0, Math.min(target, this.steps.length - 1))
if (safeIndex === this.currentStep) {
return
}
this.stopSpeech()
this.currentStep = safeIndex
this.speechStatus = '做饭模式:可朗读当前步骤'
await this.saveProgress('in_progress')
}
统一入口带来三个直接收益:
- 按钮、列表跳转和恢复逻辑共用同一边界校验。
- 每次切换步骤都会停止旧语音,避免声音与画面错位。
- 保存进度的时机不再散落在多个
onClick中。
对应按钮只表达意图:
Button('上一步')
.enabled(this.hasPrevious())
.onClick(() => this.moveToStep(this.currentStep - 1))
Button(this.hasNext() ? '下一步' : '完成')
.onClick(() => {
if (this.hasNext()) {
this.moveToStep(this.currentStep + 1)
} else {
this.confirmCompletion()
}
})
五、路由参数必须先验证,再读取步骤
当前代码把缺少参数和非法参数都退回到 recipeId 1。这种兜底对演示友好,对真实用户却不透明:用户打开“清蒸鲈鱼”,参数错误后可能看到“宫保鸡丁”的步骤。
更合适的页面状态是 loading | content | error:
type StepPageState = 'loading' | 'content' | 'error'
@State pageState: StepPageState = 'loading'
@State errorMessage: string = ''
@State recipeId: number = 0
初始化时执行严格校验:
private loadRoute(): void {
const params = router.getParams() as Partial<StepsRouterParams>
const recipeId = Number(params?.recipeId)
const recipe = Number.isInteger(recipeId) ? getRecipeById(recipeId) : undefined
if (!recipe) {
this.pageState = 'error'
this.errorMessage = '未找到对应菜谱,请返回后重试'
return
}
const steps = getCookingStepsById(recipeId)
if (steps.length === 0) {
this.pageState = 'error'
this.errorMessage = '这道菜暂时没有可用步骤'
return
}
this.recipeId = recipeId
this.recipeName = recipe.name
this.steps = steps
this.restoreProgress()
}
这里不要依赖 STEPS_DEFAULT 掩盖数据问题。默认步骤适合开发占位,但正式内容最好要求 60 个菜谱 ID 与 60 组步骤一一闭合;缺失时显示明确错误并记录日志,才能尽早发现数据回归。
六、恢复进度时必须再次钳制索引
持久化会话不是简单地把旧索引赋值回来。应用升级后某道菜可能从 7 步改成 5 步,原来的索引 6 已越界。如果直接恢复,this.steps[this.currentStep].title 会访问 undefined。
可以把恢复规则写成独立函数:
function normalizeStepIndex(saved: number, stepCount: number): number {
if (stepCount <= 0) {
return 0
}
if (!Number.isInteger(saved)) {
return 0
}
return Math.max(0, Math.min(saved, stepCount - 1))
}
读取会话后验证 recipeId、状态和索引:
private async restoreProgress(): Promise<void> {
const session = await this.cookingSessionService.get(this.recipeId)
if (!session || session.status === 'completed') {
this.currentStep = 0
this.pageState = 'content'
return
}
this.currentStep = normalizeStepIndex(
session.currentStepIndex,
this.steps.length
)
this.pageState = 'content'
}
完成会话默认从头开始,是当前产品最容易理解的规则。如果产品希望用户回看最后一次完成位置,可以保留完成记录,但不要把它自动恢复成“进行中”。
七、把保存结果变成可观察结果
用户点到下一步时,UI 可以先切换,再异步保存;但保存失败不能永久沉默。服务层应返回明确结果:
export interface SaveSessionResult {
ok: boolean
reason?: 'storage_unavailable' | 'serialize_failed' | 'write_failed'
}
async save(session: CookingSession): Promise<SaveSessionResult> {
try {
await this.preferences.put(
`cooking_session_${session.recipeId}`,
JSON.stringify(session)
)
await this.preferences.flush()
return { ok: true }
} catch (_) {
return { ok: false, reason: 'write_failed' }
}
}
页面不需要弹出阻断式对话框,但应给出轻量提示,并允许下一次操作重试:
private async saveProgress(status: CookingSessionStatus): Promise<void> {
const result = await this.cookingSessionService.save({
recipeId: this.recipeId,
currentStepIndex: this.currentStep,
status,
startedAt: this.startedAt,
updatedAt: Date.now()
})
if (!result.ok) {
promptAction.showToast({ message: '进度暂未保存,请稍后重试' })
}
}
重点不在 Toast,而在于页面不再把“内存已变更”误当成“持久化成功”。
八、完成必须是独立业务事件
当前详情页在进入做饭模式前就调用 addCooked,步骤页最后又只是返回。这样用户只要打开步骤就可能被统计为“已做”,即使他在第一步退出。
更准确的语义是:
| 用户动作 | 会话状态 | 是否写入已做记录 |
|---|---|---|
| 打开步骤页 | in_progress | 否 |
| 切换步骤 | in_progress | 否 |
| 中途返回 | paused | 否 |
| 点击最后一步“完成” | completed | 是 |
| 再次打开已完成菜谱 | 新会话或回顾 | 不重复写 |
完成方法需要先保存,再写业务记录,最后返回:
private async confirmCompletion(): Promise<void> {
if (!this.isLastStep()) {
return
}
this.releaseSpeechEngine()
const saved = await this.cookingSessionService.complete({
recipeId: this.recipeId,
currentStepIndex: this.currentStep,
completedAt: Date.now()
})
if (!saved.ok) {
promptAction.showToast({ message: '完成状态保存失败,请重试' })
return
}
UserDataManager.addCooked(this.cookedRecords, this.recipeId)
router.back()
}
如果 addCooked 也可能失败,最好由一个服务统一完成“会话完成 + 已做记录”并返回结果。Preferences 不提供数据库事务时,可以设计幂等写入:CookedRecord 以 recipeId 为唯一键,重复调用只更新时间,不新增重复记录。
九、语音朗读必须跟随步骤生命周期
当前工程使用 @kit.CoreSpeechKit 的 textToSpeech,按在线模式 1、0 依次尝试创建引擎:
const engineModes: number[] = [1, 0]
for (const mode of engineModes) {
try {
return await textToSpeech.createEngine({
language: 'zh-CN',
person: 0,
online: mode
})
} catch (_) {
}
}
这个降级思路是合理的:先尝试一种工作模式,失败后再切换。页面也在 aboutToDisappear() 中调用 releaseSpeechEngine(),避免引擎随页面泄漏。不过还要注意三个细节。
9.1 切换步骤前停止旧请求
项目现有的按钮和步骤列表已经调用 stopSpeech(),这是正确方向。统一到 moveToStep() 后可以避免未来新增手势时漏掉。
9.2 不用布尔值推断请求身份
isSpeaking 只能说明“有声音”,不能说明回调属于哪一步。快速点击两次时,旧请求的 onComplete 可能晚于新请求到达,导致状态被旧回调覆盖。可以保存当前 requestId:
private activeSpeechRequestId: string = ''
private createSpeechRequestId(): string {
return `recipe_${this.recipeId}_step_${this.currentStep}_${Date.now()}`
}
监听回调时只处理当前请求:
onComplete: (requestId: string) => {
if (requestId !== this.activeSpeechRequestId) {
return
}
this.isSpeaking = false
this.speechStatus = '本步骤朗读完成'
}
具体回调签名应以项目实际 SDK 版本的官方 API 为准;核心原则是让异步回调带着身份回来,不能只依靠全局布尔值。
9.3 空文本不发起朗读
当前 buildCurrentStepSpeechText() 在步骤不存在时返回空字符串。调用前再判断一次,能让错误更容易定位:
const text = this.buildCurrentStepSpeechText().trim()
if (!text) {
this.speechStatus = '当前步骤没有可朗读内容'
return
}
十、列表跳转后还要考虑滚动位置
当前页面把“当前步骤详情”和“全部步骤”放在同一个 Scroll 中。用户在底部总览点到第 2 步后,currentStep 虽然改变,滚动位置仍可能停在列表底部,新的步骤标题没有立刻出现在视口中。
可以使用 Scroller 管理内容位置:
private contentScroller: Scroller = new Scroller()
private async moveToStep(target: number): Promise<void> {
// 校验、停语音、保存逻辑略
this.currentStep = safeIndex
this.contentScroller.scrollTo({
xOffset: 0,
yOffset: 0,
animation: { duration: 220, curve: Curve.EaseOut }
})
}
在做饭场景中,“点了以后马上看到当前步骤”比保留总览列表位置更符合预期。若平板采用左右分栏,总览列表可以拥有独立滚动容器,就不需要每次回到顶部。
十一、页面应该显式渲染四种状态
当前 if (steps.length > 0) 只控制内容区,空数据时底部仍可能出现 1 / 0 和“完成”。更完整的 UI 分支是:
if (this.pageState === 'loading') {
LoadingProgress()
} else if (this.pageState === 'error') {
StepErrorView({
message: this.errorMessage,
onBack: () => router.back()
})
} else if (this.steps.length === 0) {
EmptyStepView({ onBack: () => router.back() })
} else {
this.StepContent()
this.StepActions()
}
四种状态对应四类测试:加载过程不会误触按钮,错误状态能返回,空数据不会显示假进度,内容状态才允许步骤操作。
十二、响应式布局不能只把手机页面拉宽
工程声明支持 phone、tablet 和 2in1。步骤页在手机上适合纵向流程,但平板和 PC/2in1 如果仍把 220vp 图片拉满整行,会产生过宽图片和很长的视线移动。
推荐按窗口宽度切换结构:
| 宽度区间 | 布局 | 操作区 |
|---|---|---|
| 小于 600vp | 当前步骤在上、全部步骤在下 | 底部固定上一项/下一项 |
| 600–960vp | 左侧当前步骤、右侧步骤总览 | 操作固定在左侧内容底部 |
| 大于 960vp | 中央内容限宽,右侧会话信息 | 支持鼠标与键盘焦点 |
ArkUI 中可以根据窗口分类计算布局,而不是让页面组件直接读取大量设备信息:
type StepLayoutMode = 'compact' | 'medium' | 'expanded'
function resolveStepLayout(widthVp: number): StepLayoutMode {
if (widthVp < 600) return 'compact'
if (widthVp < 960) return 'medium'
return 'expanded'
}
底部操作区还要保留系统导航安全距离。手机手势导航下,按钮区域至少使用系统避让区与 28vp 中较大值;长列表必须能滚动到最后一项,不能被底部栏永久盖住。

十三、状态层、服务层和页面层如何分工
建议把职责拆成四层:
13.1 数据层
CookingStepsData.ets 继续保存发布随包的菜谱步骤。它负责 recipeId 到只读步骤数组的映射,不保存用户进度。
13.2 会话服务
CookingSessionService 负责加载、保存、暂停、完成和清理会话。页面只接收 CookingSession 或明确错误。
13.3 语音适配器
CookingSpeechService 封装 SpeechKit 引擎创建、请求 ID、停止和释放。这样页面测试不依赖真实语音服务,也能模拟创建失败、朗读失败和完成回调。
13.4 页面
页面持有渲染状态,响应点击并调用服务。它不直接拼 Preferences key,也不决定 CookedRecord 的去重规则。
一个简化接口可以是:
export interface CookingSessionService {
restore(recipeId: number, stepCount: number): Promise<CookingSession>
move(recipeId: number, stepIndex: number): Promise<SaveSessionResult>
pause(recipeId: number, stepIndex: number): Promise<SaveSessionResult>
complete(recipeId: number, stepIndex: number): Promise<SaveSessionResult>
}
接口围绕用户动作命名,比暴露 put()、get() 更能守住业务语义。
十四、返回动作要区分系统返回和完成
用户点击顶部返回或系统返回键时,应把当前会话保存为 paused;点击“完成”才保存为 completed。两条路径不能都只执行 router.back()。
private async pauseAndBack(): Promise<void> {
this.releaseSpeechEngine()
await this.saveProgress('paused')
router.back()
}
如果系统返回拦截能力在当前 API 版本中可用,应让它调用同一方法;如果没有拦截,就在页面生命周期中做幂等暂停保存。需要防止“完成后页面消失”又把状态覆盖回 paused:
private completionCommitted: boolean = false
aboutToDisappear(): void {
this.releaseSpeechEngine()
if (!this.completionCommitted && this.pageState === 'content') {
this.saveProgress('paused')
}
}
completionCommitted 是短生命周期保护,不是持久化真源。真正状态仍以服务层保存结果为准。
十五、重复点击和异步竞态怎么处理
做饭时手上有水,用户可能连续点击“下一步”。如果每次点击都立即启动异步保存,旧请求可能最后完成并把较小索引写回存储。
最简单的方案是页面保存期间禁用步骤切换:
@State isSavingProgress: boolean = false
private async moveToStep(target: number): Promise<void> {
if (this.isSavingProgress) return
this.isSavingProgress = true
try {
// 计算 safeIndex、更新 UI、持久化
} finally {
this.isSavingProgress = false
}
}
更流畅的方案是给每次保存附加递增版本号,服务层只接受比已保存版本新的会话。对当前规模而言,短暂禁用按钮已经足够清晰,也便于测试。
十六、从真实数据中建立内容完整性检查
CookingStepsData.ets 目前包含 60 组、369 条步骤。发布前可以写一个只读检查,确保每组步骤至少包含标题和描述,且 stepNumber 连续:
interface StepValidationIssue {
recipeId: number
message: string
}
function validateSteps(recipeId: number, steps: CookingStep[]): StepValidationIssue[] {
const issues: StepValidationIssue[] = []
if (steps.length === 0) {
issues.push({ recipeId, message: '步骤为空' })
return issues
}
steps.forEach((step, index) => {
if (step.stepNumber !== index + 1) {
issues.push({ recipeId, message: `第 ${index + 1} 项编号不连续` })
}
if (!step.title.trim() || !step.description.trim()) {
issues.push({ recipeId, message: `第 ${index + 1} 项缺少标题或描述` })
}
})
return issues
}
这个检查不替代厨艺内容审核,但可以阻止空标题、编号跳跃和缺失说明进入页面。
十七、建议的回归测试矩阵
| 编号 | 场景 | 操作 | 预期 |
|---|---|---|---|
| S01 | 正常进入 | 从详情页打开 recipeId 1 | 显示宫保鸡丁步骤,进度为 1/N |
| S02 | 非法参数 | recipeId 为字符串或不存在 | 显示错误态,不静默换菜 |
| S03 | 上一步边界 | 在第 1 步点击上一步 | 按钮禁用,索引仍为 0 |
| S04 | 下一步边界 | 连续前进到最后一项 | 按钮文案变成完成,不越界 |
| S05 | 总览跳转 | 从第 1 步点第 5 步 | 停止旧语音,内容定位到第 5 步 |
| S06 | 返回恢复 | 第 4 步返回再打开 | 恢复第 4 步并显示 paused 会话 |
| S07 | 数据升级 | 保存索引大于新步骤数 | 钳制到最后一个有效索引 |
| S08 | 完成语义 | 最后一步点击完成 | 写入 completed 和已做记录 |
| S09 | 中途退出 | 第 2 步系统返回 | 只保存 paused,不记已做 |
| S10 | 语音切步 | 朗读时点下一步 | 旧朗读停止,新页面状态正确 |
| S11 | 引擎失败 | 模拟 SpeechKit 创建失败 | 页面仍可操作,提示语音不可用 |
| S12 | 保存失败 | 模拟 Preferences 写入失败 | 显示轻提示,不谎报已保存 |
| S13 | 重复点击 | 快速点击下一步 5 次 | 不越界,不发生旧进度覆盖 |
| S14 | 空步骤 | 数据层返回空数组 | 显示空态,底部操作栏隐藏 |
| S15 | 页面销毁 | 朗读时离开页面 | 引擎停止并 shutdown,无后台声音 |
十八、可复核的验收步骤
18.1 静态数据检查
- 遍历 recipeId 1 到 60。
- 确认每个 ID 都返回非空步骤。
- 确认每组
stepNumber从 1 连续递增。 - 确认标题、描述和图片资源引用不为空。
- 记录总步骤数,当前基线应为 369。
18.2 状态恢复检查
- 打开任一道至少 6 步的菜谱。
- 前进到第 4 步并返回详情页。
- 再次进入步骤页,确认恢复到第 4 步。
- 完成后重新进入,确认按产品规则从第 1 步开始新会话。
- 清除本地数据后进入,确认不会读取旧索引。
18.3 语音生命周期检查
- 点击朗读,确认状态从准备变为朗读中。
- 朗读中点击步骤总览另一项,确认旧声音立即停止。
- 朗读中返回,确认离开页面后没有残留声音。
- 关闭系统语音能力或模拟创建失败,确认步骤按钮仍可使用。
- 快速重复点击朗读,确认不会并发播放两条内容。
18.4 多设备检查
手机竖屏检查底部安全区和长描述滚动;手机横屏检查标题与按钮不挤压;平板检查图片不被无上限拉宽;2in1 检查鼠标点击、焦点态和窗口缩放。系统深色模式下还要验证正文、禁用按钮、进度条和底栏阴影的对比度。
十九、常见问题与定位方法
19.1 返回后总是第 1 步
原因: currentStep 只存在于 @State,页面重建后初始化为 0。
处理: 用会话服务保存 recipeId、索引和状态,页面加载完成后恢复并钳制索引。
19.2 点击完成但“已做”统计不准确
原因: 进入步骤页时就写了已做记录,完成按钮没有独立业务回调。
处理: 进入只创建 in_progress 会话,最后确认后才写 completed 与已做记录。
19.3 画面已切到下一步,声音还在读上一条
原因: 新的步骤入口没有统一停止当前语音,或旧请求回调覆盖新状态。
处理: 所有步骤变化都走 moveToStep(),并用 requestId 识别回调身份。
19.4 空步骤时出现“1 / 0”
原因: 内容区判断了 steps.length > 0,底部操作栏没有使用同一条件。
处理: 让内容和操作栏共同属于 content 状态,空数据时只渲染空态。
19.5 恢复后页面崩溃
原因: 保存索引大于升级后的步骤总数。
处理: 每次恢复都通过 normalizeStepIndex() 钳制,不信任持久化值。
19.6 快速点击后恢复到了更早一步
原因: 多次异步保存乱序完成,较旧请求最后写入。
处理: 保存期间禁用交互,或引入单调递增版本号并拒绝旧写入。
二十、工程落地顺序
不要一次性同时改页面、语音和所有数据。更稳的顺序是:
- 先增加路由参数校验与 loading/error/empty/content 状态。
- 再把所有步骤变化收口到
moveToStep()。 - 新增
CookingSession与服务层,完成保存和恢复。 - 调整完成语义,删除“进入即已做”的写入时机。
- 最后封装语音请求身份和多设备布局。
- 每一步都重跑 S01–S15 的相关用例。
这样每次修改都有单一目标:先不越界,再能恢复,再保证完成语义,最后提升语音与布局体验。
二十一、源码事实与建议实现的边界
为了避免把设计方案误写成项目已经具备的能力,下面把本文结论按真实源码快照与建议实现重新归档。读者复核时,可以直接从 CookingStepsPage.ets、CookingStepsData.ets、RecipeDetailPage.ets 和 UserDataManager.ets 对照。
| 检查项 | 当前源码事实 | 本文建议,不代表已经落地 |
|---|---|---|
| 步骤数据 | getCookingStepsById() 对 recipeId 1 至 60 有明确分支,共 60 个步骤数组、369 条 CookingStep,非法 ID 返回 STEPS_DEFAULT | 为正式数据增加构建期完整性检查,缺失时进入明确错误态 |
| 路由处理 | 参数缺失时默认 recipeId 1;参数存在但查不到菜谱时,标题显示“菜谱”,步骤仍可能使用默认数组 | 严格校验整数 ID,非法参数显示错误并允许返回 |
| 当前位置 | currentStep 是页面 @State,初始值为 0;页面重建后没有恢复逻辑 | 引入 CookingSession,保存 recipeId、索引、状态和时间 |
| 步骤切换 | 上一步、下一步和总览点击分别直接修改 currentStep,修改前会调用 stopSpeech() | 收口到 moveToStep(),统一边界校验、滚动定位和保存 |
| 完成动作 | 最后一步点击“完成”只执行 releaseSpeechEngine() 与 router.back() | 把完成定义为独立事件,成功写入 completed 后再记录已做 |
| 已做记录 | RecipeDetailPage 在进入步骤页前调用 UserDataManager.addCooked(),因此打开后立即返回也可能被统计为已做 | 将写入时机移动到最后一步确认完成,并保持 recipeId 幂等 |
| 返回语义 | 顶部返回只执行 router.back();aboutToDisappear() 只释放语音资源 | 返回保存 paused,完成保存 completed,防止生命周期回调覆盖完成态 |
| 语音模式 | 当前引擎按 online: 1、online: 0 依次尝试,页面离开时 stop 并 shutdown | 用适配器封装请求身份、失败原因和测试替身;网络与隐私说明按真实模式保持一致 |
| 空数据渲染 | 内容区有 steps.length > 0 判断,但底部操作栏始终渲染,理论上可能出现“1 / 0” | 页面显式区分 loading、error、empty、content,空态隐藏操作栏 |
| 多设备与构建 | 源码可证明页面使用百分比宽度、滚动区和 layoutWeight;本文没有执行设备安装或发布构建 | 在 phone、tablet、2in1 上验证安全区、宽屏分栏、键鼠焦点和窗口缩放 |
这张表也限定了测试结论:本文给出的状态机、会话服务和回归矩阵是可执行的改造方案,不是对当前安装包行为的虚构描述。只有在代码落地、构建通过并完成设备验证后,才能把相应建议改写成“已实现”。
二十二、总结
健康菜谱助手已经具备 60 组、369 条真实步骤,以及上一步、下一步、总览跳转和 SpeechKit 朗读能力。真正影响可靠性的不是页面少一个按钮,而是步骤进度没有会话边界,返回后无法恢复,“完成”也没有成为独立业务事件。
把页面改造成稳定做饭模式,需要坚持四条原则:
- recipeId 必须校验,不能用另一道菜静默兜底。
- currentStep 必须受状态机约束,并在恢复时重新钳制。
- paused 与 completed 必须分开,只有确认完成才记录已做。
- 语音请求必须跟随步骤和页面生命周期,切步、返回时及时停止。
当步骤位置、保存结果、语音状态和完成记录都能被明确观察和验证后,这个页面才从“可以翻步骤”升级为“用户真的能在厨房里放心使用”。
参考资料
- HarmonyOS ArkUI 概述
- HarmonyOS ArkTS 概述
- HarmonyOS 数据管理概述
- HarmonyOS AppStorage 开发指导
- 工程源码:
CookingStepsPage.ets、CookingStepsData.ets、RecipeModel.ets、RecipeDetailPage.ets
AI 辅助声明:本文由 AI 辅助完成源码统计、结构校对与配图设计,所有当前能力判断均以文中列出的本地 ArkTS 源码为依据,并由作者复核后发布;建议代码和验证矩阵不代表工程已经构建或上线。
更多推荐




所有评论(0)