ArkTS 服务卡片开发全攻略:从零打造可交互的桌面 Widget
本文详细介绍了如何在鸿蒙系统中开发ArkTS服务卡片(Widget),从核心概念到实战应用。主要内容包括:服务卡片的三要素(FormExtensionAbility、ArkTS Card UI、Form Host)及其交互机制;卡片尺寸规格和适用场景;项目结构配置与关键字段说明;以及通过FormExtensionAbility实现卡片生命周期管理和数据更新。文章以“每日待办摘要卡片”为例,演示了卡
ArkTS 服务卡片开发全攻略:从零打造可交互的桌面 Widget
鸿蒙 Form Kit 实战 | 适用于 HarmonyOS NEXT / API 12+
前言
服务卡片(Form Kit)是鸿蒙应用中最具差异化的功能之一:用户无需打开 App,就能在桌面直接查看数据、触发操作。天气、步数、待办、快捷支付……这些都是服务卡片的典型场景。
然而,很多开发者卡在以下几个坑:
- FormExtensionAbility 和 UIAbility 的职责混淆
- 卡片数据如何从 App 传递过来?
- 定时刷新与事件刷新怎么选?
- 用户点击卡片后如何拉起应用特定页面?
本文用一个**「每日待办摘要卡片」**完整案例,带你把上面这些问题全部拿下。
一、服务卡片核心概念速览
1.1 卡片三要素
┌─────────────────────────────────────────┐
│ Form Provider(卡片提供方,你的 App) │
│ ┌──────────────────────────────────┐ │
│ │ FormExtensionAbility │ │
│ │ · onAddForm() 创建 │ │
│ │ · onUpdateForm() 刷新 │ │
│ │ · onFormEvent() 接收事件 │ │
│ └──────────────────────────────────┘ │
│ ┌──────────────────────────────────┐ │
│ │ ArkTS Card UI(.ets 文件) │ │
│ │ · @Entry @Component │ │
│ │ · postCardAction() 发送事件 │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘
↕ 系统框架调度
┌─────────────────────────────────────────┐
│ Form Host(卡片使用方,如桌面) │
└─────────────────────────────────────────┘
| 角色 | 负责什么 |
|---|---|
| FormExtensionAbility | 卡片生命周期、数据更新逻辑(运行在后台进程) |
| 卡片 UI 文件 | 界面渲染,只能用有限的 ArkUI 组件 |
| Form Host | 展示卡片,转发事件(系统桌面,无需开发) |
⚠️ 注意:卡片 UI 运行在独立的沙箱进程,不能访问网络、不能使用 AppStorage,所有数据必须由 FormExtensionAbility 通过
formProvider.updateForm()推送。
1.2 支持的卡片尺寸
| 规格 | 占用桌面格数 | 适用场景 |
|---|---|---|
| 1×2 | 小 | 数据展示 |
| 2×2 | 中 | 图文混排 |
| 2×4 | 大 | 列表/图表 |
| 4×4 | 特大 | 复杂交互 |
二、项目结构搭建
2.1 在 module.json5 中声明卡片
// entry/src/main/module.json5
{
"module": {
"extensionAbilities": [
{
"name": "TodoFormAbility",
"srcEntry": "./ets/formability/TodoFormAbility.ets",
"type": "form",
"description": "每日待办摘要卡片",
"formsEnabled": true,
"forms": [
{
"name": "TodoSummaryForm",
"displayName": "待办摘要",
"description": "展示今日待办数量和进度",
"src": "./ets/widget/pages/TodoFormPage.ets",
"uiSyntax": "arkts",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "auto",
"isDefault": true,
"updateEnabled": true,
"scheduledUpdateTime": "10:30",
"updateDuration": 1,
"defaultDimension": "2*2",
"supportDimensions": ["1*2", "2*2", "2*4"]
}
]
}
]
}
}
关键字段说明:
updateEnabled: true+updateDuration: 1:每 30 分钟主动刷新(1 单位 = 30 分钟)scheduledUpdateTime: "10:30":每天 10:30 定时刷新- 两者共存时,系统取 更频繁 的策略
2.2 目录结构
entry/src/main/ets/
├── entryability/
│ └── EntryAbility.ets # 主 Ability
├── formability/
│ └── TodoFormAbility.ets # 卡片生命周期管理
├── widget/
│ └── pages/
│ └── TodoFormPage.ets # 卡片 UI
├── service/
│ └── TodoService.ets # 业务逻辑(App 进程)
└── pages/
└── Index.ets # 主页面
三、FormExtensionAbility:卡片后端
// entry/src/main/ets/formability/TodoFormAbility.ets
import FormExtensionAbility from '@ohos.app.form.FormExtensionAbility';
import formProvider from '@ohos.app.form.formProvider';
import formBindingData from '@ohos.app.form.formBindingData';
import Want from '@ohos.app.ability.Want';
import { TodoRepository } from '../service/TodoRepository';
export default class TodoFormAbility extends FormExtensionAbility {
/**
* 卡片首次添加到桌面时调用
* 必须同步返回初始数据,否则卡片显示空白
*/
onAddForm(want: Want): formBindingData.FormBindingData {
console.info('[TodoFormAbility] onAddForm, formId:', want.parameters?.['ohos.extra.param.key.form_identity']);
// 从本地存储读取待办数据(同步方式)
const summary = TodoRepository.getSummarySync();
return formBindingData.createFormBindingData({
total: summary.total,
done: summary.done,
pending: summary.pending,
lastUpdated: this.formatTime(new Date()),
nextTodo: summary.nextTodo ?? '暂无待办'
});
}
/**
* 卡片触发定时/主动刷新时调用
*/
onUpdateForm(formId: string): void {
console.info('[TodoFormAbility] onUpdateForm, formId:', formId);
// 异步获取最新数据后推送更新
TodoRepository.getSummary().then(summary => {
const bindingData = formBindingData.createFormBindingData({
total: summary.total,
done: summary.done,
pending: summary.pending,
lastUpdated: this.formatTime(new Date()),
nextTodo: summary.nextTodo ?? '暂无待办'
});
formProvider.updateForm(formId, bindingData).catch((err: Error) => {
console.error('[TodoFormAbility] updateForm failed:', err.message);
});
});
}
/**
* 接收卡片内用户触发的 message 事件
*/
onFormEvent(formId: string, message: string): void {
console.info('[TodoFormAbility] onFormEvent, message:', message);
try {
const event: { action: string; todoId?: string } = JSON.parse(message);
switch (event.action) {
case 'refresh':
// 手动触发刷新
this.onUpdateForm(formId);
break;
case 'completeTodo':
// 完成待办并更新卡片
if (event.todoId) {
TodoRepository.complete(event.todoId).then(() => {
this.onUpdateForm(formId);
});
}
break;
}
} catch (e) {
console.error('[TodoFormAbility] parse message failed:', e);
}
}
/**
* 卡片被移除桌面时调用,做清理工作
*/
onRemoveForm(formId: string): void {
console.info('[TodoFormAbility] onRemoveForm:', formId);
// 可在此清理与该 formId 相关的缓存
}
private formatTime(date: Date): string {
const h = String(date.getHours()).padStart(2, '0');
const m = String(date.getMinutes()).padStart(2, '0');
return `${h}:${m} 更新`;
}
}
四、TodoRepository:数据层
// entry/src/main/ets/service/TodoRepository.ets
import dataPreferences from '@ohos.data.preferences';
import common from '@ohos.app.ability.common';
export interface TodoSummary {
total: number;
done: number;
pending: number;
nextTodo: string;
}
const PREF_NAME = 'todo_data';
export class TodoRepository {
private static context: common.Context;
static init(ctx: common.Context): void {
TodoRepository.context = ctx;
}
/** 同步读取(供 onAddForm 使用) */
static getSummarySync(): TodoSummary {
// FormExtensionAbility 生命周期极短,尽量用轻量同步方案
// 实际项目中可读取 AppStorage 或本地缓存文件
return {
total: 5,
done: 2,
pending: 3,
nextTodo: '完成周报'
};
}
/** 异步读取(供 onUpdateForm 使用) */
static async getSummary(): Promise<TodoSummary> {
const pref = await dataPreferences.getPreferences(
TodoRepository.context,
PREF_NAME
);
const total = (await pref.get('total', 0)) as number;
const done = (await pref.get('done', 0)) as number;
const nextTodo = (await pref.get('nextTodo', '暂无待办')) as string;
return { total, done, pending: total - done, nextTodo };
}
/** 标记完成(App 侧调用后,同步触发卡片刷新) */
static async complete(todoId: string): Promise<void> {
const pref = await dataPreferences.getPreferences(
TodoRepository.context,
PREF_NAME
);
const done = (await pref.get('done', 0) as number) + 1;
await pref.put('done', done);
await pref.flush();
}
}
五、卡片 UI:TodoFormPage.ets
⚠️ 卡片 UI 限制:不能使用
@State、@Link等装饰器,只能用@LocalStorageProp接收数据;不能调用网络、文件等系统 API。
// entry/src/main/ets/widget/pages/TodoFormPage.ets
let storage = new LocalStorage();
@Entry(storage)
@Component
struct TodoFormPage {
// 通过 LocalStorageProp 接收 FormBindingData 中的字段
@LocalStorageProp('total') total: number = 0;
@LocalStorageProp('done') done: number = 0;
@LocalStorageProp('pending') pending: number = 0;
@LocalStorageProp('lastUpdated') lastUpdated: string = '--:-- 更新';
@LocalStorageProp('nextTodo') nextTodo: string = '暂无待办';
/** 进度百分比 0-1 */
get progress(): number {
return this.total > 0 ? this.done / this.total : 0;
}
build() {
Column({ space: 8 }) {
// 顶部标题栏
Row() {
Image($r('app.media.ic_todo'))
.width(20).height(20)
Text('今日待办')
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#1a1a1a')
.margin({ left: 6 })
Blank()
Text(this.lastUpdated)
.fontSize(11)
.fontColor('#999999')
}
.width('100%')
// 进度环
Stack({ alignContent: Alignment.Center }) {
Progress({
value: this.done,
total: this.total,
type: ProgressType.Ring
})
.width(72).height(72)
.color('#4CAF50')
.style({ strokeWidth: 8 })
Column() {
Text(`${this.done}`)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#1a1a1a')
Text(`/ ${this.total}`)
.fontSize(12)
.fontColor('#999999')
}
}
.width(72).height(72)
// 下一条待办
Row() {
Text('下一件:')
.fontSize(12)
.fontColor('#666666')
Text(this.nextTodo)
.fontSize(12)
.fontColor('#333333')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%')
// 操作按钮区
Row({ space: 8 }) {
// 刷新按钮 → 触发 message 事件
Button('刷新', { type: ButtonType.Normal })
.height(28)
.fontSize(12)
.layoutWeight(1)
.borderRadius(6)
.backgroundColor('#F5F5F5')
.fontColor('#666666')
.onClick(() => {
postCardAction(this, {
action: 'message',
params: { action: 'refresh' }
});
})
// 打开 App 按钮 → 触发 router 事件
Button('查看全部', { type: ButtonType.Normal })
.height(28)
.fontSize(12)
.layoutWeight(1)
.borderRadius(6)
.backgroundColor('#4CAF50')
.fontColor('#FFFFFF')
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { page: 'todo_list' }
});
})
}
.width('100%')
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
}
}
六、postCardAction 三种事件类型详解
// 1. message 事件:触发 FormExtensionAbility.onFormEvent()
// 适合:卡片内部刷新、完成待办等不需要打开 App 的操作
postCardAction(this, {
action: 'message',
params: { action: 'completeTodo', todoId: '123' }
});
// 2. router 事件:拉起 UIAbility 并跳转到指定页面
// 适合:点击卡片打开 App 详情页
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility', // 目标 Ability 名称
params: { page: 'todo_detail', id: '123' }
});
// 3. call 事件:在后台调用 UIAbility(不切换前台)
// 适合:静默操作,如切换收藏状态
postCardAction(this, {
action: 'call',
abilityName: 'EntryAbility',
params: {
method: 'toggleFavorite', // 对应 Caller.call() 的方法名
params: JSON.stringify({ id: '123' })
}
});
| 事件类型 | App 是否到前台 | 处理位置 | 适用场景 |
|---|---|---|---|
| message | ❌ 不切换 | FormExtensionAbility.onFormEvent() | 卡片内部数据刷新 |
| router | ✅ 切换前台 | EntryAbility.onNewWant() | 打开 App 详情 |
| call | ❌ 不切换 | UIAbility Callee | 静默后台操作 |
七、EntryAbility 接收卡片跳转
// entry/src/main/ets/entryability/EntryAbility.ets
import UIAbility from '@ohos.app.ability.UIAbility';
import window from '@ohos.window';
import Want from '@ohos.app.ability.Want';
export default class EntryAbility extends UIAbility {
// App 未启动时,从卡片冷启动进入
onCreate(want: Want): void {
this.handleFormWant(want);
}
// App 已在后台时,从卡片热启动进入
onNewWant(want: Want): void {
this.handleFormWant(want);
// 如果窗口已存在,需要主动刷新页面
// 通过 AppStorage 传参,Index 页面监听变化后路由
AppStorage.setOrCreate<string>('formNavPage', want.parameters?.['page'] as string ?? '');
}
private handleFormWant(want: Want): void {
const page = want.parameters?.['page'] as string;
if (page) {
console.info('[EntryAbility] from form card, target page:', page);
// 存入 AppStorage,Index 页面通过 @StorageLink 响应跳转
AppStorage.setOrCreate<string>('formNavPage', page);
}
}
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index');
}
}
// 在 Index.ets 主页响应卡片跳转
@Entry
@Component
struct Index {
@StorageLink('formNavPage') targetPage: string = '';
onPageShow(): void {
if (this.targetPage === 'todo_list') {
// 跳转到待办列表
router.pushUrl({ url: 'pages/TodoList' });
AppStorage.setOrCreate<string>('formNavPage', ''); // 清空,防止重复跳转
}
}
build() {
// 主页面内容...
Column() {
Text('主页').fontSize(24)
}
}
}
八、主动刷新卡片(App 侧推送)
当用户在 App 内新增/完成待办后,App 需要主动刷新桌面卡片:
// entry/src/main/ets/service/FormUpdateService.ets
import formProvider from '@ohos.app.form.formProvider';
import formBindingData from '@ohos.app.form.formBindingData';
import formInfo from '@ohos.app.form.formInfo';
export class FormUpdateService {
/**
* 刷新当前 App 的所有卡片
* 调用时机:新增待办、完成待办、数据同步完成后
*/
static async refreshAllForms(summary: {
total: number;
done: number;
nextTodo: string;
}): Promise<void> {
const bindingData = formBindingData.createFormBindingData({
total: summary.total,
done: summary.done,
pending: summary.total - summary.done,
lastUpdated: FormUpdateService.formatTime(new Date()),
nextTodo: summary.nextTodo
});
// 获取本应用所有已添加的卡片 ID
const forms = await formProvider.getFormsInfo();
const tasks = forms.map(form =>
formProvider.updateForm(form.formId, bindingData)
.catch(err => console.warn(`[FormUpdateService] update ${form.formId} failed:`, err))
);
await Promise.allSettled(tasks);
console.info(`[FormUpdateService] refreshed ${forms.length} forms`);
}
private static formatTime(date: Date): string {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')} 更新`;
}
}
九、踩坑总结
| 坑 | 现象 | 解法 |
|---|---|---|
| 卡片 UI 白屏 | onAddForm 返回了 null | 必须同步返回 createFormBindingData,即使是空数据也要给默认值 |
| @State 无法在卡片中使用 | 编译报错 | 卡片 UI 只能用 @LocalStorageProp,数据从 FormBindingData 注入 |
| updateForm 报 errCode 16501003 | 卡片已被用户移除 | catch 住异常,从本地缓存删除该 formId |
| router 事件无响应 | abilityName 拼写错误 | 必须与 module.json5 中 abilities[].name 完全一致 |
| 定时刷新不生效 | updateDuration 填写了 0 | 最小值为 1(30 分钟),0 表示不刷新 |
| 卡片数据延迟显示 | onAddForm 走了异步操作 | onAddForm 必须同步返回,异步更新用 onUpdateForm + updateForm |
| 进度条不更新 | total 为 0 时除以 0 | 渲染前检查 total > 0,否则 value 传 0 |
十、完整开发检查清单
✅ module.json5 中正确声明 extensionAbility(type: "form")
✅ forms[].src 路径与实际文件路径一致
✅ onAddForm 同步返回有效的 FormBindingData
✅ 卡片 UI 只使用 @LocalStorageProp 接收数据
✅ postCardAction 的 action 字段拼写正确(message/router/call)
✅ updateForm 调用后 catch 了 errCode 16501003
✅ EntryAbility 实现了 onNewWant,处理卡片热启动跳转
✅ 卡片尺寸在 supportDimensions 中全部列出
小结
服务卡片的核心心智模型:FormExtensionAbility 是数据的搬运工,卡片 UI 只是展示面板。所有业务逻辑必须在 FormExtensionAbility 中完成,通过 formProvider.updateForm() 将数据推入卡片。
掌握这个模型后,再复杂的卡片交互都可以拆解为:
- 用户触发
postCardAction→ 事件进入onFormEvent - 后台处理数据 →
updateForm推送结果 - 卡片 UI 自动更新渲染
下一篇我们将深入 ArkTS 分布式能力,探索多设备协同开发实战。
更多推荐



所有评论(0)