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 方法来实际触发停止手机端计时和发起手表流转。这样:
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 预期结果
| 点击「定时 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 后端
如果你觉得这篇文章对你有帮助,请不要吝啬你的点赞 👍、收藏 ⭐ 和评论 💬。你的支持,是我继续输出高质量技术内容的全部动力。 纯血鸿蒙,手腕掌控。我们下一篇见!