适合谁看
-
想读懂
DailyRecommendFormAbility.ets的开发者 -
想给 Flutter 项目加鸿蒙桌面卡片的人
-
想看鸿蒙动态推荐卡片怎么组织的人
问题背景
鸿蒙桌面卡片真正难的地方不在 UI,而在:
-
卡片何时创建
-
数据何时更新
-
卡片点击后如何和主应用配合
-
静态卡片和动态卡片如何共存
这些都属于鸿蒙表单生命周期问题。
项目中的真实场景
关键文件:
| 文件 | 职责 |
|---|---|
DailyRecommendFormAbility.ets |
卡片生命周期管理 |
RecommendData.ets |
推荐数据 + 日期轮询算法 |
DailyRecommendCard.ets |
卡片 UI 渲染 |
daily_recommend_form_config.json |
卡片配置(尺寸、更新频率) |
module.json5 |
注册 FormExtensionAbility |
核心实现
一、DailyRecommendFormAbility.ets------鸿蒙卡片生命周期
import { Want } from '@kit.AbilityKit';
import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit';
import { getRecommendOfToday, resolveImageResName } from './RecommendData';
const STATIC_CARD_NAMES: Set<string> = new Set([
'SearchCard',
'AiAssistantCard',
'WishBoxCard',
]);
export default class DailyRecommendFormAbility extends FormExtensionAbility {
onAddForm(want: Want): formBindingData.FormBindingData {
const formId = want.parameters?.['ohos.extra.param.key.form_identity'] as string;
const formName = want.parameters?.['ohos.extra.param.key.form_name'] as string;
// 静态卡片返回空数据
if (STATIC_CARD_NAMES.has(formName)) {
return formBindingData.createFormBindingData({});
}
// 动态卡片返回推荐数据
const item = getRecommendOfToday();
return formBindingData.createFormBindingData({
dishName: item.name,
dishRegion: item.region,
dishImage: resolveImageResName(item.imageResName),
dishId: item.id,
dishHighlight: item.highlight,
dishSummary: item.summary,
});
}
onUpdateForm(formId: string): void {
const item = getRecommendOfToday();
const bindingData = formBindingData.createFormBindingData({
dishName: item.name,
dishRegion: item.region,
dishImage: resolveImageResName(item.imageResName),
dishId: item.id,
dishHighlight: item.highlight,
dishSummary: item.summary,
});
formProvider.updateForm(formId, bindingData).catch((err: Error) => {
console.error(TAG, `updateForm failed: ${JSON.stringify(err)}`);
});
}
onRemoveForm(formId: string): void {
console.info(TAG, `onRemoveForm, formId: ${formId}`);
}
}
4 个鸿蒙表单生命周期方法:
| 方法 | 触发时机 | 作用 |
|---|---|---|
onAddForm |
用户添加卡片到鸿蒙桌面 | 返回首次展示的数据 |
onUpdateForm |
鸿蒙系统定时触发(每天 00:05) | 更新卡片内容 |
onRemoveForm |
用户移除卡片 | 清理资源(当前只记日志) |
onFormEvent |
卡片发来事件 | 处理卡片内交互(当前未使用) |
关键设计:
静态卡片和动态卡片在同一 Ability 中分流 --- STATIC_CARD_NAMES 集合判断是哪种卡片。静态卡片返回空数据,动态卡片返回推荐数据。这种设计避免了为每种卡片创建单独的鸿蒙 FormAbility。
onAddForm 和 onUpdateForm 复用同一套数据拼装 --- 两者的区别只是触发时机不同,数据结构完全一致。这保证了首次创建和后续更新的内容一致性。
二、RecommendData.ets------推荐数据和选择算法
export interface RecommendItem {
id: string;
name: string;
region: string;
imageResName: string;
highlight: string;
summary: string;
}
const RECOMMEND_LIST: RecommendItem[] = [
{ id: 'beef-curry', name: '牛肉咖喱', region: '印度 · 亚洲', imageResName: 'dish_beef_curry', highlight: '浓郁香料', summary: '椰香与香料层层叠起,入口热烈又厚实。' },
{ id: 'sukiyaki', name: '寿喜锅', region: '日本 · 亚洲', imageResName: 'dish_sukiyaki', highlight: '鲜甜酱香', summary: '牛肉、蔬菜与寿喜烧汁一起慢慢煮到刚好。' },
// ... 共 10 道菜品
];
const FALLBACK_ITEM: RecommendItem = {
id: 'fallback', name: '环球美食', region: '世界',
imageResName: 'dish_fallback', highlight: '今天吃什么',
summary: '打开食界探味,挑一道想去认识的新菜。',
};
const VALID_IMAGE_RES_NAMES: Set<string> = new Set(
RECOMMEND_LIST.map((item) => item.imageResName).concat(FALLBACK_ITEM.imageResName)
);
export function getRecommendOfToday(): RecommendItem {
if (RECOMMEND_LIST.length === 0) return FALLBACK_ITEM;
const now = new Date();
const dateNum = now.getFullYear() * 10000 + (now.getMonth() + 1) * 100 + now.getDate();
const index = dateNum % RECOMMEND_LIST.length;
return RECOMMEND_LIST[index];
}
export function resolveImageResName(imageResName: string): string {
if (!imageResName || !VALID_IMAGE_RES_NAMES.has(imageResName)) {
return FALLBACK_ITEM.imageResName;
}
return imageResName;
}
日期轮询算法 --- 用 年月日 生成数字,对列表长度取模。同一天内所有鸿蒙卡片展示同一道菜,不同天自动切换。
// 2025年1月15日 → 20250115
// 20250115 % 10 = 5 → 展示第5道菜
const dateNum = now.getFullYear() * 10000 + (now.getMonth() + 1) * 100 + now.getDate();
const index = dateNum % RECOMMEND_LIST.length;
图片资源校验 --- resolveImageResName 确保鸿蒙图片资源名存在。如果不存在,返回兜底图片。这防止了鸿蒙资源缺失导致卡片渲染崩溃。
兜底数据 --- FALLBACK_ITEM 在列表为空或鸿蒙资源异常时使用。卡片永远不会显示空白。
三、DailyRecommendCard.ets------鸿蒙卡片 UI
let dailyRecommendStorage = new LocalStorage();
@Entry(dailyRecommendStorage)
@Component
struct DailyRecommendCard {
@LocalStorageProp('dishName') dishName: string = '环球美食';
@LocalStorageProp('dishRegion') dishRegion: string = '世界';
@LocalStorageProp('dishImage') dishImage: string = 'dish_fallback';
@LocalStorageProp('dishId') dishId: string = '';
@LocalStorageProp('dishHighlight') dishHighlight: string = '今天吃什么';
@LocalStorageProp('dishSummary') dishSummary: string = '打开食界探味,挑一道想去认识的新菜。';
build() {
Row() {
Image($r(`app.media.${this.dishImage}`))
.width(144).height('100%')
.objectFit(ImageFit.Cover)
.borderRadius({ topLeft: 16, bottomLeft: 16 })
Column({ space: 6 }) {
Text('今天吃什么?').fontSize(13).fontWeight(FontWeight.Bold)
Text(this.dishName).fontSize(18).fontWeight(FontWeight.Bold)
Text(this.dishRegion).fontSize(12)
Row() {
Text(this.dishHighlight).fontSize(11)
.padding(8, 8, 4, 4).backgroundColor('#FCE4CC').borderRadius(10)
}
Text(this.dishSummary).fontSize(12).maxLines(3)
}.layoutWeight(1).padding(12)
}
.width('100%').height('100%')
.backgroundColor('#FFF8F0').borderRadius(16)
.onClick(() => { this.openDishDetail(); })
}
}
鸿蒙卡片 UI 结构:
┌──────────────────────────────────┐
│ ┌──────────┐ ┌────────────────┐ │
│ │ │ │ 今天吃什么? │ │
│ │ 菜品 │ │ 菜名 │ │
│ │ 图片 │ │ 地区 │ │
│ │ (144w) │ │ [口味标签] │ │
│ │ │ │ 一句话介绍 │ │
│ └──────────┘ └────────────────┘ │
└──────────────────────────────────┘
数据绑定通过 @LocalStorageProp 实现------鸿蒙 FormAbility 传入的数据自动绑定到 UI 属性。
四、点击跳转------复用鸿蒙 Intents Kit
private openDishDetail(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: {
pageId: this.dishId ? 'dish_detail' : 'explore',
dishId: this.dishId,
}
});
}
鸿蒙卡片点击时通过 postCardAction 跳转到主应用。关键设计:复用了鸿蒙 Intents Kit 的 pageId 机制。
这意味着:
-
鸿蒙卡片点击 → 传入
pageId=dish_detail+dishId -
InsightIntentExecutorImpl校验参数 -
IntentNavigationPlugin桥接到 Flutter -
Flutter 跳转到菜品详情页
鸿蒙卡片和 Intents Kit 共享同一套入口校验逻辑,不需要为卡片单独写路由。
五、完整的数据流
鸿蒙系统触发 onAddForm / onUpdateForm
│
▼
RecommendData.getRecommendOfToday()
│
├─ 日期轮询 → 选择今天的菜品
│
▼
formBindingData.createFormBindingData({
dishName, dishRegion, dishImage,
dishId, dishHighlight, dishSummary
})
│
▼
formProvider.updateForm(formId, bindingData)
│
▼
鸿蒙 DailyRecommendCard 接收 @LocalStorageProp
│
▼
鸿蒙 UI 渲染 → 用户看到卡片
│
▼
用户点击 → postCardAction → pageId=dish_detail
│
▼
鸿蒙 InsightIntentExecutorImpl → IntentNavigationPlugin → Flutter
│
▼
用户进入菜品详情页
六、3 张鸿蒙卡片的对比
| 鸿蒙卡片 | 数据来源 | 更新策略 | 点击跳转 |
|---|---|---|---|
| DailyRecommendCard | RecommendData.getRecommendOfToday() | 每天 00:05 自动更新 | dish_detail / explore |
| SearchCard | 无(静态) | 不更新 | /search |
| WishBoxCard | 无(静态) | 不更新 | /wish-box |
3 张鸿蒙卡片共享同一个 FormAbility,通过 STATIC_CARD_NAMES 分流。静态卡片返回空数据,由卡片 UI 自己显示默认内容。
关键代码位置
| 文件 | 作用 |
|---|---|
app/ohos/entry/src/main/ets/formability/DailyRecommendFormAbility.ets |
鸿蒙卡片生命周期 |
app/ohos/entry/src/main/ets/formability/RecommendData.ets |
推荐数据 |
app/ohos/entry/src/main/ets/widget/pages/DailyRecommendCard.ets |
鸿蒙卡片 UI |
app/ohos/entry/src/main/resources/base/profile/daily_recommend_form_config.json |
鸿蒙卡片配置 |
app/ohos/entry/src/main/module.json5 |
注册鸿蒙 FormExtensionAbility |
代码结构全景图
鸿蒙 DailyRecommendFormAbility
│
├─ onAddForm(want)
│ ├─ 读取 formName
│ ├─ 静态卡片 → 返回空数据
│ └─ 动态卡片 → getRecommendOfToday()
│ → formBindingData.createFormBindingData({...})
│
├─ onUpdateForm(formId)
│ ├─ getRecommendOfToday()
│ → formProvider.updateForm(formId, data)
│
├─ onRemoveForm(formId)
│ └─ 日志记录
│
└─ onFormEvent(formId, message)
└─ 日志记录
RecommendData
│
├─ RECOMMEND_LIST[] ← 10 道菜品
├─ FALLBACK_ITEM ← 兜底数据
├─ VALID_IMAGE_RES_NAMES ← 图片资源白名单
├─ getRecommendOfToday() ← 日期轮询算法
└─ resolveImageResName() ← 图片资源校验
鸿蒙 DailyRecommendCard
│
├─ @LocalStorageProp ← 接收数据绑定
├─ build() ← 声明式 UI
└─ openDishDetail() ← postCardAction 跳转
常见坑
-
onAddForm 和 onUpdateForm 两套数据结构不一致 --- 应该复用同一套数据拼装逻辑
-
图片资源名不做校验 ---
resolveImageResName必须兜底,防止鸿蒙资源不存在导致崩溃 -
动态卡片和静态卡片完全拆开 --- 用
STATIC_CARD_NAMES分流,共用一个鸿蒙 Ability 更好维护 -
只关注卡片展示,不关注内容来源稳定性 ---
FALLBACK_ITEM兜底很重要 -
鸿蒙卡片点击后没有复用 Intents Kit --- 应该共用
pageId机制,不要单独写路由 -
没有配置 updateEnabled --- 鸿蒙动态卡片必须启用定时更新
-
scheduledUpdateTime 不合理 --- 00:05 是好选择,避免和用户活跃时间冲突
可复用模板
鸿蒙卡片数据选择算法模板
export function getTodayItem<T>(list: T[], fallback: T): T {
if (list.length === 0) return fallback;
const now = new Date();
const dateNum = now.getFullYear() * 10000 + (now.getMonth() + 1) * 100 + now.getDate();
return list[dateNum % list.length];
}
鸿蒙卡片图片资源校验模板
const VALID_RES: Set<string> = new Set(ITEMS.map(i => i.imageRes));
export function safeImage(name: string): string {
if (!name || !VALID_RES.has(name)) return 'fallback_image';
return name;
}
鸿蒙 FormAbility 模板
export default class MyFormAbility extends FormExtensionAbility {
onAddForm(want: Want): formBindingData.FormBindingData {
const formName = want.parameters?.['ohos.extra.param.key.form_name'] as string;
if (STATIC_NAMES.has(formName)) {
return formBindingData.createFormBindingData({});
}
return formBindingData.createFormBindingData(getData());
}
onUpdateForm(formId: string): void {
formProvider.updateForm(formId, formBindingData.createFormBindingData(getData()));
}
}
鸿蒙卡片 UI 模板(ArkTS)
@Entry(storage)
@Component
struct MyCard {
@LocalStorageProp('title') title: string = '默认标题';
@LocalStorageProp('content') content: string = '默认内容';
@LocalStorageProp('image') image: string = 'fallback';
build() {
Row() {
Image($r(`app.media.${this.image}`)).width(120).height('100%')
Column() {
Text(this.title).fontSize(16).fontWeight(FontWeight.Bold)
Text(this.content).fontSize(12).maxLines(3)
}.layoutWeight(1).padding(12)
}.width('100%').height('100%')
}
}
本篇总结
DailyRecommendFormAbility 展示了一张鸿蒙动态推荐卡片的最小实现。关键不在 UI,而在:
-
配置 ---
updateEnabled: true+scheduledUpdateTime: "00:05"启用鸿蒙每日更新 -
数据 --- 日期轮询算法每天推荐不同菜品,图片资源校验 + 兜底数据
-
生命周期 --- onAddForm 创建 + onUpdateForm 更新,复用同一套数据拼装
-
静态/动态分流 ---
STATIC_CARD_NAMES判断,共用一个鸿蒙 Ability -
点击跳转 --- 复用鸿蒙 Intents Kit 的
pageId机制,不需要单独写路由
这份代码很适合当鸿蒙卡片接入的第一块样板。
