HarmonyOS 6.1 开发者盛宴|《灵犀厨房》实战(二十一):【服务卡片】在桌面查看烹饪进度------主进程强推与跨进程桥接
摘要 :上一篇我们为《灵犀厨房》接入了原子化服务,用户搜"今天吃什么"即可直达推荐页。但烹饪开始后呢?用户每次都要点开 App 才能看"烤箱还剩几分钟""电磁炉到第几步了"------这体验就像你煎牛排时每隔 30 秒跑回客厅看电视,累不累?本篇,我们将基于 HarmonyOS 6.1.0(API 23)的 FormExtensionAbility 服务卡片 能力,在手机桌面创建一张"烹饪进度卡"。特别警示:API 23 对卡片底层框架进行了大规模重构,传统网传的"卡片自驱动"或"FormAbility 回调桥接"方案均已失效。本文将演示如何通过"主进程强推 + 本地数据库跨进程桥接"的唯一正统方案,实现无需打开 App、抬手即看的高可用秒级刷新。
一、引言:从"打开 App 看进度"到"桌面扫一眼"
经过前 20 篇的积累,《灵犀厨房》已经掌握了 AI 推荐、语音播报、声控操作、通知提醒、元服务免安装直达。但有一个高频场景一直被忽略:
| 场景 | 操作路径 | 耗时 | 痛点 |
|---|---|---|---|
| 烤箱在烤蛋糕 | 解锁手机 → 找 App → 点开 → 切到厨电页 → 看计时器 | ~5s | 手上沾油,指纹不识别 😅 |
| 电磁炉在炖汤 | 同上 | ~5s | 翻一次手机就多一分糊锅风险 |
| 步骤推进中 | 同上 → 切到菜谱页 → 看当前步骤 | ~6s | 频繁切换,打断烹饪节奏 |
而 服务卡片(FormExtensionAbility) 完美解决这个痛点:
🎯 桌面卡片 = 烹饪中控台。抬手即看:菜谱名 + 第几步骤 + 计时器倒计时。2×2 卡片,屏幕一亮,灶台信息尽收眼底。
HarmonyOS 的服务卡片不是简单的"静态快捷方式"------它是可动态刷新的微型 UI 。应用可以在后台通过 formProvider.updateForm() 每秒更新卡片上的计时器数字,用户无需进入 App。这正是"纯血鸿蒙"区别于传统桌面 Widget 的核心优势:系统级卡片框架 + 毫秒级数据推送。
二、核心原理:API 23 的三体隔离与强推架构
在 API 23 中开发动态卡片,首先必须彻底抛弃两个在旧版本(API 12 及以下)中流传甚广的"伪真理":
- "卡片端自驱动"已死 :通过卡片 UI 的
@Watch触发postCardAction形成死循环刷新,在 API 23 中会被系统底层认定为恶意高频 IPC 并直接静默丢弃,卡片不会有任何反应。 - "FormAbility 注册业务回调"不可行 :很多人以为在
EntryFormAbility中把formId存入单例,主应用定时调用formProvider推送即可。大错特错!EntryFormAbility和主应用EntryAbility运行在完全隔离的两个进程中(在日志面板可以看到不同的 PID),主应用拿到的是空壳单例,数据根本推不出去。
2.1 唯一正统架构:本地 DB 跨进程桥接 + 主进程强推
在 API 23 的严苛限制下,我们沉淀出了唯一稳定、合法、高性能的架构方案:
#mermaid-svg-e7al9HzQp8sjHJoP{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-e7al9HzQp8sjHJoP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-e7al9HzQp8sjHJoP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-e7al9HzQp8sjHJoP .error-icon{fill:#552222;}#mermaid-svg-e7al9HzQp8sjHJoP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-e7al9HzQp8sjHJoP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-e7al9HzQp8sjHJoP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-e7al9HzQp8sjHJoP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-e7al9HzQp8sjHJoP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-e7al9HzQp8sjHJoP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-e7al9HzQp8sjHJoP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-e7al9HzQp8sjHJoP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-e7al9HzQp8sjHJoP .marker.cross{stroke:#333333;}#mermaid-svg-e7al9HzQp8sjHJoP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-e7al9HzQp8sjHJoP p{margin:0;}#mermaid-svg-e7al9HzQp8sjHJoP .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-e7al9HzQp8sjHJoP .cluster-label text{fill:#333;}#mermaid-svg-e7al9HzQp8sjHJoP .cluster-label span{color:#333;}#mermaid-svg-e7al9HzQp8sjHJoP .cluster-label span p{background-color:transparent;}#mermaid-svg-e7al9HzQp8sjHJoP .label text,#mermaid-svg-e7al9HzQp8sjHJoP span{fill:#333;color:#333;}#mermaid-svg-e7al9HzQp8sjHJoP .node rect,#mermaid-svg-e7al9HzQp8sjHJoP .node circle,#mermaid-svg-e7al9HzQp8sjHJoP .node ellipse,#mermaid-svg-e7al9HzQp8sjHJoP .node polygon,#mermaid-svg-e7al9HzQp8sjHJoP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-e7al9HzQp8sjHJoP .rough-node .label text,#mermaid-svg-e7al9HzQp8sjHJoP .node .label text,#mermaid-svg-e7al9HzQp8sjHJoP .image-shape .label,#mermaid-svg-e7al9HzQp8sjHJoP .icon-shape .label{text-anchor:middle;}#mermaid-svg-e7al9HzQp8sjHJoP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-e7al9HzQp8sjHJoP .rough-node .label,#mermaid-svg-e7al9HzQp8sjHJoP .node .label,#mermaid-svg-e7al9HzQp8sjHJoP .image-shape .label,#mermaid-svg-e7al9HzQp8sjHJoP .icon-shape .label{text-align:center;}#mermaid-svg-e7al9HzQp8sjHJoP .node.clickable{cursor:pointer;}#mermaid-svg-e7al9HzQp8sjHJoP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-e7al9HzQp8sjHJoP .arrowheadPath{fill:#333333;}#mermaid-svg-e7al9HzQp8sjHJoP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-e7al9HzQp8sjHJoP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-e7al9HzQp8sjHJoP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-e7al9HzQp8sjHJoP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-e7al9HzQp8sjHJoP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-e7al9HzQp8sjHJoP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-e7al9HzQp8sjHJoP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-e7al9HzQp8sjHJoP .cluster text{fill:#333;}#mermaid-svg-e7al9HzQp8sjHJoP .cluster span{color:#333;}#mermaid-svg-e7al9HzQp8sjHJoP 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-e7al9HzQp8sjHJoP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-e7al9HzQp8sjHJoP rect.text{fill:none;stroke-width:0;}#mermaid-svg-e7al9HzQp8sjHJoP .icon-shape,#mermaid-svg-e7al9HzQp8sjHJoP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-e7al9HzQp8sjHJoP .icon-shape p,#mermaid-svg-e7al9HzQp8sjHJoP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-e7al9HzQp8sjHJoP .icon-shape .label rect,#mermaid-svg-e7al9HzQp8sjHJoP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-e7al9HzQp8sjHJoP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-e7al9HzQp8sjHJoP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-e7al9HzQp8sjHJoP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ⚙️ 系统底层渲染
📱 主应用进程
💾 本地持久化
📦 卡片独立进程
- 跨进程读取
- 推送数据
- 渲染指令
EntryFormAbility
纯粹的登记员
onAddForm 提取真实 ID
将 ID 异步写入本地 DB
Preferences
存储 JSON 数组
EntryAbility
读取 DB 获取 formId
启动 1s 定时器
获取 Snapshot 快照
formProvider.updateForm
强硬推送数据
卡片管理服务
WidgetCard UI
图一解读 :这张全景架构图展示了 API 23 下卡片动态刷新的唯一合法路径。卡片进程(橙色区)只负责"登记 ID 到数据库",写完就退出,不参与任何后续通信。主应用进程(蓝色区)在切到前台时从 Preferences(紫色区)跨进程读取 formId,然后启动 1 秒定时器,通过 formProvider.updateForm() 持续向系统底层推送数据。整个过程严格遵循"卡片纯展示、主应用强推"的单向数据流原则。
设计原则:
- FormAbility 降级 :它不再承担"数据推送大脑"的角色,只做两件事:① 从
Want中提取真实的formId;② 将其存入Preferences。它甚至不关心业务逻辑,返回纯静态的硬编码数据,避免跨模块引用导致进程崩溃。 - 主应用赋能 :真正的推送大脑转移到了
EntryAbility。它在切到前台时读取 DB 拿到卡片 ID,启动定时器,每秒把业务快照狠狠推给系统。 - 卡片 UI 纯净 :不包含任何
@Watch、不包含new LocalStorage()、不包含会导致底层 C++ 崩溃的系统矢量图标。
三、分层架构:数据流全景图
按照《灵犀厨房》四层架构,烹饪进度卡片涉及以下层次:
#mermaid-svg-RDIirAL7OTByhqkU{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-RDIirAL7OTByhqkU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RDIirAL7OTByhqkU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RDIirAL7OTByhqkU .error-icon{fill:#552222;}#mermaid-svg-RDIirAL7OTByhqkU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RDIirAL7OTByhqkU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RDIirAL7OTByhqkU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RDIirAL7OTByhqkU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RDIirAL7OTByhqkU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RDIirAL7OTByhqkU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RDIirAL7OTByhqkU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RDIirAL7OTByhqkU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RDIirAL7OTByhqkU .marker.cross{stroke:#333333;}#mermaid-svg-RDIirAL7OTByhqkU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RDIirAL7OTByhqkU p{margin:0;}#mermaid-svg-RDIirAL7OTByhqkU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-RDIirAL7OTByhqkU .cluster-label text{fill:#333;}#mermaid-svg-RDIirAL7OTByhqkU .cluster-label span{color:#333;}#mermaid-svg-RDIirAL7OTByhqkU .cluster-label span p{background-color:transparent;}#mermaid-svg-RDIirAL7OTByhqkU .label text,#mermaid-svg-RDIirAL7OTByhqkU span{fill:#333;color:#333;}#mermaid-svg-RDIirAL7OTByhqkU .node rect,#mermaid-svg-RDIirAL7OTByhqkU .node circle,#mermaid-svg-RDIirAL7OTByhqkU .node ellipse,#mermaid-svg-RDIirAL7OTByhqkU .node polygon,#mermaid-svg-RDIirAL7OTByhqkU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-RDIirAL7OTByhqkU .rough-node .label text,#mermaid-svg-RDIirAL7OTByhqkU .node .label text,#mermaid-svg-RDIirAL7OTByhqkU .image-shape .label,#mermaid-svg-RDIirAL7OTByhqkU .icon-shape .label{text-anchor:middle;}#mermaid-svg-RDIirAL7OTByhqkU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-RDIirAL7OTByhqkU .rough-node .label,#mermaid-svg-RDIirAL7OTByhqkU .node .label,#mermaid-svg-RDIirAL7OTByhqkU .image-shape .label,#mermaid-svg-RDIirAL7OTByhqkU .icon-shape .label{text-align:center;}#mermaid-svg-RDIirAL7OTByhqkU .node.clickable{cursor:pointer;}#mermaid-svg-RDIirAL7OTByhqkU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-RDIirAL7OTByhqkU .arrowheadPath{fill:#333333;}#mermaid-svg-RDIirAL7OTByhqkU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-RDIirAL7OTByhqkU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-RDIirAL7OTByhqkU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RDIirAL7OTByhqkU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-RDIirAL7OTByhqkU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RDIirAL7OTByhqkU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-RDIirAL7OTByhqkU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-RDIirAL7OTByhqkU .cluster text{fill:#333;}#mermaid-svg-RDIirAL7OTByhqkU .cluster span{color:#333;}#mermaid-svg-RDIirAL7OTByhqkU 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-RDIirAL7OTByhqkU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-RDIirAL7OTByhqkU rect.text{fill:none;stroke-width:0;}#mermaid-svg-RDIirAL7OTByhqkU .icon-shape,#mermaid-svg-RDIirAL7OTByhqkU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RDIirAL7OTByhqkU .icon-shape p,#mermaid-svg-RDIirAL7OTByhqkU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-RDIirAL7OTByhqkU .icon-shape .label rect,#mermaid-svg-RDIirAL7OTByhqkU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RDIirAL7OTByhqkU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-RDIirAL7OTByhqkU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-RDIirAL7OTByhqkU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 📊 数据源
🧠 Business 层
📱 主应用控制层
💾 本地持久化
📇 Service 层
🖥️ 桌面层
系统底层注入 formBindingData
写入/删除 ID
跨进程读取 ID
每秒获取快照
读取设备状态
读取菜谱信息
formProvider.updateForm 强推
WidgetCard.ets
2×2 卡片 UI
@LocalStorageProp 纯展示
无任何业务逻辑
EntryFormAbility.ets
纯粹登记员
onAddForm: 提取 ID → 写 DB
onRemoveForm: 清理 DB
Preferences
FORM_IDS JSON 数组
跨进程唯一桥梁
EntryAbility.ets
强推大脑
onForeground: 读 DB → 启动 1s 定时器
每秒 updateForm 推送
CookingProgressManager.ets
1s 轮询聚合器
智能步骤推进
自动熄火机制
KitchenDeviceSimulator
厨电实时状态
RecipeManager
菜谱详情
图二解读 :这张分层数据流图完整展示了烹饪进度卡片在《灵犀厨房》四层架构中的位置。关键数据流有两条:(1)ID 登记流 (橙色箭头):卡片进程在 onAddForm 时将 formId 写入 Preferences,onRemoveForm 时清理;(2)数据推送流 (蓝色箭头):主应用从 Preferences 跨进程读取 ID,从 Business 层获取烹饪快照,通过 formProvider.updateForm 向系统底层推送数据,最终渲染到桌面卡片 UI。注意卡片 UI 与 FormAbility 之间没有直接调用关系------所有通信都通过系统底层和本地 DB 桥接完成。
四、关键实现步骤
Step 1:module.json5 与 form_config.json 声明
本项目配置保持官方标准,在 module.json5 的 extensionAbilities 中声明 type: "form",并指向 form_config.json。
特别注意 form_config.json 的优化:
json
{
"forms": [{
"name": "CookingProgressCard",
"isDynamic": true,
"updateEnabled": true,
"updateDuration": 0,
"defaultDimension": "2*2",
"formConfigAbility": "ability://EntryFormAbility"
}]
}
核心点解读 :
isDynamic: true开启系统级动态刷新,updateDuration: 0将刷新节奏完全交给主应用接管。这两个配置是"主进程强推"方案生效的前提。
Step 2:卡片 UI 布局(排毒与重构)
WidgetCard.ets 是最容易踩坑的地方。严禁出现以下代码:
- ❌
let storage = new LocalStorage(); @Entry(storage)--- 切断系统数据通道 - ❌
SymbolGlyph($r('sys.symbol.timer'))--- 引发卡片进程 C++ 闪退 - ❌
@Watch和postCardAction触发死循环 --- 被 API 23 底层限流
正确的写法 :使用 @LocalStorageProp 接收数据,用普通 Emoji 替代系统图标。
typescript
// entry/src/main/ets/widget/pages/WidgetCard.ets
@Entry // ★ 绝对不要传参数!
@Component
struct WidgetCard {
@LocalStorageProp('RECIPE_NAME') recipeName: string = '未在烹饪';
@LocalStorageProp('STEP_TEXT') stepText: string = '暂无步骤';
@LocalStorageProp('STEP_PROGRESS') stepProgress: string = '-/-';
@LocalStorageProp('TIMER_LABEL') timerLabel: string = '计时器';
@LocalStorageProp('TIMER_SECONDS') timerSeconds: number = 0;
@LocalStorageProp('TIMER_DISPLAY') timerDisplay: string = '--:--';
@LocalStorageProp('IS_COOKING') isCooking: boolean = false;
@LocalStorageProp('DEVICE_NAME') deviceName: string = '';
build() {
Stack() {
Column() {
// ... (省略具体排版,参考原文章 Step 3,仅修改底层组件)
// ★ 底部计时器:将 SymbolGlyph 替换为普通 Emoji
Row() {
Text('⏱️').fontSize(13)
Text(this.timerDisplay).fontSize(18)
}
// ...
}
}.onClick(() => {
// 保留 postCardAction 的 router 跳转功能
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { targetPage: 'KitchenDevicePage' }
});
})
}
}
核心点解读 :
@LocalStorageProp是卡片 UI 接收数据的唯一合法方式------系统底层会自动将formBindingData中的字段注入到同名属性中。注意@Entry后面绝对不能传LocalStorage参数,否则会切断系统内置的数据注入通道。底部计时器图标使用纯文本 Emoji⏱️代替SymbolGlyph,规避卡片渲染进程的 C++ 闪退问题。
Step 3:EntryFormAbility ------ 降级为纯粹的"登记员"
它不再注册任何业务回调,也不再负责推送数据。它的全部使命就是:拿到 ID,存入本地,然后立刻返回安全的静态数据。
typescript
import { formBindingData, FormExtensionAbility } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
import { preferences } from '@kit.ArkData';
const FORM_KEY = 'FORM_IDS';
export default class EntryFormAbility extends FormExtensionAbility {
onAddForm(want: Want): formBindingData.FormBindingData {
// ★ 避坑:API 23 真实 ID 藏在 form_identity 里
const formId: string = (want.parameters?.['ohos.extra.param.key.form_identity'] as string) ?? '';
if (formId.length > 0) {
this.saveFormId(formId); // 异步写库,不阻塞返回
}
// ★ 避坑:绝不调用主业务的 Manager,直接返回纯静态硬编码数据
return formBindingData.createFormBindingData({
'RECIPE_NAME': '等待连接...', 'STEP_TEXT': '', 'STEP_PROGRESS': '-/-',
'TIMER_LABEL': '', 'TIMER_SECONDS': 0, 'TIMER_DISPLAY': '--:--',
'IS_COOKING': false, 'DEVICE_NAME': ''
});
}
onRemoveForm(formId: string): void {
this.removeFormId(formId);
}
private async saveFormId(formId: string): Promise<void> {
const pref = await preferences.getPreferences(this.context, 'widget_store');
const oldStr: string = await pref.get(FORM_KEY, '[]') as string;
let ids: string[] = JSON.parse(oldStr) as string[];
if (!ids.includes(formId)) {
ids.push(formId);
await pref.put(FORM_KEY, JSON.stringify(ids));
await pref.flush();
}
}
private async removeFormId(formId: string): Promise<void> {
const pref = await preferences.getPreferences(this.context, 'widget_store');
const oldStr: string = await pref.get(FORM_KEY, '[]') as string;
let ids: string[] = JSON.parse(oldStr) as string[];
ids = ids.filter((id: string) => id !== formId);
await pref.put(FORM_KEY, JSON.stringify(ids));
await pref.flush();
}
}
核心点解读 :
saveFormId中的去重逻辑(if (!ids.includes(formId)))确保同一张卡片不会因为系统触发多次onAddForm而重复记录。removeFormId在用户从桌面移除卡片时清理 ID,防止主应用向已经不存在的卡片发送更新。整个 FormAbility 不导入任何业务模块(如CookingProgressManager),返回的数据是纯静态硬编码值,这是防止跨进程依赖崩溃的关键。
Step 4:EntryAbility ------ 真正的"强推大脑"
主应用接管一切。在 onForeground 时读取 DB 获取卡片 ID,启动定时器。引入 wasActive 状态机精准捕捉"倒计时结束"的瞬间。
typescript
// EntryAbility.ets 核心推送逻辑
import { formProvider, formBindingData } from '@kit.FormKit';
import { preferences } from '@kit.ArkData';
private timerHandle: number = -1;
private formIds: string[] = [];
private wasActive: boolean = false;
const FORM_KEY = 'FORM_IDS';
// 读取 DB 拿到 ID
private async loadFormIds(): Promise<void> {
const pref = await preferences.getPreferences(this.context, 'widget_store');
// ★ 避坑:不用 getAll/getAllKeys,用最原始的 get 读 JSON 字符串
const idsStr: string = await pref.get(FORM_KEY, '[]') as string;
this.formIds = JSON.parse(idsStr) as string[];
}
// 启动强推定时器
private startPushTimer(): void {
if (this.timerHandle !== -1) return;
this.timerHandle = setInterval(() => {
if (this.formIds.length === 0) return; // 没 ID 静默等待,绝不自杀
const snapshot = cookingProgressManager.getSnapshot();
if (cookingProgressManager.isActive) {
this.wasActive = true;
this.pushDataToForms(snapshot); // 正常推
} else if (this.wasActive) {
// 精准捕捉:上一秒做饭,这一秒结束
this.wasActive = false;
this.pushDataToForms(snapshot);
this.stopPushTimer();
}
}, 1000);
}
// 注意:onBackground 绝对不要调用 stopPushTimer()!做饭时切后台必须保持推送。
核心点解读 :
wasActive状态机的逻辑是:只有"上一秒还在烹饪(wasActive=true)+ 这一秒已停止(isActive=false)"才判定为"刚刚结束",触发最后一次复位推送后停止定时器。如果一开始就没在烹饪(wasActive=false且isActive=false),定时器保持静默等待,不自杀------因为用户可能随时开始下一道菜的烹饪。onBackground中绝对不能调用stopPushTimer(),否则用户切到桌面看卡片时推送已经停止,卡片就真成"墓碑"了。
Step 5:CookingProgressManager ------ 增强智能推进与自动熄火
除了原有的 1 秒轮询,增加了两个关键特性:
typescript
// CookingProgressManager.ets 核心增强逻辑
private startPolling(): void {
this.pollingHandle = setInterval(() => {
// ★ 自动熄火:检测到设备停止,自动清理状态
if (this.activeDeviceId.length > 0) {
const device = kitchenDeviceSimulator.getDevice(this.activeDeviceId);
if (!device || (device.timerSeconds <= 0 && device.status !== DeviceStatus.WORKING)) {
this.stopCooking(); // isActive 变 false,触发主应用停止定时器
return;
}
}
}, 1000);
}
// 智能步骤推进逻辑 (在 getSnapshot 中实现)
if (this.targetTimerSeconds > 0 && this.totalSteps > 0 && this.currentRecipeId > 0) {
const elapsed: number = this.targetTimerSeconds - timerSeconds;
if (elapsed > 0) {
// 按照经过时间占总时间的比例,自动计算当前步骤
this.currentStepIndex = Math.min(
Math.floor((elapsed / this.targetTimerSeconds) * this.totalSteps),
this.totalSteps - 1
);
}
}
核心点解读 :智能步骤推进解决了"用户忘记手动翻页"的问题------如果用户设置了 15 分钟的计时器,菜谱有 6 步,那么系统会自动按时间比例推进步骤(每 2.5 分钟自动翻到下一步)。自动熄火机制在检测到设备计时器归零且设备停止工作后,自动调用
stopCooking(),触发isActive变为 false,进而让EntryAbility的wasActive状态机精准捕捉并停止推送。
五、代码增删改清单
| 文件 | 新增/修改 | 职责 |
|---|---|---|
entry/src/main/ets/widget/pages/WidgetCard.ets |
新增 | 2×2 卡片 UI,纯展示,Emoji 替代系统图标 |
entry/src/main/ets/entryformability/EntryFormAbility.ets |
新增 | 纯粹登记员:提取 ID → 写 DB → 返回静态数据 |
entry/src/main/ets/entryability/EntryAbility.ets |
修改 | 强推大脑:读 DB 拿 ID → 1s 定时器 → updateForm 推送 |
entry/src/main/ets/business/CookingProgressManager.ets |
修改 | 智能步骤推进 + 自动熄火机制 |
entry/src/main/resources/base/profile/form_config.json |
新增 | 卡片元数据配置 |
六、血泪避坑总结(API 23 必看)
| 现象 | 真相 | 解决方案 |
|---|---|---|
| 卡片添加后不刷新 | postCardAction 死循环被系统底层限流静默丢弃 |
放弃卡片端自驱动,改为主应用 EntryAbility 强推 |
| 主应用推数据没反应 | FormAbility 和 EntryAbility 是两个进程,单例传不过去 |
FormAbility 将 ID 写入本地 Preferences,主应用读库强推 |
| 编译报错找不到模块/属性 | API 23 把 formHost、getAllKeys 等高级接口 TS 声明删除或修改 |
放弃高级 API,只用最底层的 pref.get('KEY', '[]') 存取 JSON 字符串 |
| 模拟器右键添加卡片没日志 | 模拟器的"右键弹窗添加"走的是静态贴图降级逻辑 | 必须使用"长按图标往上拖拽松开"的真机手势 |
卡片进程状态变 (Dead) |
UI 中使用了 SymbolGlyph($r('sys.symbol.timer')) 导致 C++ 越界崩溃 |
严禁在卡片 UI 使用系统矢量图标,替换为普通 Emoji |
七、设计决策
| 决策 | 选择 | 理由 |
|---|---|---|
| 数据流向 | 主应用强推 | API 23 彻底封死了卡片端发起 IPC 的路,主进程强推是唯一合法且高效的路径 |
| 跨进程通信 | 本地 Preferences DB | formHost 类型失效,基础 pref.get() 跨进程读写 JSON 字符串最稳妥 |
| 卡片 UI 组件 | 纯文本 Emoji | SymbolGlyph 会引发卡片进程底层闪退,2×2 空间用 Emoji 足够清晰 |
| 结束状态处理 | 状态机精准捕捉 (wasActive) |
多定时器交叉运行时,单纯判断 isActive 会导致"提前自杀"或"漏推最后一帧" |
| 后台推送策略 | 切后台绝不杀定时器 | 烹饪场景下切后台必须保持推送,等归零后再由状态机自动清理 |
八、运行验证效果
- 将程序部署到模拟器或者真机,该系列文章都是用手机模拟器。
- 鼠标右键长按灵犀厨房应用图片,在弹窗中选择【卡片】,添加到桌面。


- 点击服务卡片,选择一道菜,选择一个厨电设备,比如有定时器功能的电磁炉负责烹饪。



- 将应用退到后台,观察桌面上的卡片的变化,此时当前步骤、进度条、倒计时、状态正常显示。


- 当倒计时结束时,服务卡片恢复初始状态。

九、总结与下篇预告
本篇我们基于 HarmonyOS 6.1.0(API 23)的真实底层表现,推翻了旧版文档中的回调桥接方案,重构了《灵犀厨房》的桌面烹饪进度卡片。通过"主进程强推 + 本地 DB 跨进程桥接"的架构,彻底解决了卡片不刷新、跨进程数据丢失、倒计时归零不重置等一系列疑难杂症。
核心成就:
EntryFormAbility变身为纯粹的"登记员",通过form_identity避开 API 23 的取值陷阱,安全地将 ID 落盘。EntryAbility扛起了强推大旗,配合wasActive状态机,实现了"做饭时精准推送,做完饭完美收尾且 0 耗电"。WidgetCard成功排雷,剔除了导致 C++ 崩溃的系统级组件,成为一块稳定纯粹的显示面板。
现在,你煎牛排时只需低头看一眼桌面卡片,就能知道烤箱还剩多少秒、当前该翻面还是出锅。
下篇预告:第 22 篇《多媒体:AVPlayer 嵌入教学视频》。我们将在菜谱详情页嵌入烹饪教学视频,支持画中画播放------让用户在切菜的同时,小窗看大厨示范。敬请期待!
📚 本系列持续更新中。下一篇将为菜谱页嵌入视频教学,烹饪学习两不误。
🔗 专栏入口:《HarmonyOS6.1全场景实战》合集
📦 获取基线版本源码包 :包括第1-15篇代码 + 架构文档 + Flask 后端
如果你觉得这篇文章对你有帮助,请不要吝啬你的点赞 👍、收藏 ⭐ 和评论 💬。你的支持,是我继续输出高质量技术内容的全部动力。纯血鸿蒙,三模块一体。我们下一篇见!