【HarmonyOS 6.1 全场景实战】《灵犀厨房》实战(二十一):【服务卡片】在桌面查看烹饪进度——主进程强推与跨进程桥接

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 及以下)中流传甚广的"伪真理":

  1. "卡片端自驱动"已死 :通过卡片 UI 的 @Watch 触发 postCardAction 形成死循环刷新,在 API 23 中会被系统底层认定为恶意高频 IPC 并直接静默丢弃,卡片不会有任何反应。
  2. "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;} ⚙️ 系统底层渲染
📱 主应用进程
💾 本地持久化
📦 卡片独立进程

  1. 跨进程读取
  2. 推送数据
  3. 渲染指令
    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.json5extensionAbilities 中声明 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++ 闪退
  • @WatchpostCardAction 触发死循环 --- 被 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=falseisActive=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,进而让 EntryAbilitywasActive 状态机精准捕捉并停止推送。


五、代码增删改清单

文件 新增/修改 职责
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 强推
主应用推数据没反应 FormAbilityEntryAbility 是两个进程,单例传不过去 FormAbility 将 ID 写入本地 Preferences,主应用读库强推
编译报错找不到模块/属性 API 23 把 formHostgetAllKeys 等高级接口 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 会导致"提前自杀"或"漏推最后一帧"
后台推送策略 切后台绝不杀定时器 烹饪场景下切后台必须保持推送,等归零后再由状态机自动清理

八、运行验证效果

  1. 将程序部署到模拟器或者真机,该系列文章都是用手机模拟器。
  2. 鼠标右键长按灵犀厨房应用图片,在弹窗中选择【卡片】,添加到桌面。
  3. 点击服务卡片,选择一道菜,选择一个厨电设备,比如有定时器功能的电磁炉负责烹饪。

  4. 将应用退到后台,观察桌面上的卡片的变化,此时当前步骤、进度条、倒计时、状态正常显示。
  5. 当倒计时结束时,服务卡片恢复初始状态。

九、总结与下篇预告

本篇我们基于 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 后端
如果你觉得这篇文章对你有帮助,请不要吝啬你的点赞 👍、收藏 ⭐ 和评论 💬。你的支持,是我继续输出高质量技术内容的全部动力。

纯血鸿蒙,三模块一体。我们下一篇见!

相关推荐
若兰幽竹10 小时前
【HarmonyOS 6.1 全场景实战】《灵犀厨房》实战(排错指南):【服务卡片跳转】页面栈“迷航”——从“回不去的主页”到精准 Tab 唤醒的全链路修复
华为鸿蒙系统·灵犀厨房·harmonyos6.1·排错指南
若兰幽竹3 天前
HarmonyOS 6.1 全场景实战|《灵犀厨房》实战(番外篇):【深度排查】24小时死磕服务卡片不刷新,我踩平了 API 23 的所有底坑
华为鸿蒙系统·harmonyos6.1.0·灵犀厨房
若兰幽竹3 天前
【HarmonyOS 6.1 全场景实战】《灵犀厨房》实战(二十)扩展:【工程集成】主应用 + 元服务 + HSP 共享库——三模块一体化架构
华为鸿蒙系统·灵犀厨房·harmonyos6.1
若兰幽竹3 天前
HarmonyOS 6.1 开发者盛宴|《灵犀厨房》实战(二十点五):【排错指南】元服务跳转主应用——Want 参数传递的五个陷阱与架构修复
元服务·华为鸿蒙系统·harmonyos6.1·排除指南
若兰幽竹4 天前
HarmonyOS 6.1 开发者盛宴|《灵犀厨房》实战(二十):【元服务】一键烹饪推荐原子化服务——免安装直达美味
华为鸿蒙系统·harmonyos6.1.0·灵犀厨房
若兰幽竹5 天前
HarmonyOS 6.1 开发者盛宴|《灵犀厨房》实战(十九):【通知系统】延时烹饪提醒——让通知不再错过关键步骤
华为鸿蒙系统·harmonyos6.1.0·灵犀厨房
大师兄66688 天前
卡片状态驱动刷新——让不同卡片实例显示不同内容的正确方式
服务卡片·harmonyos6·formkit·状态驱动
若兰幽竹10 天前
HarmonyOS 6.1 全场景实战|《灵犀厨房》实战(十八):【手表协同】烹饪计时器流转至智能手表——手腕掌控烹饪节奏
智能手表·华为鸿蒙系统·harmonyos6.1.0·灵犀厨房
大师兄666812 天前
从零开发一个 HarmonyOS 输入法——KikaInputMethod 完整拆解
harmonyos·服务卡片·harmonyos6·formkit