【Jack实战】如何用 Form Kit 给《时光旅记》做桌面卡片

大家好,我是鸿蒙Jack。本期以我的《时光旅记》APP 为例,聊我是怎么把 Form Kit 接到桌面卡片场景里的。

桌面卡片不是一个单纯的 ArkUI 小页面。真正落到 APP 里,它要同时解决卡片声明、用户添加、实例保存、业务数据刷新、图片传递、点击回到 APP 指定页面这些问题。《时光旅记》目前启用了快捷语音记录、节假日倒计时、自定义倒数日、瞬间照片等卡片,其中瞬间照片还涉及 formImages 图片 fd 传递。

参考文档

官方文档:FormExtensionAbility、formBindingData。

整体架构

我这套实现的核心原则是:卡片只消费稳定数据,不直接耦合主页面状态。

用户新增瞬间、修改倒数日、云同步旅行计划或切换主题后,主应用先把业务数据写成 widget-snapshot.json,再根据卡片实例记录批量调用 formProvider.updateForm。系统定时刷新或用户手动刷新时,EntryFormAbility 也会重新读取快照并生成新的 FormBindingData。
#mermaid-svg-FueO6ijS7zulemNr{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-FueO6ijS7zulemNr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FueO6ijS7zulemNr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FueO6ijS7zulemNr .error-icon{fill:#552222;}#mermaid-svg-FueO6ijS7zulemNr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FueO6ijS7zulemNr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FueO6ijS7zulemNr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FueO6ijS7zulemNr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FueO6ijS7zulemNr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FueO6ijS7zulemNr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FueO6ijS7zulemNr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FueO6ijS7zulemNr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FueO6ijS7zulemNr .marker.cross{stroke:#333333;}#mermaid-svg-FueO6ijS7zulemNr svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FueO6ijS7zulemNr p{margin:0;}#mermaid-svg-FueO6ijS7zulemNr .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FueO6ijS7zulemNr .cluster-label text{fill:#333;}#mermaid-svg-FueO6ijS7zulemNr .cluster-label span{color:#333;}#mermaid-svg-FueO6ijS7zulemNr .cluster-label span p{background-color:transparent;}#mermaid-svg-FueO6ijS7zulemNr .label text,#mermaid-svg-FueO6ijS7zulemNr span{fill:#333;color:#333;}#mermaid-svg-FueO6ijS7zulemNr .node rect,#mermaid-svg-FueO6ijS7zulemNr .node circle,#mermaid-svg-FueO6ijS7zulemNr .node ellipse,#mermaid-svg-FueO6ijS7zulemNr .node polygon,#mermaid-svg-FueO6ijS7zulemNr .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FueO6ijS7zulemNr .rough-node .label text,#mermaid-svg-FueO6ijS7zulemNr .node .label text,#mermaid-svg-FueO6ijS7zulemNr .image-shape .label,#mermaid-svg-FueO6ijS7zulemNr .icon-shape .label{text-anchor:middle;}#mermaid-svg-FueO6ijS7zulemNr .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FueO6ijS7zulemNr .rough-node .label,#mermaid-svg-FueO6ijS7zulemNr .node .label,#mermaid-svg-FueO6ijS7zulemNr .image-shape .label,#mermaid-svg-FueO6ijS7zulemNr .icon-shape .label{text-align:center;}#mermaid-svg-FueO6ijS7zulemNr .node.clickable{cursor:pointer;}#mermaid-svg-FueO6ijS7zulemNr .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FueO6ijS7zulemNr .arrowheadPath{fill:#333333;}#mermaid-svg-FueO6ijS7zulemNr .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FueO6ijS7zulemNr .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FueO6ijS7zulemNr .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FueO6ijS7zulemNr .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FueO6ijS7zulemNr .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FueO6ijS7zulemNr .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FueO6ijS7zulemNr .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FueO6ijS7zulemNr .cluster text{fill:#333;}#mermaid-svg-FueO6ijS7zulemNr .cluster span{color:#333;}#mermaid-svg-FueO6ijS7zulemNr 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-FueO6ijS7zulemNr .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FueO6ijS7zulemNr rect.text{fill:none;stroke-width:0;}#mermaid-svg-FueO6ijS7zulemNr .icon-shape,#mermaid-svg-FueO6ijS7zulemNr .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FueO6ijS7zulemNr .icon-shape p,#mermaid-svg-FueO6ijS7zulemNr .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FueO6ijS7zulemNr .icon-shape .label rect,#mermaid-svg-FueO6ijS7zulemNr .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FueO6ijS7zulemNr .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FueO6ijS7zulemNr .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FueO6ijS7zulemNr :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户在时光旅记内操作
更新 TimeImprintStore
persistWidgetSnapshot 写 widget-snapshot.json
refreshWidgetForms 读取已发布卡片实例
WidgetDataResolver 转展示模型
WidgetFormBindingService 创建 FormBindingData
formProvider.updateForm 刷新桌面卡片
用户从 APP 添加卡片
保存 pending record
formProvider.openFormManager
系统回调 EntryFormAbility.onAddForm
用户点击桌面卡片
postCardAction router
EntryAbility 接收参数
MainPage 打开指定页面

WidgetFormRecord 保存实例配置,不是展示数据。比如照片卡片保存 momentId、mediaId、photoUri,倒数日卡片保存 countdownDayId 和 countdownStyle。标题、天数和图片 fd 在刷新时从快照解析。

添加卡片时序

WidgetCard WidgetFormBindingService EntryFormAbility 系统卡片管理页 WidgetFormConfigService MainPage 用户 WidgetCard WidgetFormBindingService EntryFormAbility 系统卡片管理页 WidgetFormConfigService MainPage 用户 #mermaid-svg-iUBgKUSxNqDmsVaj{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-iUBgKUSxNqDmsVaj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-iUBgKUSxNqDmsVaj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-iUBgKUSxNqDmsVaj .error-icon{fill:#552222;}#mermaid-svg-iUBgKUSxNqDmsVaj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-iUBgKUSxNqDmsVaj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-iUBgKUSxNqDmsVaj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-iUBgKUSxNqDmsVaj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-iUBgKUSxNqDmsVaj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-iUBgKUSxNqDmsVaj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-iUBgKUSxNqDmsVaj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-iUBgKUSxNqDmsVaj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-iUBgKUSxNqDmsVaj .marker.cross{stroke:#333333;}#mermaid-svg-iUBgKUSxNqDmsVaj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-iUBgKUSxNqDmsVaj p{margin:0;}#mermaid-svg-iUBgKUSxNqDmsVaj .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-iUBgKUSxNqDmsVaj text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-iUBgKUSxNqDmsVaj .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-iUBgKUSxNqDmsVaj .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-iUBgKUSxNqDmsVaj .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-iUBgKUSxNqDmsVaj .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-iUBgKUSxNqDmsVaj #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-iUBgKUSxNqDmsVaj .sequenceNumber{fill:white;}#mermaid-svg-iUBgKUSxNqDmsVaj #sequencenumber{fill:#333;}#mermaid-svg-iUBgKUSxNqDmsVaj #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-iUBgKUSxNqDmsVaj .messageText{fill:#333;stroke:none;}#mermaid-svg-iUBgKUSxNqDmsVaj .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-iUBgKUSxNqDmsVaj .labelText,#mermaid-svg-iUBgKUSxNqDmsVaj .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-iUBgKUSxNqDmsVaj .loopText,#mermaid-svg-iUBgKUSxNqDmsVaj .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-iUBgKUSxNqDmsVaj .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-iUBgKUSxNqDmsVaj .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-iUBgKUSxNqDmsVaj .noteText,#mermaid-svg-iUBgKUSxNqDmsVaj .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-iUBgKUSxNqDmsVaj .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-iUBgKUSxNqDmsVaj .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-iUBgKUSxNqDmsVaj .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-iUBgKUSxNqDmsVaj .actorPopupMenu{position:absolute;}#mermaid-svg-iUBgKUSxNqDmsVaj .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-iUBgKUSxNqDmsVaj .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-iUBgKUSxNqDmsVaj .actor-man circle,#mermaid-svg-iUBgKUSxNqDmsVaj line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-iUBgKUSxNqDmsVaj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击添加到桌面 保存 pending moment photo record formProvider.openFormManager(want) 选择添加至桌面 onAddForm(want) 读取 pending record 保存 formId 对应的 WidgetFormRecord 创建 FormBindingData 打开图片文件并写入 formImages 返回 FormBindingData Image(memory://imgName) 展示照片

刷新时序更直接:
WidgetCard formProvider WidgetDataResolver preferences WidgetSnapshotService 主应用页面 WidgetCard formProvider WidgetDataResolver preferences WidgetSnapshotService 主应用页面 #mermaid-svg-DWAE8mZ0WjOmZyBL{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-DWAE8mZ0WjOmZyBL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DWAE8mZ0WjOmZyBL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DWAE8mZ0WjOmZyBL .error-icon{fill:#552222;}#mermaid-svg-DWAE8mZ0WjOmZyBL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DWAE8mZ0WjOmZyBL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DWAE8mZ0WjOmZyBL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DWAE8mZ0WjOmZyBL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DWAE8mZ0WjOmZyBL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DWAE8mZ0WjOmZyBL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DWAE8mZ0WjOmZyBL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DWAE8mZ0WjOmZyBL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DWAE8mZ0WjOmZyBL .marker.cross{stroke:#333333;}#mermaid-svg-DWAE8mZ0WjOmZyBL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DWAE8mZ0WjOmZyBL p{margin:0;}#mermaid-svg-DWAE8mZ0WjOmZyBL .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-DWAE8mZ0WjOmZyBL text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-DWAE8mZ0WjOmZyBL .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-DWAE8mZ0WjOmZyBL .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-DWAE8mZ0WjOmZyBL .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-DWAE8mZ0WjOmZyBL .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-DWAE8mZ0WjOmZyBL #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-DWAE8mZ0WjOmZyBL .sequenceNumber{fill:white;}#mermaid-svg-DWAE8mZ0WjOmZyBL #sequencenumber{fill:#333;}#mermaid-svg-DWAE8mZ0WjOmZyBL #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-DWAE8mZ0WjOmZyBL .messageText{fill:#333;stroke:none;}#mermaid-svg-DWAE8mZ0WjOmZyBL .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-DWAE8mZ0WjOmZyBL .labelText,#mermaid-svg-DWAE8mZ0WjOmZyBL .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-DWAE8mZ0WjOmZyBL .loopText,#mermaid-svg-DWAE8mZ0WjOmZyBL .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-DWAE8mZ0WjOmZyBL .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-DWAE8mZ0WjOmZyBL .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-DWAE8mZ0WjOmZyBL .noteText,#mermaid-svg-DWAE8mZ0WjOmZyBL .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-DWAE8mZ0WjOmZyBL .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-DWAE8mZ0WjOmZyBL .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-DWAE8mZ0WjOmZyBL .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-DWAE8mZ0WjOmZyBL .actorPopupMenu{position:absolute;}#mermaid-svg-DWAE8mZ0WjOmZyBL .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-DWAE8mZ0WjOmZyBL .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-DWAE8mZ0WjOmZyBL .actor-man circle,#mermaid-svg-DWAE8mZ0WjOmZyBL line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-DWAE8mZ0WjOmZyBL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} loop 每个 formId persistWidgetSnapshot(context, store) 写 widget-snapshot.json refreshWidgetForms(context) getAll form records resolveWidgetBinding(widgetName, record, snapshot) WidgetBindingPayload updateForm(formId, FormBindingData) 推送 LocalStorage 数据

第一步:注册 FormExtensionAbility

entry/src/main/module.json5:

json5 复制代码
{
  "module": {
    "extensionAbilities": [
      {
        "name": "EntryFormAbility",
        "srcEntry": "./ets/entryformability/EntryFormAbility.ets",
        "label": "$string:EntryFormAbility_label",
        "description": "$string:EntryFormAbility_desc",
        "type": "form",
        "metadata": [
          {
            "name": "ohos.extension.form",
            "resource": "$profile:form_config"
          }
        ]
      }
    ]
  }
}

这里的 resource 指向 entry/src/main/resources/base/profile/form_config.json。系统会从这个文件读取当前应用可以添加哪些桌面卡片。

第二步:声明卡片配置

《时光旅记》当前启用四个卡片,全部复用 WidgetCard.ets 作为 ArkTS 入口,再根据 widgetType 分发到不同 UI。

json 复制代码
{
  "forms": [
    {
      "name": "quick_voice_moment",
      "displayName": "$string:widget_quick_voice_moment_display_name",
      "description": "$string:widget_quick_voice_moment_desc",
      "src": "./ets/widget/pages/WidgetCard.ets",
      "uiSyntax": "arkts",
      "isDynamic": true,
      "isDefault": true,
      "updateEnabled": true,
      "scheduledUpdateTime": "09:00",
      "updateDuration": 2,
      "defaultDimension": "2*2",
      "supportDimensions": ["2*2"]
    },
    {
      "name": "holiday_countdown",
      "displayName": "$string:widget_holiday_countdown_display_name",
      "description": "$string:widget_holiday_countdown_desc",
      "src": "./ets/widget/pages/WidgetCard.ets",
      "uiSyntax": "arkts",
      "isDynamic": true,
      "isDefault": false,
      "updateEnabled": true,
      "scheduledUpdateTime": "00:05",
      "updateDuration": 2,
      "defaultDimension": "2*4",
      "supportDimensions": ["2*4"]
    },
    {
      "name": "countdown_day",
      "displayName": "$string:widget_countdown_day_display_name",
      "description": "$string:widget_countdown_day_desc",
      "src": "./ets/widget/pages/WidgetCard.ets",
      "uiSyntax": "arkts",
      "isDynamic": true,
      "isDefault": false,
      "updateEnabled": true,
      "scheduledUpdateTime": "00:03",
      "updateDuration": 2,
      "defaultDimension": "2*4",
      "supportDimensions": ["2*4"]
    },
    {
      "name": "moment_photo",
      "displayName": "$string:widget_moment_photo_display_name",
      "description": "$string:widget_moment_photo_desc",
      "src": "./ets/widget/pages/WidgetCard.ets",
      "uiSyntax": "arkts",
      "isDynamic": true,
      "isDefault": false,
      "updateEnabled": true,
      "scheduledUpdateTime": "09:00",
      "updateDuration": 2,
      "defaultDimension": "2*2",
      "supportDimensions": ["2*2"]
    }
  ]
}

name 要和 openFormManager 里的 ohos.extra.param.key.form_name、业务常量一致。尺寸也要提前定好,瞬间照片适合 22,倒数日用 24。

第三步:定义卡片实例和展示模型

我把"实例配置"和"展示数据"拆开。实例配置用 WidgetFormRecord,展示数据用 WidgetBindingPayload。

ts 复制代码
import { CountdownDayRecord, TimeImprintStore } from '../model/TimeImprintModels';
import { ThemePalette } from '../utils/ThemePalette';

export const WIDGET_QUICK_VOICE_MOMENT: string = 'quick_voice_moment';
export const WIDGET_HOLIDAY_COUNTDOWN: string = 'holiday_countdown';
export const WIDGET_COUNTDOWN_DAY: string = 'countdown_day';
export const WIDGET_MOMENT_PHOTO: string = 'moment_photo';

export const COUNTDOWN_DAY_WIDGET_STYLE_MEADOW: string = 'meadow';
export const COUNTDOWN_DAY_WIDGET_STYLE_DUSK: string = 'dusk';
export const COUNTDOWN_DAY_WIDGET_STYLE_PAPER: string = 'paper';

export class WidgetFormRecord {
  widgetName: string = WIDGET_QUICK_VOICE_MOMENT;
  revisitIndex: number = 0;
  momentId: string = '';
  mediaId: string = '';
  countdownDayId: string = '';
  countdownStyle: string = COUNTDOWN_DAY_WIDGET_STYLE_MEADOW;
  photoUri: string = '';
  photoSourceUri: string = '';
  photoTitle: string = '';
}

export class WidgetBindingPayload {
  widgetType: string = WIDGET_QUICK_VOICE_MOMENT;
  theme: string = ThemePalette.DEFAULT_ID;
  title: string = '';
  contentTitle: string = '';
  primaryTarget: string = '';
  primaryFeature: string = '';
  primaryMomentId: string = '';
  countdown: string = '0';
  countdownStyle: string = COUNTDOWN_DAY_WIDGET_STYLE_MEADOW;
  holidayName: string = '';
  holidayDate: string = '';
  holidayDescription: string = '';
  holidayNote: string = '';
  emptyState: string = 'false';
  cardMode: string = 'upcoming';
  photoUri: string = '';
  imgName: string = '';
}

export function getSupportedWidgetNames(): Array<string> {
  return [
    WIDGET_QUICK_VOICE_MOMENT,
    WIDGET_HOLIDAY_COUNTDOWN,
    WIDGET_COUNTDOWN_DAY,
    WIDGET_MOMENT_PHOTO
  ];
}

export function createDefaultWidgetFormRecord(widgetName: string): WidgetFormRecord {
  let record: WidgetFormRecord = new WidgetFormRecord();
  record.widgetName = getSupportedWidgetNames().indexOf(widgetName) >= 0 ? widgetName : WIDGET_QUICK_VOICE_MOMENT;
  return record;
}

export function normalizeCountdownDayWidgetStyle(styleKey: string): string {
  if (styleKey === COUNTDOWN_DAY_WIDGET_STYLE_DUSK || styleKey === COUNTDOWN_DAY_WIDGET_STYLE_PAPER) {
    return styleKey;
  }
  return COUNTDOWN_DAY_WIDGET_STYLE_MEADOW;
}

export function normalizeWidgetFormRecord(widgetName: string, rawRecord: WidgetFormRecord | undefined): WidgetFormRecord {
  let record: WidgetFormRecord = createDefaultWidgetFormRecord(widgetName);
  if (rawRecord === undefined) {
    return record;
  }
  record.revisitIndex = rawRecord.revisitIndex >= 0 ? rawRecord.revisitIndex : 0;
  record.momentId = rawRecord.momentId ? rawRecord.momentId : '';
  record.mediaId = rawRecord.mediaId ? rawRecord.mediaId : '';
  record.countdownDayId = rawRecord.countdownDayId ? rawRecord.countdownDayId : '';
  record.countdownStyle = normalizeCountdownDayWidgetStyle(rawRecord.countdownStyle ? rawRecord.countdownStyle : '');
  record.photoUri = rawRecord.photoUri ? rawRecord.photoUri : '';
  record.photoSourceUri = rawRecord.photoSourceUri ? rawRecord.photoSourceUri : record.photoUri;
  record.photoTitle = rawRecord.photoTitle ? rawRecord.photoTitle : '';
  return record;
}

export function resolveWidgetBinding(widgetName: string, record: WidgetFormRecord, store: TimeImprintStore): WidgetBindingPayload {
  let safeRecord: WidgetFormRecord = normalizeWidgetFormRecord(widgetName, record);
  if (safeRecord.widgetName === WIDGET_COUNTDOWN_DAY) {
    return buildCountdownDayWidgetBinding(store, safeRecord);
  }
  if (safeRecord.widgetName === WIDGET_MOMENT_PHOTO) {
    return buildMomentPhotoWidgetBinding(store, safeRecord);
  }
  if (safeRecord.widgetName === WIDGET_HOLIDAY_COUNTDOWN) {
    return buildHolidayCountdownWidgetBinding(store);
  }
  return buildQuickVoiceMomentWidgetBinding(store);
}

function createBaseBinding(widgetType: string, themePaletteId: string): WidgetBindingPayload {
  let data: WidgetBindingPayload = new WidgetBindingPayload();
  data.widgetType = widgetType;
  data.theme = themePaletteId.length > 0 ? themePaletteId : ThemePalette.DEFAULT_ID;
  return data;
}

function buildQuickVoiceMomentWidgetBinding(store: TimeImprintStore): WidgetBindingPayload {
  let data: WidgetBindingPayload = createBaseBinding(WIDGET_QUICK_VOICE_MOMENT, store.themePaletteId);
  data.title = '时光旅记';
  data.contentTitle = '精彩的瞬间是...';
  data.primaryTarget = 'quick_voice_moment';
  return data;
}

function buildMomentPhotoWidgetBinding(store: TimeImprintStore, record: WidgetFormRecord): WidgetBindingPayload {
  let data: WidgetBindingPayload = createBaseBinding(WIDGET_MOMENT_PHOTO, store.themePaletteId);
  data.title = record.photoTitle.length > 0 ? record.photoTitle : '瞬间图片';
  data.photoUri = record.photoUri;
  data.primaryTarget = 'moment';
  data.primaryMomentId = record.momentId;
  return data;
}

function buildCountdownDayWidgetBinding(store: TimeImprintStore, record: WidgetFormRecord): WidgetBindingPayload {
  let data: WidgetBindingPayload = createBaseBinding(WIDGET_COUNTDOWN_DAY, store.themePaletteId);
  let countdownDay: CountdownDayRecord | undefined = findCountdownDayForWidget(store, record.countdownDayId);
  data.primaryTarget = 'feature';
  data.primaryFeature = 'countdown_day';
  data.countdownStyle = normalizeCountdownDayWidgetStyle(record.countdownStyle);
  if (countdownDay === undefined) {
    data.title = '倒数日';
    data.holidayName = '添加纪念日';
    data.holidayNote = '打开应用设置目标日';
    data.emptyState = 'true';
    return data;
  }
  let remainingDays: number = getRemainingDays(countdownDay.targetDate);
  data.title = remainingDays >= 0 ? '距离' + countdownDay.title + '还有' : countdownDay.title + '已过去';
  data.countdown = Math.abs(remainingDays).toString();
  data.holidayName = countdownDay.title;
  data.holidayDate = countdownDay.targetDate;
  data.holidayDescription = countdownDay.note;
  data.holidayNote = countdownDay.kind === 'anniversary' ? '每年纪念日' : '目标日倒计时';
  data.cardMode = remainingDays === 0 ? 'active' : 'upcoming';
  return data;
}

function buildHolidayCountdownWidgetBinding(store: TimeImprintStore): WidgetBindingPayload {
  let data: WidgetBindingPayload = createBaseBinding(WIDGET_HOLIDAY_COUNTDOWN, store.themePaletteId);
  data.title = '距离清明节还有';
  data.primaryTarget = 'home';
  data.countdown = '0';
  data.holidayName = '清明节';
  data.holidayDate = '2026.04.04 - 2026.04.06';
  data.holidayDescription = '放假3天';
  data.holidayNote = '无需调休';
  return data;
}

function findCountdownDayForWidget(store: TimeImprintStore, countdownDayId: string): CountdownDayRecord | undefined {
  let records: Array<CountdownDayRecord> = store.countdownDays ? store.countdownDays : [];
  if (countdownDayId.length > 0) {
    for (let i: number = 0; i < records.length; i++) {
      if (records[i].id === countdownDayId) {
        return records[i];
      }
    }
  }
  for (let i: number = 0; i < records.length; i++) {
    if (records[i].isPinnedToWidget) {
      return records[i];
    }
  }
  return records.length > 0 ? records[0] : undefined;
}

function getRemainingDays(targetDate: string): number {
  let matched: RegExpMatchArray | null = targetDate.trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
  if (matched === null) {
    return 0;
  }
  let target: Date = new Date(Number(matched[1]), Number(matched[2]) - 1, Number(matched[3]));
  let now: Date = new Date();
  let today: Date = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  return Math.ceil((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000));
}

项目里的 WidgetDataResolver.ets 更完整,包含年度节假日表、农历倒数日计算等分支。真实项目请以源码文件为准。

第四步:创建 FormBindingData

普通文本字段直接放到对象里即可。图片卡片多一步:拿到稳定图片路径,用 fileIo.openSync 打开文件,把 fd 放进 formImages,UI 里用 Image('memory://' + imgName) 展示。

ts 复制代码
import { Context } from '@kit.AbilityKit';
import { fileIo } from '@kit.CoreFileKit';
import { formBindingData } from '@kit.FormKit';
import { LocalMediaRecord, MediaKind, MomentRecord, NotebookRecord, TimeImprintStore } from '../model/TimeImprintModels';
import { resolveWidgetBinding, WidgetBindingPayload, WidgetFormRecord, WIDGET_MOMENT_PHOTO } from './WidgetDataResolver';
import { prepareMomentPhotoWidgetRecordSync } from './WidgetFormConfigService';

class MomentPhotoFormBindingPayload {
  widgetType: string = WIDGET_MOMENT_PHOTO;
  theme: string = '';
  title: string = '';
  primaryTarget: string = 'moment';
  primaryMomentId: string = '';
  photoUri: string = '';
  photoSourceUri: string = '';
  imgName: string = '';
  formImages: Record<string, number> = {};
}

export function createWidgetFormBindingData(
  context: Context,
  record: WidgetFormRecord,
  snapshot: TimeImprintStore
): formBindingData.FormBindingData {
  let effectiveRecord: WidgetFormRecord = resolveLatestWidgetFormRecord(record, snapshot);
  if (effectiveRecord.widgetName === WIDGET_MOMENT_PHOTO) {
    return formBindingData.createFormBindingData(
      toMomentPhotoBindingRecord(buildMomentPhotoFormBindingPayload(context, effectiveRecord, snapshot))
    );
  }
  return formBindingData.createFormBindingData(
    toWidgetBindingRecord(resolveWidgetBinding(effectiveRecord.widgetName, effectiveRecord, snapshot))
  );
}

export function resolveLatestWidgetFormRecord(record: WidgetFormRecord, snapshot: TimeImprintStore): WidgetFormRecord {
  if (record.widgetName !== WIDGET_MOMENT_PHOTO) {
    return record;
  }
  let moment: MomentRecord | undefined = findMomentById(snapshot, record.momentId);
  if (moment === undefined) {
    return record;
  }
  let media: LocalMediaRecord | undefined = findPhotoById(moment, record.mediaId) ?? findFirstPhoto(moment);
  if (media === undefined) {
    return record;
  }
  record.mediaId = media.id;
  record.photoSourceUri = media.localUri.length > 0 ? media.localUri : record.photoUri;
  record.photoUri = record.photoUri.length > 0 ? record.photoUri : record.photoSourceUri;
  record.photoTitle = moment.title.length > 0 ? moment.title : record.photoTitle;
  return record;
}

function buildMomentPhotoFormBindingPayload(
  context: Context,
  record: WidgetFormRecord,
  snapshot: TimeImprintStore
): MomentPhotoFormBindingPayload {
  let stableRecord: WidgetFormRecord = prepareMomentPhotoWidgetRecordSync(context, record);
  record.photoUri = stableRecord.photoUri;
  record.photoSourceUri = stableRecord.photoSourceUri;
  record.mediaId = stableRecord.mediaId;

  let basePayload: WidgetBindingPayload = resolveWidgetBinding(WIDGET_MOMENT_PHOTO, record, snapshot);
  let payload: MomentPhotoFormBindingPayload = new MomentPhotoFormBindingPayload();
  payload.widgetType = basePayload.widgetType;
  payload.theme = basePayload.theme;
  payload.title = basePayload.title;
  payload.primaryTarget = basePayload.primaryTarget;
  payload.primaryMomentId = basePayload.primaryMomentId;
  payload.photoUri = basePayload.photoUri;
  payload.photoSourceUri = record.photoSourceUri;

  let imageKey: string = 'momentPhoto_' + sanitizeImageKey(record.mediaId.length > 0 ? record.mediaId : record.momentId);
  let file: fileIo.File | undefined = openFile(record.photoUri);
  if (file !== undefined) {
    let images: Record<string, number> = {};
    images[imageKey] = file.fd;
    payload.formImages = images;
    payload.imgName = imageKey;
  }
  return payload;
}

function toWidgetBindingRecord(payload: WidgetBindingPayload): Record<string, Object> {
  return {
    widgetType: payload.widgetType,
    theme: payload.theme,
    title: payload.title,
    contentTitle: payload.contentTitle,
    primaryTarget: payload.primaryTarget,
    primaryFeature: payload.primaryFeature,
    primaryMomentId: payload.primaryMomentId,
    countdown: payload.countdown,
    countdownStyle: payload.countdownStyle,
    holidayName: payload.holidayName,
    holidayDate: payload.holidayDate,
    holidayDescription: payload.holidayDescription,
    holidayNote: payload.holidayNote,
    emptyState: payload.emptyState,
    cardMode: payload.cardMode,
    photoUri: payload.photoUri,
    imgName: payload.imgName,
    refreshVersion: Date.now().toString()
  };
}

function toMomentPhotoBindingRecord(payload: MomentPhotoFormBindingPayload): Record<string, Object> {
  return {
    widgetType: payload.widgetType,
    theme: payload.theme,
    title: payload.title,
    primaryTarget: payload.primaryTarget,
    primaryMomentId: payload.primaryMomentId,
    photoUri: payload.photoUri,
    photoSourceUri: payload.photoSourceUri,
    imgName: payload.imgName,
    formImages: payload.formImages,
    refreshVersion: Date.now().toString()
  };
}

function openFile(path: string): fileIo.File | undefined {
  if (path.length === 0) {
    return undefined;
  }
  try {
    return fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
  } catch (_error) {
    return undefined;
  }
}

function findMomentById(snapshot: TimeImprintStore, momentId: string): MomentRecord | undefined {
  for (let i: number = 0; i < snapshot.notebooks.length; i++) {
    let notebook: NotebookRecord = snapshot.notebooks[i];
    for (let j: number = 0; j < notebook.moments.length; j++) {
      if (notebook.moments[j].id === momentId) {
        return notebook.moments[j];
      }
    }
  }
  return undefined;
}

function findPhotoById(moment: MomentRecord, mediaId: string): LocalMediaRecord | undefined {
  for (let i: number = 0; i < moment.mediaItems.length; i++) {
    let media: LocalMediaRecord = moment.mediaItems[i];
    if (media.id === mediaId && media.kind === MediaKind.PHOTO) {
      return media;
    }
  }
  return undefined;
}

function findFirstPhoto(moment: MomentRecord): LocalMediaRecord | undefined {
  for (let i: number = 0; i < moment.mediaItems.length; i++) {
    if (moment.mediaItems[i].kind === MediaKind.PHOTO) {
      return moment.mediaItems[i];
    }
  }
  return undefined;
}

function sanitizeImageKey(value: string): string {
  let result: string = '';
  for (let i: number = 0; i < value.length; i++) {
    let ch: string = value.charAt(i);
    result += ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch === '_') ? ch : '_';
  }
  return result.length > 0 ? result : 'moment_photo';
}

formImages 是图片卡片的关键。imgName 只是 UI 引用名,真正图片数据在 formImages 里。

第五步:实现 EntryFormAbility

EntryFormAbility 负责系统卡片生命周期。创建时读取 want 和 pending record,刷新时重新读取业务快照,删除时清理实例记录。

ts 复制代码
import { Configuration, Want } from '@kit.AbilityKit';
import { preferences } from '@kit.ArkData';
import { formBindingData, FormExtensionAbility, formInfo, formProvider } from '@kit.FormKit';
import { TimeImprintStore } from '../model/TimeImprintModels';
import {
  createDefaultWidgetFormRecord,
  getSupportedWidgetNames,
  normalizeCountdownDayWidgetStyle,
  normalizeWidgetFormRecord,
  WidgetFormRecord,
  WIDGET_COUNTDOWN_DAY,
  WIDGET_MOMENT_PHOTO,
  WIDGET_QUICK_VOICE_MOMENT
} from '../widget/WidgetDataResolver';
import { createWidgetFormBindingData, resolveLatestWidgetFormRecord } from '../widget/WidgetFormBindingService';
import {
  clearPendingCountdownDayWidgetRecordSync,
  clearPendingMomentPhotoWidgetRecordSync,
  consumePendingCountdownDayWidgetRecordSync,
  consumePendingMomentPhotoWidgetRecordSync
} from '../widget/WidgetFormConfigService';
import { readWidgetSnapshotSync, refreshWidgetSnapshotFromPersistence } from '../widget/WidgetSnapshotService';

const WIDGET_STORE_NAME: string = 'time_imprint_widget_store';
const WIDGET_STORE_PREFIX: string = 'widget_form_';
const PARAM_MOMENT_ID: string = 'timeimprint.form.moment_id';
const PARAM_MEDIA_ID: string = 'timeimprint.form.media_id';
const PARAM_PHOTO_URI: string = 'timeimprint.form.photo_uri';
const PARAM_PHOTO_TITLE: string = 'timeimprint.form.photo_title';
const PARAM_COUNTDOWN_DAY_ID: string = 'timeimprint.form.countdown_day_id';
const PARAM_COUNTDOWN_STYLE: string = 'timeimprint.form.countdown_style';

export default class EntryFormAbility extends FormExtensionAbility {
  onAddForm(want: Want): formBindingData.FormBindingData {
    let widgetName: string = this.resolveWidgetName(want);
    let formId: string = this.resolveFormId(want);
    let defaultRecord: WidgetFormRecord = this.resolveDefaultFormRecord(widgetName, want);
    let finalRecord: WidgetFormRecord = defaultRecord;

    if (widgetName === WIDGET_MOMENT_PHOTO) {
      finalRecord = this.initializeMomentPhotoFormRecordSync(formId, defaultRecord);
    } else if (widgetName === WIDGET_COUNTDOWN_DAY) {
      finalRecord = this.initializeCountdownDayFormRecordSync(formId, defaultRecord);
    }

    let snapshot: TimeImprintStore = readWidgetSnapshotSync(this.context);
    finalRecord = resolveLatestWidgetFormRecord(finalRecord, snapshot);
    if (formId.length > 0) {
      void this.saveFormRecord(formId, finalRecord);
    }
    return createWidgetFormBindingData(this.context, finalRecord, snapshot);
  }

  onUpdateForm(formId: string): void {
    void this.refreshForm(formId);
  }

  onFormEvent(formId: string, _message: string): void {
    void this.refreshForm(formId);
  }

  onRemoveForm(formId: string): void {
    void this.deleteFormRecord(formId);
  }

  onCastToNormalForm(_formId: string): void {}
  onChangeFormVisibility(_newStatus: Record<string, number>): void {}
  onConfigurationUpdate(_config: Configuration): void {}

  onAcquireFormState(_want: Want): formInfo.FormState {
    return formInfo.FormState.READY;
  }

  private async refreshForm(formId: string): Promise<void> {
    if (formId.length === 0) {
      return;
    }
    let record: WidgetFormRecord | undefined = await this.loadFormRecord(formId);
    if (record === undefined) {
      return;
    }
    let snapshot: TimeImprintStore = await this.readFreshWidgetSnapshot();
    let latestRecord: WidgetFormRecord = resolveLatestWidgetFormRecord(record, snapshot);
    await formProvider.updateForm(formId, createWidgetFormBindingData(this.context, latestRecord, snapshot));
    if (JSON.stringify(latestRecord) !== JSON.stringify(record)) {
      await this.saveFormRecord(formId, latestRecord);
    }
  }

  private async readFreshWidgetSnapshot(): Promise<TimeImprintStore> {
    try {
      return await refreshWidgetSnapshotFromPersistence(this.context);
    } catch (_error) {
      return readWidgetSnapshotSync(this.context);
    }
  }

  private initializeMomentPhotoFormRecordSync(formId: string, fallbackRecord: WidgetFormRecord): WidgetFormRecord {
    let record: WidgetFormRecord | undefined = fallbackRecord.photoUri.length > 0 ?
      fallbackRecord :
      consumePendingMomentPhotoWidgetRecordSync(this.context);
    let finalRecord: WidgetFormRecord = record === undefined ? fallbackRecord : record;
    if (formId.length > 0 && fallbackRecord.photoUri.length > 0) {
      clearPendingMomentPhotoWidgetRecordSync(this.context);
    }
    return finalRecord;
  }

  private initializeCountdownDayFormRecordSync(formId: string, fallbackRecord: WidgetFormRecord): WidgetFormRecord {
    let record: WidgetFormRecord | undefined = fallbackRecord.countdownDayId.length > 0 ?
      fallbackRecord :
      consumePendingCountdownDayWidgetRecordSync(this.context);
    let finalRecord: WidgetFormRecord = record === undefined ? fallbackRecord : record;
    if (formId.length > 0 && fallbackRecord.countdownDayId.length > 0) {
      clearPendingCountdownDayWidgetRecordSync(this.context);
    }
    return finalRecord;
  }

  private resolveWidgetName(want: Want): string {
    let value: Object | undefined = this.readWantParameter(want, formInfo.FormParam.NAME_KEY);
    let widgetName: string = value === undefined ? '' : value.toString();
    return getSupportedWidgetNames().indexOf(widgetName) >= 0 ? widgetName : WIDGET_QUICK_VOICE_MOMENT;
  }

  private resolveFormId(want: Want): string {
    let value: Object | undefined = this.readWantParameter(want, formInfo.FormParam.IDENTITY_KEY);
    return value === undefined ? '' : value.toString();
  }

  private resolveDefaultFormRecord(widgetName: string, want: Want): WidgetFormRecord {
    if (widgetName === WIDGET_MOMENT_PHOTO) {
      let record: WidgetFormRecord = createDefaultWidgetFormRecord(WIDGET_MOMENT_PHOTO);
      record.momentId = this.readWantStringParameter(want, PARAM_MOMENT_ID);
      record.mediaId = this.readWantStringParameter(want, PARAM_MEDIA_ID);
      record.photoUri = this.readWantStringParameter(want, PARAM_PHOTO_URI);
      record.photoSourceUri = record.photoUri;
      record.photoTitle = this.readWantStringParameter(want, PARAM_PHOTO_TITLE);
      return record;
    }
    if (widgetName === WIDGET_COUNTDOWN_DAY) {
      let record: WidgetFormRecord = createDefaultWidgetFormRecord(WIDGET_COUNTDOWN_DAY);
      record.countdownDayId = this.readWantStringParameter(want, PARAM_COUNTDOWN_DAY_ID);
      record.countdownStyle = normalizeCountdownDayWidgetStyle(this.readWantStringParameter(want, PARAM_COUNTDOWN_STYLE));
      return record;
    }
    return createDefaultWidgetFormRecord(widgetName);
  }

  private readWantStringParameter(want: Want, key: string): string {
    let value: Object | undefined = this.readWantParameter(want, key);
    return value === undefined ? '' : value.toString();
  }

  private readWantParameter(want: Want, key: string): Object | undefined {
    if (want.parameters === undefined) {
      return undefined;
    }
    let params: Record<string, Object> = want.parameters as Record<string, Object>;
    return params[key] as Object | undefined;
  }

  private async saveFormRecord(formId: string, record: WidgetFormRecord): Promise<void> {
    let store: preferences.Preferences = await preferences.getPreferences(this.context, WIDGET_STORE_NAME);
    await store.put(WIDGET_STORE_PREFIX + formId, JSON.stringify(record));
    await store.flush();
  }

  private async loadFormRecord(formId: string): Promise<WidgetFormRecord | undefined> {
    let store: preferences.Preferences = await preferences.getPreferences(this.context, WIDGET_STORE_NAME);
    let rawValue: string = await store.get(WIDGET_STORE_PREFIX + formId, '') as string;
    if (rawValue.length === 0) {
      return undefined;
    }
    let rawRecord: WidgetFormRecord = JSON.parse(rawValue) as WidgetFormRecord;
    return normalizeWidgetFormRecord(rawRecord.widgetName, rawRecord);
  }

  private async deleteFormRecord(formId: string): Promise<void> {
    if (formId.length === 0) {
      return;
    }
    let store: preferences.Preferences = await preferences.getPreferences(this.context, WIDGET_STORE_NAME);
    await store.delete(WIDGET_STORE_PREFIX + formId);
    await store.flush();
  }
}

FormExtensionAbility 不能常驻后台,生命周期调度完成后会被系统清理。所以我不会在这里做重网络、重计算或长时间图片处理。重活放在主应用侧,卡片侧只读快照并组装展示数据。

第六步:从 APP 内打开系统卡片管理页

用户从 APP 内选择照片或倒数日后,我先保存 pending record,再打开系统卡片管理页。等用户点击"添加至桌面",系统才回调 onAddForm。

瞬间照片卡片:

ts 复制代码
import { Context, Want } from '@kit.AbilityKit';
import { formProvider } from '@kit.FormKit';
import {
  createMomentPhotoWidgetRecord,
  savePendingMomentPhotoWidgetRecordSync
} from '../../widget/WidgetFormConfigService';
import { WIDGET_MOMENT_PHOTO } from '../../widget/WidgetDataResolver';

const APP_BUNDLE_NAME: string = 'com.smartcloud.lifetime';

private async addMomentPhotoToDesktop(momentId: string, mediaId: string, photoUri: string, title: string): Promise<void> {
  const hostContext: Context | undefined = this.getUIContext().getHostContext();
  if (hostContext === undefined) {
    this.showToast('当前页面暂时无法添加卡片');
    return;
  }
  savePendingMomentPhotoWidgetRecordSync(
    hostContext,
    createMomentPhotoWidgetRecord(momentId, mediaId, photoUri, title)
  );
  const want: Want = {
    bundleName: APP_BUNDLE_NAME,
    abilityName: 'EntryFormAbility',
    parameters: {
      'ohos.extra.param.key.form_dimension': 2,
      'ohos.extra.param.key.form_name': WIDGET_MOMENT_PHOTO,
      'ohos.extra.param.key.module_name': 'entry',
      'timeimprint.form.moment_id': momentId,
      'timeimprint.form.media_id': mediaId,
      'timeimprint.form.photo_uri': photoUri,
      'timeimprint.form.photo_title': title
    }
  };
  formProvider.openFormManager(want);
  this.showToast('已打开卡片管理页,请选择"添加至桌面"');
}

倒数日卡片:

ts 复制代码
import { Context, Want } from '@kit.AbilityKit';
import { formProvider } from '@kit.FormKit';
import {
  createCountdownDayWidgetRecord,
  savePendingCountdownDayWidgetRecordSync
} from '../../widget/WidgetFormConfigService';

private async openWidgetManager(recordId: string, styleKey: string): Promise<void> {
  const hostContext: Context | undefined = this.getUIContext().getHostContext();
  if (hostContext === undefined || recordId.length === 0) {
    this.showToast('当前页面暂时无法添加卡片');
    return;
  }
  savePendingCountdownDayWidgetRecordSync(hostContext, createCountdownDayWidgetRecord(recordId, styleKey));
  const want: Want = {
    bundleName: 'com.smartcloud.lifetime',
    abilityName: 'EntryFormAbility',
    parameters: {
      'ohos.extra.param.key.form_dimension': 3,
      'ohos.extra.param.key.form_name': 'countdown_day',
      'ohos.extra.param.key.module_name': 'entry',
      'timeimprint.form.countdown_day_id': recordId,
      'timeimprint.form.countdown_style': styleKey
    }
  };
  formProvider.openFormManager(want);
  this.showToast('已打开倒数日卡片,请选择"添加至桌面"');
}

这里的 form_dimension 要和 form_config.json 支持的尺寸匹配。《时光旅记》里 22 用 2,2 4 用 3。

第七步:保存 pending record 和稳定图片副本

openFormManager 不是同步添加成功。为了保证刚选择的照片或倒数日不丢,我会在 filesDir/time-imprint 下保存 pending record。瞬间照片还会复制到 filesDir/time-imprint/widget-images,避免原始 uri 后面打不开。

ts 复制代码
import { Context } from '@kit.AbilityKit';
import { util } from '@kit.ArkTS';
import { fileIo } from '@kit.CoreFileKit';
import {
  createDefaultWidgetFormRecord,
  normalizeCountdownDayWidgetStyle,
  normalizeWidgetFormRecord,
  WidgetFormRecord,
  WIDGET_COUNTDOWN_DAY,
  WIDGET_MOMENT_PHOTO
} from './WidgetDataResolver';

const WIDGET_CONFIG_DIRECTORY: string = 'time-imprint';
const WIDGET_IMAGE_DIRECTORY: string = 'widget-images';
const PENDING_MOMENT_PHOTO: string = 'pending-moment-photo-widget.json';
const PENDING_COUNTDOWN_DAY: string = 'pending-countdown-day-widget.json';

export function createMomentPhotoWidgetRecord(momentId: string, mediaId: string, photoUri: string, title: string): WidgetFormRecord {
  let record: WidgetFormRecord = createDefaultWidgetFormRecord(WIDGET_MOMENT_PHOTO);
  record.momentId = momentId;
  record.mediaId = mediaId;
  record.photoUri = photoUri;
  record.photoSourceUri = photoUri;
  record.photoTitle = title;
  return record;
}

export function createCountdownDayWidgetRecord(countdownDayId: string, style: string): WidgetFormRecord {
  let record: WidgetFormRecord = createDefaultWidgetFormRecord(WIDGET_COUNTDOWN_DAY);
  record.countdownDayId = countdownDayId;
  record.countdownStyle = normalizeCountdownDayWidgetStyle(style);
  return record;
}

export function savePendingMomentPhotoWidgetRecordSync(context: Context, record: WidgetFormRecord): void {
  writeRecordSync(getPendingMomentPhotoPath(context), prepareMomentPhotoWidgetRecordSync(context, record));
}

export function consumePendingMomentPhotoWidgetRecordSync(context: Context): WidgetFormRecord | undefined {
  let record: WidgetFormRecord | undefined = readRecordSync(getPendingMomentPhotoPath(context));
  clearPendingMomentPhotoWidgetRecordSync(context);
  return record === undefined ? undefined : normalizeWidgetFormRecord(WIDGET_MOMENT_PHOTO, record);
}

export function clearPendingMomentPhotoWidgetRecordSync(context: Context): void {
  unlinkIfExistsSync(getPendingMomentPhotoPath(context));
}

export function savePendingCountdownDayWidgetRecordSync(context: Context, record: WidgetFormRecord): void {
  writeRecordSync(getPendingCountdownDayPath(context), normalizeWidgetFormRecord(WIDGET_COUNTDOWN_DAY, record));
}

export function consumePendingCountdownDayWidgetRecordSync(context: Context): WidgetFormRecord | undefined {
  let record: WidgetFormRecord | undefined = readRecordSync(getPendingCountdownDayPath(context));
  clearPendingCountdownDayWidgetRecordSync(context);
  return record === undefined ? undefined : normalizeWidgetFormRecord(WIDGET_COUNTDOWN_DAY, record);
}

export function clearPendingCountdownDayWidgetRecordSync(context: Context): void {
  unlinkIfExistsSync(getPendingCountdownDayPath(context));
}

export function prepareMomentPhotoWidgetRecordSync(context: Context, record: WidgetFormRecord): WidgetFormRecord {
  let safeRecord: WidgetFormRecord = normalizeWidgetFormRecord(WIDGET_MOMENT_PHOTO, record);
  safeRecord.photoSourceUri = safeRecord.photoSourceUri.length > 0 ? safeRecord.photoSourceUri : safeRecord.photoUri;
  let stablePath: string = getMomentPhotoWidgetImagePath(context, safeRecord);
  if (safeRecord.photoSourceUri.length > 0 && copyFileSync(safeRecord.photoSourceUri, stablePath)) {
    safeRecord.photoUri = stablePath;
  }
  return safeRecord;
}

function writeRecordSync(path: string, record: WidgetFormRecord): void {
  let directory: string = path.substring(0, path.lastIndexOf('/'));
  if (!fileIo.accessSync(directory)) {
    fileIo.mkdirSync(directory, true);
  }
  let payload: Uint8Array = util.TextEncoder.create('utf-8').encode(JSON.stringify(record));
  let file: fileIo.File = fileIo.openSync(path, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
  try {
    fileIo.writeSync(file.fd, payload.buffer);
  } finally {
    fileIo.closeSync(file);
  }
}

function readRecordSync(path: string): WidgetFormRecord | undefined {
  if (!fileIo.accessSync(path)) {
    return undefined;
  }
  let stat = fileIo.statSync(path);
  let buffer: Uint8Array = new Uint8Array(stat.size);
  let file: fileIo.File = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
  try {
    fileIo.readSync(file.fd, buffer.buffer);
  } finally {
    fileIo.closeSync(file);
  }
  return JSON.parse(util.TextDecoder.create('utf-8', { ignoreBOM: true }).decodeToString(buffer)) as WidgetFormRecord;
}

function copyFileSync(source: string, target: string): boolean {
  let sourceFile: fileIo.File | undefined = undefined;
  let targetFile: fileIo.File | undefined = undefined;
  try {
    sourceFile = fileIo.openSync(source, fileIo.OpenMode.READ_ONLY);
    let stat = fileIo.statSync(sourceFile.fd);
    let buffer: Uint8Array = new Uint8Array(stat.size);
    fileIo.readSync(sourceFile.fd, buffer.buffer);
    let dir: string = target.substring(0, target.lastIndexOf('/'));
    if (!fileIo.accessSync(dir)) {
      fileIo.mkdirSync(dir, true);
    }
    targetFile = fileIo.openSync(target, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.TRUNC);
    fileIo.writeSync(targetFile.fd, buffer.buffer);
    return true;
  } catch (_error) {
    return false;
  } finally {
    if (sourceFile !== undefined) {
      fileIo.closeSync(sourceFile);
    }
    if (targetFile !== undefined) {
      fileIo.closeSync(targetFile);
    }
  }
}

function unlinkIfExistsSync(path: string): void {
  if (fileIo.accessSync(path)) {
    fileIo.unlinkSync(path);
  }
}

function getWidgetConfigDirectory(context: Context): string {
  return context.filesDir + '/' + WIDGET_CONFIG_DIRECTORY;
}

function getPendingMomentPhotoPath(context: Context): string {
  return getWidgetConfigDirectory(context) + '/' + PENDING_MOMENT_PHOTO;
}

function getPendingCountdownDayPath(context: Context): string {
  return getWidgetConfigDirectory(context) + '/' + PENDING_COUNTDOWN_DAY;
}

function getMomentPhotoWidgetImagePath(context: Context, record: WidgetFormRecord): string {
  let base: string = record.mediaId.length > 0 ? record.mediaId : record.momentId;
  return getWidgetConfigDirectory(context) + '/' + WIDGET_IMAGE_DIRECTORY + '/' + sanitizeFileName(base) + '.jpg';
}

function sanitizeFileName(value: string): string {
  return value.replace(/[^a-zA-Z0-9_]/g, '_') || 'moment_photo';
}

第八步:主应用写快照并刷新卡片

卡片数据来自《时光旅记》的本地业务模型,但 FormExtensionAbility 不能依赖主页面内存。所以我把卡片需要的数据写到应用沙箱文件里,再读取已保存的 formId 批量刷新。

ts 复制代码
import { Context } from '@kit.AbilityKit';
import { util } from '@kit.ArkTS';
import { preferences } from '@kit.ArkData';
import { fileIo } from '@kit.CoreFileKit';
import { formProvider } from '@kit.FormKit';
import { TimeImprintStore } from '../model/TimeImprintModels';
import { normalizeWidgetFormRecord, WidgetFormRecord } from './WidgetDataResolver';
import { createWidgetFormBindingData, resolveLatestWidgetFormRecord } from './WidgetFormBindingService';
import { readPersistedTimeImprintSnapshot } from '../utils/TimeImprintPersistence';

const SNAPSHOT_DIR: string = 'time-imprint';
const SNAPSHOT_FILE: string = 'widget-snapshot.json';
const WIDGET_FORM_STORE_NAME: string = 'time_imprint_widget_store';
const WIDGET_FORM_KEY_PREFIX: string = 'widget_form_';
let widgetRefreshQueue: Promise<void> = Promise.resolve();

export async function persistWidgetSnapshot(context: Context, store: TimeImprintStore): Promise<void> {
  let directory: string = context.filesDir + '/' + SNAPSHOT_DIR;
  if (!fileIo.accessSync(directory)) {
    await fileIo.mkdir(directory, true);
  }
  let payload: Uint8Array = util.TextEncoder.create('utf-8').encode(JSON.stringify(store));
  let file: fileIo.File = fileIo.openSync(directory + '/' + SNAPSHOT_FILE, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
  try {
    fileIo.writeSync(file.fd, payload.buffer);
  } finally {
    fileIo.closeSync(file);
  }
}

export function readWidgetSnapshotSync(context: Context): TimeImprintStore {
  let path: string = context.filesDir + '/' + SNAPSHOT_DIR + '/' + SNAPSHOT_FILE;
  if (!fileIo.accessSync(path)) {
    return new TimeImprintStore();
  }
  let stat = fileIo.statSync(path);
  let buffer: Uint8Array = new Uint8Array(stat.size);
  let file: fileIo.File = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
  try {
    fileIo.readSync(file.fd, buffer.buffer);
  } finally {
    fileIo.closeSync(file);
  }
  return JSON.parse(util.TextDecoder.create('utf-8', { ignoreBOM: true }).decodeToString(buffer)) as TimeImprintStore;
}

export async function refreshWidgetSnapshotFromPersistence(context: Context): Promise<TimeImprintStore> {
  let snapshot: TimeImprintStore = await readPersistedTimeImprintSnapshot(context);
  await persistWidgetSnapshot(context, snapshot);
  return readWidgetSnapshotSync(context);
}

export async function refreshWidgetForms(context: Context): Promise<void> {
  widgetRefreshQueue = widgetRefreshQueue.then(async (): Promise<void> => {
    let snapshot: TimeImprintStore = await refreshWidgetSnapshotFromPersistence(context).catch(() => readWidgetSnapshotSync(context));
    let store: preferences.Preferences = await preferences.getPreferences(context, WIDGET_FORM_STORE_NAME);
    let values: Record<string, Object> = await store.getAll() as Record<string, Object>;
    let keys: Array<string> = Object.keys(values);
    for (let i: number = 0; i < keys.length; i++) {
      let key: string = keys[i];
      if (key.indexOf(WIDGET_FORM_KEY_PREFIX) !== 0 || typeof values[key] !== 'string') {
        continue;
      }
      let formId: string = key.slice(WIDGET_FORM_KEY_PREFIX.length);
      let rawRecord: WidgetFormRecord = JSON.parse(values[key] as string) as WidgetFormRecord;
      let record: WidgetFormRecord = resolveLatestWidgetFormRecord(normalizeWidgetFormRecord(rawRecord.widgetName, rawRecord), snapshot);
      await formProvider.updateForm(formId, createWidgetFormBindingData(context, record, snapshot));
      await store.put(key, JSON.stringify(record));
      await store.flush();
    }
  }).catch((): void => {});
  return widgetRefreshQueue;
}

主页面调用很简单:

ts 复制代码
private async syncWidgetSnapshot(): Promise<void> {
  let hostContext: Context | undefined = this.getUIContext().getHostContext();
  if (hostContext === undefined) {
    return;
  }
  await persistWidgetSnapshot(hostContext, this.store);
  await refreshWidgetForms(hostContext);
}

我在这些地方触发同步:小本和瞬间变化后、旅行计划云同步后、设置里切换主题后、用户登录后。原则是只要卡片展示依赖的数据变了,就主动刷新一次。

第九步:卡片 UI 接收数据并回到 APP

FormBindingData 传入的字段会进入卡片页 LocalStorage,所以 WidgetCard.ets 用 @LocalStorageProp 接收。点击卡片时,用 postCardAction 把路由参数传回 EntryAbility。

ts 复制代码
import { CountdownDayForm } from './forms/CountdownDayForm';
import { HolidayCountdownForm } from './forms/HolidayCountdownForm';
import { MomentPhotoForm } from './forms/MomentPhotoForm';
import { QuickVoiceMomentForm } from './forms/QuickVoiceMomentForm';
import { WIDGET_COUNTDOWN_DAY, WIDGET_HOLIDAY_COUNTDOWN, WIDGET_MOMENT_PHOTO, WIDGET_QUICK_VOICE_MOMENT } from './forms/WidgetFormTypes';

let storage: LocalStorage = new LocalStorage();

@Entry(storage)
@Component
export struct WidgetCard {
  @LocalStorageProp('widgetType') widgetType: string = WIDGET_QUICK_VOICE_MOMENT;
  @LocalStorageProp('title') title: string = '';
  @LocalStorageProp('primaryMomentId') primaryMomentId: string = '';
  @LocalStorageProp('cardMode') cardMode: string = 'upcoming';
  @LocalStorageProp('countdown') countdown: string = '0';
  @LocalStorageProp('countdownStyle') countdownStyle: string = 'meadow';
  @LocalStorageProp('holidayName') holidayName: string = '清明节';
  @LocalStorageProp('holidayDate') holidayDate: string = '';
  @LocalStorageProp('holidayDescription') holidayDescription: string = '';
  @LocalStorageProp('holidayNote') holidayNote: string = '';
  @LocalStorageProp('imgName') imgName: string = '';

  build(): void {
    Column() {
      if (this.widgetType === WIDGET_HOLIDAY_COUNTDOWN) {
        HolidayCountdownForm({
          cardMode: this.cardMode,
          countdown: this.countdown,
          holidayName: this.holidayName,
          holidayDate: this.holidayDate,
          holidayDescription: this.holidayDescription,
          holidayNote: this.holidayNote,
          onOpenTarget: (target: string, feature: string, momentId: string): void => this.openTarget(target, feature, momentId)
        })
      } else if (this.widgetType === WIDGET_COUNTDOWN_DAY) {
        CountdownDayForm({
          cardMode: this.cardMode,
          countdown: this.countdown,
          styleKey: this.countdownStyle,
          title: this.title,
          eventName: this.holidayName,
          eventDate: this.holidayDate,
          note: this.holidayDescription.length > 0 ? this.holidayDescription : this.holidayNote,
          onOpenTarget: (target: string, feature: string, momentId: string): void => this.openTarget(target, feature, momentId)
        })
      } else if (this.widgetType === WIDGET_MOMENT_PHOTO) {
        MomentPhotoForm({
          imgName: this.imgName,
          momentId: this.primaryMomentId,
          title: this.title,
          onOpenTarget: (target: string, feature: string, momentId: string): void => this.openTarget(target, feature, momentId)
        })
      } else {
        QuickVoiceMomentForm({
          onOpenTarget: (target: string, feature: string, momentId: string): void => this.openTarget(target, feature, momentId)
        })
      }
    }
    .width('100%')
    .height('100%')
  }

  private openTarget(target: string, feature: string, momentId: string): void {
    if (target.length === 0) {
      return;
    }
    postCardAction(this, {
      action: 'router',
      abilityName: 'EntryAbility',
      params: {
        target: target,
        feature: feature,
        momentId: momentId,
        source: 'widget'
      }
    });
  }
}

瞬间照片卡片 UI 的关键点是 memory://:

ts 复制代码
import { WidgetTargetAction } from './WidgetFormTypes';

@Component
export struct MomentPhotoForm {
  @Prop imgName: string = '';
  @Prop momentId: string = '';
  @Prop title: string = '';
  onOpenTarget: WidgetTargetAction = () => {};

  build(): void {
    Stack({ alignContent: Alignment.BottomStart }) {
      if (this.imgName.length > 0) {
        Image('memory://' + this.imgName)
          .width('100%')
          .height('100%')
          .objectFit(ImageFit.Cover)
      } else {
        Text('请前往瞬间中添加')
          .fontSize(13)
          .fontColor('#5C6675')
      }

      if (this.title.length > 0) {
        Text(this.title)
          .fontSize(12)
          .fontWeight(FontWeight.Medium)
          .fontColor(Color.White)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .padding({ left: 10, right: 10, top: 6, bottom: 6 })
          .backgroundColor('#660F172A')
          .borderRadius(14)
          .margin({ left: 12, bottom: 12, right: 12 })
      }
    }
    .width('100%')
    .height('100%')
    .clip(true)
    .onClick(() => {
      this.onOpenTarget('moment', '', this.momentId);
    })
  }
}

主应用里消费卡片路由:

ts 复制代码
private applyPendingLaunchAction(action: LaunchActionPayload): void {
  this.resetTransientStateForLaunchAction();
  if (action.target === 'quick_voice_moment') {
    void this.openQuickVoiceMomentPageFromWidget();
    return;
  }
  if (action.target === 'moment_composer') {
    this.openPrimaryMomentFlow();
    return;
  }
  if (action.target === 'feature' && action.feature.length > 0) {
    this.switchTab(MainTab.NOTEBOOKS);
    this.openFeaturePage(action.feature);
    return;
  }
  if (action.target === 'moment' && action.momentId.length > 0) {
    this.openTimelineFeaturePage();
    this.openMomentDetail(action.momentId);
  }
}

我踩过的坑

formId 一定要保存。系统里同一种卡片可以添加多次,每个实例都有自己的 formId。如果只按卡片名刷新,会丢掉"这个实例绑定哪张照片、哪个倒数日"的关系。

图片不要只传路径。图片卡片要通过 formImages 传 fd,UI 里用 memory:// 读取。并且我会先复制到应用自己的稳定目录,避免原始 uri 后面打不开。

FormExtensionAbility 不适合做重活。它不是后台服务,生命周期结束后会被系统清理。我的做法是主应用负责持久化快照,卡片侧只做轻量读取和绑定。

openFormManager 不是马上添加成功。它只是打开系统卡片管理页,用户还要在系统页确认添加。所以我用了 pending record,保证跨系统 UI 之后业务参数还在。

卡片字段类型要保守。官方文档提到,通过 @LocalStorageProp 接收刷新数据时,数据会序列化。项目里我把大多数字段都按 string 处理,比如 countdown、emptyState、refreshVersion,这样更稳。

小结

Form Kit 接入到《时光旅记》后,我最终得到的不是一个静态桌面入口,而是一套和业务数据同步的轻量信息面板。

快捷记录卡片解决"少一步打开 APP"的问题;倒数日卡片把重要日子放到桌面;瞬间照片卡片固定一段回忆,并且点击后能直达详情。它们共用同一条链路:form_config 声明卡片,EntryFormAbility 管生命周期,WidgetFormRecord 保存实例配置,FormBindingData 推送展示数据,postCardAction 把点击带回主应用。

如果你的 APP 也有高频入口、轻量状态、纪念日、照片、待办、行程这类场景,Form Kit 很值得做。但别只写卡片 UI,稳定的接入点,是把卡片当成一个会被系统随时拉起的小型数据消费者来设计。