HarmonyOS 服务卡片开发实战:从入门到避坑
HarmonyOS 服务卡片开发实践指南
本文基于喵屿 App 的卡片开发经验与 CardInfoRefresh 官方示例,系统梳理 HarmonyOS ArkTS 服务卡片的开发流程、核心技术点及避坑策略。
一、服务卡片概述
1.1 什么是服务卡片
服务卡片(Service Widget)是 HarmonyOS 提供的一种信息外显形态,能将应用关键数据直接呈现在系统桌面,用户无需打开 App 即可快速获取信息。卡片作为用户高频触达的入口,开发时需重点关注数据及时性和资源消耗,避免因频繁刷新或大图片加载导致系统性能下降。

1.2 架构模型
服务卡片涉及三方协作:
- 卡片提供方(应用):通过
FormExtensionAbility管理卡片生命周期,提供 UI 布局和数据。 - 卡片使用方(桌面):负责卡片的展示、位置管理和交互响应。
- 卡片框架(系统服务):统一管理卡片的创建、刷新、渲染和销毁。
关键认知:
- 主应用进程(
UIAbility)和卡片进程(FormExtensionAbility)是两个独立进程,它们通过 Preferences 进行数据共享 —— 这是理解卡片数据流转的基石。 - 卡片 UI 由系统渲染服务统一绘制,应用仅提供数据和布局描述,因此卡片页面无法直接访问文件系统或数据库,所有数据必须由
FormExtensionAbility通过FormBindingData传递。 - 每张卡片每天最多触发 50 次定时刷新,超出配额后系统会静默跳过 —— 在设计刷新策略时必须将此约束纳入考量。
1.3 动态卡片 vs 静态卡片
| 特性 | 动态卡片 (isDynamic: true) |
静态卡片 |
|---|---|---|
| 数据绑定 | 支持 @LocalStorageProp |
不支持 |
| 内容更新 | formProvider.updateForm() 实时推送 |
仅创建时初始化 |
| 交互组件 | postCardAction(router/message/call) |
仅 FormLink |
选择建议:绝大多数需要实时数据展示或用户交互的场景都应使用动态卡片;静态卡片仅适用于展示固定内容的纯信息型卡片。
二、创建服务卡片
本章核心:掌握
module.json5配置、form_config.json定义、FormExtensionAbility生命周期管理以及卡片 UI 的数据绑定机制。
2.1 工程配置
在 entry/src/main/module.json5 中注册 FormExtensionAbility,必须正确填写 srcEntry 路径和 metadata 中的 resource 指向,否则卡片无法正常加载。
// module.json5 — 注册卡片扩展能力
{
"module": {
"extensionAbilities": [
{
// 扩展能力名称,需与 srcEntry 导出的类名对应
"name": "EntryFormAbility",
// 实现文件路径,相对于 src/main/ 目录
"srcEntry": "./ets/entryformability/EntryFormAbility.ets",
// 类型固定为 "form"
"type": "form",
// 卡片展示名称(桌面长按图标时显示)
"label": "$string:EntryFormAbility_label",
"description": "$string:EntryFormAbility_desc",
// 元数据:关联卡片配置文件
"metadata": [
{
"name": "ohos.extension.form",
// 指向 resources/base/profile/form_config.json
"resource": "$profile:form_config"
}
]
}
]
}
}
2.2 卡片配置文件
resources/base/profile/form_config.json 定义卡片的名称、尺寸、UI 入口和刷新策略。此文件中的 name、src、isDynamic、updateEnabled 等字段直接决定卡片行为,需仔细配置。
// form_config.json — 卡片配置文件
{
"forms": [
{
// 卡片名称,在 onAddForm 的 want.parameters 中用于区分不同卡片
"name": "widget",
// 卡片选择列表中显示的名称,引用 string.json 资源
"displayName": "$string:widget_display_name",
// 卡片描述
"description": "$string:widget_desc",
// 卡片 UI 页面文件路径,相对于 src/main/
"src": "./ets/widget/pages/WidgetCard.ets",
// 语法类型
"uiSyntax": "arkts",
// 设计尺寸基准:以 720px 宽度为基准进行自适应缩放
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
// 颜色模式:auto = 跟随系统深色/浅色模式自动切换
"colorMode": "auto",
// true = 动态卡片,支持 @LocalStorageProp 数据绑定和实时更新
"isDynamic": true,
// true = 默认卡片,长按图标后优先展示
"isDefault": true,
// 启用周期性刷新(定时/定点)
"updateEnabled": true,
// 定点刷新时间(HH:MM 格式),需同时设置 updateDuration: 0
"scheduledUpdateTime": "01:00",
// 定时刷新周期,单位 30 分钟。0 = 仅定点刷新,N = 每 N*30 分钟刷新
"updateDuration": 0,
// 默认卡片尺寸(列*行),2*4 表示 2 列 4 行
"defaultDimension": "2*4",
// 支持的卡片尺寸列表
"supportDimensions": ["2*4"]
}
]
}
关键字段说明:
| 字段 | 说明 |
|---|---|
name |
卡片名称,在 onAddForm 的 want.parameters 中用于区分不同卡片 |
src |
卡片 UI 页面路径,相对模块 src/main/ 目录 |
isDynamic |
true 表示动态卡片,支持 @LocalStorageProp 数据绑定 |
updateEnabled |
启用周期性刷新 |
scheduledUpdateTime |
定点刷新时间,格式 HH:MM |
updateDuration |
定时刷新周期,单位 30 分钟。定点刷新时须设为 0(定时与定点互斥,定时优先) |
defaultDimension |
默认卡片尺寸,如 2*4 表示 2 列 × 4 行 |
重要提醒:updateDuration 和 scheduledUpdateTime 不可同时生效 —— 当 updateDuration > 0 时,定时刷新优先,定点刷新被忽略。每日定时刷新上限为 50 次,超出后系统静默跳过。
2.3 FormExtensionAbility 生命周期
以下代码来自 CardInfoRefresh 官方示例的 EntryFormAbility.ets,核心 API 包括 onAddForm(保存 formId)、onUpdateForm(定时刷新)、onFormEvent(卡片事件)、onRemoveForm(清理 formId)。
// entry/src/main/ets/entryformability/EntryFormAbility.ets
// 来源:CardInfoRefresh 官方示例
import { BusinessError, systemDateTime } from '@kit.BasicServicesKit';
import { Want } from '@kit.AbilityKit';
import { formBindingData, FormExtensionAbility, formInfo, formProvider } from '@kit.FormKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { CardListItemData, CommonData, FormData } from '../common/CommonData';
import { CommonConstants } from '../common/CommonConstants';
import { PreferencesUtil } from '../common/utils/PreferencesUtil';
const TAG: string = 'EntryFormAbility';
export default class EntryFormAbility extends FormExtensionAbility {
/**
* 卡片添加到桌面时回调(包括预览弹窗中选中卡片时)
* @param want - 包含卡片元信息,parameters 中可取 form_identity(卡片 ID)、form_name(卡片名称)
* @returns FormBindingData - 卡片初始化时要显示的数据
*
* 关键参数:
* want.parameters[formInfo.FormParam.IDENTITY_KEY] → 卡片唯一 ID(必须持久化保存)
* want.parameters[formInfo.FormParam.NAME_KEY] → 卡片名称(对应 form_config.json 的 name)
*/
onAddForm(want: Want): formBindingData.FormBindingData {
// 参数校验
if (!want || !want.parameters) {
hilog.error(0x0000, TAG, `FormAbility onAddForm want or want.parameters is undefined`);
return formBindingData.createFormBindingData('');
}
do {
// 提取卡片名称和 ID
let formName: string = want.parameters[formInfo.FormParam.NAME_KEY] as string;
let formId: string = want.parameters[formInfo.FormParam.IDENTITY_KEY] as string;
// 获取 Preferences 工具实例,每次清除缓存后重新获取
let util = PreferencesUtil.getInstance();
let preferences = util.getPreferences(this.context);
if (!preferences) {
break; // Preferences 获取失败,返回空数据
}
// 持久化 formId,供主应用推送更新时使用
util.addFormId(preferences, formId);
// 根据卡片类型返回不同初始数据
if (formName === 'card_info_refresh') {
// 卡片类型 A:显示时间戳
let formData = new FormData(formId);
formData.formTime = systemDateTime.getTime().toString();
let tempFormInfo: formBindingData.FormBindingData =
formBindingData.createFormBindingData(formData);
return tempFormInfo;
}
// 卡片类型 B:显示列表数据,记录当前展示项索引
let key: string = `${formId}_show_index`;
let data = util.getFormInitData(key, preferences);
if (formName === 'card_info_update') {
// 保存当前展示的数据项索引
util.preferencesPut(preferences, key, data.id);
let formData = new FormData(formId);
formData.cardList.push(data);
let tempFormInfo: formBindingData.FormBindingData =
formBindingData.createFormBindingData(formData);
return tempFormInfo;
}
} while (0);
return formBindingData.createFormBindingData('');
}
/**
* 定时/定点刷新回调(系统根据 form_config.json 配置自动调用)
* @param formId - 卡片 ID
*/
onUpdateForm(formId: string) {
hilog.info(0x0000, TAG, `FormAbility onUpdateForm, formId = ${formId}`);
// 构建数据类
let formData = new FormData(formId);
formData.formTime = systemDateTime.getTime().toString();
// 获取 Preferences 实例
let util = PreferencesUtil.getInstance();
let preferences = util.getPreferences(this.context);
if (!preferences) {
return;
}
// 读取当前展示数据项的索引,轮播到下一项
let key: string = `${formId}_show_index`;
try {
if (preferences.hasSync(key)) {
let index = preferences.getSync(key, 0) as number;
let newIndex = (index + 1) % 12;
let dataItem: CardListItemData =
(preferences.getSync('dataArr', []) as CardListItemData[])[newIndex];
dataItem.favour = (preferences.getSync('statusArr', []) as boolean[])[newIndex];
util.preferencesPut(preferences, key, newIndex);
formData.cardList = [dataItem];
}
} catch (error) {
let err = error as BusinessError;
hilog.error(0x0000, TAG, `hasSync failed, error code=${err.code}`);
}
// 推送数据到卡片
let formMsg: formBindingData.FormBindingData =
formBindingData.createFormBindingData(formData);
formProvider.updateForm(formId, formMsg).catch((err: BusinessError) => {
hilog.error(0x0000, TAG, `updateForm failed, error code=${err.code}`);
});
}
/**
* 卡片事件回调(用户点击卡片按钮触发)
* @param formId - 卡片 ID
* @param message - postCardAction 中 params 传递的数据
*/
onFormEvent(formId: string, message: string) {
hilog.info(0x0000, TAG, `FormAbility onFormEvent, formId = ${formId}`);
let formData = new FormData(formId);
let util = PreferencesUtil.getInstance();
let preferences = util.getPreferences(this.context);
if (!preferences) {
return;
}
try {
// 获取下一条展示数据
let index: number = preferences.getSync(CommonConstants.DATA_INDEX, 0) as number;
formData.cardList = CommonData.getData(index);
util.preferencesPut(preferences, CommonConstants.DATA_INDEX, index + 1);
// 推送数据到卡片
let formMsg: formBindingData.FormBindingData =
formBindingData.createFormBindingData(formData);
formProvider.updateForm(formId, formMsg).then(() => {
hilog.info(0x0000, TAG, 'updateForm success.');
}).catch((error: Error) => {
let err = error as BusinessError;
hilog.error(0x0000, TAG, `updateForm failed. error code=${err.code}`);
});
} catch (error) {
let err = error as BusinessError;
hilog.error(0x0000, TAG, `getSync failed, error code=${err.code}`);
}
}
/**
* 卡片从桌面移除时回调 — 必须清理 formId
* @param formId - 卡片 ID
*/
async onRemoveForm(formId: string): Promise<void> {
hilog.info(0x00, TAG, `remove formId: ${formId}`);
PreferencesUtil.getInstance().removeFormId(this.context, formId);
}
/**
* 卡片使用方查询卡片状态时触发
*/
onAcquireFormState(_want: Want) {
return formInfo.FormState.READY;
}
}
生命周期触发时机总览:
| 回调 | 触发场景 | formId 操作 |
|---|---|---|
onAddForm(want) |
卡片添加到桌面、预览弹窗 | 保存 formId |
onUpdateForm(formId) |
定时/定点刷新到期 | 更新数据 |
onFormEvent(formId, msg) |
用户点击卡片按钮 | 更新数据 |
onRemoveForm(formId) |
卡片从桌面移除 | 删除 formId |
核心要点:onAddForm 中必须持久化 formId,否则应用侧将无法主动推送更新;onRemoveForm 中务必清理关联数据,避免残留导致内存泄漏。
2.4 卡片 UI 页面
卡片页面通过 @LocalStorageProp 接收 FormBindingData 中的数据。必须创建独立的 LocalStorage 实例并传递给 @Entry,否则 formProvider.updateForm() 推送的数据无法到达卡片。以下代码来自 CardInfoRefresh 官方示例的 WidgetCard.ets:
// entry/src/main/ets/widget/pages/WidgetCard.ets
// 来源:CardInfoRefresh 官方示例
import { CardListComponent } from '../view/CardListComponent';
import { CardListParameter } from '../viewmodel/CardListParameter';
import { CardListItemData } from '../../common/CommonData';
/**
* 第一步:创建专属 LocalStorage 实例
* 必须在 @Entry 之前定义,确保卡片页与 FormBindingData 绑定
* 【关键】不加这一行,formProvider.updateForm() 推送的数据无法到达卡片
*/
let storageLocal = new LocalStorage();
/**
* 第二步:@Entry 绑定该 LocalStorage
*/
@Entry(storageLocal)
@Component
struct WidgetCard {
// 常量:卡片交互参数
readonly ACTION_TYPE: string = 'router';
readonly ABILITY_NAME: string = 'EntryAbility';
readonly MESSAGE: string = 'add detail';
readonly FULL_WIDTH_PERCENT: string = '100%';
readonly FULL_HEIGHT_PERCENT: string = '100%';
/**
* 第三步:@LocalStorageProp 声明所有数据字段
* key 必须与 FormBindingData 中的键名完全一致(大小写敏感)
* 类型必须为 string(FormBindingData 序列化后所有值皆为 string)
* @Watch 装饰器:值变化时触发指定回调
*/
@LocalStorageProp('formTime') @Watch('onFormTimeChange') formTime: string = '';
@LocalStorageProp('formId') formId: string = '';
@LocalStorageProp('cardList') cardList: Array<CardListItemData> = [];
// 卡片 UI 参数:背景色、标题、Logo、列表项计数等
@State cardListParameter: CardListParameter = new CardListParameter(
$r('sys.color.ohos_id_color_background'),
$r('app.string.card_list_title'), '', ImageSize.Cover, $r('app.media.logo'), false,
$r('sys.color.ohos_id_color_background'), true, this.cardList.length,
$r('sys.color.ohos_id_color_emphasize'), $r('app.color.list_item_count_background'),
'', false
);
/**
* formTime 变化时的回调:通过 call 事件拉起主应用后台刷新
*/
onFormTimeChange() {
postCardAction(this, {
action: 'call',
abilityName: 'EntryAbility',
params: {
formId: this.formId,
method: 'updateCardInfo',
message: 'Call refresh card.'
}
});
}
/**
* @Builder:刷新按钮 UI 组件
* @param text - 按钮文本资源
*/
@Builder
buttonBuilder(text: ResourceStr) {
Column() {
Image($r('app.media.refresh'))
.width($r('app.float.refresh_image_size'))
.height($r('app.float.refresh_image_size'))
Text(text)
.fontColor($r('app.color.refresh_color'))
.fontSize($r('app.float.item_content_font_size'))
.margin({ top: $r('app.float.text_image_space') })
}
.justifyContent(FlexAlign.Center)
.height($r('app.float.refresh_area_height'))
.width($r('app.float.refresh_area_width'))
.borderRadius($r('app.float.border_radius'))
.backgroundColor($r('sys.color.comp_background_focus'))
}
/**
* @Builder:卡片列表内容 UI
* 使用 ForEach 遍历 cardList,每个列表项显示标题、内容和图标
*/
@Builder
cardListBuilder() {
if (this.cardList.length > 0) {
Column() {
Column() {
ForEach(this.cardList, (item: CardListItemData) => {
ListItem() {
Row() {
Column() {
Text(item.title)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.fontSize($r('app.float.item_content_font_size'))
.fontWeight(FontWeight.Medium)
.fontColor(Color.Black)
.height($r('app.float.item_text_height'))
.margin({ top: $r('app.float.item_text_margin') })
Text(item.content)
.maxLines(1)
.fontSize($r('app.float.item_content_font_size'))
.textOverflow({ overflow: TextOverflow.Ellipsis })
.fontWeight(FontWeight.Regular)
.height($r('app.float.item_text_height'))
Divider()
.strokeWidth(0.38)
.lineCap(LineCapStyle.Square)
.margin({ top: $r('app.float.list_divider_margin') })
// 最后一项不显示分割线
.visibility(item.id === 4 ? Visibility.None : Visibility.Visible)
}
.margin({ right: $r('app.float.list_row_margin') })
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Image(item.icon)
.width($r('app.float.item_image_size'))
.height($r('app.float.item_image_size'))
.borderRadius($r('app.float.border_radius'))
}
.alignItems(VerticalAlign.Center)
.width(this.FULL_WIDTH_PERCENT)
}
.width(this.FULL_WIDTH_PERCENT)
.height($r('app.float.item_height'))
}, (item: CardListItemData, index) => index + JSON.stringify(item))
}
// 底部操作按钮行:router / call / message 三种事件
Row() {
// router 事件:点击跳转到主应用 UIAbility
Row() {
this.buttonBuilder($r('app.string.router'))
}
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { message: 'Router refresh card.' }
});
})
// call 事件:拉起 UIAbility 后台执行指定方法
Row() {
this.buttonBuilder($r('app.string.call'))
}
.onClick(() => {
postCardAction(this, {
action: 'call',
abilityName: 'EntryAbility',
params: {
formId: this.formId,
method: 'updateCardInfo',
params: { message: 'Call refresh card.' }
}
});
})
// message 事件:触发 FormExtensionAbility.onFormEvent
Row() {
this.buttonBuilder($r('app.string.message'))
}
.onClick(() => {
postCardAction(this, {
action: 'message',
params: { message: 'Message refresh card.' }
});
})
}
.width(this.FULL_WIDTH_PERCENT)
.justifyContent(FlexAlign.SpaceBetween)
}
.height(this.FULL_HEIGHT_PERCENT)
.justifyContent(FlexAlign.SpaceBetween)
}
}
build() {
Row() {
CardListComponent({ cardListParameter: this.cardListParameter }) {
this.cardListBuilder()
}
}
.height(this.FULL_HEIGHT_PERCENT)
.onClick(() => {
// 整个卡片点击跳转主应用
postCardAction(this, {
action: this.ACTION_TYPE,
abilityName: this.ABILITY_NAME,
params: { message: this.MESSAGE }
});
})
}
}
关键约束:卡片页面不能使用 aboutToAppear、aboutToDisappear 等生命周期方法,所有数据必须通过 @LocalStorageProp 绑定。
2.5 数据模型
以下代码来自 CardInfoRefresh 官方示例的 CommonData.ets,FormData 类中必须包含 formImages 等特殊字段(如需加载图片),且字段名不可更改。
// entry/src/main/ets/common/CommonData.ets
// 来源:CardInfoRefresh 官方示例
// 卡片列表项数据接口
export interface CardListItemData {
id: number; // 数据项 ID
title: ResourceStr; // 标题
content: ResourceStr; // 内容
icon: Resource; // 图标资源
favour: boolean; // 收藏状态
bgImage: Resource; // 背景图
}
/**
* FormBindingData 数据类
* formImages 等特殊字段必须通过 class 传递
*/
export class FormData {
formId: string = ''; // 卡片 ID
formTime: string = ''; // 刷新时间
isFavor?: boolean = false; // 收藏状态
index?: number = 0; // 数据索引
cardList: Array<CardListItemData> = []; // 列表数据
constructor(formId: string) {
this.formId = formId;
}
}
三、卡片刷新与交互
本章核心:掌握系统被动刷新、应用主动推送、卡片 UI 触发刷新三种更新方式,以及卡片图片加载的完整机制。
3.1 被动刷新(系统触发)
以下配置说明来自华为官方文档,在 form_config.json 中配置:
定点刷新(推荐日常定时更新场景):
// form_config.json
{
"updateEnabled": true,
"scheduledUpdateTime": "01:00", // 每天凌晨 1 点自动刷新
"updateDuration": 0 // 必须为 0!否则 scheduledUpdateTime 不生效
}
系统在指定时间调用 onUpdateForm,在其中重新读取数据并调用 updateForm。
定时刷新(周期性更新):
{
"updateEnabled": true,
"updateDuration": 2 // 每 2 × 30 = 60 分钟刷新一次
}
下次刷新(动态设置最短 5 分钟后刷新):
// FormExtensionAbility 中调用
import { formProvider } from '@kit.FormKit';
const FIVE_MINUTE: number = 5;
formProvider.setFormNextRefreshTime(formId, FIVE_MINUTE, (err: BusinessError) => {
if (err) {
console.error(`Failed to setFormNextRefreshTime. Code: ${err.code}`);
}
});
定时/定点刷新的约束:
| 约束 | 说明 |
|---|---|
| 每日上限 | 每张卡片每天最多 50 次 定时刷新,0 点重置 |
| 最短间隔 | 定时刷新最短 30 分钟,setFormNextRefreshTime 最短 5 分钟 |
| 互斥规则 | updateDuration > 0 时定时刷新优先,定点刷新被忽略 |
| 可见性 | 仅在卡片可见时触发渲染 |
3.2 主动刷新(应用侧推送)
主应用通过 formProvider.updateForm() 即时推送数据。以下代码来自 CardInfoRefresh 官方示例的应用侧 Index.ets,重点在于从 Preferences 中读取所有 formId 并逐张推送。
// entry/src/main/ets/pages/Index.ets — 应用侧收藏按钮点击刷新卡片
// 来源:CardInfoRefresh 官方示例
import { formBindingData, formProvider } from '@kit.FormKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { PreferencesUtil } from '../common/utils/PreferencesUtil';
import { FormData } from '../common/CommonData';
Row() {
// 收藏按钮 UI
}
.onClick(() => {
// 获取 Preferences 实例(每次清除缓存后重新获取)
let util = PreferencesUtil.getInstance();
let preferences = util.getPreferences(this.getUIContext().getHostContext()!);
if (!preferences) {
return;
}
// 更新收藏状态
this.statusArr[this.itemData.id] = !this.statusArr[this.itemData.id];
this.itemData.favour = this.statusArr[this.itemData.id!];
util.preferencesPut(preferences, 'statusArr', this.statusArr);
// 同步更新应用内 UI
AppStorage.set('statusArr', [...this.statusArr]);
// 读取所有已加桌卡片的 formId
let idArr = PreferencesUtil.getInstance().getFormIds(preferences);
if (idArr.length > 0) {
idArr.forEach((formId: string) => {
if (!preferences) {
return;
}
try {
// 检查当前卡片是否展示的是被收藏的数据项
if (preferences.getSync(`${formId}_show_index`, -1) as number === this.itemData.id) {
// 构建更新数据
let formData = new FormData(formId);
formData.cardList = [this.itemData];
let formMsg: formBindingData.FormBindingData =
formBindingData.createFormBindingData(formData);
// 推送数据到卡片
formProvider.updateForm(formId, formMsg).then(() => {
hilog.info(0x0000, TAG, `updateForm success.`);
}).catch((error: Error) => {
let err = error as BusinessError;
hilog.error(0x0000, TAG, `updateForm failed: error code=${err.code}`);
});
}
} catch (error) {
let err = error as BusinessError;
hilog.error(0x0000, TAG, `getSync failed, error code=${err.code}`);
}
})
}
})
关键点:
- formId 在
onAddForm中通过want.parameters[formInfo.FormParam.IDENTITY_KEY]获取并持久化。 - 读取 formId 前必须调用
removePreferencesFromCacheSync清除缓存,确保多进程场景读到最新数据。 - 主应用和卡片进程是不同进程,通过 Preferences 共享 formId 数据。
3.3 卡片 UI 触发刷新(message 事件)
在卡片页面通过 postCardAction 触发 onFormEvent,action 类型必须为 'message':
// 卡片页面的刷新按钮 — message 事件
Row() {
this.buttonBuilder($r('app.string.message'))
}
.onClick(() => {
postCardAction(this, {
action: 'message', // 触发 FormExtensionAbility.onFormEvent
params: {
message: 'Message refresh card.'
}
});
})
3.4 卡片图片加载
图片必须通过 formImages + 文件描述符传递,卡片页面使用 memory:// 协议引用。此机制是卡片进程无法访问应用沙箱的必然选择。
// FormExtensionAbility 中构建图片数据
import { fileIo } from '@kit.CoreFileKit';
// 图片数据必须用 class 传递,formImages 字段名不可更改
class CardData {
// 图片在 formImages 中的 key 名,卡片页用 memory:// 引用
imgName: string = 'petImg';
// 必填字段,名称为 formImages,值为 { key: fd } 的 Record
formImages: Record<string, number> = {};
// 其他数据字段
text: string = 'Hello';
}
// 打开图片文件,获取文件描述符
const file = fileIo.openSync('/data/storage/.../avatar.jpg');
const data = new CardData();
data.formImages = { 'petImg': file.fd };
return formBindingData.createFormBindingData(data);
// 卡片页面中引用图片 — 使用 memory:// 协议
@LocalStorageProp('imgName') imgName: string = '';
Image('memory://' + this.imgName)
.width(64).height(64)
.borderRadius(32)
.objectFit(ImageFit.Cover)
图片加载约束:
| 约束 | 说明 |
|---|---|
| 数量限制 | API 19 之前最多 5 张,API 20+ 最多 20 张 |
| 大小限制 | API 19 之前每张 ≤ 2MB,API 20+ 总大小 ≤ 10MB |
| 传递方式 | 必须通过 formImages: Record<string, number> 字段传递文件描述符 |
| 引用方式 | 卡片页用 Image('memory://' + key) 引用 |
| imgName 刷新 | 每次传递的 imgName 必须不同,连续相同值不会触发图片刷新 |
四、从应用内请求卡片加桌
本章核心:掌握
formProvider.openFormManager()的调用方式与兼容处理。
使用 formProvider.openFormManager()(API 18+)在应用内打开卡片管理页面,需传入 Want 参数指定目标卡片信息:
// 应用内添加卡片到桌面入口
import { formProvider } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
// 构建 Want 参数,指定目标卡片信息
const want: Want = {
// 应用包名
bundleName: 'com.akensx.catisland',
// FormExtensionAbility 名称
abilityName: 'EntryFormAbility',
// parameters 中指定卡片尺寸、名称和模块
parameters: {
// 卡片尺寸编号:1=1×2, 2=2×2, 3=2×4, 4=4×4, 5=6×4
'ohos.extra.param.key.form_dimension': 3,
// 卡片名称(对应 form_config.json 中的 name 字段)
'ohos.extra.param.key.form_name': 'catInfo',
// 卡片所属模块名
'ohos.extra.param.key.module_name': 'entry'
}
};
try {
// 拉起系统卡片管理页面
formProvider.openFormManager(want);
} catch (error) {
let err = error as BusinessError;
console.error(`openFormManager failed, code=${err.code}, message=${err.message}`);
}
注意:该 API 最低要求 API 18,低版本设备需做兼容检查:
// 兼容检查
if (deviceInfo.distributionOSApiVersion >= /* API 18 */) {
formProvider.openFormManager(want);
}

五、喵屿卡片实践
本章核心:通过喵屿项目实战案例,展示多卡片管理、待办数据计算和卡片数据推送的完整实现路径。
喵屿是一款宠物健康管理应用,基于上述技术实现三张服务卡片。以下代码来自项目实际开发,展示多卡片 FormExtensionAbility 管理、待办数据计算和卡片数据推送的完整流程。
5.1 整体架构
喵屿 App(主进程 UIAbility)
├── ShowItemUpdateUtil (待办计算 + 卡片推送调度)
│ └── pushAllCardUpdates() → 分别推送三种卡片数据
├── CardFormIdUtil (formId 持久化管理)
├── EntryFormAbility(卡片进程)
│ ├── onAddForm → 存储 formId + 返回初始数据
│ ├── onUpdateForm → 每日定点刷新
│ ├── onFormEvent → 点击卡片刷新按钮
│ └── computeTodos → 超期计算 (DateUtil.dayDiff2)
│
└── 卡片 UI 页面
├── CatInfoCard.ets (待办提醒)
├── PetInfoCard.ets (宠物信息)
└── PetTodoCard.ets (宠物+待办合并)
5.2 待办提醒卡片(catInfo)核心代码
// entry/src/main/ets/catinfo/pages/CatInfoCard.ets
// 待办行数据类型
interface TodoRowItem {
index: number; // 序号(1-based)
title: string; // 标题(如 "猫三联已超期,尽快接种")
state: string; // 状态:'ERROR'(紧急)或 'ALARM'(提醒)
}
// 创建独立 LocalStorage(必须,否则 updateForm 数据无法到达卡片)
let catInfoStorage = new LocalStorage();
@Entry(catInfoStorage)
@Component
struct CatInfoCard {
readonly actionType = 'router';
readonly abilityName = 'EntryAbility';
// @LocalStorageProp:key 必须与 FormBindingData 中完全一致,类型必须为 string
@LocalStorageProp('todoCount') todoCount: string = '0';
@LocalStorageProp('todoTitle0') todoTitle0: string = '';
@LocalStorageProp('todoTitle1') todoTitle1: string = '';
@LocalStorageProp('todoTitle2') todoTitle2: string = '';
@LocalStorageProp('todoTitle3') todoTitle3: string = '';
@LocalStorageProp('todoState0') todoState0: string = '';
@LocalStorageProp('todoState1') todoState1: string = '';
@LocalStorageProp('todoState2') todoState2: string = '';
@LocalStorageProp('todoState3') todoState3: string = '';
// 旋转动画角度(刷新按钮)
@State rotateAngle: number = 0;
/**
* 将分散的 @LocalStorageProp 字段组装为数组,供 ForEach 遍历
* 每次 build() 重新执行时获取最新值
*/
private getTodoItems(): TodoRowItem[] {
const items: TodoRowItem[] = [];
const titles = [this.todoTitle0, this.todoTitle1, this.todoTitle2, this.todoTitle3];
const states = [this.todoState0, this.todoState1, this.todoState2, this.todoState3];
for (let i = 0; i < titles.length; i++) {
if (titles[i]) { // 只收录非空的待办项
items.push({ index: i + 1, title: titles[i], state: states[i] });
}
}
return items;
}
build() {
Column() {
// 标题栏:名称 + 数量
Row() {
Text('喵屿待办')
.fontSize(15).fontWeight(FontWeight.Bold)
.fontColor($r('app.color.card_text_primary'))
Blank()
Text(this.todoCount)
.fontSize(15).fontWeight(FontWeight.Bold)
.fontColor($r('app.color.card_accent'))
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 6 })
// 内容区域
if (this.todoCount === '0') {
Column() {
Text('太棒啦,今天没有任务哦~')
.fontSize(12).fontColor($r('app.color.card_text_hint'))
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else {
/**
* 使用 Column + ForEach 渲染待办列
* 【避坑】不要用 @Builder(卡片中 @Builder 缓存输出,参数变化不重新执行)
* 【避坑】ForEach key 不可用 String(index),数据变化 key 不变 → 不复渲染
*/
Column() {
ForEach(this.getTodoItems(), (item: TodoRowItem) => {
Row() {
Text(`${item.index}`)
.fontSize(10).fontWeight(FontWeight.Bold)
.fontColor(Color.White)
.backgroundColor($r('app.color.card_accent'))
.borderRadius(8).width(16).height(16).textAlign(TextAlign.Center)
Text(item.title)
.fontSize(11).fontColor($r('app.color.card_text_secondary'))
.margin({ left: 6 }).maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1)
Text(item.state === 'ERROR' ? '紧急' : '提醒')
.fontSize(9)
.fontColor(item.state === 'ERROR'
? $r('app.color.card_badge_error_text')
: $r('app.color.card_badge_warn_text'))
.backgroundColor(item.state === 'ERROR'
? $r('app.color.card_badge_error_bg')
: $r('app.color.card_badge_warn_bg'))
.borderRadius(4)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
}
.width('100%')
.padding({ left: 14, right: 14, top: 4, bottom: 4 })
}, (item: TodoRowItem) =>
// key 使用字符串拼接反映数据变化(JSON.stringify 在卡片中不可用)
String(item.index) + '_' + item.title + '_' + item.state
)
}
.width('100%').layoutWeight(1)
}
// 底部分隔 + 刷新按钮
Divider().strokeWidth(0.5).color($r('app.color.card_divider'))
Row() {
Image($r('app.media.refresh'))
.width(15).height(15)
.rotate({ angle: this.rotateAngle })
.animation({ duration: 1000, curve: Curve.EaseInOut })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ top: 4, bottom: 6 })
.onClick(() => {
this.rotateAngle += 360;
postCardAction(this, {
action: 'message',
params: { msgTest: 'messageEvent' }
});
})
}
.width('100%').height('100%')
.backgroundColor($r('app.color.card_bg')).borderRadius(14)
.onClick(() => {
postCardAction(this, {
action: this.actionType, abilityName: this.abilityName,
params: { message: 'openTodo' }
});
})
}
}
5.3 多卡片 EntryFormAbility 管理
一个 FormExtensionAbility 管理三张卡片,通过 formName 区分。此设计避免了为每种卡片单独注册 ExtensionAbility,降低了资源开销。
// entry/src/main/ets/entryformability/EntryFormAbility.ets(摘录)
import { CardFormIdUtil } from '../util/CardFormIdUtil';
const FORM_TODO = 'catInfo'; // 待办卡片
const FORM_PET = 'petInfo'; // 宠物信息卡片
const FORM_PET_TODO = 'petTodo'; // 合并卡片
export default class EntryFormAbility extends FormExtensionAbility {
private formIdUtil: CardFormIdUtil = CardFormIdUtil.getInstance();
onAddForm(want: Want): formBindingData.FormBindingData {
const name = want.parameters?.[formInfo.FormParam.NAME_KEY] as string ?? '';
const formId = want.parameters?.[formInfo.FormParam.IDENTITY_KEY] as string ?? '';
// 持久化 formId,key 直接用卡片名称
if (formId && name) {
this.formIdUtil.addFormId(name, this.context.getApplicationContext(), formId);
}
// 根据卡片类型构建不同数据
return formBindingData.createFormBindingData(this.buildDataForCard(name));
}
// 卡片类型 → 数据构建分发
private buildDataForCard(formName: string): Object {
if (formName === FORM_PET) return this.buildPetData();
if (formName === FORM_PET_TODO) return this.buildPetTodoData();
return this.buildTodoData(); // 默认待办卡片
}
// 通过持久化存储查找 formId 所属卡片类型
private getCardType(formId: string): string {
const prefs = this.formIdUtil.getPreferences(this.context.getApplicationContext());
if (!prefs) return '';
for (const key of ['catInfo', 'petInfo', 'petTodo']) {
if (this.formIdUtil.getFormIds(prefs, key).indexOf(formId) >= 0) {
return key;
}
}
return '';
}
}
5.4 应用侧待办数据推送
// entry/src/main/ets/util/ShowItemUpdateUtil.ets(摘录)
// 待办数据更新后延迟推送(debounce 1000ms)
private static scheduleCardRefresh(): void {
if (ShowItemUpdateUtil.updateTimer !== -1) {
clearTimeout(ShowItemUpdateUtil.updateTimer);
}
ShowItemUpdateUtil.updateTimer = setTimeout(() => {
ShowItemUpdateUtil.pushAllCardUpdates();
}, 1000);
}
// 一次 getPreferences I/O 读取所有卡片类型的 formId
private static getAllFormIds(): Record<string, string[]> {
const result: Record<string, string[]> = {};
if (!PreferenceUtil.context) return result;
const prefs = ShowItemUpdateUtil.formIdUtil.getPreferences(PreferenceUtil.context);
if (!prefs) return result;
for (const key of ['catInfo', 'petInfo', 'petTodo']) {
result[key] = ShowItemUpdateUtil.formIdUtil.getFormIds(prefs, key);
}
return result;
}
// 推送卡片更新
private static pushForm(ids: string[], bd: formBindingData.FormBindingData): void {
for (const fid of ids) {
formProvider.updateForm(fid, bd).catch((err: BusinessError) => {
// 错误码 16501001:卡片已被用户移除,下次 onRemoveForm 会清理
if (err.code === 16501001) {
console.warn(`[CardRefresh] Form ${fid} removed`);
}
});
}
}

服务卡片不仅可以添加到桌面,还可以添加到负一屏,为用户提供更多触达入口。
六、避坑指南
本章核心:汇总喵屿项目实际开发中遇到的典型问题,涵盖 UI 渲染、数据处理、进程与存储三大类常见陷阱。
6.1 UI 渲染相关
1. @LocalStorageProp 值均为 string 类型
FormBindingData 会序列化所有值,因此 number 类型字段也必须声明为 string,条件判断时使用 === '0' 而非 === 0。
// 错误:声明为 number,实际收到 string,判断永远不成立
@LocalStorageProp('todoCount') todoCount: number = 0;
if (this.todoCount === 0) { ... }
// 正确
@LocalStorageProp('todoCount') todoCount: string = '0';
if (this.todoCount === '0') { ... }
2. 卡片必须创建独立 LocalStorage 并传给 @Entry()
// 错误:缺少 LocalStorage,updateForm 推送数据无法到达
@Entry
@Component
struct MyCard { ... }
// 正确
let storage = new LocalStorage();
@Entry(storage)
@Component
struct MyCard { ... }
3. ForEach 的 key 函数不能只依赖索引
使用 String(index) 会导致数据变化时 key 不变,ForEach 不复渲染。应使用能反映数据内容的字符串拼接作为 key。
4. Swiper 的 duration/curve 属性在卡片中不支持
编译时会报错 'duration' can't support form application。仅使用 autoPlay、displayCount、interval、indicator 等基础属性。
6.2 数据处理相关
5. 图片必须通过 formImages + 文件描述符传递
URI 字符串在卡片进程中无效——卡片进程无法访问主应用沙箱。
// 错误:直接传 URI
const data = { 'petIcon': '/data/storage/.../avatar.jpg' };
// 正确:打开文件获取 fd,通过 formImages 传递
const file = fileIo.openSync('/data/storage/.../avatar.jpg');
class CardData {
imgName: string = 'petImg';
formImages: Record<string, number> = { 'petImg': file.fd };
}
6. formImages 字段必须通过 class 传递
使用 Record<string, string | Record<string, number>> 类型改写可能导致 formImages 未被正确序列化,必须使用 class 定义。
7. 卡片图片刷新依赖 imgName 变化
Image 组件通过传入参数是否有变化来决定是否刷新图片。每次 updateForm 传递的 imgName 必须不同,连续两次相同值不会触发图片刷新。
// 错误:每次使用固定的 'avatar'
data.imgName = 'avatar';
// 正确:包含时间戳或序号确保唯一
data.imgName = 'avatar_' + Date.now();
8. 超期计算函数必须与 App 端一致
Math.ceil 和 dayjs.diff 对边界时间的处理不同。喵屿使用 DateUtil.dayDiff2() 统一计算,确保卡片与 App 内数据一致。
6.3 进程与存储相关
9. 主应用和卡片进程的 Preferences Context 必须一致
两个进程中读取同一个 Preferences 文件时,必须使用相同类型的 Context:
// 错误:卡片进程用 FormExtensionContext,主应用用 UIContext
// 两个 context 的 getPreferencesSync 可能读取到不同路径的文件
// 正确:统一使用 ApplicationContext
// 卡片进程
const appCtx = this.context.getApplicationContext();
preferences.getPreferencesSync(appCtx, { name: 'catIsland' });
// 主应用(PreferenceUtil.initContext 在 EntryAbility 中)
PreferenceUtil.initContext(this.context.getApplicationContext());
如果 Context 不一致,两个进程拿到的 Preferences 实例可能指向不同的底层文件,导致数据无法共享。
10. 读取 formId 前必须清除 Preferences 缓存
多进程场景下,卡片进程写入 formId 后,主应用的 Preferences 缓存可能仍是旧值:
// 错误:直接读取,可能拿到旧缓存
const ids = PreferenceUtil.getPreferenceByNameSync(BaseConstants.DEFAULT_NAME, key, '[]');
// 正确:读取前先清除缓存
preferences.removePreferencesFromCacheSync(context, 'cardFormIdStore');
const ids = PreferenceUtil.getPreferenceByNameSync(...);
6.4 卡片约束
| 约束 | 说明 |
|---|---|
| 10 秒无操作清理 | FormExtensionAbility 创建后 10 秒内无新操作会被系统销毁 |
| 不支持部分多媒体模块 | @ohos.multimedia.audio、.camera、.media、@ohos.resourceschedule.backgroundTaskManager |
| 不支持生命周期方法 | 卡片页面不能使用 aboutToAppear、aboutToDisappear、onPageShow 等 |
| 不支持 @State/@Watch | 卡片页面只能使用 @LocalStorageProp 进行数据绑定 |
| 定时刷新配额 | 每张卡片每天最多 50 次,0 点重置 |
| 定点/定时互斥 | scheduledUpdateTime 仅在 updateDuration: 0 时生效 |
| API 版本兼容 | formProvider.openFormManager 需 API 18+,低版本需兼容检查 |
七、总结
HarmonyOS 服务卡片开发的核心在于理解以下三点:
1. 双进程架构
主应用(UIAbility)和卡片(FormExtensionAbility)是独立进程,通过 Preferences 共享 formId 和数据。Preferences 必须使用统一的 Context 类型(推荐 ApplicationContext),读取前必须清除缓存。关键 API:preferences.getPreferencesSync、removePreferencesFromCacheSync、formProvider.updateForm。
2. 数据单向流动
数据流为 FormBindingData → LocalStorage → @LocalStorageProp,所有值被序列化为 string。卡片页面不直接访问文件系统或数据库,数据完全由 FormExtensionAbility 提供。图片必须通过 formImages + fd 传递,memory:// 协议引用。关键 API:formBindingData.createFormBindingData、LocalStorage、@LocalStorageProp。
3. 生命周期与刷新策略onAddForm 保存 formId,onUpdateForm 处理定时刷新,onFormEvent 响应用户操作,onRemoveForm 清理数据。刷新受每日配额和可见性约束,需合理设计更新频率。关键 API:onAddForm、onUpdateForm、onFormEvent、postCardAction、formProvider.setFormNextRefreshTime。
掌握以上三点,结合本文的避坑指南和喵屿实践案例,开发者可以快速构建稳定、高效的 HarmonyOS 服务卡片。
参考来源:
更多推荐




所有评论(0)