HarmonyOS 服务卡片开发实践指南

本文基于喵屿 App 的卡片开发经验与 CardInfoRefresh 官方示例,系统梳理 HarmonyOS ArkTS 服务卡片的开发流程、核心技术点及避坑策略。


一、服务卡片概述

1.1 什么是服务卡片

服务卡片(Service Widget)是 HarmonyOS 提供的一种信息外显形态,能将应用关键数据直接呈现在系统桌面,用户无需打开 App 即可快速获取信息。卡片作为用户高频触达的入口,开发时需重点关注数据及时性资源消耗,避免因频繁刷新或大图片加载导致系统性能下降。

在这里插入图片描述

1.2 架构模型

服务卡片涉及三方协作:

  • 卡片提供方(应用):通过 FormExtensionAbility 管理卡片生命周期,提供 UI 布局和数据。
  • 卡片使用方(桌面):负责卡片的展示、位置管理和交互响应。
  • 卡片框架(系统服务):统一管理卡片的创建、刷新、渲染和销毁。

关键认知

  • 主应用进程(UIAbility)和卡片进程(FormExtensionAbility)是两个独立进程,它们通过 Preferences 进行数据共享 —— 这是理解卡片数据流转的基石。
  • 卡片 UI 由系统渲染服务统一绘制,应用仅提供数据和布局描述,因此卡片页面无法直接访问文件系统或数据库,所有数据必须由 FormExtensionAbility 通过 FormBindingData 传递。
  • 每张卡片每天最多触发 50 次定时刷新,超出配额后系统会静默跳过 —— 在设计刷新策略时必须将此约束纳入考量。

1.3 动态卡片 vs 静态卡片

特性 动态卡片 (isDynamic: true) 静态卡片
数据绑定 支持 @LocalStorageProp 不支持
内容更新 formProvider.updateForm() 实时推送 仅创建时初始化
交互组件 postCardAction(router/message/call) FormLink

选择建议:绝大多数需要实时数据展示或用户交互的场景都应使用动态卡片;静态卡片仅适用于展示固定内容的纯信息型卡片。


二、创建服务卡片

本章核心:掌握 module.json5 配置、form_config.json 定义、FormExtensionAbility 生命周期管理以及卡片 UI 的数据绑定机制。

2.1 工程配置

entry/src/main/module.json5 中注册 FormExtensionAbility必须正确填写 srcEntry 路径和 metadata 中的 resource 指向,否则卡片无法正常加载。

// module.json5 — 注册卡片扩展能力
{
  "module": {
    "extensionAbilities": [
      {
        // 扩展能力名称,需与 srcEntry 导出的类名对应
        "name": "EntryFormAbility",
        // 实现文件路径,相对于 src/main/ 目录
        "srcEntry": "./ets/entryformability/EntryFormAbility.ets",
        // 类型固定为 "form"
        "type": "form",
        // 卡片展示名称(桌面长按图标时显示)
        "label": "$string:EntryFormAbility_label",
        "description": "$string:EntryFormAbility_desc",
        // 元数据:关联卡片配置文件
        "metadata": [
          {
            "name": "ohos.extension.form",
            // 指向 resources/base/profile/form_config.json
            "resource": "$profile:form_config"
          }
        ]
      }
    ]
  }
}

2.2 卡片配置文件

resources/base/profile/form_config.json 定义卡片的名称、尺寸、UI 入口和刷新策略。此文件中的 namesrcisDynamicupdateEnabled 等字段直接决定卡片行为,需仔细配置。

// form_config.json — 卡片配置文件
{
  "forms": [
    {
      // 卡片名称,在 onAddForm 的 want.parameters 中用于区分不同卡片
      "name": "widget",
      // 卡片选择列表中显示的名称,引用 string.json 资源
      "displayName": "$string:widget_display_name",
      // 卡片描述
      "description": "$string:widget_desc",
      // 卡片 UI 页面文件路径,相对于 src/main/
      "src": "./ets/widget/pages/WidgetCard.ets",
      // 语法类型
      "uiSyntax": "arkts",
      // 设计尺寸基准:以 720px 宽度为基准进行自适应缩放
      "window": {
        "designWidth": 720,
        "autoDesignWidth": true
      },
      // 颜色模式:auto = 跟随系统深色/浅色模式自动切换
      "colorMode": "auto",
      // true = 动态卡片,支持 @LocalStorageProp 数据绑定和实时更新
      "isDynamic": true,
      // true = 默认卡片,长按图标后优先展示
      "isDefault": true,
      // 启用周期性刷新(定时/定点)
      "updateEnabled": true,
      // 定点刷新时间(HH:MM 格式),需同时设置 updateDuration: 0
      "scheduledUpdateTime": "01:00",
      // 定时刷新周期,单位 30 分钟。0 = 仅定点刷新,N = 每 N*30 分钟刷新
      "updateDuration": 0,
      // 默认卡片尺寸(列*行),2*4 表示 2 列 4 行
      "defaultDimension": "2*4",
      // 支持的卡片尺寸列表
      "supportDimensions": ["2*4"]
    }
  ]
}

关键字段说明

字段 说明
name 卡片名称,在 onAddFormwant.parameters 中用于区分不同卡片
src 卡片 UI 页面路径,相对模块 src/main/ 目录
isDynamic true 表示动态卡片,支持 @LocalStorageProp 数据绑定
updateEnabled 启用周期性刷新
scheduledUpdateTime 定点刷新时间,格式 HH:MM
updateDuration 定时刷新周期,单位 30 分钟。定点刷新时须设为 0(定时与定点互斥,定时优先)
defaultDimension 默认卡片尺寸,如 2*4 表示 2 列 × 4 行

重要提醒updateDurationscheduledUpdateTime 不可同时生效 —— 当 updateDuration > 0 时,定时刷新优先,定点刷新被忽略。每日定时刷新上限为 50 次,超出后系统静默跳过。

2.3 FormExtensionAbility 生命周期

以下代码来自 CardInfoRefresh 官方示例的 EntryFormAbility.ets核心 API 包括 onAddForm(保存 formId)、onUpdateForm(定时刷新)、onFormEvent(卡片事件)、onRemoveForm(清理 formId)

// entry/src/main/ets/entryformability/EntryFormAbility.ets
// 来源:CardInfoRefresh 官方示例
import { BusinessError, systemDateTime } from '@kit.BasicServicesKit';
import { Want } from '@kit.AbilityKit';
import { formBindingData, FormExtensionAbility, formInfo, formProvider } from '@kit.FormKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { CardListItemData, CommonData, FormData } from '../common/CommonData';
import { CommonConstants } from '../common/CommonConstants';
import { PreferencesUtil } from '../common/utils/PreferencesUtil';

const TAG: string = 'EntryFormAbility';

export default class EntryFormAbility extends FormExtensionAbility {
  /**
   * 卡片添加到桌面时回调(包括预览弹窗中选中卡片时)
   * @param want - 包含卡片元信息,parameters 中可取 form_identity(卡片 ID)、form_name(卡片名称)
   * @returns FormBindingData - 卡片初始化时要显示的数据
   *
   * 关键参数:
   *   want.parameters[formInfo.FormParam.IDENTITY_KEY] → 卡片唯一 ID(必须持久化保存)
   *   want.parameters[formInfo.FormParam.NAME_KEY]     → 卡片名称(对应 form_config.json 的 name)
   */
  onAddForm(want: Want): formBindingData.FormBindingData {
    // 参数校验
    if (!want || !want.parameters) {
      hilog.error(0x0000, TAG, `FormAbility onAddForm want or want.parameters is undefined`);
      return formBindingData.createFormBindingData('');
    }
    do {
      // 提取卡片名称和 ID
      let formName: string = want.parameters[formInfo.FormParam.NAME_KEY] as string;
      let formId: string = want.parameters[formInfo.FormParam.IDENTITY_KEY] as string;

      // 获取 Preferences 工具实例,每次清除缓存后重新获取
      let util = PreferencesUtil.getInstance();
      let preferences = util.getPreferences(this.context);
      if (!preferences) {
        break; // Preferences 获取失败,返回空数据
      }

      // 持久化 formId,供主应用推送更新时使用
      util.addFormId(preferences, formId);

      // 根据卡片类型返回不同初始数据
      if (formName === 'card_info_refresh') {
        // 卡片类型 A:显示时间戳
        let formData = new FormData(formId);
        formData.formTime = systemDateTime.getTime().toString();
        let tempFormInfo: formBindingData.FormBindingData =
          formBindingData.createFormBindingData(formData);
        return tempFormInfo;
      }

      // 卡片类型 B:显示列表数据,记录当前展示项索引
      let key: string = `${formId}_show_index`;
      let data = util.getFormInitData(key, preferences);
      if (formName === 'card_info_update') {
        // 保存当前展示的数据项索引
        util.preferencesPut(preferences, key, data.id);
        let formData = new FormData(formId);
        formData.cardList.push(data);
        let tempFormInfo: formBindingData.FormBindingData =
          formBindingData.createFormBindingData(formData);
        return tempFormInfo;
      }
    } while (0);
    return formBindingData.createFormBindingData('');
  }

  /**
   * 定时/定点刷新回调(系统根据 form_config.json 配置自动调用)
   * @param formId - 卡片 ID
   */
  onUpdateForm(formId: string) {
    hilog.info(0x0000, TAG, `FormAbility onUpdateForm, formId = ${formId}`);

    // 构建数据类
    let formData = new FormData(formId);
    formData.formTime = systemDateTime.getTime().toString();

    // 获取 Preferences 实例
    let util = PreferencesUtil.getInstance();
    let preferences = util.getPreferences(this.context);
    if (!preferences) {
      return;
    }

    // 读取当前展示数据项的索引,轮播到下一项
    let key: string = `${formId}_show_index`;
    try {
      if (preferences.hasSync(key)) {
        let index = preferences.getSync(key, 0) as number;
        let newIndex = (index + 1) % 12;
        let dataItem: CardListItemData =
          (preferences.getSync('dataArr', []) as CardListItemData[])[newIndex];
        dataItem.favour = (preferences.getSync('statusArr', []) as boolean[])[newIndex];
        util.preferencesPut(preferences, key, newIndex);
        formData.cardList = [dataItem];
      }
    } catch (error) {
      let err = error as BusinessError;
      hilog.error(0x0000, TAG, `hasSync failed, error code=${err.code}`);
    }

    // 推送数据到卡片
    let formMsg: formBindingData.FormBindingData =
      formBindingData.createFormBindingData(formData);
    formProvider.updateForm(formId, formMsg).catch((err: BusinessError) => {
      hilog.error(0x0000, TAG, `updateForm failed, error code=${err.code}`);
    });
  }

  /**
   * 卡片事件回调(用户点击卡片按钮触发)
   * @param formId - 卡片 ID
   * @param message - postCardAction 中 params 传递的数据
   */
  onFormEvent(formId: string, message: string) {
    hilog.info(0x0000, TAG, `FormAbility onFormEvent, formId = ${formId}`);

    let formData = new FormData(formId);
    let util = PreferencesUtil.getInstance();
    let preferences = util.getPreferences(this.context);
    if (!preferences) {
      return;
    }

    try {
      // 获取下一条展示数据
      let index: number = preferences.getSync(CommonConstants.DATA_INDEX, 0) as number;
      formData.cardList = CommonData.getData(index);
      util.preferencesPut(preferences, CommonConstants.DATA_INDEX, index + 1);

      // 推送数据到卡片
      let formMsg: formBindingData.FormBindingData =
        formBindingData.createFormBindingData(formData);
      formProvider.updateForm(formId, formMsg).then(() => {
        hilog.info(0x0000, TAG, 'updateForm success.');
      }).catch((error: Error) => {
        let err = error as BusinessError;
        hilog.error(0x0000, TAG, `updateForm failed. error code=${err.code}`);
      });
    } catch (error) {
      let err = error as BusinessError;
      hilog.error(0x0000, TAG, `getSync failed, error code=${err.code}`);
    }
  }

  /**
   * 卡片从桌面移除时回调 — 必须清理 formId
   * @param formId - 卡片 ID
   */
  async onRemoveForm(formId: string): Promise<void> {
    hilog.info(0x00, TAG, `remove formId: ${formId}`);
    PreferencesUtil.getInstance().removeFormId(this.context, formId);
  }

  /**
   * 卡片使用方查询卡片状态时触发
   */
  onAcquireFormState(_want: Want) {
    return formInfo.FormState.READY;
  }
}

生命周期触发时机总览

回调 触发场景 formId 操作
onAddForm(want) 卡片添加到桌面、预览弹窗 保存 formId
onUpdateForm(formId) 定时/定点刷新到期 更新数据
onFormEvent(formId, msg) 用户点击卡片按钮 更新数据
onRemoveForm(formId) 卡片从桌面移除 删除 formId

核心要点onAddForm 中必须持久化 formId,否则应用侧将无法主动推送更新;onRemoveForm 中务必清理关联数据,避免残留导致内存泄漏。

2.4 卡片 UI 页面

卡片页面通过 @LocalStorageProp 接收 FormBindingData 中的数据。必须创建独立的 LocalStorage 实例并传递给 @Entry,否则 formProvider.updateForm() 推送的数据无法到达卡片。以下代码来自 CardInfoRefresh 官方示例的 WidgetCard.ets

// entry/src/main/ets/widget/pages/WidgetCard.ets
// 来源:CardInfoRefresh 官方示例
import { CardListComponent } from '../view/CardListComponent';
import { CardListParameter } from '../viewmodel/CardListParameter';
import { CardListItemData } from '../../common/CommonData';

/**
 * 第一步:创建专属 LocalStorage 实例
 * 必须在 @Entry 之前定义,确保卡片页与 FormBindingData 绑定
 * 【关键】不加这一行,formProvider.updateForm() 推送的数据无法到达卡片
 */
let storageLocal = new LocalStorage();

/**
 * 第二步:@Entry 绑定该 LocalStorage
 */
@Entry(storageLocal)
@Component
struct WidgetCard {
  // 常量:卡片交互参数
  readonly ACTION_TYPE: string = 'router';
  readonly ABILITY_NAME: string = 'EntryAbility';
  readonly MESSAGE: string = 'add detail';
  readonly FULL_WIDTH_PERCENT: string = '100%';
  readonly FULL_HEIGHT_PERCENT: string = '100%';

  /**
   * 第三步:@LocalStorageProp 声明所有数据字段
   * key 必须与 FormBindingData 中的键名完全一致(大小写敏感)
   * 类型必须为 string(FormBindingData 序列化后所有值皆为 string)
   * @Watch 装饰器:值变化时触发指定回调
   */
  @LocalStorageProp('formTime') @Watch('onFormTimeChange') formTime: string = '';
  @LocalStorageProp('formId') formId: string = '';
  @LocalStorageProp('cardList') cardList: Array<CardListItemData> = [];

  // 卡片 UI 参数:背景色、标题、Logo、列表项计数等
  @State cardListParameter: CardListParameter = new CardListParameter(
    $r('sys.color.ohos_id_color_background'),
    $r('app.string.card_list_title'), '', ImageSize.Cover, $r('app.media.logo'), false,
    $r('sys.color.ohos_id_color_background'), true, this.cardList.length,
    $r('sys.color.ohos_id_color_emphasize'), $r('app.color.list_item_count_background'),
    '', false
  );

  /**
   * formTime 变化时的回调:通过 call 事件拉起主应用后台刷新
   */
  onFormTimeChange() {
    postCardAction(this, {
      action: 'call',
      abilityName: 'EntryAbility',
      params: {
        formId: this.formId,
        method: 'updateCardInfo',
        message: 'Call refresh card.'
      }
    });
  }

  /**
   * @Builder:刷新按钮 UI 组件
   * @param text - 按钮文本资源
   */
  @Builder
  buttonBuilder(text: ResourceStr) {
    Column() {
      Image($r('app.media.refresh'))
        .width($r('app.float.refresh_image_size'))
        .height($r('app.float.refresh_image_size'))
      Text(text)
        .fontColor($r('app.color.refresh_color'))
        .fontSize($r('app.float.item_content_font_size'))
        .margin({ top: $r('app.float.text_image_space') })
    }
    .justifyContent(FlexAlign.Center)
    .height($r('app.float.refresh_area_height'))
    .width($r('app.float.refresh_area_width'))
    .borderRadius($r('app.float.border_radius'))
    .backgroundColor($r('sys.color.comp_background_focus'))
  }

  /**
   * @Builder:卡片列表内容 UI
   * 使用 ForEach 遍历 cardList,每个列表项显示标题、内容和图标
   */
  @Builder
  cardListBuilder() {
    if (this.cardList.length > 0) {
      Column() {
        Column() {
          ForEach(this.cardList, (item: CardListItemData) => {
            ListItem() {
              Row() {
                Column() {
                  Text(item.title)
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                    .fontSize($r('app.float.item_content_font_size'))
                    .fontWeight(FontWeight.Medium)
                    .fontColor(Color.Black)
                    .height($r('app.float.item_text_height'))
                    .margin({ top: $r('app.float.item_text_margin') })
                  Text(item.content)
                    .maxLines(1)
                    .fontSize($r('app.float.item_content_font_size'))
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                    .fontWeight(FontWeight.Regular)
                    .height($r('app.float.item_text_height'))
                  Divider()
                    .strokeWidth(0.38)
                    .lineCap(LineCapStyle.Square)
                    .margin({ top: $r('app.float.list_divider_margin') })
                    // 最后一项不显示分割线
                    .visibility(item.id === 4 ? Visibility.None : Visibility.Visible)
                }
                .margin({ right: $r('app.float.list_row_margin') })
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                Image(item.icon)
                  .width($r('app.float.item_image_size'))
                  .height($r('app.float.item_image_size'))
                  .borderRadius($r('app.float.border_radius'))
              }
              .alignItems(VerticalAlign.Center)
              .width(this.FULL_WIDTH_PERCENT)
            }
            .width(this.FULL_WIDTH_PERCENT)
            .height($r('app.float.item_height'))
          }, (item: CardListItemData, index) => index + JSON.stringify(item))
        }

        // 底部操作按钮行:router / call / message 三种事件
        Row() {
          // router 事件:点击跳转到主应用 UIAbility
          Row() {
            this.buttonBuilder($r('app.string.router'))
          }
          .onClick(() => {
            postCardAction(this, {
              action: 'router',
              abilityName: 'EntryAbility',
              params: { message: 'Router refresh card.' }
            });
          })

          // call 事件:拉起 UIAbility 后台执行指定方法
          Row() {
            this.buttonBuilder($r('app.string.call'))
          }
          .onClick(() => {
            postCardAction(this, {
              action: 'call',
              abilityName: 'EntryAbility',
              params: {
                formId: this.formId,
                method: 'updateCardInfo',
                params: { message: 'Call refresh card.' }
              }
            });
          })

          // message 事件:触发 FormExtensionAbility.onFormEvent
          Row() {
            this.buttonBuilder($r('app.string.message'))
          }
          .onClick(() => {
            postCardAction(this, {
              action: 'message',
              params: { message: 'Message refresh card.' }
            });
          })
        }
        .width(this.FULL_WIDTH_PERCENT)
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .height(this.FULL_HEIGHT_PERCENT)
      .justifyContent(FlexAlign.SpaceBetween)
    }
  }

  build() {
    Row() {
      CardListComponent({ cardListParameter: this.cardListParameter }) {
        this.cardListBuilder()
      }
    }
    .height(this.FULL_HEIGHT_PERCENT)
    .onClick(() => {
      // 整个卡片点击跳转主应用
      postCardAction(this, {
        action: this.ACTION_TYPE,
        abilityName: this.ABILITY_NAME,
        params: { message: this.MESSAGE }
      });
    })
  }
}

关键约束:卡片页面不能使用 aboutToAppearaboutToDisappear 等生命周期方法,所有数据必须通过 @LocalStorageProp 绑定。

2.5 数据模型

以下代码来自 CardInfoRefresh 官方示例的 CommonData.etsFormData 类中必须包含 formImages 等特殊字段(如需加载图片),且字段名不可更改

// entry/src/main/ets/common/CommonData.ets
// 来源:CardInfoRefresh 官方示例

// 卡片列表项数据接口
export interface CardListItemData {
  id: number;           // 数据项 ID
  title: ResourceStr;   // 标题
  content: ResourceStr; // 内容
  icon: Resource;       // 图标资源
  favour: boolean;      // 收藏状态
  bgImage: Resource;    // 背景图
}

/**
 * FormBindingData 数据类
 * formImages 等特殊字段必须通过 class 传递
 */
export class FormData {
  formId: string = '';                      // 卡片 ID
  formTime: string = '';                    // 刷新时间
  isFavor?: boolean = false;               // 收藏状态
  index?: number = 0;                      // 数据索引
  cardList: Array<CardListItemData> = [];  // 列表数据

  constructor(formId: string) {
    this.formId = formId;
  }
}

三、卡片刷新与交互

本章核心:掌握系统被动刷新、应用主动推送、卡片 UI 触发刷新三种更新方式,以及卡片图片加载的完整机制。

3.1 被动刷新(系统触发)

以下配置说明来自华为官方文档,在 form_config.json 中配置:

定点刷新(推荐日常定时更新场景):

// form_config.json
{
  "updateEnabled": true,
  "scheduledUpdateTime": "01:00",  // 每天凌晨 1 点自动刷新
  "updateDuration": 0              // 必须为 0!否则 scheduledUpdateTime 不生效
}

系统在指定时间调用 onUpdateForm,在其中重新读取数据并调用 updateForm

定时刷新(周期性更新):

{
  "updateEnabled": true,
  "updateDuration": 2  // 每 2 × 30 = 60 分钟刷新一次
}

下次刷新(动态设置最短 5 分钟后刷新):

// FormExtensionAbility 中调用
import { formProvider } from '@kit.FormKit';

const FIVE_MINUTE: number = 5;
formProvider.setFormNextRefreshTime(formId, FIVE_MINUTE, (err: BusinessError) => {
  if (err) {
    console.error(`Failed to setFormNextRefreshTime. Code: ${err.code}`);
  }
});

定时/定点刷新的约束

约束 说明
每日上限 每张卡片每天最多 50 次 定时刷新,0 点重置
最短间隔 定时刷新最短 30 分钟,setFormNextRefreshTime 最短 5 分钟
互斥规则 updateDuration > 0 时定时刷新优先,定点刷新被忽略
可见性 仅在卡片可见时触发渲染

3.2 主动刷新(应用侧推送)

主应用通过 formProvider.updateForm() 即时推送数据。以下代码来自 CardInfoRefresh 官方示例的应用侧 Index.ets重点在于从 Preferences 中读取所有 formId 并逐张推送

// entry/src/main/ets/pages/Index.ets — 应用侧收藏按钮点击刷新卡片
// 来源:CardInfoRefresh 官方示例
import { formBindingData, formProvider } from '@kit.FormKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { PreferencesUtil } from '../common/utils/PreferencesUtil';
import { FormData } from '../common/CommonData';

Row() {
  // 收藏按钮 UI
}
.onClick(() => {
  // 获取 Preferences 实例(每次清除缓存后重新获取)
  let util = PreferencesUtil.getInstance();
  let preferences = util.getPreferences(this.getUIContext().getHostContext()!);
  if (!preferences) {
    return;
  }

  // 更新收藏状态
  this.statusArr[this.itemData.id] = !this.statusArr[this.itemData.id];
  this.itemData.favour = this.statusArr[this.itemData.id!];
  util.preferencesPut(preferences, 'statusArr', this.statusArr);

  // 同步更新应用内 UI
  AppStorage.set('statusArr', [...this.statusArr]);

  // 读取所有已加桌卡片的 formId
  let idArr = PreferencesUtil.getInstance().getFormIds(preferences);
  if (idArr.length > 0) {
    idArr.forEach((formId: string) => {
      if (!preferences) {
        return;
      }
      try {
        // 检查当前卡片是否展示的是被收藏的数据项
        if (preferences.getSync(`${formId}_show_index`, -1) as number === this.itemData.id) {
          // 构建更新数据
          let formData = new FormData(formId);
          formData.cardList = [this.itemData];
          let formMsg: formBindingData.FormBindingData =
            formBindingData.createFormBindingData(formData);

          // 推送数据到卡片
          formProvider.updateForm(formId, formMsg).then(() => {
            hilog.info(0x0000, TAG, `updateForm success.`);
          }).catch((error: Error) => {
            let err = error as BusinessError;
            hilog.error(0x0000, TAG, `updateForm failed: error code=${err.code}`);
          });
        }
      } catch (error) {
        let err = error as BusinessError;
        hilog.error(0x0000, TAG, `getSync failed, error code=${err.code}`);
      }
    })
  }
})

关键点

  • formId 在 onAddForm 中通过 want.parameters[formInfo.FormParam.IDENTITY_KEY] 获取并持久化。
  • 读取 formId 前必须调用 removePreferencesFromCacheSync 清除缓存,确保多进程场景读到最新数据。
  • 主应用和卡片进程是不同进程,通过 Preferences 共享 formId 数据。

3.3 卡片 UI 触发刷新(message 事件)

在卡片页面通过 postCardAction 触发 onFormEventaction 类型必须为 'message'

// 卡片页面的刷新按钮 — message 事件
Row() {
  this.buttonBuilder($r('app.string.message'))
}
.onClick(() => {
  postCardAction(this, {
    action: 'message',            // 触发 FormExtensionAbility.onFormEvent
    params: {
      message: 'Message refresh card.'
    }
  });
})

3.4 卡片图片加载

图片必须通过 formImages + 文件描述符传递,卡片页面使用 memory:// 协议引用。此机制是卡片进程无法访问应用沙箱的必然选择

// FormExtensionAbility 中构建图片数据
import { fileIo } from '@kit.CoreFileKit';

// 图片数据必须用 class 传递,formImages 字段名不可更改
class CardData {
  // 图片在 formImages 中的 key 名,卡片页用 memory:// 引用
  imgName: string = 'petImg';
  // 必填字段,名称为 formImages,值为 { key: fd } 的 Record
  formImages: Record<string, number> = {};
  // 其他数据字段
  text: string = 'Hello';
}

// 打开图片文件,获取文件描述符
const file = fileIo.openSync('/data/storage/.../avatar.jpg');
const data = new CardData();
data.formImages = { 'petImg': file.fd };
return formBindingData.createFormBindingData(data);
// 卡片页面中引用图片 — 使用 memory:// 协议
@LocalStorageProp('imgName') imgName: string = '';

Image('memory://' + this.imgName)
  .width(64).height(64)
  .borderRadius(32)
  .objectFit(ImageFit.Cover)

图片加载约束

约束 说明
数量限制 API 19 之前最多 5 张,API 20+ 最多 20 张
大小限制 API 19 之前每张 ≤ 2MB,API 20+ 总大小 ≤ 10MB
传递方式 必须通过 formImages: Record<string, number> 字段传递文件描述符
引用方式 卡片页用 Image('memory://' + key) 引用
imgName 刷新 每次传递的 imgName 必须不同,连续相同值不会触发图片刷新

四、从应用内请求卡片加桌

本章核心:掌握 formProvider.openFormManager() 的调用方式与兼容处理。

使用 formProvider.openFormManager()(API 18+)在应用内打开卡片管理页面,需传入 Want 参数指定目标卡片信息

// 应用内添加卡片到桌面入口
import { formProvider } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

// 构建 Want 参数,指定目标卡片信息
const want: Want = {
  // 应用包名
  bundleName: 'com.akensx.catisland',
  // FormExtensionAbility 名称
  abilityName: 'EntryFormAbility',
  // parameters 中指定卡片尺寸、名称和模块
  parameters: {
    // 卡片尺寸编号:1=1×2, 2=2×2, 3=2×4, 4=4×4, 5=6×4
    'ohos.extra.param.key.form_dimension': 3,
    // 卡片名称(对应 form_config.json 中的 name 字段)
    'ohos.extra.param.key.form_name': 'catInfo',
    // 卡片所属模块名
    'ohos.extra.param.key.module_name': 'entry'
  }
};

try {
  // 拉起系统卡片管理页面
  formProvider.openFormManager(want);
} catch (error) {
  let err = error as BusinessError;
  console.error(`openFormManager failed, code=${err.code}, message=${err.message}`);
}

注意:该 API 最低要求 API 18,低版本设备需做兼容检查:

// 兼容检查
if (deviceInfo.distributionOSApiVersion >= /* API 18 */) {
  formProvider.openFormManager(want);
}

在这里插入图片描述


五、喵屿卡片实践

本章核心:通过喵屿项目实战案例,展示多卡片管理、待办数据计算和卡片数据推送的完整实现路径。

喵屿是一款宠物健康管理应用,基于上述技术实现三张服务卡片。以下代码来自项目实际开发,展示多卡片 FormExtensionAbility 管理、待办数据计算和卡片数据推送的完整流程。

5.1 整体架构

喵屿 App(主进程 UIAbility)
├── ShowItemUpdateUtil (待办计算 + 卡片推送调度)
│   └── pushAllCardUpdates() → 分别推送三种卡片数据
├── CardFormIdUtil (formId 持久化管理)
├── EntryFormAbility(卡片进程)
│   ├── onAddForm    → 存储 formId + 返回初始数据
│   ├── onUpdateForm → 每日定点刷新
│   ├── onFormEvent  → 点击卡片刷新按钮
│   └── computeTodos → 超期计算 (DateUtil.dayDiff2)
│
└── 卡片 UI 页面
    ├── CatInfoCard.ets   (待办提醒)
    ├── PetInfoCard.ets   (宠物信息)
    └── PetTodoCard.ets   (宠物+待办合并)

5.2 待办提醒卡片(catInfo)核心代码

// entry/src/main/ets/catinfo/pages/CatInfoCard.ets

// 待办行数据类型
interface TodoRowItem {
  index: number;   // 序号(1-based)
  title: string;   // 标题(如 "猫三联已超期,尽快接种")
  state: string;   // 状态:'ERROR'(紧急)或 'ALARM'(提醒)
}

// 创建独立 LocalStorage(必须,否则 updateForm 数据无法到达卡片)
let catInfoStorage = new LocalStorage();

@Entry(catInfoStorage)
@Component
struct CatInfoCard {
  readonly actionType = 'router';
  readonly abilityName = 'EntryAbility';

  // @LocalStorageProp:key 必须与 FormBindingData 中完全一致,类型必须为 string
  @LocalStorageProp('todoCount') todoCount: string = '0';
  @LocalStorageProp('todoTitle0') todoTitle0: string = '';
  @LocalStorageProp('todoTitle1') todoTitle1: string = '';
  @LocalStorageProp('todoTitle2') todoTitle2: string = '';
  @LocalStorageProp('todoTitle3') todoTitle3: string = '';
  @LocalStorageProp('todoState0') todoState0: string = '';
  @LocalStorageProp('todoState1') todoState1: string = '';
  @LocalStorageProp('todoState2') todoState2: string = '';
  @LocalStorageProp('todoState3') todoState3: string = '';
  // 旋转动画角度(刷新按钮)
  @State rotateAngle: number = 0;

  /**
   * 将分散的 @LocalStorageProp 字段组装为数组,供 ForEach 遍历
   * 每次 build() 重新执行时获取最新值
   */
  private getTodoItems(): TodoRowItem[] {
    const items: TodoRowItem[] = [];
    const titles = [this.todoTitle0, this.todoTitle1, this.todoTitle2, this.todoTitle3];
    const states = [this.todoState0, this.todoState1, this.todoState2, this.todoState3];
    for (let i = 0; i < titles.length; i++) {
      if (titles[i]) {  // 只收录非空的待办项
        items.push({ index: i + 1, title: titles[i], state: states[i] });
      }
    }
    return items;
  }

  build() {
    Column() {
      // 标题栏:名称 + 数量
      Row() {
        Text('喵屿待办')
          .fontSize(15).fontWeight(FontWeight.Bold)
          .fontColor($r('app.color.card_text_primary'))
        Blank()
        Text(this.todoCount)
          .fontSize(15).fontWeight(FontWeight.Bold)
          .fontColor($r('app.color.card_accent'))
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 12, bottom: 6 })

      // 内容区域
      if (this.todoCount === '0') {
        Column() {
          Text('太棒啦,今天没有任务哦~')
            .fontSize(12).fontColor($r('app.color.card_text_hint'))
        }
        .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
      } else {
        /**
         * 使用 Column + ForEach 渲染待办列
         * 【避坑】不要用 @Builder(卡片中 @Builder 缓存输出,参数变化不重新执行)
         * 【避坑】ForEach key 不可用 String(index),数据变化 key 不变 → 不复渲染
         */
        Column() {
          ForEach(this.getTodoItems(), (item: TodoRowItem) => {
            Row() {
              Text(`${item.index}`)
                .fontSize(10).fontWeight(FontWeight.Bold)
                .fontColor(Color.White)
                .backgroundColor($r('app.color.card_accent'))
                .borderRadius(8).width(16).height(16).textAlign(TextAlign.Center)
              Text(item.title)
                .fontSize(11).fontColor($r('app.color.card_text_secondary'))
                .margin({ left: 6 }).maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1)
              Text(item.state === 'ERROR' ? '紧急' : '提醒')
                .fontSize(9)
                .fontColor(item.state === 'ERROR'
                  ? $r('app.color.card_badge_error_text')
                  : $r('app.color.card_badge_warn_text'))
                .backgroundColor(item.state === 'ERROR'
                  ? $r('app.color.card_badge_error_bg')
                  : $r('app.color.card_badge_warn_bg'))
                .borderRadius(4)
                .padding({ left: 5, right: 5, top: 1, bottom: 1 })
            }
            .width('100%')
            .padding({ left: 14, right: 14, top: 4, bottom: 4 })
          }, (item: TodoRowItem) =>
            // key 使用字符串拼接反映数据变化(JSON.stringify 在卡片中不可用)
            String(item.index) + '_' + item.title + '_' + item.state
          )
        }
        .width('100%').layoutWeight(1)
      }

      // 底部分隔 + 刷新按钮
      Divider().strokeWidth(0.5).color($r('app.color.card_divider'))
      Row() {
        Image($r('app.media.refresh'))
          .width(15).height(15)
          .rotate({ angle: this.rotateAngle })
          .animation({ duration: 1000, curve: Curve.EaseInOut })
      }
      .width('100%').justifyContent(FlexAlign.Center)
      .padding({ top: 4, bottom: 6 })
      .onClick(() => {
        this.rotateAngle += 360;
        postCardAction(this, {
          action: 'message',
          params: { msgTest: 'messageEvent' }
        });
      })
    }
    .width('100%').height('100%')
    .backgroundColor($r('app.color.card_bg')).borderRadius(14)
    .onClick(() => {
      postCardAction(this, {
        action: this.actionType, abilityName: this.abilityName,
        params: { message: 'openTodo' }
      });
    })
  }
}

5.3 多卡片 EntryFormAbility 管理

一个 FormExtensionAbility 管理三张卡片,通过 formName 区分。此设计避免了为每种卡片单独注册 ExtensionAbility,降低了资源开销

// entry/src/main/ets/entryformability/EntryFormAbility.ets(摘录)
import { CardFormIdUtil } from '../util/CardFormIdUtil';

const FORM_TODO = 'catInfo';       // 待办卡片
const FORM_PET = 'petInfo';        // 宠物信息卡片
const FORM_PET_TODO = 'petTodo';   // 合并卡片

export default class EntryFormAbility extends FormExtensionAbility {
  private formIdUtil: CardFormIdUtil = CardFormIdUtil.getInstance();

  onAddForm(want: Want): formBindingData.FormBindingData {
    const name = want.parameters?.[formInfo.FormParam.NAME_KEY] as string ?? '';
    const formId = want.parameters?.[formInfo.FormParam.IDENTITY_KEY] as string ?? '';

    // 持久化 formId,key 直接用卡片名称
    if (formId && name) {
      this.formIdUtil.addFormId(name, this.context.getApplicationContext(), formId);
    }

    // 根据卡片类型构建不同数据
    return formBindingData.createFormBindingData(this.buildDataForCard(name));
  }

  // 卡片类型 → 数据构建分发
  private buildDataForCard(formName: string): Object {
    if (formName === FORM_PET) return this.buildPetData();
    if (formName === FORM_PET_TODO) return this.buildPetTodoData();
    return this.buildTodoData();  // 默认待办卡片
  }

  // 通过持久化存储查找 formId 所属卡片类型
  private getCardType(formId: string): string {
    const prefs = this.formIdUtil.getPreferences(this.context.getApplicationContext());
    if (!prefs) return '';
    for (const key of ['catInfo', 'petInfo', 'petTodo']) {
      if (this.formIdUtil.getFormIds(prefs, key).indexOf(formId) >= 0) {
        return key;
      }
    }
    return '';
  }
}

5.4 应用侧待办数据推送

// entry/src/main/ets/util/ShowItemUpdateUtil.ets(摘录)

// 待办数据更新后延迟推送(debounce 1000ms)
private static scheduleCardRefresh(): void {
  if (ShowItemUpdateUtil.updateTimer !== -1) {
    clearTimeout(ShowItemUpdateUtil.updateTimer);
  }
  ShowItemUpdateUtil.updateTimer = setTimeout(() => {
    ShowItemUpdateUtil.pushAllCardUpdates();
  }, 1000);
}

// 一次 getPreferences I/O 读取所有卡片类型的 formId
private static getAllFormIds(): Record<string, string[]> {
  const result: Record<string, string[]> = {};
  if (!PreferenceUtil.context) return result;
  const prefs = ShowItemUpdateUtil.formIdUtil.getPreferences(PreferenceUtil.context);
  if (!prefs) return result;
  for (const key of ['catInfo', 'petInfo', 'petTodo']) {
    result[key] = ShowItemUpdateUtil.formIdUtil.getFormIds(prefs, key);
  }
  return result;
}

// 推送卡片更新
private static pushForm(ids: string[], bd: formBindingData.FormBindingData): void {
  for (const fid of ids) {
    formProvider.updateForm(fid, bd).catch((err: BusinessError) => {
      // 错误码 16501001:卡片已被用户移除,下次 onRemoveForm 会清理
      if (err.code === 16501001) {
        console.warn(`[CardRefresh] Form ${fid} removed`);
      }
    });
  }
}

在这里插入图片描述
服务卡片不仅可以添加到桌面,还可以添加到负一屏,为用户提供更多触达入口。


六、避坑指南

本章核心:汇总喵屿项目实际开发中遇到的典型问题,涵盖 UI 渲染、数据处理、进程与存储三大类常见陷阱。

6.1 UI 渲染相关

1. @LocalStorageProp 值均为 string 类型

FormBindingData 会序列化所有值,因此 number 类型字段也必须声明为 string,条件判断时使用 === '0' 而非 === 0

// 错误:声明为 number,实际收到 string,判断永远不成立
@LocalStorageProp('todoCount') todoCount: number = 0;
if (this.todoCount === 0) { ... }

// 正确
@LocalStorageProp('todoCount') todoCount: string = '0';
if (this.todoCount === '0') { ... }

2. 卡片必须创建独立 LocalStorage 并传给 @Entry()

// 错误:缺少 LocalStorage,updateForm 推送数据无法到达
@Entry
@Component
struct MyCard { ... }

// 正确
let storage = new LocalStorage();
@Entry(storage)
@Component
struct MyCard { ... }

3. ForEach 的 key 函数不能只依赖索引

使用 String(index) 会导致数据变化时 key 不变,ForEach 不复渲染。应使用能反映数据内容的字符串拼接作为 key。

4. Swiperduration/curve 属性在卡片中不支持

编译时会报错 'duration' can't support form application。仅使用 autoPlaydisplayCountintervalindicator 等基础属性。

6.2 数据处理相关

5. 图片必须通过 formImages + 文件描述符传递

URI 字符串在卡片进程中无效——卡片进程无法访问主应用沙箱。

// 错误:直接传 URI
const data = { 'petIcon': '/data/storage/.../avatar.jpg' };

// 正确:打开文件获取 fd,通过 formImages 传递
const file = fileIo.openSync('/data/storage/.../avatar.jpg');
class CardData {
  imgName: string = 'petImg';
  formImages: Record<string, number> = { 'petImg': file.fd };
}

6. formImages 字段必须通过 class 传递

使用 Record<string, string | Record<string, number>> 类型改写可能导致 formImages 未被正确序列化,必须使用 class 定义。

7. 卡片图片刷新依赖 imgName 变化

Image 组件通过传入参数是否有变化来决定是否刷新图片。每次 updateForm 传递的 imgName 必须不同,连续两次相同值不会触发图片刷新。

// 错误:每次使用固定的 'avatar'
data.imgName = 'avatar';

// 正确:包含时间戳或序号确保唯一
data.imgName = 'avatar_' + Date.now();

8. 超期计算函数必须与 App 端一致

Math.ceildayjs.diff 对边界时间的处理不同。喵屿使用 DateUtil.dayDiff2() 统一计算,确保卡片与 App 内数据一致。

6.3 进程与存储相关

9. 主应用和卡片进程的 Preferences Context 必须一致

两个进程中读取同一个 Preferences 文件时,必须使用相同类型的 Context:

// 错误:卡片进程用 FormExtensionContext,主应用用 UIContext
// 两个 context 的 getPreferencesSync 可能读取到不同路径的文件

// 正确:统一使用 ApplicationContext
// 卡片进程
const appCtx = this.context.getApplicationContext();
preferences.getPreferencesSync(appCtx, { name: 'catIsland' });

// 主应用(PreferenceUtil.initContext 在 EntryAbility 中)
PreferenceUtil.initContext(this.context.getApplicationContext());

如果 Context 不一致,两个进程拿到的 Preferences 实例可能指向不同的底层文件,导致数据无法共享。

10. 读取 formId 前必须清除 Preferences 缓存

多进程场景下,卡片进程写入 formId 后,主应用的 Preferences 缓存可能仍是旧值:

// 错误:直接读取,可能拿到旧缓存
const ids = PreferenceUtil.getPreferenceByNameSync(BaseConstants.DEFAULT_NAME, key, '[]');

// 正确:读取前先清除缓存
preferences.removePreferencesFromCacheSync(context, 'cardFormIdStore');
const ids = PreferenceUtil.getPreferenceByNameSync(...);

6.4 卡片约束

约束 说明
10 秒无操作清理 FormExtensionAbility 创建后 10 秒内无新操作会被系统销毁
不支持部分多媒体模块 @ohos.multimedia.audio.camera.media@ohos.resourceschedule.backgroundTaskManager
不支持生命周期方法 卡片页面不能使用 aboutToAppearaboutToDisappearonPageShow
不支持 @State/@Watch 卡片页面只能使用 @LocalStorageProp 进行数据绑定
定时刷新配额 每张卡片每天最多 50 次,0 点重置
定点/定时互斥 scheduledUpdateTime 仅在 updateDuration: 0 时生效
API 版本兼容 formProvider.openFormManager 需 API 18+,低版本需兼容检查

七、总结

HarmonyOS 服务卡片开发的核心在于理解以下三点:

1. 双进程架构
主应用(UIAbility)和卡片(FormExtensionAbility)是独立进程,通过 Preferences 共享 formId 和数据。Preferences 必须使用统一的 Context 类型(推荐 ApplicationContext),读取前必须清除缓存。关键 API:preferences.getPreferencesSyncremovePreferencesFromCacheSyncformProvider.updateForm

2. 数据单向流动
数据流为 FormBindingData → LocalStorage → @LocalStorageProp,所有值被序列化为 string。卡片页面不直接访问文件系统或数据库,数据完全由 FormExtensionAbility 提供。图片必须通过 formImages + fd 传递,memory:// 协议引用。关键 API:formBindingData.createFormBindingDataLocalStorage@LocalStorageProp

3. 生命周期与刷新策略
onAddForm 保存 formId,onUpdateForm 处理定时刷新,onFormEvent 响应用户操作,onRemoveForm 清理数据。刷新受每日配额和可见性约束,需合理设计更新频率。关键 API:onAddFormonUpdateFormonFormEventpostCardActionformProvider.setFormNextRefreshTime

掌握以上三点,结合本文的避坑指南和喵屿实践案例,开发者可以快速构建稳定、高效的 HarmonyOS 服务卡片。


参考来源:

Logo

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

更多推荐