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

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

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

参考文档

官方文档:FormExtensionAbilityformBindingData

整体架构

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

用户新增瞬间、修改倒数日、云同步旅行计划或切换主题后,主应用先把业务数据写成 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 保存实例配置,不是展示数据。比如照片卡片保存 momentIdmediaIdphotoUri,倒数日卡片保存 countdownDayIdcountdownStyle。标题、天数和图片 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 处理,比如 countdownemptyStaterefreshVersion,这样更稳。

小结

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

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

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