HarmonyOS 卡片(FormExtensionAbility)开发实践:从配置到动态刷新全解析

封面信息图

> 不写"万能卡片能做什么"——而是直接从「Service Widget 在锁屏/桌面真实渲染」出发,讲清楚一次卡片刷新为什么会失败、数据怎么流动、生命周期为何与 UIAbility 完全不同。

>

> 适用版本:HarmonyOS NEXT / API 12+ | 预计阅读:18 分钟

---

1. 卡片开发的真实起点:一次失败的刷新

你写好了 onUpdateForm,在 Deveco Studio 预览里数据刷新正常,但部署到真机后,桌面卡片数据永远停在第一次渲染的状态。

这是 HarmonyOS 卡片开发最典型的第一个坑:卡片的生命周期由系统 FormManagerService 驱动,不是由你的进程驱动

理解这一点,是搞定整个 FormExtensionAbility 开发的前提。

---

2. 核心架构:卡片不属于你的进程

┌─────────────────────────────────────────────────────────────┐

│ 桌面 / 锁屏宿主进程 │

│ ┌──────────────────────────┐ │

│ │ FormRenderer (ArkUI渲染) │ ←── 接收 formBindingData │

│ └──────────────┬───────────┘ │

│ │ IPC │

└─────────────────┼───────────────────────────────────────────┘

FormManagerService (系统服务)

┌────────────▼─────────────┐

│ FormExtensionAbility │ ← 你的代码所在

│ (独立沙箱进程,临时存活) │

│ onAddForm() │

│ onUpdateForm() │

│ onRemoveForm() │

│ onFormEvent() │

└──────────────────────────┘

关键结论

1. FormExtensionAbility 运行在一个独立进程(Extension 进程),与主进程隔离。

2. 系统在需要时拉起该进程,调用完生命周期后最短5秒内回收(不保留长驻)。

3. 卡片 UI 渲染发生在宿主进程,你只负责提供数据(FormBindingData)。

---

3. 配置:module.json5 中的必填项

很多开发者直接复制模板,漏掉关键配置后调试半天找不到原因。

// module.json5

{

"module": {

"extensionAbilities": [

{

"name": "EntryFormAbility",

"srcEntry": "./ets/entryformability/EntryFormAbility.ets",

"type": "form", // 必须是 "form"

"exported": true,

"metadata": [

{

"name": "ohos.extension.form",

"resource": "$profile:form_config" // 指向 form_config.json

}

]

}

]

}

}

// resources/base/profile/form_config.json

{

"forms": [

{

"name": "widget",

"displayName": "$string:widget_display_name",

"description": "$string:widget_desc",

"src": "./ets/widget/pages/WidgetCard.ets",

"uiSyntax": "arkts",

"window": {

"designWidth": 720,

"autoDesignWidth": true

},

"colorMode": "auto",

"isDefault": true,

"updateEnabled": true, // 必须为 true 才能触发 onUpdateForm

"scheduledUpdateTime": "10:30", // 定时更新时间(可选)

"updateDuration": 1, // 更新间隔:单位30分钟,最小值1=30min

"defaultDimension": "2*2",

"supportDimensions": ["2*2", "2*4"],

"formConfigAbility": "ability://EntryAbility"

}

]

}

updateDuration 陷阱:值为 0 时禁用定时更新,值为 1 代表每 30 分钟,系统实际触发间隔受电量优化影响。不能用它实现"实时刷新"。

---

4. FormExtensionAbility 生命周期全解

用户添加卡片

onAddForm(want: Want): FormBindingData

│ 同步返回初始数据,渲染首帧

卡片存活期间(系统按需唤起进程)

├── 定时触发 / 应用主动调用 requestForm()

│ ↓

│ onUpdateForm(formId: string): void

│ ↓

│ formProvider.updateForm(formId, data)

├── 用户点击卡片内按钮(message事件)

│ ↓

│ onFormEvent(formId: string, message: string): void

├── 临时卡片转正式卡片

│ ↓

│ onCastToNormalForm(formId: string): void

└── 用户移除卡片

onRemoveForm(formId: string): void

重要onAddForm / onUpdateForm 的调用上下文没有持久 UI 线程,不能执行长耗时操作,必须异步处理后通过 formProvider.updateForm 推送数据。

---

5. 数据传递:FormBindingData 与 LocalStorageProp

5.1 ExtensionAbility 侧(数据提供方)

import { formBindingData, FormExtensionAbility, formProvider } from '@kit.FormKit';

import { Want } from '@kit.AbilityKit';

import { preferences } from '@kit.DataPreferencesKit';

export default class EntryFormAbility extends FormExtensionAbility {

onAddForm(want: Want): formBindingData.FormBindingData {

const formId = want.parameters?.['ohos.extra.param.key.form_identity'] as string;

// 持久化 formId,主进程推送数据时需要

const prefs = preferences.getPreferencesSync(this.context, { name: 'widget_prefs' });

prefs.putSync(active_form_id, formId);

prefs.flushSync();

// 同步返回 placeholder,真实数据用 updateForm 二次推送

return formBindingData.createFormBindingData({

temperature: '--',

city: '获取中...',

updateTime: ''

});

// 注意:不能在这里 await 网络请求,方法签名为同步

}

onUpdateForm(formId: string): void {

this.fetchAndPush(formId); // 异步,不阻塞

}

private async fetchAndPush(formId: string): Promise {

try {

const weather = await this.getWeatherData();

const data = formBindingData.createFormBindingData({

temperature: weather.temp,

city: weather.city,

updateTime: new Date().toLocaleTimeString()

});

await formProvider.updateForm(formId, data);

} catch (err) {

console.error([Widget] updateForm failed: ${JSON.stringify(err)});

}

}

private async getWeatherData(): Promise<{ temp: string; city: string }> {

// 实际实现:网络请求或读本地缓存

return { temp: '25°C', city: '北京' };

}

onRemoveForm(formId: string): void {

// 清理缓存,避免主进程继续对已删除卡片推送数据

const prefs = preferences.getPreferencesSync(this.context, { name: 'widget_prefs' });

prefs.deleteSync(active_form_id);

prefs.flushSync();

}

onFormEvent(formId: string, message: string): void {

try {

const params = JSON.parse(message) as Record ;

if (params['action'] === 'refresh') {

this.fetchAndPush(formId);

}

} catch (e) {

console.error('[Widget] onFormEvent parse error:', e);

}

}

}

5.2 卡片页面侧(ArkUI)

// WidgetCard.ets — 注意:卡片页面不能使用普通 @State 初始化时加载数据

let storage = new LocalStorage();

@Entry(storage)

@Component

struct WidgetCard {

// key 必须与 FormBindingData 中的字段名完全一致(大小写敏感)

@LocalStorageProp('temperature') temperature: string = '--';

@LocalStorageProp('city') city: string = '未知';

@LocalStorageProp('updateTime') updateTime: string = '';

build() {

Column({ space: 8 }) {

Text(this.city)

.fontSize(14)

.fontColor('#666666')

Text(this.temperature)

.fontSize(32)

.fontWeight(FontWeight.Bold)

if (this.updateTime !== '') {

Text(更新于 ${this.updateTime})

.fontSize(10)

.fontColor('#999999')

}

Button('立即刷新')

.width('80%')

.height(28)

.fontSize(12)

.onClick(() => {

postCardAction(this, {

action: 'message',

params: { action: 'refresh' }

});

})

}

.width('100%')

.height('100%')

.padding(12)

}

}

---

6. postCardAction 三种交互模式

// mode 1:触发 onFormEvent,不切换前台

postCardAction(this, {

action: 'message',

params: { action: 'refresh', itemId: '123' }

});

// mode 2:跳转到应用内页面

postCardAction(this, {

action: 'router',

abilityName: 'EntryAbility',

params: { page: 'detail', id: '123' }

});

// 接收方:UIAbility.onNewWant(want) 中

// const paramsStr = want.parameters?.['params'] as string;

// const { id } = JSON.parse(paramsStr); // 正确取法

// mode 3:静默后台调用(应用不切到前台)

postCardAction(this, {

action: 'call',

abilityName: 'EntryAbility',

params: { method: 'silentSync' }

});

---

7. 从主进程主动推送卡片数据

主进程获取新数据后(如推送通知),主动更新卡片:

import { formProvider, formBindingData } from '@kit.FormKit';

import { preferences } from '@kit.DataPreferencesKit';

async function pushDataToWidget(context: Context, newData: Record ): Promise {

const prefs = preferences.getPreferencesSync(context, { name: 'widget_prefs' });

const formId = prefs.getSync('active_form_id', '') as string;

if (!formId) {

console.warn('[App] No active formId found');

return;

}

try {

const bindingData = formBindingData.createFormBindingData(newData);

await formProvider.updateForm(formId, bindingData);

} catch (err) {

// err.code === 16500050:卡片已被用户移除,清理 formId

if ((err as { code: number }).code === 16500050) {

prefs.deleteSync('active_form_id');

prefs.flushSync();

}

console.error('[App] updateForm failed:', JSON.stringify(err));

}

}

---

8. 最佳实践

8.1 onAddForm 同步返回占位数据,异步推送真实数据

方案:返回含占位符的 FormBindingData,立即触发 fetchAndPush(formId) 做异步数据加载。 原因onAddForm 是同步接口,无法 await。若在此处等待网络,系统5秒超时后卡片添加失败,用户看到错误提示。 不这么做:直接写 return await fetchData() → TypeScript 不报错(方法可以加 async),但卡片框架不等待 Promise,首帧拿到的是 undefined,渲染空白。

8.2 多张卡片场景下以 formId 为 key 独立存储

方案:用 widget_form_${formId} 作为 Preferences key,每张卡片独立存储状态。 原因:同类型卡片可以添加多张(最多5张),若所有卡片公用同一 key,刷新一张会导致其他张数据被覆盖。 不这么做:用户添加两张天气卡片显示不同城市 → 刷新其中一张 → 两张都变成同一城市数据。

8.3 onRemoveForm 中清理所有关联缓存

方案onRemoveForm 删除 Preferences 中与该 formId 关联的所有 key,并取消任何与该 formId 相关的后台任务。 原因:FormExtensionAbility 进程频繁被回收再拉起,如果不清理,下次启动时读到脏数据,同时 formProvider.updateForm(oldId) 持续报错 16500050,影响应用稳定性评分。

8.4 禁止在卡片页面使用不受支持的 ArkUI 组件

卡片运行在 FormRenderer 受限沙箱,不可用VideoWebCameraXComponentAlertDialog,使用后静默失败(不报错、不渲染),极难调试。验证方法:在 Deveco Studio 卡片预览器中加载,若报 FormNotSupportedWidgets 则说明有不支持的组件。

---

9. 常见坑点

坑点1:数据推送成功但 UI 不更新

现象formProvider.updateForm 无异常,卡片显示不变。 原因FormBindingData 的字段名与 @LocalStorageProp 的 key 大小写不一致(严格字符串匹配)。
// 错误:ExtensionAbility 用 "Temperature"(大写T)

formBindingData.createFormBindingData({ Temperature: '25°C' });

// 卡片页面用 "temperature"(小写t)

@LocalStorageProp('temperature') temperature: string = '--';

// 结果:temperature 永远是 '--'

解决:在 shared/WidgetKeys.ts 中定义常量,两侧引用同一常量,彻底避免拼写差异。

---

坑点2:定时更新在省电模式下失效

现象:设置 updateDuration: 1,实际卡片数小时不刷新。 原因:设备进入深度睡眠或省电模式时,系统推迟/跳过卡片唤醒。用户手动强制停止应用后,卡片更新完全停止。 解决onUpdateForm 中同步更新本地缓存(Preferences);注册 WorkScheduler 后台任务作为补充,二者结合保障数据相对新鲜;卡片 UI 上展示上次更新时间,让用户感知数据时效。

---

坑点3:router 跳转参数取不到

现象postCardActionrouter 事件触发, UIAbility.onNewWant 中取不到自定义 params
// 卡片中

postCardAction(this, { action: 'router', abilityName: 'EntryAbility', params: { articleId: '123' } });

// UIAbility 中(错误)

const id = want.parameters?.['articleId']; // undefined

// UIAbility 中(正确)

const paramsStr = want.parameters?.['params'] as string; // 系统封装进了 'params' key

const { articleId } = JSON.parse(paramsStr); // '123'

根因:卡片事件的参数由框架封装进 want.parameters['params'] 字段(JSON字符串),不直接展开到 parameters 顶层。

---

坑点4:Extension 进程回收导致异步任务中断

现象onUpdateForm 触发了网络请求,但卡片数据有时更新有时不更新(概率性失败)。 原因FormExtensionAbility 进程在调用完生命周期方法后5秒内被回收。如果网络请求耗时超过5秒,进程被回收后异步任务强制中止, formProvider.updateForm 永远不会被调用。 解决
onUpdateForm(formId: string): void {

// 先尝试读本地缓存推送,保证卡片能立即看到相对新的数据

this.pushCachedData(formId);

// 再启动网络请求,超时后回调可能不执行(接受这个概率)

this.fetchAndPush(formId);

}

对于必须保证的数据更新,使用 WorkScheduler 在后台完成网络请求并写入缓存,onUpdateForm 只做缓存读取+推送,彻底规避进程回收风险。

---

10. 总结

1. FormExtensionAbility 是临时进程:生命周期由系统控制,5秒后被回收,不能持有长生命周期状态。

2. 数据单向推送:Extension 通过 formProvider.updateForm 向宿主推送,卡片页面通过 postCardAction 触发回调,两者不直接通信。

3. onAddForm 必须同步,真实数据异步推送:首帧返回占位符,异步加载完成后二次推送。

4. 字段名严格匹配FormBindingData key 与 @LocalStorageProp key 大小写完全一致,用常量管理。

5. 定时更新最小30分钟:分钟级实时性需结合用户触发(postCardAction message)和 WorkScheduler 后台任务实现。

> 核心结论:卡片开发的本质是"数据分发"而非"UI 控制"——你控制数据何时推送、推送什么,渲染交给系统。

---

参考资料

- HarmonyOS 官方文档:FormExtensionAbility 开发指南

- 官方文档:卡片数据交互(formProvider API)

- 官方文档:卡片配置文件说明(form_config.json)

- 官方文档:postCardAction 事件说明

- OpenHarmony 源码参考:foundation/ability/form_fwk/services/src/form_mgr_service.cpp

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐