HarmonyOS 6.1 开发者盛宴|《灵犀厨房》实战(十九):【通知系统】延时烹饪提醒------让通知不再错过关键步骤
摘要 :上一篇我们为厨电控制页装上了"手腕大脑"------将计时器从手机流转至手表,实现了手腕掌控烹饪节奏。但烹饪中的提醒远不止计时:蒸鱼该关火了但你在阳台晾衣服?炖肉可以加盐了但正在客厅看电视?本篇,我们将接入 HarmonyOS 6.1.0(API 23)的
@kit.NotificationKit,为《灵犀厨房》实现烹饪延时通知的全链路:用户在菜谱步骤页一键设置延时提醒→系统到点自动弹出通知→点击通知通过 WantAgent 直接回到菜谱步骤。严格遵循 API 23 规范,代码即文档。
一、引言与系列定位
经过第 16 篇的语音播报和第 17 篇的声控操作,你的《灵犀厨房》已经能"说"会"听"。但这里有一个关键的体验断层:
| 场景 | 问题 | 解决 |
|---|---|---|
| 红烧肉大火收汁 5 分钟 | 离开厨房去客厅,忘记还剩多久 | 设置延时通知,到点手机自动弹出提醒 |
| 蒸鱼定时 8 分钟 | 在阳台晾衣服,手机在厨房充电 | 设定提醒后放心离开,到时回来关火 |
| 炖牛肉需要中途加盐 | 正在切菜,腾不出手看屏幕 | 提前设好 30 分钟提醒------"该加盐了" |
| 烤箱烤到一半该翻面了 | 闹钟只响一声没听到 | 通知横幅 + 浮动图标双重提醒 |
设计决策 :为什么用
deliveryTime而非setTimeout?
deliveryTime 是 NotificationRequest 的属性,接受绝对时间戳。通知的"发布时间"交给系统 Alarm 调度------不需要应用保活、不需要后台线程、不需要 CPU 唤醒。即使应用被杀,通知准时弹出。这是 HarmonyOS 为"延时通知"场景专门设计的最优路径。
本篇的设计原则:
- 一键设置延时提醒:在菜谱步骤页选择分钟:秒,确认后提交给系统调度
- 系统级 deliveryTime,无需后台保活:通知在预定时间自动弹出,应用被杀也不影响
- WantAgent 回到应用:点击通知直接打开灵犀厨房,回到菜谱步骤页
二、核心原理与底层机制深度解读
2.1 延时通知的三种实现路径
HarmonyOS 6.1.0(API 23)为实现"延时通知"提供了三条技术路径:
#mermaid-svg-1JYh6hhdx0vbwfNU{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-1JYh6hhdx0vbwfNU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1JYh6hhdx0vbwfNU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1JYh6hhdx0vbwfNU .error-icon{fill:#552222;}#mermaid-svg-1JYh6hhdx0vbwfNU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1JYh6hhdx0vbwfNU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1JYh6hhdx0vbwfNU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1JYh6hhdx0vbwfNU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1JYh6hhdx0vbwfNU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1JYh6hhdx0vbwfNU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1JYh6hhdx0vbwfNU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1JYh6hhdx0vbwfNU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1JYh6hhdx0vbwfNU .marker.cross{stroke:#333333;}#mermaid-svg-1JYh6hhdx0vbwfNU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1JYh6hhdx0vbwfNU p{margin:0;}#mermaid-svg-1JYh6hhdx0vbwfNU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-1JYh6hhdx0vbwfNU .cluster-label text{fill:#333;}#mermaid-svg-1JYh6hhdx0vbwfNU .cluster-label span{color:#333;}#mermaid-svg-1JYh6hhdx0vbwfNU .cluster-label span p{background-color:transparent;}#mermaid-svg-1JYh6hhdx0vbwfNU .label text,#mermaid-svg-1JYh6hhdx0vbwfNU span{fill:#333;color:#333;}#mermaid-svg-1JYh6hhdx0vbwfNU .node rect,#mermaid-svg-1JYh6hhdx0vbwfNU .node circle,#mermaid-svg-1JYh6hhdx0vbwfNU .node ellipse,#mermaid-svg-1JYh6hhdx0vbwfNU .node polygon,#mermaid-svg-1JYh6hhdx0vbwfNU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1JYh6hhdx0vbwfNU .rough-node .label text,#mermaid-svg-1JYh6hhdx0vbwfNU .node .label text,#mermaid-svg-1JYh6hhdx0vbwfNU .image-shape .label,#mermaid-svg-1JYh6hhdx0vbwfNU .icon-shape .label{text-anchor:middle;}#mermaid-svg-1JYh6hhdx0vbwfNU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1JYh6hhdx0vbwfNU .rough-node .label,#mermaid-svg-1JYh6hhdx0vbwfNU .node .label,#mermaid-svg-1JYh6hhdx0vbwfNU .image-shape .label,#mermaid-svg-1JYh6hhdx0vbwfNU .icon-shape .label{text-align:center;}#mermaid-svg-1JYh6hhdx0vbwfNU .node.clickable{cursor:pointer;}#mermaid-svg-1JYh6hhdx0vbwfNU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1JYh6hhdx0vbwfNU .arrowheadPath{fill:#333333;}#mermaid-svg-1JYh6hhdx0vbwfNU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1JYh6hhdx0vbwfNU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1JYh6hhdx0vbwfNU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1JYh6hhdx0vbwfNU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1JYh6hhdx0vbwfNU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1JYh6hhdx0vbwfNU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1JYh6hhdx0vbwfNU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1JYh6hhdx0vbwfNU .cluster text{fill:#333;}#mermaid-svg-1JYh6hhdx0vbwfNU .cluster span{color:#333;}#mermaid-svg-1JYh6hhdx0vbwfNU 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-1JYh6hhdx0vbwfNU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1JYh6hhdx0vbwfNU rect.text{fill:none;stroke-width:0;}#mermaid-svg-1JYh6hhdx0vbwfNU .icon-shape,#mermaid-svg-1JYh6hhdx0vbwfNU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1JYh6hhdx0vbwfNU .icon-shape p,#mermaid-svg-1JYh6hhdx0vbwfNU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1JYh6hhdx0vbwfNU .icon-shape .label rect,#mermaid-svg-1JYh6hhdx0vbwfNU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1JYh6hhdx0vbwfNU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1JYh6hhdx0vbwfNU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1JYh6hhdx0vbwfNU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ⚙️ 系统层
📱 灵犀厨房
选择路径
延时通知路径
路径 A
NotificationRequest.deliveryTime
系统 Alarm 调度
路径 B
setTimeout + publish
应用层定时
路径 C
WorkScheduler + publish
后台任务调度
NotificationHelper
scheduleReminder()
NotificationService
到点自动弹出
| 路径 | 适用场景 | 优势 | 劣势 | 本篇选型 |
|---|---|---|---|---|
| A: deliveryTime | 延时通知 | 系统级调度,精准可靠,无需保活 | 不支持复杂的条件触发 | ✅ 采用 |
| B: setTimeout | 简单延时 | 实现简单 | 应用被杀则失效,精度受 JS 引擎影响 | 不采用 |
| C: WorkScheduler | 复杂定时任务 | 系统保活,省电 | 任务间隔限制(≥15分钟),不适合秒级精度 | 不采用 |
2.2 通知生命周期状态机
#mermaid-svg-ApGVjR2vgAblY4nR{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-ApGVjR2vgAblY4nR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ApGVjR2vgAblY4nR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ApGVjR2vgAblY4nR .error-icon{fill:#552222;}#mermaid-svg-ApGVjR2vgAblY4nR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ApGVjR2vgAblY4nR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ApGVjR2vgAblY4nR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ApGVjR2vgAblY4nR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ApGVjR2vgAblY4nR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ApGVjR2vgAblY4nR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ApGVjR2vgAblY4nR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ApGVjR2vgAblY4nR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ApGVjR2vgAblY4nR .marker.cross{stroke:#333333;}#mermaid-svg-ApGVjR2vgAblY4nR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ApGVjR2vgAblY4nR p{margin:0;}#mermaid-svg-ApGVjR2vgAblY4nR defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-ApGVjR2vgAblY4nR g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-ApGVjR2vgAblY4nR g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-ApGVjR2vgAblY4nR g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-ApGVjR2vgAblY4nR g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-ApGVjR2vgAblY4nR g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-ApGVjR2vgAblY4nR .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-ApGVjR2vgAblY4nR .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-ApGVjR2vgAblY4nR .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-ApGVjR2vgAblY4nR .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ApGVjR2vgAblY4nR .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-ApGVjR2vgAblY4nR .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-ApGVjR2vgAblY4nR .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-ApGVjR2vgAblY4nR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ApGVjR2vgAblY4nR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ApGVjR2vgAblY4nR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ApGVjR2vgAblY4nR .edgeLabel .label text{fill:#333;}#mermaid-svg-ApGVjR2vgAblY4nR .label div .edgeLabel{color:#333;}#mermaid-svg-ApGVjR2vgAblY4nR .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-ApGVjR2vgAblY4nR .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-ApGVjR2vgAblY4nR .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-ApGVjR2vgAblY4nR .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-ApGVjR2vgAblY4nR .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-ApGVjR2vgAblY4nR .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ApGVjR2vgAblY4nR .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ApGVjR2vgAblY4nR #statediagram-barbEnd{fill:#333333;}#mermaid-svg-ApGVjR2vgAblY4nR .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ApGVjR2vgAblY4nR .cluster-label,#mermaid-svg-ApGVjR2vgAblY4nR .nodeLabel{color:#131300;}#mermaid-svg-ApGVjR2vgAblY4nR .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-ApGVjR2vgAblY4nR .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-ApGVjR2vgAblY4nR .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-ApGVjR2vgAblY4nR .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-ApGVjR2vgAblY4nR .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-ApGVjR2vgAblY4nR .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-ApGVjR2vgAblY4nR .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-ApGVjR2vgAblY4nR .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-ApGVjR2vgAblY4nR .note-edge{stroke-dasharray:5;}#mermaid-svg-ApGVjR2vgAblY4nR .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-ApGVjR2vgAblY4nR .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-ApGVjR2vgAblY4nR .statediagram-note text{fill:black;}#mermaid-svg-ApGVjR2vgAblY4nR .statediagram-note .nodeLabel{color:black;}#mermaid-svg-ApGVjR2vgAblY4nR .statediagram .edgeLabel{color:red;}#mermaid-svg-ApGVjR2vgAblY4nR #dependencyStart,#mermaid-svg-ApGVjR2vgAblY4nR #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-ApGVjR2vgAblY4nR .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ApGVjR2vgAblY4nR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 无通知
用户设置延时提醒
deliveryTime 到达
用户取消
用户点击通知
用户划掉通知
WantAgent 回到应用
通知消失
通知取消
IDLE
SCHEDULED
DELIVERED
CANCELLED
CLICKED
DISMISSED
deliveryTime = Date.now() + delay
通知横幅 + 浮动图标
WantAgent 拉起 EntryAbility
2.3 通知渠道(Slot)机制
HarmonyOS 的通知体系要求每条通知必须归属于某个渠道,类似 Android 的 Notification Channel。渠道决定了通知的重要级别、展示样式和行为策略。
为什么用数值
1(SOCIAL_COMMUNICATION)?
在 @kit.NotificationKit 中,SlotType 是一个枚举,但 addSlot 和 NotificationRequest.slotType 的参数类型在运行时是 number。使用数值 1 等价于 SOCIAL_COMMUNICATION(社交通讯类通知),这一级别适合互动提醒场景------通知会以横幅 + 浮动图标的形式展示,声音适中,不会像闹钟那样强打扰。
三、架构设计 / 核心逻辑图解
3.1 通知系统的四层架构
#mermaid-svg-7Dfx7B4OL7NFIGup{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-7Dfx7B4OL7NFIGup .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7Dfx7B4OL7NFIGup .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7Dfx7B4OL7NFIGup .error-icon{fill:#552222;}#mermaid-svg-7Dfx7B4OL7NFIGup .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7Dfx7B4OL7NFIGup .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7Dfx7B4OL7NFIGup .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7Dfx7B4OL7NFIGup .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7Dfx7B4OL7NFIGup .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7Dfx7B4OL7NFIGup .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7Dfx7B4OL7NFIGup .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7Dfx7B4OL7NFIGup .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7Dfx7B4OL7NFIGup .marker.cross{stroke:#333333;}#mermaid-svg-7Dfx7B4OL7NFIGup svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7Dfx7B4OL7NFIGup p{margin:0;}#mermaid-svg-7Dfx7B4OL7NFIGup .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-7Dfx7B4OL7NFIGup .cluster-label text{fill:#333;}#mermaid-svg-7Dfx7B4OL7NFIGup .cluster-label span{color:#333;}#mermaid-svg-7Dfx7B4OL7NFIGup .cluster-label span p{background-color:transparent;}#mermaid-svg-7Dfx7B4OL7NFIGup .label text,#mermaid-svg-7Dfx7B4OL7NFIGup span{fill:#333;color:#333;}#mermaid-svg-7Dfx7B4OL7NFIGup .node rect,#mermaid-svg-7Dfx7B4OL7NFIGup .node circle,#mermaid-svg-7Dfx7B4OL7NFIGup .node ellipse,#mermaid-svg-7Dfx7B4OL7NFIGup .node polygon,#mermaid-svg-7Dfx7B4OL7NFIGup .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7Dfx7B4OL7NFIGup .rough-node .label text,#mermaid-svg-7Dfx7B4OL7NFIGup .node .label text,#mermaid-svg-7Dfx7B4OL7NFIGup .image-shape .label,#mermaid-svg-7Dfx7B4OL7NFIGup .icon-shape .label{text-anchor:middle;}#mermaid-svg-7Dfx7B4OL7NFIGup .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7Dfx7B4OL7NFIGup .rough-node .label,#mermaid-svg-7Dfx7B4OL7NFIGup .node .label,#mermaid-svg-7Dfx7B4OL7NFIGup .image-shape .label,#mermaid-svg-7Dfx7B4OL7NFIGup .icon-shape .label{text-align:center;}#mermaid-svg-7Dfx7B4OL7NFIGup .node.clickable{cursor:pointer;}#mermaid-svg-7Dfx7B4OL7NFIGup .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7Dfx7B4OL7NFIGup .arrowheadPath{fill:#333333;}#mermaid-svg-7Dfx7B4OL7NFIGup .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7Dfx7B4OL7NFIGup .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7Dfx7B4OL7NFIGup .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7Dfx7B4OL7NFIGup .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7Dfx7B4OL7NFIGup .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7Dfx7B4OL7NFIGup .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7Dfx7B4OL7NFIGup .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7Dfx7B4OL7NFIGup .cluster text{fill:#333;}#mermaid-svg-7Dfx7B4OL7NFIGup .cluster span{color:#333;}#mermaid-svg-7Dfx7B4OL7NFIGup 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-7Dfx7B4OL7NFIGup .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7Dfx7B4OL7NFIGup rect.text{fill:none;stroke-width:0;}#mermaid-svg-7Dfx7B4OL7NFIGup .icon-shape,#mermaid-svg-7Dfx7B4OL7NFIGup .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7Dfx7B4OL7NFIGup .icon-shape p,#mermaid-svg-7Dfx7B4OL7NFIGup .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7Dfx7B4OL7NFIGup .icon-shape .label rect,#mermaid-svg-7Dfx7B4OL7NFIGup .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7Dfx7B4OL7NFIGup .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7Dfx7B4OL7NFIGup .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7Dfx7B4OL7NFIGup :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 🔧 配置层
🏗️ 基础设施层
⚙️ 服务能力层
🎨 用户交互层
RecipeDetailPage
⏰ 提醒按钮
时间选择器(TextPicker)
权限引导弹窗(promptAction)
NotificationHelper
createSlot() 渠道管理
scheduleReminder() 延时推送
cancelReminder() 取消通知
hasNotificationPermission() 权限检查
@kit.NotificationKit
notificationManager
@kit.AbilityKit
wantAgent(type 导入)
module.json5
权限声明
string.json
多语言字符串
设计原则 :NotificationHelper 作为 Services 层单例,负责通知的全生命周期管理。UI 层不直接调用 notificationManager,而是通过 NotificationHelper 的封装方法操作。
- 单一职责:NotificationHelper 只管"何时发、发给谁",UI 层决定"发什么内容、是否检查权限、失败后如何提示"。
- 依赖注入:Context 由 EntryAbility 在启动时注入,而非硬编码或全局变量。
- WantAgent 缓存:避免每次发通知都重新创建,提升性能。
- 幂等初始化:通知渠道创建失败不影响应用启动(尽力而为策略)。
3.2 完整时序图
WantAgent notificationManager NotificationHelper RecipeDetailPage 👤 用户 WantAgent notificationManager NotificationHelper RecipeDetailPage 👤 用户 #mermaid-svg-2xQRt2GMVw25kDvT{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-2xQRt2GMVw25kDvT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2xQRt2GMVw25kDvT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2xQRt2GMVw25kDvT .error-icon{fill:#552222;}#mermaid-svg-2xQRt2GMVw25kDvT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2xQRt2GMVw25kDvT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2xQRt2GMVw25kDvT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2xQRt2GMVw25kDvT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2xQRt2GMVw25kDvT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2xQRt2GMVw25kDvT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2xQRt2GMVw25kDvT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2xQRt2GMVw25kDvT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2xQRt2GMVw25kDvT .marker.cross{stroke:#333333;}#mermaid-svg-2xQRt2GMVw25kDvT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2xQRt2GMVw25kDvT p{margin:0;}#mermaid-svg-2xQRt2GMVw25kDvT .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2xQRt2GMVw25kDvT text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-2xQRt2GMVw25kDvT .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-2xQRt2GMVw25kDvT .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-2xQRt2GMVw25kDvT .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-2xQRt2GMVw25kDvT .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-2xQRt2GMVw25kDvT #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-2xQRt2GMVw25kDvT .sequenceNumber{fill:white;}#mermaid-svg-2xQRt2GMVw25kDvT #sequencenumber{fill:#333;}#mermaid-svg-2xQRt2GMVw25kDvT #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-2xQRt2GMVw25kDvT .messageText{fill:#333;stroke:none;}#mermaid-svg-2xQRt2GMVw25kDvT .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2xQRt2GMVw25kDvT .labelText,#mermaid-svg-2xQRt2GMVw25kDvT .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-2xQRt2GMVw25kDvT .loopText,#mermaid-svg-2xQRt2GMVw25kDvT .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-2xQRt2GMVw25kDvT .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-2xQRt2GMVw25kDvT .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-2xQRt2GMVw25kDvT .noteText,#mermaid-svg-2xQRt2GMVw25kDvT .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-2xQRt2GMVw25kDvT .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2xQRt2GMVw25kDvT .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2xQRt2GMVw25kDvT .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2xQRt2GMVw25kDvT .actorPopupMenu{position:absolute;}#mermaid-svg-2xQRt2GMVw25kDvT .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-2xQRt2GMVw25kDvT .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2xQRt2GMVw25kDvT .actor-man circle,#mermaid-svg-2xQRt2GMVw25kDvT line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-2xQRt2GMVw25kDvT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户在菜谱步骤页 登记通知 + deliveryTime slotType=1, isFloatingIcon=true Toast: "提醒已设置:5 分 0 秒后通知" ... 5 分钟后 ... 横幅 "🍳 烹饪提醒 - 红烧肉" 浮动图标 + 通知栏卡片 点击「⏰ 提醒」按钮 showReminderPicker = true 选择 5分钟:0秒 → 点击「确认提醒」 hasNotificationPermission() isNotificationEnabled() true 权限正常 scheduleReminder(title, text, 300, id) ensureWantAgent() WantAgent 实例(缓存命中) deliveryTime = Date.now() + 300000 publish(request) 发布成功 成功 到达 deliveryTime → 弹出通知 点击通知 WantAgent 触发 回到 RecipeDetailPage
四、实战:为菜谱详情页装上"定时提醒大脑"
Step 1:NotificationHelper ------ 通知管理封装
重写 services/NotificationHelper.ets(从 Stub 升级为完整实现)。核心任务:将通知渠道管理、延时推送、取消、权限检查封装为可复用的服务层。
typescript
// services/NotificationHelper.ets
// 所属层:服务能力层 (Service Layer)
// 职责:通知推送封装 ------ 延时通知管理
// API Level: 23
// 依赖: @kit.NotificationKit, @kit.AbilityKit, @kit.PerformanceAnalysisKit
import { notificationManager } from '@kit.NotificationKit';
import { wantAgent, Want, common } from '@kit.AbilityKit';
import type { WantAgent } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
const TAG = 'NotificationHelper';
const DOMAIN = 0x0001;
export class NotificationHelper {
private context: common.Context | null = null;
private cachedWantAgent: WantAgent | null = null;
init(context: common.Context): void {
this.context = context;
hilog.info(DOMAIN, TAG, 'NotificationHelper 已初始化,context 注入成功');
}
导入策略说明 :
WantAgent使用type导入------编译时参与类型检查但不生成运行时代码。Want和common为运行时需要的值导入。这是 API 23 推荐的导入方式,避免了类型与值混合导致的打包问题。
(1)创建通知渠道------幂等设计
typescript
/**
* 创建通知渠道(幂等)
* slotType 使用数值 1 对应 SOCIAL_COMMUNICATION
*/
async createSlot(): Promise<void> {
try {
await notificationManager.addSlot(1);
hilog.info(DOMAIN, TAG, '通知渠道 SOCIAL_COMMUNICATION 创建成功');
} catch (err) {
const busErr = err as BusinessError;
if (busErr.code === 2) {
hilog.info(DOMAIN, TAG, '通知渠道已存在,跳过创建');
return;
}
hilog.warn(DOMAIN, TAG, '创建通知渠道时发生非预期错误, code: %{public}d, msg: %{public}s',
busErr.code, busErr.message);
}
}
核心点解读 :
addSlot的参数类型在运行时接受数值,这里直接使用1而非枚举引用,避免模块导出路径在不同 ROM 上的兼容性问题。渠道已存在时(错误码 2)捕获并忽略,实现幂等------EntryAbility 每次启动调用createSlot()都不会报错。
(2)scheduleReminder------延时通知核心方法
typescript
/**
* 安排一条延时通知(本机)
*/
async scheduleReminder(
title: string, text: string, delayInSeconds: number, id: number
): Promise<void> {
if (delayInSeconds <= 0) {
hilog.warn(DOMAIN, TAG, 'delayInSeconds 必须 ≥ 1,当前值: %{public}d', delayInSeconds);
throw new Error('delayInSeconds 必须 ≥ 1');
}
try {
const agent: WantAgent = await this.ensureWantAgent();
const deliveryTime: number = Date.now() + delayInSeconds * 1000;
const request: notificationManager.NotificationRequest = {
id: id,
content: {
notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
normal: { title: title, text: text }
},
slotType: 1, // SOCIAL_COMMUNICATION
deliveryTime: deliveryTime,
wantAgent: agent,
isFloatingIcon: true,
color: 0xFF6B35 // 灵犀厨房品牌色
};
await notificationManager.publish(request);
hilog.info(DOMAIN, TAG,
'延时通知已安排, id: %{public}d, delay: %{public}ds, deliveryTime: %{public}d',
id, delayInSeconds, deliveryTime);
} catch (err) {
const busErr = err as BusinessError;
hilog.error(DOMAIN, TAG,
'安排延时通知失败, id: %{public}d, code: %{public}d, msg: %{public}s',
id, busErr.code, busErr.message);
throw new Error(busErr.message);
}
}
核心点解读:
- deliveryTime :
Date.now() + delayInSeconds * 1000,转为绝对时间戳。系统用 Alarm 机制调度,精确到秒,应用被杀也不影响。- slotType = 1 :与
createSlot保持一致,使用数值而非枚举引用,提高运行时兼容性。- 错误封装 :
throw new Error(busErr.message)而非直接throw err,将系统级 BusinessError 转为标准 Error,调用方无需区分错误类型也能安全 catch。- isFloatingIcon = true:通知在状态栏以浮动图标展示,与横幅通知互补,提升可见性。
(3)WantAgent 创建------带缓存 + 显式 Want 声明
typescript
/**
* 获取或创建 WantAgent(带缓存)
* 严格遵循 @kit.AbilityKit 官方文档的导入与调用方式
*/
private async ensureWantAgent(): Promise<WantAgent> {
if (this.cachedWantAgent !== null) {
return this.cachedWantAgent;
}
if (this.context === null) {
throw new Error('NotificationHelper 尚未初始化,请先调用 init(context)');
}
try {
// 显式声明 Want 对象以满足数组类型推断
const launchWant: Want = {
bundleName: this.context.applicationInfo.name,
abilityName: 'EntryAbility'
};
// 使用 wantAgent.WantAgentInfo 类型(无需单独导入)
const wantAgentInfo: wantAgent.WantAgentInfo = {
wants: [launchWant],
operationType: wantAgent.OperationType.START_ABILITY,
requestCode: 0,
wantAgentFlags: [wantAgent.WantAgentFlags.CONSTANT_FLAG]
};
const agent: WantAgent = await wantAgent.getWantAgent(wantAgentInfo);
this.cachedWantAgent = agent;
hilog.info(DOMAIN, TAG, 'WantAgent 创建成功并已缓存');
return agent;
} catch (err) {
const busErr = err as BusinessError;
hilog.error(DOMAIN, TAG,
'创建 WantAgent 失败, code: %{public}d, msg: %{public}s',
busErr.code, busErr.message);
throw new Error(busErr.message);
}
}
核心点解读:
- 显式 Want 变量 :
const launchWant: Want = { ... }提前声明,然后放入wants数组。这样做是为了满足 ArkTS 的类型推断------在@ComponentV2下,内联对象字面量的类型推断可能因上下文不同而失败,显式声明变量可彻底解决。- 缓存复用 :首次创建后保存到
cachedWantAgent,后续调用直接返回缓存,避免重复创建的开销。- CONSTANT_FLAG:确保 WantAgent 创建后不受后续修改影响。
(4)取消与权限检查
typescript
async cancelReminder(id: number): Promise<void> {
try {
await notificationManager.cancel(id);
hilog.info(DOMAIN, TAG, '通知已取消, id: %{public}d', id);
} catch (err) {
const busErr = err as BusinessError;
hilog.error(DOMAIN, TAG, '取消通知失败, code: %{public}d, msg: %{public}s',
busErr.code, busErr.message);
throw new Error(busErr.message);
}
}
async cancelAllReminders(): Promise<void> {
try {
await notificationManager.cancelAll();
hilog.info(DOMAIN, TAG, '所有通知已取消');
} catch (err) {
const busErr = err as BusinessError;
hilog.error(DOMAIN, TAG, '取消全部通知失败, code: %{public}d, msg: %{public}s',
busErr.code, busErr.message);
throw new Error(busErr.message);
}
}
async hasNotificationPermission(): Promise<boolean> {
try {
const enabled: boolean = await notificationManager.isNotificationEnabled();
hilog.info(DOMAIN, TAG, '通知权限状态: %{public}s', enabled ? '已开启' : '未开启');
return enabled;
} catch (err) {
const busErr = err as BusinessError;
hilog.error(DOMAIN, TAG, '检查通知权限失败, code: %{public}d, msg: %{public}s',
busErr.code, busErr.message);
return false;
}
}
}
export const notificationHelper: NotificationHelper = new NotificationHelper();
Step 2:改造 RecipeDetailPage.ets,集成提醒 UI
在菜谱步骤详情页中新增提醒设置功能。
(1)新增导入与状态变量
typescript
// pages/RecipeDetailPage.ets(新增/修改)
import { AlertDialog, promptAction, router, window } from '@kit.ArkUI';
import { abilityAccessCtrl, common, PermissionRequestResult } from '@kit.AbilityKit';
import { notificationHelper } from '../services/NotificationHelper';
// ---- 提醒设置状态 ----
@Local showReminderPicker: boolean = false;
@Local reminderMinutes: number = 3;
@Local reminderSeconds: number = 0;
@Local reminderIdCounter: number = 0;
/** 时间选择器范围数组 */
private minuteRange: string[] = [];
private secondRange: string[] = [];
导入说明 :
promptAction用于权限引导弹窗(showDialog),AlertDialog保留给其他已有功能使用,common用于UIAbilityContext类型转换。
(2)初始化范围数组(aboutToAppear 中调用)
typescript
private initPickerRanges(): void {
this.minuteRange = [];
this.secondRange = [];
for (let i = 0; i < 60; i++) {
this.minuteRange.push(i.toString());
this.secondRange.push(i.toString());
}
}
(3)handleSetReminder ------ 设置提醒的核心方法
typescript
private async handleSetReminder(): Promise<void> {
const totalSeconds = this.reminderMinutes * 60 + this.reminderSeconds;
if (totalSeconds <= 0) {
ToastUtil.showToast(this.getUIContext(), '请选择大于 0 秒的提醒时间');
return;
}
this.showReminderPicker = false;
try {
const hasPermission = await notificationHelper.hasNotificationPermission();
if (!hasPermission) {
console.warn('[RecipeDetail] 通知权限未开启');
this.showPermissionDialog();
return;
}
const stepLabel = `第 ${this.currentStepIndex + 1} 步`;
const title = `🍳 烹饪提醒 - ${this.recipe.name}`;
const text = `${stepLabel}:${this.reminderMinutes} 分 ${this.reminderSeconds} 秒到了,请查看菜谱!`;
const id = Date.now() + this.reminderIdCounter;
this.reminderIdCounter++;
await notificationHelper.scheduleReminder(title, text, totalSeconds, id);
ToastUtil.showToast(this.getUIContext(),
`⏰ 提醒已设置:${this.reminderMinutes} 分 ${this.reminderSeconds} 秒后通知`);
console.info(`[RecipeDetail] 提醒已安排, id: ${id}, delay: ${totalSeconds}s`);
} catch (err) {
console.error(`[RecipeDetail] 设置提醒失败: ${JSON.stringify(err)}`);
ToastUtil.showToast(this.getUIContext(), '提醒设置失败,请重试');
}
}
核心点解读:
- 权限前置检查 :在调用
scheduleReminder之前先检查通知权限。未授权时弹出引导弹窗,避免用户设置完提醒却发现收不到。- 通知 ID 唯一性 :
Date.now() + counter确保短时间内多次设置提醒不会 ID 冲突。- 友好降级 :
scheduleReminder失败时捕获异常,显示 Toast 而非静默失败。
(4)权限引导弹窗------使用 promptAction.showDialog
typescript
/**
* 显示权限引导弹窗
* ★ 使用 promptAction.showDialog 替代 AlertDialog.show ------
* 解决 @ComponentV2 下 AlertDialog 生命周期绑定问题
*/
private showPermissionDialog(): void {
promptAction.showDialog({
title: '需要通知权限',
message: '烹饪提醒需要在"设置 > 应用 > 灵犀厨房"中开启通知权限。',
buttons: [
{
text: '去设置',
color: '#FF6B35'
},
{
text: '取消',
color: '#666'
}
]
}).then((result) => {
if (result.index === 0) {
try {
const ctx: common.UIAbilityContext =
this.getUIContext().getHostContext() as common.UIAbilityContext;
ctx.startAbility({
bundleName: 'com.huawei.hmos.settings',
abilityName: 'com.huawei.hmos.settings.MainAbility',
uri: 'application_info_entry',
parameters: {
pushParams: ctx.applicationInfo.name
}
});
} catch (err) {
console.error(`[RecipeDetail] 跳转设置失败: ${JSON.stringify(err)}`);
}
} else {
console.info('[RecipeDetail] 用户取消权限引导');
}
});
}
核心点解读 :为什么用
promptAction.showDialog而非AlertDialog.show?在@ComponentV2装饰的组件中,AlertDialog.show是静态方法,不与组件实例绑定------这意味着 dialog 的生命周期不受页面aboutToDisappear管理,可能导致页面销毁后弹窗残留的 UI 异常。promptAction.showDialog通过 UIContext 绑定到当前页面,生命周期随页面销毁而自动清理。
(5)时间选择器 @Builder------TextPicker.onChange 联合类型处理
typescript
@Builder
buildReminderPicker() {
Column() {
// 半透明遮罩
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(() => this.showReminderPicker = false)
// 选择器面板
Column() {
Text($r('app.string.reminder_time_picker_title'))
.fontSize(18).fontWeight(FontWeight.Bold).fontColor('#333')
.margin({ bottom: 20 })
Row({ space: 16 }) {
// 分钟
Column({ space: 4 }) {
Text($r('app.string.reminder_minutes_label')).fontSize(12).fontColor('#999')
TextPicker({
range: this.minuteRange,
selected: this.reminderMinutes,
value: this.reminderMinutes.toString()
})
.onChange((value: string | string[], _index: number | number[]) => {
// ★ 联合类型安全取值
const strValue = typeof value === 'string'
? value
: (value as string[])[0] || '0';
this.reminderMinutes = parseInt(strValue);
})
.width(80)
}
Text(':').fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FF6B35')
// 秒
Column({ space: 4 }) {
Text($r('app.string.reminder_seconds_label')).fontSize(12).fontColor('#999')
TextPicker({
range: this.secondRange,
selected: this.reminderSeconds,
value: this.reminderSeconds.toString()
})
.onChange((value: string | string[], _index: number | number[]) => {
const strValue = typeof value === 'string'
? value
: (value as string[])[0] || '0';
this.reminderSeconds = parseInt(strValue);
})
.width(80)
}
}
.margin({ bottom: 24 })
Row({ space: 20 }) {
Button('取消')
.fontSize(14).height(40).layoutWeight(1)
.backgroundColor('#F5F5F5').fontColor('#666')
.borderRadius(12)
.onClick(() => this.showReminderPicker = false)
Button('确认提醒')
.fontSize(14).height(40).layoutWeight(1)
.backgroundColor('#FF6B35').fontColor(Color.White)
.borderRadius(12)
.onClick(() => this.handleSetReminder())
}
.width('100%')
}
.width('85%').padding(24)
.backgroundColor(Color.White).borderRadius(20)
.shadow({ radius: 20, color: '#30000000' })
.position({ bottom: 120 })
}
.width('100%').height('100%')
}
核心点解读 :
TextPicker.onChange的value参数在 API 23 中类型为string | string[](取决于range是否为多维数组)。使用typeof value === 'string'做类型收窄后安全取值,比起直接parseInt(value as string)更符合 ArkTS 的严格类型检查规范。
(6)底部操作栏新增「提醒」按钮
typescript
// buildBottomBar 中新增
Row({ space: 12 }) {
// ★ 第19篇:设置提醒按钮
Button() {
Row({ space: 4 }) {
SymbolGlyph($r('sys.symbol.alarm'))
.fontSize(16).fontColor([Color.White])
Text('提醒')
.fontSize(12).fontColor(Color.White)
}
}
.type(ButtonType.Capsule).height(36)
.backgroundColor('#FF9800')
.onClick(() => this.showReminderPicker = true)
// ... 原有按钮 ...
}
(7)在 build() 的 Stack 中添加时间选择器覆盖层
typescript
build() {
Stack() {
// ... 原有布局(手机/平板/智慧屏) ...
// ── 烹饪提醒时间选择器(覆盖层) ──
if (this.showReminderPicker) {
this.buildReminderPicker()
}
}
}
Step 3:EntryAbility ------ 应用启动初始化
typescript
// entryability/EntryAbility.ets(新增部分)
import { notificationHelper } from '../services/NotificationHelper';
import { BusinessError } from '@kit.BasicServicesKit';
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
// ... 原有代码 ...
// ★ 第19篇:初始化通知服务
notificationHelper.init(this.context);
notificationHelper.createSlot().then(() => {
hilog.info(DOMAIN, 'LingxiKitchen', '通知渠道初始化完成');
}).catch((err: BusinessError) => {
hilog.warn(DOMAIN, 'LingxiKitchen',
'通知渠道初始化警告, code: %{public}d, msg: %{public}s',
err.code, err.message);
});
}
核心点解读 :
createSlot()不阻塞onCreate,使用.then().catch()异步调用。渠道创建失败不会影响应用启动------这是一个"尽力而为"的初始化策略。
Step 4:权限声明 ------ module.json5
json5
// 权限声明:通知管理权限暂不声明(HarmonyOS 6.1.0 中通知为系统级能力,
// 用户可在"设置 → 通知和状态栏"独立管理,不需要应用级权限声明)
//
// {
// "name": "ohos.permission.NOTIFICATION_CONTROLLER",
// "reason": "$string:notification_permission_reason",
// ...
// },
{
"name": "ohos.permission.DISTRIBUTED_DATASYNC",
"reason": "$string:distributed_permission_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "always" }
}
核心点解读 :在 HarmonyOS 6.1.0(API 23)中,
NOTIFICATION_CONTROLLER为系统 API 级别权限,应用不需要声明即可通过notificationManager调用通知相关接口。通知的启用/禁用由用户在系统设置中独立管理。保留DISTRIBUTED_DATASYNC权限,为后续多设备通知同步预留。
| 权限 | 级别 | 用途 | 状态 |
|---|---|---|---|
ohos.permission.NOTIFICATION_CONTROLLER |
system_api | 发送/取消通知 | 已注释(无需声明) |
ohos.permission.DISTRIBUTED_DATASYNC |
normal | 多设备分布式数据同步 | 已声明(预留) |
Step 5:字符串资源 ------ string.json
json
{
"name": "notification_permission_reason",
"value": "用于在烹饪过程中向您发送定时提醒通知,确保不错过关键步骤"
},
{
"name": "distributed_permission_reason",
"value": "用于将烹饪提醒同步推送到您的手表、平板等设备,多设备协同提醒"
},
{
"name": "reminder_set_success",
"value": "提醒已设置"
},
{
"name": "reminder_cancel_success",
"value": "提醒已取消"
},
{
"name": "reminder_notification_disabled",
"value": "通知权限未开启,请在设置中允许通知"
},
{
"name": "reminder_time_picker_title",
"value": "设置烹饪提醒"
},
{
"name": "reminder_minutes_label",
"value": "分钟"
},
{
"name": "reminder_seconds_label",
"value": "秒"
}
五、代码交付清单
| 文件 | 新增/修改 | 职责 |
|---|---|---|
services/NotificationHelper.ets |
重写 | 从 Stub 升级:通知渠道创建(幂等,slotType=1)、延时推送(deliveryTime)、WantAgent 缓存(显式 Want 变量)、取消通知、权限检查 |
pages/RecipeDetailPage.ets |
修改 | 新增提醒按钮、TextPicker 时间选择器(onChange 联合类型处理)、promptAction.showDialog 权限引导弹窗、handleSetReminder 核心方法 |
entryability/EntryAbility.ets |
修改 | 应用启动时注入 Context 并创建通知渠道(非阻塞) |
module.json5 |
修改 | NOTIFICATION_CONTROLLER 已注释(API 23 无需声明),保留 DISTRIBUTED_DATASYNC |
resources/base/element/string.json |
修改 | 新增 8 个字符串资源(权限理由 + UI 文案) |
六、设计决策
| 决策 | 选择 | 理由 |
|---|---|---|
| 延时实现方案 | deliveryTime 系统调度 |
应用被杀也不影响,比 setTimeout 可靠;精度秒级,比 WorkScheduler 灵活 |
| 通知渠道类型 | 数值 1(SOCIAL_COMMUNICATION) |
直接使用数值,避免枚举引用在不同 ROM 上的兼容性问题 |
| WantAgent 策略 | 单例 + 缓存 + 显式 Want 变量 | 避免每次发通知都重新创建;显式变量解决 @ComponentV2 下的类型推断问题 |
| 通知 ID 生成 | Date.now() + 自增计数器 |
确保短时间内多次设置提醒不冲突 |
| 渠道初始化时机 | EntryAbility.onCreate(非阻塞) |
不阻塞应用启动,失败仅 warn 不 crash |
| 权限检查位置 | 设置提醒前(非初始化时) | 给予用户最大控制权:在设置提醒的瞬间才感知权限缺失并跳转 |
| 权限弹窗实现 | promptAction.showDialog |
绑定 UIContext 生命周期,避免 @ComponentV2 下 AlertDialog.show() 的残留问题 |
| TextPicker 回调 | 联合类型 `string | string[]` + 类型收窄 |
| 错误抛出 | throw new Error(busErr.message) |
将系统 BusinessError 转为标准 Error,调用方无需区分错误类型 |
isFloatingIcon |
true |
通知在状态栏以浮动图标展示,与横幅互补,提升可见性 |
NOTIFICATION_CONTROLLER |
已注释 | API 23 中该权限为 system_api 级别,应用无需声明 |
七、运行与结果验证
7.1 操作步骤
-
部署到真机或模拟器。
-
进入菜谱详情页,滑动到某步骤(如"打单 ")

-
点击底部「⏰ 提醒」按钮 → 弹出时间选择器,默认 3 分 0 秒。



-
调整时间,点击「确认提醒」→ Toast 提示"⏰ 提醒已设置:X 分 X 秒后通知"。

-
等待到达设定时间(或设为 10 秒快速测试)→ 手机弹出横幅通知。


-
点击通知 → WantAgent 触发,回到菜谱详情页。
7.2 预期结果
| 操作 | 预期 UI 变化 | 预期日志 |
|---|---|---|
| 点击「⏰ 提醒」 | 时间选择器覆盖层弹出,默认 3:00 | --- |
| 选择 5:00 → 确认 | Toast: "提醒已设置" | [NotificationHelper] 延时通知已安排, id: xxxxx, delay: 300s, deliveryTime: xxxxx |
| 等待到时 | 通知横幅弹出 + 浮动图标 | --- |
| 点击通知 | 回到 RecipeDetailPage | WantAgent 触发 → EntryAbility |
| 通知权限未开 → 点击提醒 | promptAction 引导弹窗 |
[RecipeDetail] 通知权限未开启 |
| 点击「去设置」 | 跳转到系统应用详情页 | --- |
| 第二次设置提醒 | WantAgent 不再重新创建 | 不会出现 "WantAgent 创建成功" 日志 |
7.3 控制台完整日志
[NotificationHelper] NotificationHelper 已初始化,context 注入成功
[NotificationHelper] 通知渠道 SOCIAL_COMMUNICATION 创建成功
[RecipeDetail] 菜谱详情加载: 红烧肉, 共5步
[NotificationHelper] WantAgent 创建成功并已缓存
[NotificationHelper] 延时通知已安排, id: 1716307200123, delay: 300s, deliveryTime: 1716307500123
[RecipeDetail] 提醒已安排, id: 1716307200123, delay: 300s
...(5分钟后通知弹出)...
[NotificationHelper] 通知权限状态: 已开启
验证要点:
- 延时通知安排了之后,日志中
deliveryTime比当前时间精确多delayInSeconds秒。- WantAgent 创建后缓存,第二次及以上设置提醒不会再出现 "WantAgent 创建成功" 的日志。
- 通知权限未开时点「提醒」,会弹出
promptAction.showDialog引导弹窗。createSlot在渠道已存在时日志显示"通知渠道已存在,跳过创建"。
八、注意事项
8.1 deliveryTime 的时区问题
deliveryTime 使用设备本地时间戳(Date.now()),在不同时区的设备间可能存在偏差。目前《灵犀厨房》仅在国内市场分发,时区问题不突出。若未来国际化,建议统一使用 UTC 并做好客户端转换。
8.2 省电模式的影响
省电模式可能批量处理非紧急通知,将 deliveryTime 相近的几条合并推送。对于烹饪场景(3-15 分钟),偏差在可接受范围内。如需秒级精确,考虑使用前台 Service 替代。
8.3 应用被杀后的通知有效性
HarmonyOS 的延时通知机制由系统 NotificationService 管理,与应用的存活状态无关。即使灵犀厨房被用户从多任务中划掉,已安排的通知仍然会在预定时间弹出。这是 deliveryTime 相较 setTimeout 的核心优势。
8.4 slotType 使用数值而非枚举
在 addSlot 和 NotificationRequest.slotType 中直接使用数值 1,而非 notificationManager.SlotType.SOCIAL_COMMUNICATION。这是因为不同 HarmonyOS ROM 版本上模块导出路径可能存在差异,数值直接对应系统底层定义,兼容性更好。
8.5 promptAction.showDialog vs AlertDialog.show
在 @ComponentV2 装饰的组件中,AlertDialog.show() 是静态方法,不与组件实例绑定,可能导致页面销毁后弹窗残留。promptAction.showDialog 通过 UIContext 绑定,生命周期随页面销毁而自动清理。
8.6 WantAgent 显式 Want 变量
在 ArkTS 严格模式下,wants 数组的元素类型推断可能因上下文不同而失败。显式声明 const launchWant: Want = { ... } 确保类型检查通过,避免编译器报 arkts-no-inferred-generic-params 错误。
8.7 通知 ID 唯一性
在同一 deliveryTime 范围内确保 ID 唯一。我们使用 Date.now() + counter 的组合策略,避免 notificationManager.cancel(id) 时误删其他通知。
九、本阶段总结与下篇预告
今天,我们为《灵犀厨房》装上了"通知系统"------实现了烹饪延时提醒从设置到自动弹出的完整链路:
- 重写
NotificationHelper:从 Stub 升级为完整实现。渠道幂等创建(slotType=1)、deliveryTime延时调度、WantAgent 显式声明 + 缓存复用、BusinessError转标准 Error 的错误封装。代码约 160 行,覆盖通知全生命周期。 - 集成提醒 UI :菜谱步骤页底部新增「⏰ 提醒」按钮,
TextPicker时间选择器支持分钟:秒精确设置(联合类型string | string[]安全取值),promptAction.showDialog权限引导弹窗(解决@ComponentV2下生命周期问题)。 - 兼容性优先 :
slotType直接使用数值1而非枚举引用;NOTIFICATION_CONTROLLER权限已注释(API 23 无需声明);ensureWantAgent使用显式Want变量满足严格类型推断。 - 权限与资源配置 :
module.json5保留DISTRIBUTED_DATASYNC权限,string.json新增 8 个资源字符串。
现在,你在厨房炖牛肉,设置一个 30 分钟的提醒------去客厅看电视。30 分钟后,手机弹出横幅"🍳 烹饪提醒 - 炖牛肉:该加盐了"。点击通知,直接回到菜谱步骤,无缝继续烹饪。
但烹饪的智慧远不止于此------你想知道这道红烧肉有多少卡路里吗?太咸了该怎么办?
下篇预告 :第 20 篇《AI 烹饪助手:接入大语言模型实现语音问答》。我们将接入华为 CoreAI 的 NLP 能力,让《灵犀厨房》能够听懂用户的自然语言问题------"红烧肉的替代食材有哪些?""太咸了怎么补救?""这道菜适合高血压人群吗?"------真正做到像厨师朋友一样对话。
📚 本系列持续更新中:下一篇将接入大语言模型,让灵犀厨房从"工具"进化为"伙伴"。
🔗 专栏入口:《HarmonyOS6.1全场景实战》合集
📦 获取基线版本源码包 :包括第1-15篇代码 + 架构文档 + Flask 后端
如果你觉得这篇文章对你有帮助,请不要吝啬你的点赞 👍、收藏 ⭐ 和评论 💬。你的支持,是我继续输出高质量技术内容的全部动力。
纯血鸿蒙,提醒不再错过。我们下一篇见!