ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

【健康菜谱助手|02】HarmonyOS ArkTS 烹饪步骤实战:把分步操作、完成状态和返回恢复做稳

【健康菜谱助手|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:

  1. 点击“上一步”执行currentStep--。
  2. 点击“下一步”执行currentStep++。
  3. 点击“全部步骤”中的任意行,把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') }

统一入口带来三个直接收益:

  1. 按钮、列表跳转和恢复逻辑共用同一边界校验。
  2. 每次切换步骤都会停止旧语音,避免声音与画面错位。
  3. 保存进度的时机不再散落在多个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 静态数据检查

  1. 遍历 recipeId 1 到 60。
  2. 确认每个 ID 都返回非空步骤。
  3. 确认每组stepNumber从 1 连续递增。
  4. 确认标题、描述和图片资源引用不为空。
  5. 记录总步骤数,当前基线应为 369。

18.2 状态恢复检查

  1. 打开任一道至少 6 步的菜谱。
  2. 前进到第 4 步并返回详情页。
  3. 再次进入步骤页,确认恢复到第 4 步。
  4. 完成后重新进入,确认按产品规则从第 1 步开始新会话。
  5. 清除本地数据后进入,确认不会读取旧索引。

18.3 语音生命周期检查

  1. 点击朗读,确认状态从准备变为朗读中。
  2. 朗读中点击步骤总览另一项,确认旧声音立即停止。
  3. 朗读中返回,确认离开页面后没有残留声音。
  4. 关闭系统语音能力或模拟创建失败,确认步骤按钮仍可使用。
  5. 快速重复点击朗读,确认不会并发播放两条内容。

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 快速点击后恢复到了更早一步

原因:多次异步保存乱序完成,较旧请求最后写入。

处理:保存期间禁用交互,或引入单调递增版本号并拒绝旧写入。

二十、工程落地顺序

不要一次性同时改页面、语音和所有数据。更稳的顺序是:

  1. 先增加路由参数校验与 loading/error/empty/content 状态。
  2. 再把所有步骤变化收口到moveToStep()。
  3. 新增CookingSession与服务层,完成保存和恢复。
  4. 调整完成语义,删除“进入即已做”的写入时机。
  5. 最后封装语音请求身份和多设备布局。
  6. 每一步都重跑 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 源码为依据,并由作者复核后发布;建议代码和验证矩阵不代表工程已经构建或上线。

返回列表