大家好,我是鸿蒙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,稳定的接入点,是把卡片当成一个会被系统随时拉起的小型数据消费者来设计。