[鸿蒙2025领航者闯关] HarmonyOS服务卡片实战
如何在 HarmonyOS 中开发一个服务卡片(Widget),让用户无需打开应用即可查看数据?
·
问题描述
如何在 HarmonyOS 中开发一个服务卡片(Widget),让用户无需打开应用即可查看数据?开发者常遇到:
- 不知道如何创建 FormExtensionAbility
- 卡片数据如何从数据库获取
- 卡片如何自动更新
- 点击卡片如何跳转到应用
关键字:服务卡片、FormExtension、Widget、数据更新
解决方案
1. 技术架构
┌────────────────────────────────────┐
│ FeedingFormAbility │
│ (FormExtensionAbility) │
│ - onAddForm: 创建卡片 │
│ - onUpdateForm: 更新卡片 │
│ - onFormEvent: 处理点击 │
└────────────────────────────────────┘
↕
┌────────────────────────────────────┐
│ FormDataService │
│ - 管理所有卡片ID │
│ - 从数据库获取数据 │
│ - 更新所有卡片 │
└────────────────────────────────────┘
↕
┌────────────────────────────────────┐
│ FeedingWidgetCard.ets │
│ - 卡片UI页面 │
│ - 使用@LocalStorageProp绑定数据 │
└────────────────────────────────────┘
2. 完整实现代码
步骤 1: 配置 form_config.json
位置: entry/src/main/resources/base/profile/form_config.json
{
"forms": [
{
"name": "FeedingWidget",
"description": "喂养记录卡片",
"src": "./ets/widget/pages/FeedingWidgetCard.ets",
"uiSyntax": "arkts",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "auto",
"isDefault": true,
"updateEnabled": true,
"scheduledUpdateTime": "10:30",
"updateDuration": 1,
"defaultDimension": "2*2",
"supportDimensions": ["2*2", "2*4", "4*4"],
"formConfigAbility": "ability://entry/EntryAbility"
}
]
}
关键配置:
uiSyntax: "arkts": 必须指定 UI 语法updateEnabled: true: 启用自动更新updateDuration: 1: 每 1 小时更新一次formConfigAbility: 点击跳转的 Ability
步骤 2: 注册 FormExtensionAbility
位置: entry/src/main/module.json5
{
"module": {
"extensionAbilities": [
{
"name": "FeedingFormAbility",
"srcEntry": "./ets/entryformability/FeedingFormAbility.ets",
"type": "form",
"exported": true,
"description": "喂养记录卡片",
"metadata": [
{
"name": "ohos.extension.form",
"resource": "$profile:form_config"
}
]
}
]
}
}
步骤 3: 创建 FormExtensionAbility
位置: entry/src/main/ets/entryformability/FeedingFormAbility.ets
import { FormExtensionAbility, formInfo, formBindingData } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
import { FormDataService } from '../services/FormDataService';
const TAG = 'FeedingFormAbility';
export default class FeedingFormAbility extends FormExtensionAbility {
/**
* 创建卡片时调用
*/
onAddForm(want: Want): formBindingData.FormBindingData {
const formId = want.parameters?.[formInfo.FormParam.IDENTITY_KEY] as string;
console.info(TAG, `创建卡片, formId: ${formId}`);
// 注册卡片ID
FormDataService.getInstance().registerForm(formId);
// 获取卡片数据
const formData = this.getFormData();
return formBindingData.createFormBindingData(formData);
}
/**
* 更新卡片时调用
*/
onUpdateForm(formId: string): void {
console.info(TAG, `更新卡片, formId: ${formId}`);
// 异步更新卡片数据
FormDataService.getInstance().updateForm(formId);
}
/**
* 移除卡片时调用
*/
onRemoveForm(formId: string): void {
console.info(TAG, `移除卡片, formId: ${formId}`);
// 注销卡片ID
FormDataService.getInstance().unregisterForm(formId);
}
/**
* 卡片事件处理
*/
onFormEvent(formId: string, message: string): void {
console.info(TAG, `卡片事件, formId: ${formId}, message: ${message}`);
if (message === 'router') {
// 跳转到应用内页面
// 实际跳转由点击卡片时触发
}
}
/**
* 获取卡片数据
*/
private getFormData(): Record<string, Object> {
// 这里返回默认数据
// 实际数据由FormDataService异步更新
return {
hasData: false,
todayCount: 0,
milkCount: 0,
sleepCount: 0,
diaperCount: 0,
latestType: '',
latestTime: '',
latestIcon: ''
};
}
}
步骤 4: 创建卡片数据服务
位置: entry/src/main/ets/services/FormDataService.ets
import { formProvider } from '@kit.FormKit';
import { FeedingRecordDao } from '../database/FeedingRecordDao';
import { FeedingRecord, FeedingType } from '../models/FeedingRecord';
const TAG = 'FormDataService';
/**
* 卡片数据服务
* 管理所有卡片的数据更新
*/
export class FormDataService {
private static instance: FormDataService;
private formIds: Set<string> = new Set();
private feedingDao: FeedingRecordDao = new FeedingRecordDao();
private constructor() {}
static getInstance(): FormDataService {
if (!FormDataService.instance) {
FormDataService.instance = new FormDataService();
}
return FormDataService.instance;
}
/**
* 注册卡片
*/
registerForm(formId: string): void {
this.formIds.add(formId);
console.info(TAG, `注册卡片: ${formId}, 总数: ${this.formIds.size}`);
// 立即更新数据
this.updateForm(formId);
}
/**
* 注销卡片
*/
unregisterForm(formId: string): void {
this.formIds.delete(formId);
console.info(TAG, `注销卡片: ${formId}, 总数: ${this.formIds.size}`);
}
/**
* 更新所有卡片
*/
async updateAllForms(): Promise<void> {
console.info(TAG, `更新所有卡片, 总数: ${this.formIds.size}`);
for (const formId of this.formIds) {
await this.updateForm(formId);
}
}
/**
* 更新指定卡片
*/
async updateForm(formId: string): Promise<void> {
try {
// 从数据库获取今日数据
const formData = await this.getFormData();
// 创建更新数据
const formBindingData = {
data: JSON.stringify(formData)
};
// 更新卡片
await formProvider.updateForm(formId, formBindingData);
console.info(TAG, `卡片更新成功: ${formId}`);
} catch (err) {
console.error(TAG, `更新卡片失败: ${JSON.stringify(err)}`);
}
}
/**
* 获取卡片数据
*/
private async getFormData(): Promise<Record<string, Object>> {
try {
// 获取今日开始时间
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayStart = today.getTime();
// 查询今日所有记录
const todayRecords = await this.feedingDao.findByDateRange(
todayStart,
Date.now()
);
if (todayRecords.length === 0) {
return {
hasData: false,
todayCount: 0,
milkCount: 0,
sleepCount: 0,
diaperCount: 0,
latestType: '',
latestTime: '',
latestIcon: ''
};
}
// 统计各类型数量
const milkCount = todayRecords.filter(r =>
r.type === FeedingType.BREAST || r.type === FeedingType.BOTTLE
).length;
const sleepCount = todayRecords.filter(r =>
r.type === FeedingType.SLEEP
).length;
const diaperCount = todayRecords.filter(r =>
r.type === FeedingType.DIAPER
).length;
// 获取最新记录
const latest = todayRecords[0];
return {
hasData: true,
todayCount: todayRecords.length,
milkCount: milkCount,
sleepCount: sleepCount,
diaperCount: diaperCount,
latestType: this.getTypeName(latest.type),
latestTime: this.formatTime(latest.recordTime),
latestIcon: this.getTypeIcon(latest.type)
};
} catch (err) {
console.error(TAG, `获取卡片数据失败: ${JSON.stringify(err)}`);
return {
hasData: false,
todayCount: 0,
milkCount: 0,
sleepCount: 0,
diaperCount: 0,
latestType: '',
latestTime: '',
latestIcon: ''
};
}
}
/**
* 获取类型名称
*/
private getTypeName(type: FeedingType): string {
const typeMap: Record<FeedingType, string> = {
[FeedingType.BREAST]: '母乳喂养',
[FeedingType.BOTTLE]: '奶瓶喂养',
[FeedingType.SLEEP]: '睡眠',
[FeedingType.DIAPER]: '换尿布',
[FeedingType.SOLID_FOOD]: '辅食',
[FeedingType.MEDICINE]: '用药'
};
return typeMap[type] || '';
}
/**
* 获取类型图标
*/
private getTypeIcon(type: FeedingType): string {
const iconMap: Record<FeedingType, string> = {
[FeedingType.BREAST]: '🍼',
[FeedingType.BOTTLE]: '🍼',
[FeedingType.SLEEP]: '😴',
[FeedingType.DIAPER]: '🧷',
[FeedingType.SOLID_FOOD]: '🥄',
[FeedingType.MEDICINE]: '💊'
};
return iconMap[type] || '📝';
}
/**
* 格式化时间
*/
private formatTime(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (minutes < 1) {
return '刚刚';
} else if (minutes < 60) {
return `${minutes}分钟前`;
} else if (hours < 24) {
return `${hours}小时前`;
} else {
return `${days}天前`;
}
}
}
步骤 5: 创建卡片 UI 页面
位置: entry/src/main/ets/widget/pages/FeedingWidgetCard.ets
@Entry
@Component
struct FeedingWidgetCard {
// 绑定卡片数据
@LocalStorageProp('hasData') hasData: boolean = false;
@LocalStorageProp('todayCount') todayCount: number = 0;
@LocalStorageProp('milkCount') milkCount: number = 0;
@LocalStorageProp('sleepCount') sleepCount: number = 0;
@LocalStorageProp('diaperCount') diaperCount: number = 0;
@LocalStorageProp('latestType') latestType: string = '';
@LocalStorageProp('latestTime') latestTime: string = '';
@LocalStorageProp('latestIcon') latestIcon: string = '';
build() {
Column() {
if (this.hasData) {
// 有数据时显示
this.buildDataView();
} else {
// 无数据时显示
this.buildEmptyView();
}
}
.width('100%')
.height('100%')
.padding(16)
.backgroundColor('#FF9472')
.backgroundImage($r('app.media.widget_bg'), ImageRepeat.NoRepeat)
.backgroundImageSize(ImageSize.Cover)
.borderRadius(16)
.onClick(() => {
// 点击跳转到应用
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: {
page: 'pages/FeedingRecordPage'
}
});
})
}
/**
* 有数据视图
*/
@Builder
buildDataView() {
Column({ space: 12 }) {
// 标题栏
Row() {
Text('喂养记录')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White);
Blank();
Text('🍼')
.fontSize(20);
}
.width('100%')
// 统计卡片
Row({ space: 8 }) {
this.buildStatCard('📊', this.todayCount.toString(), '今日');
this.buildStatCard('🍼', this.milkCount.toString(), '喂奶');
this.buildStatCard('😴', this.sleepCount.toString(), '睡眠');
this.buildStatCard('🧷', this.diaperCount.toString(), '尿布');
}
.width('100%')
// 最新记录
Column({ space: 4 }) {
Text('最新记录')
.fontSize(12)
.fontColor('rgba(255, 255, 255, 0.8)')
.alignSelf(ItemAlign.Start);
Row({ space: 8 }) {
Text(this.latestIcon)
.fontSize(20);
Column({ space: 2 }) {
Text(this.latestType)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White);
Text(this.latestTime)
.fontSize(12)
.fontColor('rgba(255, 255, 255, 0.8)');
}
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(12)
.backgroundColor('rgba(255, 255, 255, 0.2)')
.borderRadius(8)
}
.width('100%')
}
.width('100%')
.height('100%')
}
/**
* 空数据视图
*/
@Builder
buildEmptyView() {
Column({ space: 12 }) {
Text('👶')
.fontSize(48);
Text('暂无记录')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White);
Text('点击添加喂养记录')
.fontSize(14)
.fontColor('rgba(255, 255, 255, 0.8)');
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
/**
* 统计卡片
*/
@Builder
buildStatCard(icon: string, value: string, label: string) {
Column({ space: 4 }) {
Text(icon).fontSize(20);
Text(value)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White);
Text(label)
.fontSize(10)
.fontColor('rgba(255, 255, 255, 0.8)');
}
.width('25%')
.padding(8)
.backgroundColor('rgba(255, 255, 255, 0.2)')
.borderRadius(8)
}
}
步骤 6: 在应用中触发卡片更新
// 添加记录后更新所有卡片
import { FormDataService } from '../services/FormDataService';
async function addFeedingRecord(record: FeedingRecord): Promise<void> {
// 保存到数据库
await feedingDao.insert(record);
// 更新所有卡片
await FormDataService.getInstance().updateAllForms();
}
3. 运行效果

关键要点
1. 配置文件必须正确
form_config.json:
- ✅
uiSyntax: "arkts"- 必须指定 - ✅
formConfigAbility- 点击跳转的 Ability
module.json5:
- ✅
type: "form"- 扩展类型 - ✅
exported: true- 必须导出
2. 数据绑定
使用 @LocalStorageProp 绑定卡片数据:
@LocalStorageProp('todayCount') todayCount: number = 0;
3. 更新机制
自动更新:
- 系统定时更新(updateDuration)
- 定点更新(scheduledUpdateTime)
手动更新:
await formProvider.updateForm(formId, formBindingData);
4. 点击跳转
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: {
page: 'pages/FeedingRecordPage'
}
});
常见问题
Q1: 卡片无法添加到桌面?
检查配置:
// module.json5
"extensionAbilities": [{
"type": "form", // ✅ 必须是form
"exported": true, // ✅ 必须导出
"metadata": [{
"name": "ohos.extension.form", // ✅ 名称正确
"resource": "$profile:form_config" // ✅ 路径正确
}]
}]
Q2: 卡片数据不更新?
检查:
- FormDataService 是否注册了 formId
- updateForm 是否被调用
- formProvider.updateForm 是否成功
// 添加日志
console.info(TAG, `更新卡片: ${formId}`);
await formProvider.updateForm(formId, formBindingData);
console.info(TAG, `更新成功`);
Q3: 点击卡片无法跳转?
检查:
- formConfigAbility 配置是否正确
- abilityName 是否正确
- 目标页面是否存在
// 正确的跳转配置
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility', // ✅ 与module.json5一致
params: {
page: 'pages/FeedingRecordPage' // ✅ 页面路径正确
}
});
参考资料
更多推荐



所有评论(0)