本篇开发桌面服务卡片,实现年俗日历小组件

【完整案例】桌面卡片完整开发 教程结构图

图:【完整案例】桌面卡片完整开发 的关键流程与实现要点。

学习目标

  • ✅ 配置卡片能力
  • ✅ 实现卡片 UI
  • ✅ 开发数据更新机制
  • ✅ 实现点击跳转

预计学习时间

约 120 分钟


实战一:配置卡片能力

第一步:在 module.json5 中声明卡片

entry/src/main/module.json5extensionAbilities 中添加:

{
  "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课讲解应用打包与华为应用市场上架流程。


项目开源地址

https://gitcode.com/daleishen/gujinzhijian

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐