【鸿蒙】HarmonyOS 卡片(FormExtensionAbility)开发实践:从配置到动态刷新全解析
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 受限沙箱,不可用:Video、Web、Camera、XComponent、AlertDialog,使用后静默失败(不报错、不渲染),极难调试。验证方法:在 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 跳转参数取不到
现象:postCardAction 的 router 事件触发, 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)
- OpenHarmony 源码参考:foundation/ability/form_fwk/services/src/form_mgr_service.cpp
更多推荐



所有评论(0)