摘要: 做番茄钟应用时,产品要求"把番茄钟放到桌面,点击就能用,不用打开 App"。这就是鸿蒙的互动卡片(FormKit)------桌面卡片可交互、数据可推送、与主应用联动。本文用番茄钟贯穿全链路:卡片生命周期、数据推送、事件交互、主应用联动,附 5 个真实踩坑。
适用版本: HarmonyOS NEXT 7.x / API 14+ / FormKit(2026 年稳定版)
开篇:把核心功能搬到桌面卡片上
"把番茄钟放到桌面上,点一下就开始计时,不用打开 App。"
2026 年 8 月,番茄钟 App 做用户调研,最高频需求是"桌面快捷启动"。我查文档发现鸿蒙的**互动卡片(FormKit)**不是静态小组件------它可以交互、可以实时推送数据、可以和主应用双向联动。但接入链路比想象长:卡片模板、数据更新、事件处理、跨进程通信。踩了 5 个坑,这篇文章把全链路讲透。

一、互动卡片是什么:不止是桌面小组件

| 维度 | 静态卡片 | 互动卡片 |
|---|---|---|
| 数据更新 | 定时拉取 | 实时推送 + 主动更新 |
| 用户交互 | 仅跳转 | 卡片内直接操作 |
| 状态同步 | 单向 | 双向(卡片 ↔ App) |
| 能力范围 | 展示 | 展示 + 交互 + 联动 |
二、卡片生命周期:从添加到移除的 5 个状态
互动卡片和普通组件最大的不同:它的生命周期由系统托管,而不是由页面路由控制。理解这 5 个状态,是排查"卡片不更新""卡片数据丢了"这类问题的前提。

| 状态 | 触发时机 | 回调方法 | 开发者要做什么 |
|---|---|---|---|
| 已创建 | 用户把卡片添加到桌面 | onAddForm |
返回首屏 FormBindingData,卡片据此渲染初始 UI |
| 已渲染 | 首屏数据绑定完成 | --- | 卡片 UI 可见,用户可交互 |
| 已更新 | updateForm 推送新数据 |
onUpdateForm |
重新渲染,只改数据不重建卡片 |
| 已移除 | 用户从桌面删除卡片 | onRemoveForm |
清理该卡片关联的状态/资源(定时器、KV key 等) |
| 已销毁 | 系统回收卡片进程 | --- | 系统行为,无需处理;状态应已持久化到 KV/Preferences |
关键点 :卡片进程可能被系统随时回收,绝不能把状态只放在内存里。计时、番茄钟的"是否运行中"这类状态,必须落到分布式 KV 或 Preferences,否则进程一回收,卡片和主应用都读不到。
三、全链路接入
步骤 1:创建卡片模板
text
DevEco Studio → New → Form → 卡片模板
→ 命名: TomatoCard
→ 类型: 互动卡片
→ 尺寸: 2x2
步骤 2:module.json5 声明 FormExtensionAbility
json5
{
"module": {
"extensionAbilities": [
{
"name": "TomatoFormAbility",
"srcEntry": "./ets/tomatoform/TomatoFormAbility.ets",
"type": "form",
"metadata": [
{ "name": "ohos.extension.form", "resource": "$profile:form_config" }
]
}
]
}
}
步骤 3:卡片配置
json
// resources/base/profile/form_config.json
{
"forms": [
{
"name": "tomatoCard",
"displayName": "番茄钟",
"description": "桌面番茄钟计时",
"src": "./ets/tomatoform/pages/TomatoCardPage.ets",
"window": { "designWidth": 720, "autoDesignWidth": true },
"isDynamic": true,
"supportDimensions": ["2*2"],
"defaultDimension": "2*2",
"updateEnabled": true,
"scheduledUpdateTime": "10:30",
"updateDuration": 1
}
]
}
避坑 1 :isDynamic: true 是互动卡片的关键,false 就是静态卡片不能交互。
步骤 4:实现 FormExtensionAbility
typescript
// TomatoFormAbility.ets
import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
export default class TomatoFormAbility extends FormExtensionAbility {
// 卡片创建:返回首屏数据(卡片据此渲染初始 UI)
onAddForm(want: Want): formBindingData.FormBindingData {
return formBindingData.createFormBindingData({
title: '番茄钟',
time: '25:00',
status: 'idle'
});
}
// 卡片更新(定时触发 / 手动触发):formId 由系统传入,据此定位要更新的卡片
onUpdateForm(formId: string): void {
formProvider.updateForm(formId, formBindingData.createFormBindingData({
title: '番茄钟',
time: this.getCurrentTime(),
status: 'running'
}));
}
// 卡片被移除:清理该卡片关联的状态/资源
onRemoveForm(formId: string): void {
// 清理该卡片的状态
}
// 卡片内事件(点击按钮):message 是卡片 postCardAction 透传过来的字符串,
// 需要 JSON 反序列化后取出 params
onFormEvent(formId: string, message: string): void {
const data = JSON.parse(message) as Record<string, string>;
if (data.message === 'start') {
this.startTimer(formId);
} else if (data.message === 'pause') {
this.pauseTimer(formId);
}
}
}
步骤 5:卡片 UI 与事件
typescript
// TomatoCardPage.ets
let storage = new LocalStorage();
@Entry(storage)
@Component
struct TomatoCardPage {
@LocalStorageProp('title') title: string = '番茄钟';
@LocalStorageProp('time') time: string = '25:00';
@LocalStorageProp('status') status: string = 'idle';
build() {
Column() {
Text(this.title).fontSize(14)
Text(this.time).fontSize(32).fontWeight(FontWeight.Bold)
Button(this.status === 'idle' ? '开始' : '暂停')
// postCardAction 是卡片环境注入的全局函数,
// 事件在 FormExtensionAbility.onFormEvent 中处理
.onClick(() => {
postCardAction(this, {
action: 'event',
params: { message: this.status === 'idle' ? 'start' : 'pause' }
});
})
}
.width('100%').height('100%')
.backgroundColor('#FFEFD5')
}
}
说明 :卡片运行在独立的卡片进程 里,不能直接访问主应用内存。卡片内只能做两件事:① 渲染 UI(读
LocalStorage数据);② 通过postCardAction把用户操作(事件)或跳转意图透传给系统/主应用。真正的计时、状态变更逻辑都跑在FormExtensionAbility(卡片服务进程)里。
四、5 个真实踩坑
1. 卡片数据不更新
现象:主应用里改了状态,桌面卡片不刷新。
根因 :updateForm 没调用,或 formId 不对。
解决:主应用通过 formProvider 主动推送:
typescript
import { formProvider, formBindingData } from '@kit.FormKit';
// 主应用里更新卡片:把最新状态打包成 FormBindingData 推给指定 formId
async function updateCard(formId: string, data: Record<string, string>): Promise<void> {
const bindingData = formBindingData.createFormBindingData(data);
await formProvider.updateForm(formId, bindingData);
}
2. 卡片点击跳转失效
现象:点卡片空白区域不跳转到主应用。
解决 :用 postCardAction 显式声明跳转:
typescript
Column() {
// 卡片内容
}
.onClick(() => {
// 卡片整体点击:通过 postCardAction 拉起主应用(router 动作),
// 由系统唤起 EntryAbility 的指定页面
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { page: 'tomatoDetail' }
});
})
3. 卡片与主应用状态不同步
现象:卡片里启动计时,打开主应用看到的还是初始状态。
根因:卡片进程和主应用进程独立,内存不共享。
解决:用分布式数据或公共文件同步:
typescript
import { distributedKVStore } from '@kit.dataKVStoreKit';
// 卡片启动计时时写入分布式 KV
async function syncToMain(status: string): Promise<void> {
const kvManager = distributedKVStore.createKVManager({ /* config */ });
const store = await kvManager.getKVStore({ /* config */ });
await store.put('tomatoStatus', status);
}
// 主应用监听 KV 变化
store.on('dataChange', (data) => {
this.updateUI(data.value);
});
4. 卡片尺寸适配
现象:2x2 卡片在不同设备上显示效果差异大。
解决 :用 supportDimensions 声明多尺寸,各尺寸独立 UI:
json
{
"supportDimensions": ["2*2", "2*4", "4*4"],
"defaultDimension": "2*2"
}
5. 卡片更新频率限制
现象 :频繁 updateForm 报错 update too frequently。
根因:系统限制卡片更新频率(通常 5 分钟内有限次)。
解决 :合并更新,用 updateDuration 定时更新而非每次状态变都推。
五、卡片与主应用联动时序

避坑要点
- isDynamic: true 才能交互:静态卡片不能响应事件
- 卡片与主应用跨进程:用分布式 KV 同步状态
- 更新频率有限制:合并更新,别频繁 updateForm
边界与已知限制
| 限制项 | 具体表现 | 规避方式 |
|---|---|---|
| 尺寸规格 | 卡片尺寸受系统栅格约束,不能任意指定 | 只设计官方支持的几种规格 |
| 刷新频率 | 卡片刷新有频率与次数约束 | 合并更新,避免短时间高频刷新 |
| 交互能力 | 卡片可承载的交互控件有限 | 复杂交互落地到应用内,卡片只做入口 |
| 数据量 | 卡片不适合承载大量数据 | 只放核心状态,详情进应用看 |
| 进程独立 | 卡片运行在独立进程,不共享应用内存状态 | 数据落存储后共享,不要依赖内存变量 |
| 机型差异 | 部分桌面/机型不支持或展示异常 | 提供兜底布局与降级方案 |
| 生命周期 | 卡片可能被系统回收后重建 | 状态必须可重建,不能只靠内存 |
小结
桌面卡片的设计原则是**"一屏内给出最关键的那一个信息"**:把它当成应用的快捷入口,而不是应用的缩略版。塞太多内容既超出规格,也会让卡片失去"一眼看完"的价值。
技术上最容易踩的是进程独立这一点------卡片和应用不共享内存,所有要展示的数据都必须先落盘再读取。想明白这一点,卡片数据不同步的问题就解决了一大半。
评论区交流: 你的互动卡片接入遇到什么问题?评论区交流。