鸿蒙 DailyRecommendFormAbility 解析:每日推荐卡片怎么做

适合谁看

  • 想读懂 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,而在:

  1. 配置 --- updateEnabled: true + scheduledUpdateTime: "00:05" 启用鸿蒙每日更新

  2. 数据 --- 日期轮询算法每天推荐不同菜品,图片资源校验 + 兜底数据

  3. 生命周期 --- onAddForm 创建 + onUpdateForm 更新,复用同一套数据拼装

  4. 静态/动态分流 --- STATIC_CARD_NAMES 判断,共用一个鸿蒙 Ability

  5. 点击跳转 --- 复用鸿蒙 Intents Kit 的 pageId 机制,不需要单独写路由

这份代码很适合当鸿蒙卡片接入的第一块样板。