HarmonyOS APP<<古今职鉴定>>开源教程第29篇:【完整案例】桌面卡片完整开发
本篇开发桌面服务卡片,实现年俗日历小组件

图:【完整案例】桌面卡片完整开发 的关键流程与实现要点。
学习目标
- ✅ 配置卡片能力
- ✅ 实现卡片 UI
- ✅ 开发数据更新机制
- ✅ 实现点击跳转
预计学习时间
约 120 分钟
实战一:配置卡片能力
第一步:在 module.json5 中声明卡片
在 entry/src/main/module.json5 的 extensionAbilities 中添加:
{
"extensionAbilities": [
{
"name": "EntryFormAbility",
"srcEntry": "./ets/entryformability/EntryFormAbility.ets",
"type": "form",
"metadata": [
{
"name": "ohos.extension.form",
"resource": "$profile:form_config"
}
]
}
]
}
原理解释:
type: "form"声明这是一个卡片扩展能力metadata指向卡片配置文件form_config.json
第二步:创建卡片配置文件
在 entry/src/main/resources/base/profile/form_config.json 中:
{
"forms": [
{
"name": "widget_1x2",
"displayName": "年俗日历(小)",
"description": "显示当日年俗或官场冷知识",
"src": "./ets/widget/pages/WidgetCard.ets",
"uiSyntax": "arkts",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "auto",
"isDefault": true,
"updateEnabled": true,
"scheduledUpdateTime": "00:00",
"updateDuration": 1,
"defaultDimension": "1*2",
"supportDimensions": ["1*2"]
},
{
"name": "widget_2x2",
"displayName": "年俗日历(中)",
"description": "显示当日年俗详情或官场冷知识",
"src": "./ets/widget/pages/WidgetCard2x2.ets",
"uiSyntax": "arkts",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "auto",
"isDefault": false,
"updateEnabled": true,
"scheduledUpdateTime": "00:00",
"updateDuration": 1,
"defaultDimension": "2*2",
"supportDimensions": ["2*2"]
}
]
}
配置说明:<br />| 字段 | 说明 |<br />|------|------|<br />| name | 卡片唯一标识 |<br />| displayName | 用户可见的卡片名称 |<br />| src | 卡片 UI 文件路径 |<br />| updateEnabled | 是否启用定时更新 |<br />| scheduledUpdateTime | 定时更新时间(每天0点) |<br />| updateDuration | 更新间隔(小时) |<br />| defaultDimension | 默认尺寸 |<br />| supportDimensions | 支持的尺寸列表 |
| supportDimensions | 支持的尺寸列表 |
案例效果:两种卡片尺寸对比:
┌── 1×2 小卡片 ──────────────────┐
│ 🏮 正月初一 · 春节 │
│ 爆竹声中一岁除 │
└────────────────────────────────┘
↑ 窄条形,适合单行信息展示
┌── 2×2 中卡片 ──────────────────┐
│ │
│ 🏮 │
│ │
│ 正月初一 · 春节 │
│ 爆竹声中一岁除 │
│ │
│ ┌────┐ ┌────┐ ┌────┐ │
│ │守岁│ │拜年│ │贴联│ │
│ └────┘ └────┘ └────┘ │
└────────────────────────────────┘
↑ 方形,可展示更多详情和活动标签
实战二:实现卡片能力类
第一步:创建 EntryFormAbility
import { formBindingData, FormExtensionAbility, formInfo, formProvider } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
import { isInFestivalPeriod, getLunarDateKey } from '../common/LunarDateUtil';
import { getFestivalCustomByDate } from '../data/LunarFestivalCustoms';
import { getRandomTrivia } from '../common/DataManager';
// 卡片数据接口
interface WidgetData {
isInFestival: boolean;
festivalName: string;
festivalDate: string;
festivalDesc: string;
festivalImage: string;
activities: string[];
triviaText: string;
}
export default class EntryFormAbility extends FormExtensionAbility {
// 卡片创建时调用
onAddForm(want: Want) {
const formData = this.getWidgetData();
return formBindingData.createFormBindingData(formData);
}
// 卡片更新时调用
onUpdateForm(formId: string) {
const formData = this.getWidgetData();
const formBinding = formBindingData.createFormBindingData(formData);
formProvider.updateForm(formId, formBinding);
}
// 卡片事件处理
onFormEvent(formId: string, message: string) {
// 处理卡片点击等事件
}
// 卡片移除时调用
onRemoveForm(formId: string) {
// 清理资源
}
// 获取卡片状态
onAcquireFormState(want: Want) {
return formInfo.FormState.READY;
}
// 获取卡片显示数据
private getWidgetData(): WidgetData {
// 判断是否在年俗期间
const inFestival = isInFestivalPeriod();
if (inFestival) {
// 获取当前农历日期对应的年俗
const dateKey = getLunarDateKey();
const festival = getFestivalCustomByDate(dateKey);
if (festival) {
return {
isInFestival: true,
festivalName: festival.name,
festivalDate: festival.lunarDate,
festivalDesc: festival.shortDesc,
festivalImage: festival.image,
activities: festival.activities,
triviaText: ''
};
}
}
// 非年俗期间,显示冷知识
return {
isInFestival: false,
festivalName: '',
festivalDate: '',
festivalDesc: '',
festivalImage: '',
activities: [],
triviaText: getRandomTrivia()
};
}
}
生命周期说明:<br />| 方法 | 触发时机 |<br />|------|----------|<br />| onAddForm | 用户添加卡片到桌面 |<br />| onUpdateForm | 定时更新或主动更新 |<br />| onFormEvent | 卡片内部事件(如点击) |<br />| onRemoveForm | 用户移除卡片 |
实战三:实现卡片 UI
第一步:创建 1×2 小卡片
// WidgetCard.ets
let widgetStorage = new LocalStorage();
@Entry(widgetStorage)
@Component
struct WidgetCard {
// 卡片数据(通过 LocalStorage 接收)
@LocalStorageProp('isInFestival') isInFestival: boolean = false;
@LocalStorageProp('festivalName') festivalName: string = '';
@LocalStorageProp('festivalDate') festivalDate: string = '';
@LocalStorageProp('festivalDesc') festivalDesc: string = '';
@LocalStorageProp('triviaText') triviaText: string = '';
// 点击跳转配置
readonly actionType: string = 'router';
readonly abilityName: string = 'EntryAbility';
build() {
Column() {
if (this.isInFestival) {
this.FestivalContent()
} else {
this.TriviaContent()
}
}
.width('100%')
.height('100%')
.backgroundColor('#8B0000')
.borderRadius(12)
.padding(8)
.onClick(() => {
postCardAction(this, {
action: this.actionType,
abilityName: this.abilityName,
params: {
message: 'widget_click'
}
});
})
}
// 年俗内容(紧凑版)
@Builder
FestivalContent() {
Row() {
// 左侧装饰图标
Text('🏮')
.fontSize(24)
.margin({ right: 8 })
// 右侧内容
Column() {
Text(this.festivalDate + ' · ' + this.festivalName)
.fontSize(12)
.fontColor('#FFD700')
.fontWeight(FontWeight.Bold)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.festivalDesc)
.fontSize(10)
.fontColor('#FFEFD5')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.alignItems(VerticalAlign.Center)
}
// 冷知识内容
@Builder
TriviaContent() {
Column() {
Text('📜 官场冷知识')
.fontSize(11)
.fontColor('#FFD700')
.fontWeight(FontWeight.Medium)
Text(this.triviaText)
.fontSize(10)
.fontColor(Color.White)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 4 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Start)
}
}
原理解释:
@Entry(widgetStorage)指定卡片使用 LocalStorage 接收数据@LocalStorageProp声明从 LocalStorage 读取的属性postCardAction处理卡片点击,跳转到应用
**案例效果**:1×2 小卡片的两种状态:
┌── 年俗期间(春节前后)──────────────┐<br />│ │<br />│ ┌─────────────────────────────┐ │<br />│ │ 🏮 正月初一·春节 爆竹声中… │ │ ← 深红背景(#8B0000)<br />│ └─────────────────────────────┘ │ 金色标题(#FFD700)<br />│ │ 米白描述(#FFEFD5)<br />└─────────────────────────────────────┘
┌── 非年俗期间 ───────────────────────┐<br />│ │<br />│ ┌─────────────────────────────┐ │<br />│ │ 📜 官场冷知识 │ │ ← 深红背景<br />│ │ 唐朝宰相一天要批阅上千… │ │ 金色标题<br />│ └─────────────────────────────┘ │ 白色正文<br />└─────────────────────────────────────┘
> **效果说明**:
> - 年俗期间:左侧🏮灯笼图标 + 右侧日期·名称 + 简短描述
> - 非年俗期间:📜图标 + 随机官场冷知识
> - 点击卡片通过 `postCardAction` 跳转到应用对应页面
> - 统一深红色(#8B0000)背景,12px 圆角
### 第二步:创建 2×2 中卡片
// WidgetCard2x2.ets<br />let widget2x2Storage = new LocalStorage();
@Entry(widget2x2Storage)<br />@Component<br />struct WidgetCard2x2 {<br />@LocalStorageProp('isInFestival') isInFestival: boolean = false;<br />@LocalStorageProp('festivalName') festivalName: string = '';<br />@LocalStorageProp('festivalDate') festivalDate: string = '';<br />@LocalStorageProp('festivalDesc') festivalDesc: string = '';<br />@LocalStorageProp('activities') activities: string[] = [];<br />@LocalStorageProp('triviaText') triviaText: string = '';
readonly actionType: string = 'router';<br />readonly abilityName: string = 'EntryAbility';
build() {<br />Column() {<br />if (this.isInFestival) {<br />this.FestivalContent2x2()<br />} else {<br />this.TriviaContent2x2()<br />}<br />}<br />.width('100%')<br />.height('100%')<br />.backgroundColor('#8B0000')<br />.borderRadius(16)<br />.padding(12)<br />.onClick(() => {<br />postCardAction(this, {<br />action: this.actionType,<br />abilityName: this.abilityName,<br />params: { message: 'widget_click' }<br />});<br />})<br />}
// 年俗内容(完整版)<br />@Builder<br />FestivalContent2x2() {<br />Column() {<br />// 顶部装饰图标<br />Row() {<br />Text('🏮')<br />.fontSize(40)<br />}<br />.width('100%')<br />.justifyContent(FlexAlign.Center)<br />.margin({ bottom: 8 })
// 日期和名称<br />Text(this.festivalDate + ' · ' + this.festivalName)<br />.fontSize(16)<br />.fontColor('#FFD700')<br />.fontWeight(FontWeight.Bold)<br />.maxLines(1)<br />.textOverflow({ overflow: TextOverflow.Ellipsis })
// 描述<br />Text(this.festivalDesc)<br />.fontSize(12)<br />.fontColor('#FFEFD5')<br />.maxLines(2)<br />.textOverflow({ overflow: TextOverflow.Ellipsis })<br />.margin({ top: 6 })
// 底部活动标签<br />Row() {<br />ForEach(this.activities.slice(0, 3), (activity: string) => {<br />Text(activity)<br />.fontSize(10)<br />.fontColor('#8B0000')<br />.backgroundColor('#FFD700')<br />.borderRadius(6)<br />.padding({ left: 6, right: 6, top: 2, bottom: 2 })<br />.margin({ right: 4 })<br />})<br />}<br />.width('100%')<br />.margin({ top: 8 })<br />}<br />.width('100%')<br />.height('100%')<br />.justifyContent(FlexAlign.Center)<br />.alignItems(HorizontalAlign.Start)<br />}
// 冷知识内容(2×2 版)<br />@Builder<br />TriviaContent2x2() {<br />Column() {<br />Text('📜 官场冷知识')<br />.fontSize(14)<br />.fontColor('#FFD700')<br />.fontWeight(FontWeight.Bold)
// 分隔线<br />Row()<br />.width('100%')<br />.height(1)<br />.backgroundColor('#FFD700')<br />.opacity(0.3)<br />.margin({ top: 8, bottom: 8 })
Text(this.triviaText)<br />.fontSize(12)<br />.fontColor(Color.White)<br />.lineHeight(18)<br />.maxLines(4)<br />.textOverflow({ overflow: TextOverflow.Ellipsis })<br />}<br />.width('100%')<br />.height('100%')<br />.justifyContent(FlexAlign.Center)<br />.alignItems(HorizontalAlign.Start)<br />}<br />}
案例效果:2×2 中卡片的两种状态:
┌── 年俗期间 ──────────────────────────┐
│ │
│ ┌══════════════════════════════┐ │
│ ║ 🏮 ║ │ ← 大号灯笼(40px)
│ ║ ║ │
│ ║ 正月初一 · 春节 ║ │ ← 金色粗体(#FFD700)
│ ║ 爆竹声中一岁除, ║ │ ← 米白色(#FFEFD5)
│ ║ 春风送暖入屠苏 ║ │
│ ║ ║ │
│ ║ ┌────┐ ┌────┐ ┌────┐ ║ │ ← 金色标签
│ ║ │守岁│ │拜年│ │贴联│ ║ │ 深红色文字(#8B0000)
│ ║ └────┘ └────┘ └────┘ ║ │
│ └══════════════════════════════┘ │
│ 深红背景 + 16px圆角 │
└──────────────────────────────────────┘
┌── 非年俗期间 ────────────────────────┐
│ │
│ ┌══════════════════════════════┐ │
│ ║ 📜 官场冷知识 ║ │ ← 金色粗体标题
│ ║ ───────────────────────── ║ │ ← 金色半透明分隔线
│ ║ ║ │
│ ║ 唐朝实行"三省六部制", ║ │ ← 白色正文
│ ║ 中书省负责草拟诏令,门下 ║ │ 行高18px
│ ║ 省负责审核,尚书省负责 ║ │ 最多4行
│ ║ 执行,三省互相制衡。 ║ │
│ └══════════════════════════════┘ │
└──────────────────────────────────────┘
效果说明:<br />- 年俗期间:大号🏮 + 日期名称 + 描述 + 底部活动标签(最多3个)<br />- 活动标签:金色背景(#FFD700)+ 深红文字(#8B0000)<br />- 非年俗期间:标题 + 金色分隔线 + 冷知识正文(最多4行)<br />- 统一深红背景,16px 圆角,比小卡片更大的内边距(12px)
实战四:实现数据更新
第一步:定时更新配置
在 form_config.json 中已配置:
{
"updateEnabled": true,
"scheduledUpdateTime": "00:00",
"updateDuration": 1
}
scheduledUpdateTime: 每天 0 点更新updateDuration: 每 1 小时更新一次
第二步:主动更新卡片
在应用内主动触发卡片更新:
import { formProvider, formBindingData } from '@kit.FormKit';
// 更新所有卡片
async function updateAllWidgets() {
try {
// 获取所有卡片信息
const formInfos = await formProvider.getFormsInfo();
for (const info of formInfos) {
// 获取新数据
const widgetData = getWidgetData();
const formBinding = formBindingData.createFormBindingData(widgetData);
// 更新卡片
await formProvider.updateForm(info.formId, formBinding);
console.info('卡片更新成功: ' + info.formId);
}
} catch (err) {
console.error('卡片更新失败: ' + err.message);
}
}
第三步:农历日期判断
// LunarDateUtil.ets
// 判断是否在年俗期间(腊月二十三到正月十五)
export function isInFestivalPeriod(): boolean {
const lunar = getLunarDate(new Date());
// 腊月二十三到腊月三十
if (lunar.month === 12 && lunar.day >= 23) {
return true;
}
// 正月初一到正月十五
if (lunar.month === 1 && lunar.day <= 15) {
return true;
}
return false;
}
// 获取农历日期键值(用于匹配年俗数据)
export function getLunarDateKey(): string {
const lunar = getLunarDate(new Date());
return `${lunar.month}-${lunar.day}`;
}
实战五:实现点击跳转
第一步:卡片点击处理
// 在卡片 UI 中
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: {
message: 'widget_click',
targetPage: 'NewYearCustomPage'
}
});
})
第二步:应用端接收参数
在 EntryAbility.ets 中处理:
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
// 检查是否从卡片启动
if (want.parameters) {
const message = want.parameters['message'] as string;
const targetPage = want.parameters['targetPage'] as string;
if (message === 'widget_click' && targetPage) {
// 保存目标页面,在 onWindowStageCreate 中跳转
AppStorage.setOrCreate('widgetTargetPage', targetPage);
}
}
}
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
return;
}
// 检查是否需要跳转
const targetPage = AppStorage.get<string>('widgetTargetPage');
if (targetPage) {
// 延迟跳转,等待页面加载完成
setTimeout(() => {
const navStack = AppStorage.get<NavPathStack>('mainNavPathStack');
if (navStack) {
navStack.pushPathByName(targetPage, null);
}
AppStorage.delete('widgetTargetPage');
}, 500);
}
});
}
}
完整代码汇总
卡片配置 form_config.json
{
"forms": [
{
"name": "widget_1x2",
"displayName": "年俗日历(小)",
"src": "./ets/widget/pages/WidgetCard.ets",
"uiSyntax": "arkts",
"isDefault": true,
"updateEnabled": true,
"scheduledUpdateTime": "00:00",
"updateDuration": 1,
"defaultDimension": "1*2",
"supportDimensions": ["1*2"]
},
{
"name": "widget_2x2",
"displayName": "年俗日历(中)",
"src": "./ets/widget/pages/WidgetCard2x2.ets",
"uiSyntax": "arkts",
"isDefault": false,
"updateEnabled": true,
"scheduledUpdateTime": "00:00",
"updateDuration": 1,
"defaultDimension": "2*2",
"supportDimensions": ["2*2"]
}
]
}
本课小结
| 功能 | 实现方式 |<br />|---|---|<br />| 卡片配置 | form_config.json + module.json5 |<br />| 卡片能力 | FormExtensionAbility 生命周期 |<br />| 数据传递 | LocalStorage + @LocalStorageProp |<br />| 定时更新 | scheduledUpdateTime + updateDuration |<br />| 点击跳转 | postCardAction + Want 参数 |
卡片开发注意事项
1. 卡片 UI 组件有限制,不支持所有 ArkUI 组件<br />2. 卡片不能执行耗时操作,数据应在 Ability 中准备<br />3. 卡片更新频率有限制,最小间隔 30 分钟<br />4. 卡片尺寸固定,需要适配不同规格
课后练习
1. 添加 4×4 大卡片样式<br />2. 实现卡片的深色/浅色模式适配<br />3. 添加卡片刷新按钮
下一课预告
第30课讲解应用打包与华为应用市场上架流程。
项目开源地址
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)