HarmonyOS 6.1 开发者盛宴|《灵犀厨房》实战(十九):【通知系统】延时烹饪提醒——让通知不再错过关键步骤

HarmonyOS 6.1 开发者盛宴|《灵犀厨房》实战(十九):【通知系统】延时烹饪提醒------让通知不再错过关键步骤

摘要 :上一篇我们为厨电控制页装上了"手腕大脑"------将计时器从手机流转至手表,实现了手腕掌控烹饪节奏。但烹饪中的提醒远不止计时:蒸鱼该关火了但你在阳台晾衣服?炖肉可以加盐了但正在客厅看电视?本篇,我们将接入 HarmonyOS 6.1.0(API 23)的 @kit.NotificationKit,为《灵犀厨房》实现烹饪延时通知的全链路:用户在菜谱步骤页一键设置延时提醒→系统到点自动弹出通知→点击通知通过 WantAgent 直接回到菜谱步骤。严格遵循 API 23 规范,代码即文档。


一、引言与系列定位

经过第 16 篇的语音播报和第 17 篇的声控操作,你的《灵犀厨房》已经能"说"会"听"。但这里有一个关键的体验断层:

场景 问题 解决
红烧肉大火收汁 5 分钟 离开厨房去客厅,忘记还剩多久 设置延时通知,到点手机自动弹出提醒
蒸鱼定时 8 分钟 在阳台晾衣服,手机在厨房充电 设定提醒后放心离开,到时回来关火
炖牛肉需要中途加盐 正在切菜,腾不出手看屏幕 提前设好 30 分钟提醒------"该加盐了"
烤箱烤到一半该翻面了 闹钟只响一声没听到 通知横幅 + 浮动图标双重提醒

设计决策 :为什么用 deliveryTime 而非 setTimeout

deliveryTimeNotificationRequest 的属性,接受绝对时间戳。通知的"发布时间"交给系统 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 是一个枚举,但 addSlotNotificationRequest.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 的封装方法操作。

  1. 单一职责:NotificationHelper 只管"何时发、发给谁",UI 层决定"发什么内容、是否检查权限、失败后如何提示"。
  2. 依赖注入:Context 由 EntryAbility 在启动时注入,而非硬编码或全局变量。
  3. WantAgent 缓存:避免每次发通知都重新创建,提升性能。
  4. 幂等初始化:通知渠道创建失败不影响应用启动(尽力而为策略)。

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 导入------编译时参与类型检查但不生成运行时代码。Wantcommon 为运行时需要的值导入。这是 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);
    }
  }

核心点解读

  • deliveryTimeDate.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.onChangevalue 参数在 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 生命周期,避免 @ComponentV2AlertDialog.show() 的残留问题
TextPicker 回调 联合类型 `string string[]` + 类型收窄
错误抛出 throw new Error(busErr.message) 将系统 BusinessError 转为标准 Error,调用方无需区分错误类型
isFloatingIcon true 通知在状态栏以浮动图标展示,与横幅互补,提升可见性
NOTIFICATION_CONTROLLER 已注释 API 23 中该权限为 system_api 级别,应用无需声明

七、运行与结果验证

7.1 操作步骤

  1. 部署到真机或模拟器。

  2. 进入菜谱详情页,滑动到某步骤(如"打单 ")

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

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

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

  6. 点击通知 → 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 使用数值而非枚举

addSlotNotificationRequest.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 后端

如果你觉得这篇文章对你有帮助,请不要吝啬你的点赞 👍、收藏 ⭐ 和评论 💬。你的支持,是我继续输出高质量技术内容的全部动力。

纯血鸿蒙,提醒不再错过。我们下一篇见!

相关推荐
若兰幽竹6 天前
HarmonyOS 6.1 全场景实战|《灵犀厨房》实战(十八):【手表协同】烹饪计时器流转至智能手表——手腕掌控烹饪节奏
智能手表·华为鸿蒙系统·harmonyos6.1.0·灵犀厨房
若兰幽竹9 天前
【HarmonyOS 6.1 全场景实战】《灵犀厨房》实战(十七):【语音识别】免提声控启动播报——动口不动手
语音识别·华为鸿蒙系统·harmonyos6.1.0·灵犀厨房
若兰幽竹10 天前
【HarmonyOS6.1全场景实战】基线版本:我用了15篇文章,造出了一个能登录、能推荐、带后台的鸿蒙全栈App
华为鸿蒙系统·harmonyos6.1.0·灵犀厨房
若兰幽竹11 天前
【HarmonyOS 6.1 全场景实战】《灵犀厨房》实战(十五)之【超级设备模拟器实战】多设备交互调试:像上帝一样俯瞰整个智能厨房
华为鸿蒙系统·harmonyos6.1.0·灵犀厨房
若兰幽竹11 天前
【HarmonyOS 6.1 全场景实战】《灵犀厨房》实战(十四)之【分布式流转】让菜谱“飞”:手机选、平板看、智慧屏播的全场景秘诀
分布式·华为鸿蒙系统·harmonyos6.1.0·灵犀厨房
若兰幽竹11 天前
【HarmonyOS 6.1 全场景实战】《灵犀厨房》实战(十三)之【智能厨电模拟】用代码“凭空”创造智能厨房:《灵犀厨房》的全场景前奏
华为鸿蒙系统·harmonyos6.1.0·灵犀厨房
若兰幽竹11 天前
【HarmonyOS 6.1 全场景实战】《灵犀厨房》实战(十二)之【营养分析引擎】计算个性化卡路里建议:给《灵犀厨房》装上“营养大脑”
华为鸿蒙系统·harmonyos6.1.0·灵犀厨房
若兰幽竹13 天前
【HarmonyOS 6.1 全场景实战】《灵犀厨房》实战之补充【架构进化】灵犀厨房四层分层设计:给鸿蒙 App 搭一副坚不可摧的骨架
架构·鸿蒙系统·harmonyos6.1.0·灵犀厨房
若兰幽竹19 天前
【HarmonyOS 6.1 全场景实战】《灵犀厨房》实战(三):ArkTS 高效开发:TypeScript 核心与 API 23 新规
harmonyos·鸿蒙系统·harmonyos6.1.0