欢迎光临
我们一直在努力

HarmonyOS 6.1 全场景实战|《灵犀厨房》实战(十八):【手表协同】烹饪计时器流转至智能手表——手腕掌控烹饪节奏

HarmonyOS 6.1 全场景实战|《灵犀厨房》实战(十八):【手表协同】烹饪计时器流转至智能手表——手腕掌控烹饪节奏

摘要:上一篇我们为菜谱详情页装上了"耳朵"——接入 CoreSpeechKit 语音识别实现了免提声控启动播报。但烹饪中还有一个高频痛点:计时。炖牛肉要 40 分钟,蒸鱼要 8 分钟,你不可能一直盯着手机。如果计时器能流转到手表上——手腕一抬就看到倒计时,到点了轻震提醒——那才是真正的"云端厨房"。本篇,我们将接入 HarmonyOS 6.1.0(API 23)的分布式设备协同能力,为《灵犀厨房》的厨电控制页实现计时器从手机流转至智能手表的全链路:手机端一键发送→手表端接管倒计时→倒计时最后 10 秒渐强震动→计时完成重复震动提醒。严格遵循 API 23 规范,代码即文档。


一、引言与系列定位

经过第 13 篇的厨电模拟和第 17 篇的声控播报,你的《灵犀厨房》已经成为一台强大的烹饪助手。但你很快会发现一个尴尬的现实:

场景问题解决
电磁炉定时 15 分钟煮汤 离开厨房就看不到倒计时 流转至手表,手腕上实时显示
烤箱烘烤 40 分钟 在客厅看电视,忘记还剩多久 抬手看手表,剩余时间一目了然
计时器响了但手机在隔壁 铃声听不到,菜烧糊了 手表震动提醒,戴在手上不会错过
倒计时最后 10 秒 需要紧张地等待关火 手表渐强震动,倒计时节奏可感知

设计决策:为何不在手表上操作计时器的"开始/暂停/停止"?

经过对烹饪场景的分析,我们发现:在厨房里操作手机已经满手油污,操作手表的小屏幕同样不便。真正的痛点不是"用哪只手操作",而是"不用手操作"。因此,本篇的设计原则是:

  • 手机端发起计时:在厨电控制页点击"定时 5 分钟"或"定时 15 分钟"(第 13 篇已实现),计时器在手机上运行
  • 一键流转至手表:计时器运行后,点击"⌚ 流转至手表",手机端计时停止,手表接管倒计时
  • 手表专职提醒:倒计时最后 10 秒渐强震动 → 计时完成重复震动 → 手腕一抬就知道该关火了
  • 手机端回看状态:手机端页面顶部显示"电磁炉 计时中 · HUAWEI WATCH GT 4 · 剩余 03:15"

这就是手表协同的核心价值——从"盯着屏幕"变为"手腕感知"。不是用手表控制厨电,而是让计时跟着你的手腕走。


二、核心原理与底层机制深度解读

2.1 HarmonyOS 分布式手表通信:三条技术路径

HarmonyOS 6.1.0(API 23)为手机与手表的协同提供了三条技术路径:

#mermaid-svg-QQM5ED0JRg2rLyD7{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QQM5ED0JRg2rLyD7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QQM5ED0JRg2rLyD7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QQM5ED0JRg2rLyD7 .error-icon{fill:#552222;}#mermaid-svg-QQM5ED0JRg2rLyD7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QQM5ED0JRg2rLyD7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QQM5ED0JRg2rLyD7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QQM5ED0JRg2rLyD7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QQM5ED0JRg2rLyD7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QQM5ED0JRg2rLyD7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QQM5ED0JRg2rLyD7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QQM5ED0JRg2rLyD7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QQM5ED0JRg2rLyD7 .marker.cross{stroke:#333333;}#mermaid-svg-QQM5ED0JRg2rLyD7 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QQM5ED0JRg2rLyD7 p{margin:0;}#mermaid-svg-QQM5ED0JRg2rLyD7 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-QQM5ED0JRg2rLyD7 .cluster-label text{fill:#333;}#mermaid-svg-QQM5ED0JRg2rLyD7 .cluster-label span{color:#333;}#mermaid-svg-QQM5ED0JRg2rLyD7 .cluster-label span p{background-color:transparent;}#mermaid-svg-QQM5ED0JRg2rLyD7 .label text,#mermaid-svg-QQM5ED0JRg2rLyD7 span{fill:#333;color:#333;}#mermaid-svg-QQM5ED0JRg2rLyD7 .node rect,#mermaid-svg-QQM5ED0JRg2rLyD7 .node circle,#mermaid-svg-QQM5ED0JRg2rLyD7 .node ellipse,#mermaid-svg-QQM5ED0JRg2rLyD7 .node polygon,#mermaid-svg-QQM5ED0JRg2rLyD7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-QQM5ED0JRg2rLyD7 .rough-node .label text,#mermaid-svg-QQM5ED0JRg2rLyD7 .node .label text,#mermaid-svg-QQM5ED0JRg2rLyD7 .image-shape .label,#mermaid-svg-QQM5ED0JRg2rLyD7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-QQM5ED0JRg2rLyD7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-QQM5ED0JRg2rLyD7 .rough-node .label,#mermaid-svg-QQM5ED0JRg2rLyD7 .node .label,#mermaid-svg-QQM5ED0JRg2rLyD7 .image-shape .label,#mermaid-svg-QQM5ED0JRg2rLyD7 .icon-shape .label{text-align:center;}#mermaid-svg-QQM5ED0JRg2rLyD7 .node.clickable{cursor:pointer;}#mermaid-svg-QQM5ED0JRg2rLyD7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-QQM5ED0JRg2rLyD7 .arrowheadPath{fill:#333333;}#mermaid-svg-QQM5ED0JRg2rLyD7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-QQM5ED0JRg2rLyD7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-QQM5ED0JRg2rLyD7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QQM5ED0JRg2rLyD7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-QQM5ED0JRg2rLyD7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QQM5ED0JRg2rLyD7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-QQM5ED0JRg2rLyD7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-QQM5ED0JRg2rLyD7 .cluster text{fill:#333;}#mermaid-svg-QQM5ED0JRg2rLyD7 .cluster span{color:#333;}#mermaid-svg-QQM5ED0JRg2rLyD7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-QQM5ED0JRg2rLyD7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-QQM5ED0JRg2rLyD7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-QQM5ED0JRg2rLyD7 .icon-shape,#mermaid-svg-QQM5ED0JRg2rLyD7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QQM5ED0JRg2rLyD7 .icon-shape p,#mermaid-svg-QQM5ED0JRg2rLyD7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-QQM5ED0JRg2rLyD7 .icon-shape .label rect,#mermaid-svg-QQM5ED0JRg2rLyD7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QQM5ED0JRg2rLyD7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-QQM5ED0JRg2rLyD7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-QQM5ED0JRg2rLyD7 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

⌚ 手表端

📱 手机端

选择路径

通信路径

路径 A@kit.WearEnginewearEngine.sendMessage()

路径 B@kit.DistributedServiceKitcontinuationManager

路径 C自定义蓝牙通道(低功耗)

灵犀厨房 App

WatchTimerManager

WatchServiceHelper

手表 App/服务

倒计时引擎

震动反馈

路径适用场景优势劣势本篇选型
A: WearEngine 手表专属消息通道 延迟低(~50ms),支持后台保活 仅限手表设备 ✅ 模拟实现
B: Continuation 跨设备任务迁移 通用性强,支持任意设备 延迟较高(~200ms),需用户确认 备选
C: 自定义蓝牙 极端省电场景 功耗最低 开发成本高,需自行维护连接 不考虑

为什么选 WearEngine 模拟实现?

@kit.WearEngine 是 HarmonyOS 专门为手表协同设计的 Kit,提供了消息收发、设备状态查询、震动控制等 API。在不具备真实手表开发环境的情况下,我们采用与第 14 篇分布式流转一致的策略:用模拟代码完整保留 API 23 的真实接口签名,后续接入真实手表仅需替换服务层实现,上层 UI 零改动。

2.2 计时器流转的状态机设计

这是本篇最核心的设计——手机与手表之间的计时器流转不是一个简单的数据拷贝,而是一个涉及设备间状态同步的分布式状态机:

#mermaid-svg-vnWIrJfIXzLRQniT{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vnWIrJfIXzLRQniT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vnWIrJfIXzLRQniT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vnWIrJfIXzLRQniT .error-icon{fill:#552222;}#mermaid-svg-vnWIrJfIXzLRQniT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vnWIrJfIXzLRQniT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vnWIrJfIXzLRQniT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vnWIrJfIXzLRQniT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vnWIrJfIXzLRQniT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vnWIrJfIXzLRQniT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vnWIrJfIXzLRQniT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vnWIrJfIXzLRQniT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vnWIrJfIXzLRQniT .marker.cross{stroke:#333333;}#mermaid-svg-vnWIrJfIXzLRQniT svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vnWIrJfIXzLRQniT p{margin:0;}#mermaid-svg-vnWIrJfIXzLRQniT defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-vnWIrJfIXzLRQniT g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-vnWIrJfIXzLRQniT g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-vnWIrJfIXzLRQniT g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-vnWIrJfIXzLRQniT g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-vnWIrJfIXzLRQniT g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-vnWIrJfIXzLRQniT .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-vnWIrJfIXzLRQniT .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-vnWIrJfIXzLRQniT .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-vnWIrJfIXzLRQniT .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-vnWIrJfIXzLRQniT .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-vnWIrJfIXzLRQniT .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-vnWIrJfIXzLRQniT .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-vnWIrJfIXzLRQniT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vnWIrJfIXzLRQniT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vnWIrJfIXzLRQniT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vnWIrJfIXzLRQniT .edgeLabel .label text{fill:#333;}#mermaid-svg-vnWIrJfIXzLRQniT .label div .edgeLabel{color:#333;}#mermaid-svg-vnWIrJfIXzLRQniT .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-vnWIrJfIXzLRQniT .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-vnWIrJfIXzLRQniT .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-vnWIrJfIXzLRQniT .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-vnWIrJfIXzLRQniT .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-vnWIrJfIXzLRQniT .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vnWIrJfIXzLRQniT .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vnWIrJfIXzLRQniT #statediagram-barbEnd{fill:#333333;}#mermaid-svg-vnWIrJfIXzLRQniT .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vnWIrJfIXzLRQniT .cluster-label,#mermaid-svg-vnWIrJfIXzLRQniT .nodeLabel{color:#131300;}#mermaid-svg-vnWIrJfIXzLRQniT .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-vnWIrJfIXzLRQniT .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-vnWIrJfIXzLRQniT .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-vnWIrJfIXzLRQniT .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-vnWIrJfIXzLRQniT .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-vnWIrJfIXzLRQniT .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-vnWIrJfIXzLRQniT .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-vnWIrJfIXzLRQniT .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-vnWIrJfIXzLRQniT .note-edge{stroke-dasharray:5;}#mermaid-svg-vnWIrJfIXzLRQniT .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-vnWIrJfIXzLRQniT .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-vnWIrJfIXzLRQniT .statediagram-note text{fill:black;}#mermaid-svg-vnWIrJfIXzLRQniT .statediagram-note .nodeLabel{color:black;}#mermaid-svg-vnWIrJfIXzLRQniT .statediagram .edgeLabel{color:red;}#mermaid-svg-vnWIrJfIXzLRQniT #dependencyStart,#mermaid-svg-vnWIrJfIXzLRQniT #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-vnWIrJfIXzLRQniT .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vnWIrJfIXzLRQniT :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

无计时器

用户点击"定时5分钟"

点击"⌚流转至手表"

手表确认接收

流转失败→回退

手表端倒计时结束

用户取消手表计时

用户清除完成状态

IDLE

COUNTING_ON_PHONE

TRANSFERRING_TO_WATCH

COUNTING_ON_WATCH

COMPLETED_ON_WATCH

手机端 setInterval 每秒递减

手表端接管倒计时手机端通过回调显示进度

手表震动已触发手机端显示"计时完成"

核心机制:流转不是"同步两份数据",而是单源倒计时 + 状态上报。计时器在任一时刻只在一个设备上运行(手机或手表),另一端通过回调被动接收状态更新。这避免了双端倒计时不一致的问题。

2.3 手表震动反馈的三段式设计

倒计时从"数字"变成"触觉",需要分阶段提醒:

阶段触发条件震动模式用户感知
安静期 剩余 > 10 秒 无震动 手腕无感,正常看倒计时数字
预警期 剩余 10 秒 RAMP_UP 渐强震动(500ms) “快好了,准备好关火”
完成期 剩余 0 秒 REPEAT 重复震动(3 秒) “到了!马上去关火!”

这就是震动反馈的三段式设计:安静 → 预警 → 告警,让用户不依赖屏幕就能感知计时节奏。


三、架构设计 / 核心逻辑图解

3.1 手表协同的四层架构

#mermaid-svg-e5GMczhPJbbVSXD8{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-e5GMczhPJbbVSXD8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-e5GMczhPJbbVSXD8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-e5GMczhPJbbVSXD8 .error-icon{fill:#552222;}#mermaid-svg-e5GMczhPJbbVSXD8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-e5GMczhPJbbVSXD8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-e5GMczhPJbbVSXD8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-e5GMczhPJbbVSXD8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-e5GMczhPJbbVSXD8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-e5GMczhPJbbVSXD8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-e5GMczhPJbbVSXD8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-e5GMczhPJbbVSXD8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-e5GMczhPJbbVSXD8 .marker.cross{stroke:#333333;}#mermaid-svg-e5GMczhPJbbVSXD8 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-e5GMczhPJbbVSXD8 p{margin:0;}#mermaid-svg-e5GMczhPJbbVSXD8 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-e5GMczhPJbbVSXD8 .cluster-label text{fill:#333;}#mermaid-svg-e5GMczhPJbbVSXD8 .cluster-label span{color:#333;}#mermaid-svg-e5GMczhPJbbVSXD8 .cluster-label span p{background-color:transparent;}#mermaid-svg-e5GMczhPJbbVSXD8 .label text,#mermaid-svg-e5GMczhPJbbVSXD8 span{fill:#333;color:#333;}#mermaid-svg-e5GMczhPJbbVSXD8 .node rect,#mermaid-svg-e5GMczhPJbbVSXD8 .node circle,#mermaid-svg-e5GMczhPJbbVSXD8 .node ellipse,#mermaid-svg-e5GMczhPJbbVSXD8 .node polygon,#mermaid-svg-e5GMczhPJbbVSXD8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-e5GMczhPJbbVSXD8 .rough-node .label text,#mermaid-svg-e5GMczhPJbbVSXD8 .node .label text,#mermaid-svg-e5GMczhPJbbVSXD8 .image-shape .label,#mermaid-svg-e5GMczhPJbbVSXD8 .icon-shape .label{text-anchor:middle;}#mermaid-svg-e5GMczhPJbbVSXD8 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-e5GMczhPJbbVSXD8 .rough-node .label,#mermaid-svg-e5GMczhPJbbVSXD8 .node .label,#mermaid-svg-e5GMczhPJbbVSXD8 .image-shape .label,#mermaid-svg-e5GMczhPJbbVSXD8 .icon-shape .label{text-align:center;}#mermaid-svg-e5GMczhPJbbVSXD8 .node.clickable{cursor:pointer;}#mermaid-svg-e5GMczhPJbbVSXD8 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-e5GMczhPJbbVSXD8 .arrowheadPath{fill:#333333;}#mermaid-svg-e5GMczhPJbbVSXD8 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-e5GMczhPJbbVSXD8 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-e5GMczhPJbbVSXD8 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-e5GMczhPJbbVSXD8 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-e5GMczhPJbbVSXD8 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-e5GMczhPJbbVSXD8 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-e5GMczhPJbbVSXD8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-e5GMczhPJbbVSXD8 .cluster text{fill:#333;}#mermaid-svg-e5GMczhPJbbVSXD8 .cluster span{color:#333;}#mermaid-svg-e5GMczhPJbbVSXD8 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-e5GMczhPJbbVSXD8 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-e5GMczhPJbbVSXD8 rect.text{fill:none;stroke-width:0;}#mermaid-svg-e5GMczhPJbbVSXD8 .icon-shape,#mermaid-svg-e5GMczhPJbbVSXD8 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-e5GMczhPJbbVSXD8 .icon-shape p,#mermaid-svg-e5GMczhPJbbVSXD8 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-e5GMczhPJbbVSXD8 .icon-shape .label rect,#mermaid-svg-e5GMczhPJbbVSXD8 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-e5GMczhPJbbVSXD8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-e5GMczhPJbbVSXD8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-e5GMczhPJbbVSXD8 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

🏗️ 基础设施层

⚙️ 服务能力层

🧠 业务功能层

🎨 用户交互层

KitchenDevicePage⌚ 手表状态指示条设备卡片中的流转按钮计时完成通知

WatchTimerManager流转状态机手表回调桥接transferToWatch / cancelFromWatch

KitchenDeviceSimulator(第13篇已实现)计时器引擎

WatchServiceHelper手表设备发现计时器数据传输震动控制倒计时模拟

@kit.WearEngine(模拟)消息收发 + 设备管理

@kit.DistributedServiceKit(可选)continuationManager

设计原则:手表协同(WatchTimerManager)与计时器引擎(KitchenDeviceSimulator)是独立的 Business 层模块。WatchTimerManager 的职责是"协调流转 + 状态上报 + 回调通知 UI",由 UI 层(KitchenDevicePage)的 handleWatchTransfer 方法来实际触发停止手机端计时和发起手表流转。这样:

  • 单一职责:WatchTimerManager 只管"何时流转、流转到哪、流转状态",UI 层决定"流转前停止哪些手机端计时器、流转后显示什么状态"。
  • 双端解耦:transferToWatch 时不依赖 KitchenDeviceSimulator 的内部状态——它只接收 remainingSeconds(剩余秒数)作为参数,手表端独立倒计时,与手机端彻底隔离。
  • 震动独立控制:手表震动逻辑在 WatchServiceHelper 层,不污染业务层。
  • 3.2 流转时序图

    ⌚ 手表

    WatchServiceHelper

    WatchTimerManager

    KitchenDeviceSimulator

    KitchenDeviceViewModel

    KitchenDevicePage

    👤 用户

    ⌚ 手表

    WatchServiceHelper

    WatchTimerManager

    KitchenDeviceSimulator

    KitchenDeviceViewModel

    KitchenDevicePage

    👤 用户

    #mermaid-svg-VFYYcZHXfWh18Eav{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VFYYcZHXfWh18Eav .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VFYYcZHXfWh18Eav .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VFYYcZHXfWh18Eav .error-icon{fill:#552222;}#mermaid-svg-VFYYcZHXfWh18Eav .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VFYYcZHXfWh18Eav .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VFYYcZHXfWh18Eav .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VFYYcZHXfWh18Eav .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VFYYcZHXfWh18Eav .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VFYYcZHXfWh18Eav .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VFYYcZHXfWh18Eav .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VFYYcZHXfWh18Eav .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VFYYcZHXfWh18Eav .marker.cross{stroke:#333333;}#mermaid-svg-VFYYcZHXfWh18Eav svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VFYYcZHXfWh18Eav p{margin:0;}#mermaid-svg-VFYYcZHXfWh18Eav .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VFYYcZHXfWh18Eav text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-VFYYcZHXfWh18Eav .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-VFYYcZHXfWh18Eav .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-VFYYcZHXfWh18Eav .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-VFYYcZHXfWh18Eav .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-VFYYcZHXfWh18Eav #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-VFYYcZHXfWh18Eav .sequenceNumber{fill:white;}#mermaid-svg-VFYYcZHXfWh18Eav #sequencenumber{fill:#333;}#mermaid-svg-VFYYcZHXfWh18Eav #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-VFYYcZHXfWh18Eav .messageText{fill:#333;stroke:none;}#mermaid-svg-VFYYcZHXfWh18Eav .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VFYYcZHXfWh18Eav .labelText,#mermaid-svg-VFYYcZHXfWh18Eav .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-VFYYcZHXfWh18Eav .loopText,#mermaid-svg-VFYYcZHXfWh18Eav .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-VFYYcZHXfWh18Eav .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-VFYYcZHXfWh18Eav .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-VFYYcZHXfWh18Eav .noteText,#mermaid-svg-VFYYcZHXfWh18Eav .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-VFYYcZHXfWh18Eav .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VFYYcZHXfWh18Eav .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VFYYcZHXfWh18Eav .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VFYYcZHXfWh18Eav .actorPopupMenu{position:absolute;}#mermaid-svg-VFYYcZHXfWh18Eav .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-VFYYcZHXfWh18Eav .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VFYYcZHXfWh18Eav .actor-man circle,#mermaid-svg-VFYYcZHXfWh18Eav line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-VFYYcZHXfWh18Eav :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    用户在厨电控制页

    手机端倒计时开始

    手机端计时器停止(保留剩余秒数)

    顶部显示"电磁炉 计时中 · HUAWEI WATCH GT 4"

    更新手表倒计时显示

    loop

    [每秒]

    顶部显示"电磁炉 计时完成!"

    点击"定时 5分钟"

    startTimer(deviceId, 300)

    startCookingTimer(deviceId, 300)

    手机端显示 04:59, 04:58…

    点击"⌚ 流转至手表"

    stopTimer(deviceId)

    transferToWatch(deviceId, deviceName, remaining, total)

    状态 → TRANSFERRING_TO_WATCH

    transferTimer(context)

    模拟蓝牙传输 (~250ms)

    发送计时数据

    手表端启动倒计时

    流转成功

    状态 → COUNTING_ON_WATCH

    onTransferred(context)

    mockWatchRemaining–

    onWatchTimerStateChange(COUNTING, remaining)

    onWatchTick(remainingSeconds, totalSeconds)

    最后10秒 → 渐强震动

    remaining=0

    重复震动 3秒

    状态 → COMPLETED

    onWatchComplete()

    关键时序:手机端停止计时 → 再发起手表流转 → 手表端启动倒计时。这个顺序很重要——如果先流转再停止,可能出现双端同时计时的竞态。


    四、实战:为厨电控制页装上"手腕大脑"

    Step 1:WatchServiceHelper —— 封装手表通信

    新建 services/WatchServiceHelper.ets。核心任务:将手表的设备发现、消息传输、震动控制封装为可复用的服务层,完整保留 API 23 的真实接口签名。

    // services/WatchServiceHelper.ets
    // 所属层:服务能力层 (Service Layer)
    // 职责:封装 @kit.WearEngine 的手表通信能力
    // 版本:v1 — 第18篇,基于 API 23

    // ========== 真实手表 API(上架后取消注释) ==========
    // import { wearEngine } from '@kit.WearEngine';
    // import { distributedDeviceManager } from '@kit.DistributedServiceKit';

    /** 手表设备信息 */
    export interface WatchDevice {
    deviceId: string; // 设备唯一 ID
    name: string; // 'HUAWEI WATCH GT 4'
    model: string; // 'PNX-B19'
    status: WatchConnectionStatus;
    osVersion: string;
    batteryLevel: number; // 0-100
    isWorn: boolean; // 是否穿戴中
    }

    /** 计时器流转上下文:手机→手表传输的计时数据 */
    export interface WatchTimerContext {
    recipeName: string;
    deviceName: string; // 厨电名称,如"电磁炉"
    remainingSeconds: number; // 剩余秒数
    totalSeconds: number; // 总秒数
    timestamp: number;
    vibrationPattern?: WatchVibrationPattern;
    }

    /** 手表震动模式 */
    export enum WatchVibrationPattern {
    SHORT = 'short', // 单次短震
    REPEAT = 'repeat', // 重复短震(计时到点)
    RAMP_UP = 'rampUp' // 渐强震动(最后10秒)
    }

    /** 手表端计时器状态 */
    export enum WatchTimerState {
    IDLE = 'idle', RECEIVING = 'receiving',
    COUNTING = 'counting', COMPLETED = 'completed', CANCELLED = 'cancelled'
    }

    export class WatchServiceHelper {
    private watchDevice: WatchDevice | null = null;
    private timerState: WatchTimerState = WatchTimerState.IDLE;
    private mockWatchRemaining: number = 0;

    // ★ 初始化:扫描并连接手表
    async initialize(): Promise<WatchDevice | null> {
    await this.delay(400); // 模拟蓝牙扫描延迟
    this.watchDevice = {
    deviceId: 'WA:TC:H0:00:00:01',
    name: 'HUAWEI WATCH GT 4',
    model: 'PNX-B19',
    status: WatchConnectionStatus.CONNECTED,
    osVersion: 'HarmonyOS 4.2.0',
    batteryLevel: 85,
    isWorn: true
    };
    return this.watchDevice;
    }

    /** ★ 检查手表是否可用(已连接 + 穿戴中) */
    isWatchAvailable(): boolean {
    return this.watchDevice !== null
    && this.watchDevice.status === WatchConnectionStatus.CONNECTED
    && this.watchDevice.isWorn;
    }

    /** ★ 将计时器流转至手表 */
    async transferTimer(context: WatchTimerContext): Promise<boolean> {
    if (!this.isWatchAvailable()) return false;
    // 模拟数据传输延迟
    await this.delay(250);
    // 启动模拟手表端倒计时
    this.startMockWatchCountdown(context.remainingSeconds);
    return true;
    }

    /** ★ 启动模拟手表端倒计时(每秒递减 + 震动逻辑) */
    private startMockWatchCountdown(seconds: number): void {
    this.mockWatchRemaining = seconds;
    this.mockWatchInterval = setInterval(() => {
    if (this.mockWatchRemaining <= 0) {
    // 计时完成 → 触发手表震动
    this.triggerVibration(WatchVibrationPattern.REPEAT, 3000);
    this.notifyTimerCompleted();
    return;
    }
    this.mockWatchRemaining;
    // 最后 10 秒 → 渐强震动提醒
    if (this.mockWatchRemaining === 10) {
    this.triggerVibration(WatchVibrationPattern.RAMP_UP, 500);
    }
    this.notifyWatchTimerStateChange(WatchTimerState.COUNTING, this.mockWatchRemaining);
    }, 1000);
    }
    }

    export const watchServiceHelper: WatchServiceHelper = new WatchServiceHelper();

    核心点解读:

    • isWorn 检查:如果手表摘下放在桌上(isWorn=false),震动提醒将失效——流转前必须确认手表佩戴状态,避免"手表在桌上震,人在厨房听不到"。
    • 模拟倒计时的真实性:setInterval 每秒触发一次递减,完全模拟真实手表端的计时节奏。后续接入真机时,倒计时逻辑移至手表 App 内执行,手机端通过 onMessage 回调被动接收进度。
    • 全局单例导出:export const watchServiceHelper 确保整个应用共用一个手表连接实例。

    Step 2:WatchTimerManager —— 流转状态机 + 业务逻辑

    新建 business/WatchTimerManager.ets。核心任务:(1)定义 6 种流转状态;(2)实现 transferToWatch 核心方法,协调"停止手机计时→发起手表流转"的全流程;(3)桥接 Service 层回调到 Business 层语义。

    // business/WatchTimerManager.ets
    // 所属层:业务功能层 (Business Layer)
    // 职责:管理计时器在手机与手表之间的流转状态

    /** 手表计时器管理器状态枚举 */
    export enum TimerTransferState {
    IDLE = 'idle', // 无计时器运行
    COUNTING_ON_PHONE = 'countingOnPhone', // 仅在手机端
    TRANSFERRING_TO_WATCH = 'transferringToWatch', // 流转中
    COUNTING_ON_WATCH = 'countingOnWatch', // 在手表端
    COMPLETED_ON_WATCH = 'completedOnWatch', // 手表完成
    CANCELLED = 'cancelled'
    }

    export class WatchTimerManager {
    private state: TimerTransferState = TimerTransferState.IDLE;
    private preservedSeconds: number = 0; // 流转前保存的剩余秒数
    private totalSeconds: number = 0;

    /** ★ 核心方法:将计时器流转至手表 */
    async transferToWatch(
    deviceId: string, deviceName: string, recipeName: string,
    remainingSeconds: number, totalSeconds: number
    ): Promise<boolean> {
    if (!watchServiceHelper.isWatchAvailable()) {
    this.notifyError('手表未连接或未佩戴,请戴好手表后重试');
    return false;
    }
    // 保存上下文(用于从手表收回时恢复)
    this.preservedSeconds = remainingSeconds;
    this.totalSeconds = totalSeconds;
    this.setState(TimerTransferState.TRANSFERRING_TO_WATCH);

    const context: WatchTimerContext = {
    recipeName, deviceName, remainingSeconds, totalSeconds,
    timestamp: Date.now()
    };

    const success = await watchServiceHelper.transferTimer(context);
    if (success) {
    this.setState(TimerTransferState.COUNTING_ON_WATCH);
    } else {
    this.setState(TimerTransferState.COUNTING_ON_PHONE); // 回退
    }
    return success;
    }

    /** 从手表取消计时器 */
    async cancelFromWatch(resumeOnPhone: boolean = true): Promise<void> {
    await watchServiceHelper.cancelWatchTimer();
    if (resumeOnPhone && this.preservedSeconds > 0) {
    this.setState(TimerTransferState.COUNTING_ON_PHONE);
    } else {
    this.setState(TimerTransferState.IDLE);
    }
    }
    }

    export const watchTimerManager: WatchTimerManager = new WatchTimerManager();

    核心点解读:

    • 流转失败的状态回退:如果 transferTimer 失败(如蓝牙中断),状态立即从 TRANSFERRING_TO_WATCH 回退到 COUNTING_ON_PHONE——用户不需要额外操作,手机端计时器继续运行。
    • preservedSeconds 预留恢复能力:保留流转前的剩余秒数,即使流转到手表后断连,也能在取消时回到手机端重新计时。
    • 状态变更全部通过 setState 做日志记录:每个状态迁移都有控制台日志,方便调试分布式场景下的时序问题。

    Step 3:改造 KitchenDevicePage.ets,集成手表流转 UI

    在厨电控制页中新增手表状态管理和流转逻辑。

    (1)新增状态变量

    // ★ mock 场景下手表始终可用,初始 true 确保首帧即显示手表电量 badge
    @Local isWatchConnected: boolean = true;
    @Local watchDeviceName: string = 'HUAWEI WATCH GT 4';
    @Local watchDeviceBattery: number = 85;
    @Local transferState: TimerTransferState = TimerTransferState.IDLE;
    @Local watchRemainingSeconds: number = 0;
    @Local watchTimerDeviceName: string = '';

    // ★ 记录各设备的原始计时总秒数(流转时传 totalSeconds,而非当前剩余秒数)
    private deviceTimerTotal: Map<string, number> = new Map();

    (2)生命周期集成

    async aboutToAppear(): Promise<void> {
    this.vm.loadDevices();
    await this.initWatchManager(); // ★ 初始化手表连接
    }

    aboutToDisappear(): void {
    this.vm.destroy();
    watchTimerManager.destroy(); // ★ 销毁手表连接
    }

    (3)手表初始化和回调注册

    private async initWatchManager(): Promise<void> {
    watchTimerManager.registerCallbacks({
    onStateChange: (state) => { this.transferState = state; },
    onWatchTick: (remaining, total) => {
    this.watchRemainingSeconds = remaining;
    this.watchTotalSeconds = total;
    },
    onWatchComplete: () => { /* 显示计时完成状态 */ },
    onWatchConnectionChange: (status, device) => {
    this.isWatchConnected = (status === WatchConnectionStatus.CONNECTED);
    if (device) {
    this.watchDeviceName = device.name;
    this.watchDeviceBattery = device.batteryLevel;
    }
    }
    });
    await watchTimerManager.initialize();
    const device = watchTimerManager.getWatchDevice();
    if (device) { /* this.isWatchConnected already true; only update name/battery */ }
    }

    (4)handleWatchTransfer —— 发起流转的核心方法

    private async handleWatchTransfer(
    deviceId: string, deviceName: string,
    remainingSeconds: number, totalSeconds: number
    ): Promise<void> {
    console.info(`[KitchenDevicePage] 🔄 流转计时器: ${deviceName}, 剩余 ${remainingSeconds}`);

    this.watchTimerDeviceName = deviceName;
    this.watchRemainingSeconds = remainingSeconds;
    this.watchTotalSeconds = totalSeconds;

    // ★ 先停止手机端计时器
    this.vm.stopTimer(deviceId);

    // 执行手表流转
    const success = await watchTimerManager.transferToWatch(
    deviceId, deviceName, '', remainingSeconds, totalSeconds
    );

    if (!success) {
    console.error('[KitchenDevicePage] 流转至手表失败');
    this.watchTimerDeviceName = '';
    }
    }

    (5)UI 层:手表流转状态指示条

    // ★ 流转中 / 计时中状态
    if (this.transferState === TimerTransferState.COUNTING_ON_WATCH) {
    Row() {
    Text('⌚').fontSize(18)
    Column() {
    Text(`${this.watchTimerDeviceName} 计时中`)
    .fontSize(13).fontColor('#FF6B35').fontWeight(FontWeight.Medium)
    Text(`${this.watchDeviceName} · 剩余 ${this.formatWatchTimer(this.watchRemainingSeconds)}`)
    .fontSize(11).fontColor('#999')
    }
    Blank()
    Button('取消手表计时').onClick(() => { /* … */ })
    }
    .backgroundColor('#FFF3E0').borderRadius(10)
    }

    // ★ 计时完成状态
    if (this.transferState === TimerTransferState.COMPLETED_ON_WATCH) {
    Row() {
    Text('⌚').fontSize(18)
    Text(`${this.watchTimerDeviceName} 计时完成!`)
    .fontSize(13).fontColor('#4CAF50')
    Button('清除')
    }
    .backgroundColor('#F0FFF0').borderRadius(10)
    }

    (6)DeviceControlCard 新增流转按钮

    在 DeviceControlCard 中新增 @Event onWatchTransfer 并在计时区域渲染按钮:

    // ★ @Event 声明(与 onTimerStop 等并列)
    @Event onWatchTransfer: () => void = () => {};

    // build() 中计时区域新增按钮
    if (this.device.timerSeconds > 0) {
    Row() {
    Text('⏱️').fontSize(18)
    Text(this.formatTimer(this.device.timerSeconds))
    .fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FF6B35').fontFamily('monospace')
    .margin({ left: 8 })
    Blank()
    // ★ "⌚ 流转至手表"按钮 — onClick 直接传 @Event 引用
    Button() {
    Text('⌚ 流转至手表').fontSize(11).fontColor('#FF6B35')
    }
    .height(30).backgroundColor('#FFF0E6').borderRadius(8)
    .margin({ right: 6 })
    .onClick(this.onWatchTransfer)

    Button('停止计时')
    .fontSize(12).height(30).backgroundColor('#F44336').fontColor(Color.White).borderRadius(8)
    .onClick(() => this.onTimerStop?.())
    }
    .width('100%').padding(10).backgroundColor('#FFF3E0').borderRadius(8).margin({ bottom: 12 })
    }

    父组件 KitchenDevicePage 在 ForEach 中绑定回调,记录原始总秒数并通过 handleWatchTransfer 执行流转:

    DeviceControlCard({
    device: device,
    // …其他属性…
    onTimerStart: (seconds: number) => {
    this.deviceTimerTotal.set(device.deviceId, seconds);
    this.vm.startTimer(device.deviceId, seconds);
    },
    onTimerStop: () => { this.vm.stopTimer(device.deviceId); },
    // ★ 绑定流转回调:从 deviceTimerTotal 取原始总秒数
    onWatchTransfer: () => {
    const total: number = this.deviceTimerTotal.get(device.deviceId) ?? device.timerSeconds;
    console.info(`[KitchenDevicePage] ⌚ 流转: device=${device.deviceId}, name=${device.name}, remaining=${device.timerSeconds}, total=${total}`);
    this.handleWatchTransfer(device.deviceId, device.name, device.timerSeconds, total);
    }
    })

    核心点解读:

    • "先停止手机端计时,再发起手表流转"的顺序至关重要:如果反过来,可能出现手机端 setInterval 仍在递减而手表端也在递减的双端竞态,导致两个设备显示的剩余时间不一致。
    • 手表状态指示条位于页面顶部,全局可见:不是嵌在某个设备卡片里——流转后用户切换到"设备控制"页或其他 Tab,仍然能看到手表上的计时进度。
    • onClick(this.onWatchTransfer) 直接传 @Event 引用:与 ArkUI 原生 onClick 签名匹配,无需额外箭头函数包装,避免 ?.() 可选链或闭包捕获过期引用的问题。
    • deviceTimerTotal Map 记录原始总秒数:onTimerStart 时存入原始值(300 / 900),onWatchTransfer 时取出作为 totalSeconds,与当前剩余秒数分开传递。

    五、代码交付清单

    文件新增/修改职责
    services/WatchServiceHelper.ets 新增 手表通信封装:设备发现、数据传输、震动控制、模拟手表端倒计时
    business/WatchTimerManager.ets 新增 流转状态机:6 种状态管理、transferToWatch 核心方法、Service 回调桥接
    components/DeviceControlCard.ets 修改 ★ v3 新增 ⌚ 流转至手表 按钮和 @Event onWatchTransfer 回调
    pages/KitchenDevicePage.ets 修改 ★ v3 新增手表连接状态、流转状态指示条、生命周期集成

    六、设计决策

    决策选择理由
    流转后手机端计时 立即停止 避免双端竞态。手表是单源倒计时,手机端仅被动显示进度
    手表连接可用性检查 isWorn 穿戴检测 手表摘下放桌上时震动无效,必须在佩戴状态下才允许流转
    流转失败恢复策略 状态回退到 COUNTING_ON_PHONE 不丢失计时进度,用户不必重新设置
    手表震动时机 最后 10 秒 + 完成 三段式触觉反馈:安静→预警→告警,不依赖屏幕
    手机端流转状态位置 页面顶部全局指示条 用户切换到其他 Tab 也能看到手表上的计时进度
    preservedSeconds Business 层保存 流转时记录的剩余秒数,供取消流转时恢复使用
    是否支持手表操作"暂停" 不支持 设计原则:手表专职感知(看时间 + 感受震动),手机专职控制(开始/停止/流转)
    模拟实现策略 API 签名对齐真实 @kit.WearEngine 后续接入真机仅需取消注释 import 并替换 transferTimer 内部实现

    七、运行与结果验证

    7.1 操作步骤

  • 部署到真机或者模拟器。

  • 进入厨电控制页,点击某台电磁炉的**「定时 5 分钟」**按钮 → 手机端倒计时开始显示。 在这里插入图片描述

  • 在计时器显示区域点击**「⌚ 流转至手表」**按钮 → 手机端计时器停止,页面顶部出现橙色状态条"电磁炉 计时中 · HUAWEI WATCH GT 4 · 剩余 04:58"。 在这里插入图片描述 在这里插入图片描述

  • 等待倒计时进入最后 10 秒 → 日志显示手表渐强震动。

  • 倒计时归零 → 状态条变绿"电磁炉 计时完成!",日志显示手表重复震动。 在这里插入图片描述

  • 7.2 预期结果

    操作预期 UI 变化预期日志
    点击「定时 5 分钟」 设备卡片显示 “⏱️ 05:00” [KitchenDeviceVM] 启动计时器: dev_02 → 300 秒
    点击「⌚ 流转至手表」 手机端计时消失,顶部出现橙色条 [KitchenDevicePage] 🔄 流转计时器 → [WatchTimerManager] 状态: countingOnPhone → transferringToWatch → countingOnWatch
    手表倒计时中每秒 橙色条实时更新剩余时间 [WatchServiceHelper] 手表端倒计时: 287 → 286 → …
    剩余 10 秒 日志显示渐强震动 [WatchServiceHelper] ⏰ 倒计时最后 10 秒,手表渐强震动…
    倒计时归零 橙色条变绿"计时完成" [WatchServiceHelper] ⌛ 手表端计时完成,已触发震动提醒
    点击「清除」 绿色条消失,状态回 IDLE [WatchTimerManager] 状态: completedOnWatch → idle

    7.3 控制台完整日志

    [KitchenDevicePage] 初始化手表计时器服务
    [WatchServiceHelper] 初始化手表服务…
    [WatchServiceHelper] 发现手表: HUAWEI WATCH GT 4 (PNX-B19), 电量: 85%
    [KitchenDevicePage] 手表已连接: HUAWEI WATCH GT 4
    [KitchenDeviceVM] 启动计时器: dev_02 → 300 秒
    [MockDS] 电磁炉 计时器: 300秒
    …(手机端倒计时: 299, 298, 297…)
    [KitchenDevicePage] 🔄 流转计时器: 电磁炉, 剩余 287秒
    [KitchenDeviceVM] 停止计时器: dev_02
    [WatchTimerManager] 状态变更: countingOnPhone → transferringToWatch
    [WatchServiceHelper] ⏱️ 计时器流转至手表: "电磁炉" → 287秒
    [WatchServiceHelper] ✅ 计时器已成功流转至手表,手表端开始倒计时
    [WatchTimerManager] 状态变更: transferringToWatch → countingOnWatch
    [KitchenDevicePage] ✅ 计时器已流转至手表
    …(手表端倒计时: 286, 285, 284…)
    [WatchServiceHelper] ⏰ 倒计时最后 10 秒,手表渐强震动…
    [WatchServiceHelper] 📳 手表震动: 渐强震动, 500ms
    …(手表端倒计时: 3, 2, 1, 0)
    [WatchServiceHelper] 📳 手表震动: 重复震动, 3000ms
    [WatchServiceHelper] ⌛ 手表端计时完成,已触发震动提醒
    [WatchTimerManager] 状态变更: countingOnWatch → completedOnWatch
    [KitchenDevicePage] ⌛ 手表计时完成!

    验证要点:

    • 流转后手机端的 setInterval 确认已停止——不会再看到 [MockDS] 电磁炉 计时器: X秒 的日志。
    • 手表端倒计时独立运行,手机端通过 onWatchTimerStateChange 回调被动更新。
    • 渐强震动在剩余 10 秒时准确触发,重复震动在归零时触发。

    八、本阶段总结与下篇预告

    今天,我们为《灵犀厨房》装上了"手腕大脑"——实现了烹饪计时器从手机流转至智能手表的完整链路:

    • 封装 WatchServiceHelper:将 @kit.WearEngine 的手表通信 API 包装为服务层,实现设备发现、计时数据传输、三段式震动控制,完整保留 API 23 的真实接口签名。
    • 构建 WatchTimerManager:定义 6 种流转状态 + 状态机,transferToWatch 协调"停止手机计时→发起手表流转"的全流程,preservedSeconds 预留恢复能力。
    • 关键设计决策——手表专职感知,手机专职控制:流转后手机端立即停止计时,手表端独立倒计时并做震动反馈。不支持手表端操作"暂停/停止",保持"手机发起、手表感知"的简洁交互模型。
    • 集成流转 UI:厨电控制页顶部全局状态指示条 + 设备卡片内"⌚ 流转至手表"按钮 + 手表连接电量显示。

    现在,你在厨房给电磁炉定时 15 分钟煮汤,点一下「⌚ 流转至手表」——然后去客厅看电视。手腕一抬,剩余时间一目了然;最后 10 秒渐强震动让你从容走向厨房;计时完成,重复震动告诉你:该关火了。

    但烹饪中的提醒不止计时——明天要做红烧肉但忘了买五花肉?炖汤该加盐了但正在切菜腾不出手?

    下篇预告:第 19 篇《通知系统:多设备推送提醒》。我们将接入 @kit.NotificationKit,实现购物清单到期提醒、计时完成跨设备推送、烹饪小贴士定时弹窗——让《灵犀厨房》从"被动查询"变为"主动告知"。


    📚 本系列持续更新中:下一篇将实现多设备通知推送,让烹饪提醒无处不在。

    🔗 专栏入口:[《HarmonyOS6.1全场景实战》合集]

    📦 获取基线版本源码包:包括第1-15篇所有代码 + 架构文档 + Flask 后端

    如果你觉得这篇文章对你有帮助,请不要吝啬你的点赞 👍、收藏 ⭐ 和评论 💬。你的支持,是我继续输出高质量技术内容的全部动力。 纯血鸿蒙,手腕掌控。我们下一篇见!

    赞(0)
    未经允许不得转载:171主机测评 » HarmonyOS 6.1 全场景实战|《灵犀厨房》实战(十八):【手表协同】烹饪计时器流转至智能手表——手腕掌控烹饪节奏
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址