鸿蒙元服务与卡片开发

What — 是什么

元服务(Atomic Service)是鸿蒙生态中一种免安装、即用即走的轻量化应用形态,以服务卡片(Widget/Service Card)为主要交互入口,用户无需下载安装即可直达核心功能,是鸿蒙”服务随处可达”理念的关键载体。

核心概念:

  • 元服务(Atomic Service):免安装的轻量应用形态,无桌面图标,通过卡片、语音、扫码等入口触达
  • 服务卡片(Service Card / Widget):元服务的可视化界面,嵌入桌面/负一屏等位置,展示关键信息与快捷操作
  • FormExtensionAbility:卡片的生命周期管理扩展,负责卡片的创建、更新、销毁
  • formBindingData:卡片数据绑定机制,将数据推送到卡片 UI 进行渲染
  • 免安装分发:元服务通过应用市场服务分发,用户触达即用,无需显式安装

整体架构:

┌─────────────────────────────────────────────────────────────┐
│                     鸿蒙元服务架构                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌──────────────┐   ┌──────────────┐   ┌──────────────┐    │
│  │  服务卡片 UI  │   │  服务卡片 UI  │   │  服务卡片 UI  │    │
│  │  (静态卡片)   │   │  (动态卡片)   │   │  (动态卡片)   │    │
│  └──────┬───────┘   └──────┬───────┘   └──────┬───────┘    │
│         │                  │                  │             │
│         ▼                  ▼                  ▼             │
│  ┌─────────────────────────────────────────────────┐       │
│  │            FormExtensionAbility                   │       │
│  │   (卡片生命周期管理:onCreate/onUpdate/onDelete)   │       │
│  └─────────────────────┬───────────────────────────┘       │
│                        │                                    │
│         ┌──────────────┼──────────────┐                    │
│         ▼              ▼              ▼                    │
│  ┌────────────┐ ┌────────────┐ ┌────────────┐             │
│  │formProvider │ │formInfo    │ │formBinding │             │
│  │(卡片提供者) │ │(卡片信息)  │ │Data(数据)  │             │
│  └──────┬─────┘ └────────────┘ └─────┬──────┘             │
│         │                             │                    │
│         ▼                             ▼                    │
│  ┌─────────────────────────────────────────────────┐       │
│  │            元服务宿主 Ability                      │       │
│  │   (UIAbility / ServiceExtensionAbility)           │       │
│  └─────────────────────┬───────────────────────────┘       │
│                        │                                    │
│                        ▼                                    │
│  ┌─────────────────────────────────────────────────┐       │
│  │            鸿蒙系统服务层                          │       │
│  │   (卡片管理服务 / 免安装运行时 / 分发服务)          │       │
│  └─────────────────────────────────────────────────┘       │
│                                                             │
└─────────────────────────────────────────────────────────────┘

元服务与普通应用对比:

维度元服务(Atomic Service)普通应用(Traditional App)
安装方式免安装,即用即走需下载安装完整包
桌面图标无独立图标有桌面图标
入口形态卡片、语音、扫码、搜索点击图标启动
包体积≤ 10MB(推荐 ≤ 2MB)无严格限制
交互模式卡片为主,轻量交互全功能 UI
分发方式服务分发(触达即用)应用分发(下载安装)
后台运行受限,无长驻后台可后台运行
更新机制静默更新,用户无感需用户确认更新
开发框架ArkTS + ArkUIArkTS + ArkUI / Java
使用场景快捷服务、信息展示、轻交互复杂功能、深度使用

服务卡片类型:

类型说明渲染方式数据更新交互能力
静态卡片(Static Widget)固定布局,数据驱动更新系统渲染定时/条件触发router/message 事件
动态卡片(Dynamic Widget)支持动态 UI 和动画应用渲染实时更新丰富交互

卡片尺寸规格:

尺寸网格宽高(px)典型用途
小卡片1×22×4 网格单一信息展示(天气图标+温度)
中卡片2×24×4 网格信息+操作(天气概要)
大卡片2×44×8 网格丰富信息+多操作(天气详情+预报)
超大卡片4×48×8 网格复合信息面板(天气+日历+待办)

Why — 为什么

适用场景:

  • 信息速览:天气、日程、股价、快递状态等无需打开 App 即可查看
  • 快捷操作:扫码支付、运动打卡、计时器等一键触达
  • 服务直达:点餐、打车、查路线等核心功能免安装使用
  • 智能推荐:基于场景、位置、习惯主动推荐服务
  • IoT 控制:智能家居设备状态展示与快捷控制
  • 企业办公:待办审批、考勤打卡、会议提醒等轻量办公

与 iOS Widget / Android App Widget 的对比:

维度鸿蒙服务卡片iOS WidgetAndroid App Widget
免安装支持(元服务形态)不支持不支持
卡片类型静态+动态静态为主静态
渲染方式系统/应用双渲染系统 WidgetKit 渲染系统 RemoteViews 渲染
动画支持动态卡片支持有限(SwiftUI 动画)不支持
交互方式router + messageDeep LinkPendingIntent
数据更新定时 + 条件 + 实时Timeline 预加载定时 + 通知触发
尺寸规格4 种(1×2/2×2/2×4/4×4)3 种(小/中/大)自由(minResizeWidth/Height)
开发语言ArkTSSwiftUIKotlin/Java + XML
桌面位置桌面 + 负一屏 + 服务中心桌面 + 今日视图桌面
分发方式服务分发(免安装)App StoreGoogle Play
设计理念服务随处可达信息速览信息速览+快捷操作

元服务核心优势:

  • ✅ 免安装即用:用户无需下载安装,极大降低使用门槛,提升转化率
  • ✅ 卡片入口:桌面常驻信息展示,用户触达频率远高于传统 App
  • ✅ 服务分发:基于场景主动推荐,从”人找服务”变为”服务找人”
  • ✅ 静默更新:后台自动更新,用户始终使用最新版本
  • ✅ 一次开发多端部署:手机、平板、车机、手表等设备统一开发

元服务局限性:

  • ❌ 包体积受限(≤ 10MB),复杂功能难以承载
  • ❌ 无桌面图标,用户心智认知需要培养
  • ❌ 后台受限,无法长驻后台运行
  • ❌ 卡片 UI 限制多,组件和布局有严格约束
  • ❌ 动态卡片内存占用高,过多卡片影响系统性能
  • ❌ 生态仍在发展,用户习惯尚未完全养成

How — 怎么用

元服务开发流程

1. 创建元服务项目

使用 DevEco Studio 创建元服务项目:

DevEco Studio → File → New → Create Project
  → 选择 "Atomic Service" 模板
  → 填写项目信息(Bundle Name、Project Name)
  → 选择卡片模板(空白/天气/待办等)
  → 完成创建

项目结构:

MyAtomicService/
├── entry/
│   └── src/
│       └── main/
│           ├── ets/
│           │   ├── entryability/              # UIAbility 宿主
│           │   │   └── EntryAbility.ets
│           │   ├── widget/
│           │   │   ├── pages/                 # 卡片 UI 页面
│           │   │   │   ├── WidgetCard.ets      # 动态卡片页面
│           │   │   │   └── WidgetStatic.ets    # 静态卡片页面
│           │   │   └── WidgetAbility.ets       # FormExtensionAbility
│           │   ├── common/                    # 公共工具
│           │   │   ├── constants.ets
│           │   │   └── utils.ets
│           │   └── model/                     # 数据模型
│           │       └── WeatherModel.ets
│           ├── resources/
│           │   ├── base/
│           │   │   ├── element/               # 颜色、字符串
│           │   │   ├── media/                 # 图片资源
│           │   │   └── profile/
│           │   │       └── main_pages.json    # 页面路由
│           │   └── rawfile/                   # 原始文件
│           └── module.json5                   # 模块配置(核心)
├── build-profile.json5                        # 构建配置
├── hvigorfile.ts                              # 构建脚本
└── oh-package.json5                           # 依赖管理

2. module.json5 配置卡片信息

{
  "module": {
    "name": "entry",
    "type": "entry",
    "description": "$string:module_desc",
    "mainElement": "EntryAbility",
    "deviceTypes": ["phone", "tablet", "2in1"],
    "deliveryWithInstall": true,
    "installationFree": true,  // 关键:标记为免安装元服务
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:layered_image",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:startIcon",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,
        "skills": [
          {
            "entities": ["entity.system.home"],
            "actions": ["action.system.home"]
          }
        ]
      }
    ],
    // 卡片扩展能力配置 —— 核心配置项
    "extensionAbilities": [
      {
        "name": "WidgetAbility",
        "srcEntry": "./ets/widget/WidgetAbility.ets",
        "description": "$string:WidgetAbility_desc",
        "icon": "$media:layered_image",
        "label": "$string:WidgetAbility_label",
        "exported": true,
        "type": "form",  // 固定为 form 类型
        "metadata": [
          {
            "name": "ohos.extension.form",
            "resource": "$profile:form_config"  // 指向卡片配置文件
          }
        ]
      }
    ]
  }
}

3. 卡片配置文件 form_config.json

{
  "forms": [
    {
      // ===== 静态卡片配置(1×2 小卡片) =====
      "name": "widgetStaticSmall",
      "displayName": "天气小卡片",
      "description": "显示当前天气信息",
      "src": "./ets/widget/pages/WidgetStatic.ets",  // 卡片页面路径
      "uiSyntax": "arkts",     // 使用 ArkTS 语法
      "window": {
        "designWidth": 720,
        "autoDesignWidth": true
      },
      "colorMode": "auto",     // 跟随系统深浅色
      "isDefault": true,       // 是否为默认卡片
      "updateEnabled": true,   // 是否允许定时更新
      "scheduledUpdateTime": "10:30",  // 定时更新时间
      "updateDuration": 1,     // 更新间隔(单位:小时,0=仅定时,1=每1小时)
      "defaultDimension": "1*2",        // 默认尺寸
      "supportDimensions": ["1*2"],     // 支持的尺寸列表
      "formConfigAbility": "entry:WidgetAbility",  // 关联的 ExtensionAbility
      "metadata": {
        "customizeData": [
          {
            "name": "widgetSrc",
            "value": "WidgetStatic"
          }
        ]
      }
    },
    {
      // ===== 动态卡片配置(2×2 中卡片) =====
      "name": "widgetDynamic",
      "displayName": "天气动态卡片",
      "description": "动态展示天气详情与预报",
      "src": "./ets/widget/pages/WidgetCard.ets",
      "uiSyntax": "arkts",
      "window": {
        "designWidth": 720,
        "autoDesignWidth": true
      },
      "colorMode": "auto",
      "isDefault": false,
      "updateEnabled": true,
      "scheduledUpdateTime": "10:30",
      "updateDuration": 0,     // 仅按 scheduledUpdateTime 更新
      "defaultDimension": "2*2",
      "supportDimensions": ["2*2", "2*4", "4*4"],  // 支持多种尺寸
      "formConfigAbility": "entry:WidgetAbility"
    },
    {
      // ===== 静态卡片配置(2×4 大卡片) =====
      "name": "widgetLarge",
      "displayName": "待办大卡片",
      "description": "展示待办列表和快捷添加",
      "src": "./ets/widget/pages/TodoWidget.ets",
      "uiSyntax": "arkts",
      "window": {
        "designWidth": 720,
        "autoDesignWidth": true
      },
      "colorMode": "auto",
      "isDefault": false,
      "updateEnabled": true,
      "scheduledUpdateTime": "08:00",
      "updateDuration": 1,
      "defaultDimension": "2*4",
      "supportDimensions": ["2*4", "4*4"],
      "formConfigAbility": "entry:WidgetAbility"
    }
  ]
}

FormExtensionAbility — 卡片生命周期

FormExtensionAbility 是管理卡片生命周期的核心扩展,处理卡片的创建、更新和销毁事件。

// WidgetAbility.ets — 卡片生命周期管理
import formProvider from '@ohos.app.form.formProvider';
import formInfo from '@ohos.app.form.formInfo';
import FormExtensionAbility from '@ohos.app.form.FormExtensionAbility';
import dataStorage from '@ohos.data.storage';
import { WeatherModel } from '../model/WeatherModel';

export default class WidgetAbility extends FormExtensionAbility {
  // 存储 formId 与卡片的映射关系,用于后续更新
  private formStore: Map<string, formInfo.FormState> = new Map();

  /**
   * 卡片创建时回调
   * @param want - 包含卡片信息的 Want 对象
   * @returns formBindingData.FormBindingData - 卡片要展示的数据
   */
  onAddForm(want: Want): formBindingData.FormBindingData {
    console.info('[WidgetAbility] onAddForm');

    // 从 want 中获取卡片信息
    const formId = want.parameters?.['ohos.extra.param.key.form_identity'] as string;
    const formName = want.parameters?.['ohos.extra.param.key.form_name'] as string;
    const dimension = want.parameters?.['ohos.extra.param.key.form_dimension'] as number;

    console.info(`[WidgetAbility] formId: ${formId}, formName: ${formName}, dimension: ${dimension}`);

    // 存储 formId 用于后续更新
    this.formStore.set(formId, formInfo.FormState.READY);

    // 根据卡片名称返回不同的初始数据
    let formData: Record<string, Object>;
    if (formName === 'widgetStaticSmall') {
      formData = {
        temperature: '26°',
        weather: '晴',
        city: '北京',
        weatherIcon: '☀️',
        updateTime: this.getCurrentTime()
      };
    } else if (formName === 'widgetDynamic') {
      formData = {
        temperature: '26°',
        weather: '晴',
        city: '北京',
        weatherIcon: '☀️',
        humidity: '45%',
        wind: '东南风 3级',
        forecast: [
          { day: '明天', icon: '⛅', temp: '22°/28°' },
          { day: '后天', icon: '🌧', temp: '18°/24°' }
        ],
        updateTime: this.getCurrentTime()
      };
    } else {
      formData = {
        title: '我的待办',
        todoList: [
          { content: '完成项目文档', done: false },
          { content: '代码评审', done: true },
          { content: '周报提交', done: false }
        ],
        remaining: 2,
        updateTime: this.getCurrentTime()
      };
    }

    // 使用 formBindingData 创建数据绑定对象
    return formBindingData.createFormBindingData(formData);
  }

  /**
   * 卡片更新时回调
   * 当定时更新或主动请求更新时触发
   */
  onUpdateForm(formId: string): void {
    console.info(`[WidgetAbility] onUpdateForm, formId: ${formId}`);

    // 获取最新数据
    const newData = this.fetchLatestData(formId);

    // 创建新的绑定数据
    const formData = formBindingData.createFormBindingData(newData);

    // 通知卡片更新
    formProvider.updateForm(formId, formData)
      .then(() => {
        console.info(`[WidgetAbility] updateForm success, formId: ${formId}`);
      })
      .catch((error: Error) => {
        console.error(`[WidgetAbility] updateForm failed: ${error.message}`);
      });
  }

  /**
   * 卡片删除时回调
   */
  onRemoveForm(formId: string): void {
    console.info(`[WidgetAbility] onRemoveForm, formId: ${formId}`);
    // 清理与该卡片关联的资源
    this.formStore.delete(formId);
  }

  /**
   * 卡片提供者收到消息回调(message 事件触发)
   */
  onFormEvent(formId: string, message: string): void {
    console.info(`[WidgetAbility] onFormEvent, formId: ${formId}, message: ${message}`);

    // 解析消息内容,执行对应操作
    try {
      const msgObj = JSON.parse(message) as Record<string, string>;
      if (msgObj.action === 'refresh') {
        // 处理刷新请求
        this.onUpdateForm(formId);
      } else if (msgObj.action === 'toggleTodo') {
        // 处理待办切换
        this.handleTodoToggle(formId, msgObj.index);
      }
    } catch (e) {
      console.error(`[WidgetAbility] onFormEvent parse error: ${e}`);
    }
  }

  /**
   * 宿主应用切到前台/后台时回调
   */
  onVisibilityChange(formIds: string[], visibility: formInfo.FormVisibility): void {
    console.info(`[WidgetAbility] onVisibilityChange, visibility: ${visibility}`);
    // 可根据可见性调整更新频率,节省资源
  }

  /**
   * 连接到 FormExtension 时回调
   */
  onConnect(want: Want): rpc.RemoteObject {
    console.info('[WidgetAbility] onConnect');
    // 返回 RemoteObject 用于跨进程通信
    return new Stub('WidgetAbilityStub');
  }

  // ===== 辅助方法 =====

  private getCurrentTime(): string {
    const now = new Date();
    return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
  }

  private fetchLatestData(formId: string): Record<string, Object> {
    // 实际项目中应从网络或本地存储获取最新数据
    return {
      temperature: '27°',
      weather: '多云',
      city: '北京',
      weatherIcon: '⛅',
      humidity: '52%',
      wind: '南风 2级',
      updateTime: this.getCurrentTime()
    };
  }

  private handleTodoToggle(formId: string, index: string): void {
    // 处理待办状态切换逻辑
    console.info(`[WidgetAbility] toggle todo at index: ${index}`);
    this.onUpdateForm(formId);
  }
}

卡片生命周期流程:

┌──────────────────────────────────────────────────────────────┐
│                   卡片生命周期                                │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  用户长按桌面 → 添加卡片                                      │
│        │                                                     │
│        ▼                                                     │
│  ┌──────────────┐                                            │
│  │  onAddForm   │ ← 首次添加卡片时触发,返回初始数据            │
│  └──────┬───────┘                                            │
│         │                                                    │
│         ▼                                                    │
│  ┌──────────────┐                                            │
│  │  卡片渲染展示  │ ← 系统根据 formBindingData 渲染卡片 UI      │
│  └──────┬───────┘                                            │
│         │                                                    │
│         ├──── 定时更新触发 ──→ onUpdateForm                    │
│         │                         │                          │
│         │                         ▼                          │
│         │              formProvider.updateForm()              │
│         │                         │                          │
│         │                         ▼                          │
│         │                  卡片 UI 刷新                        │
│         │                                                    │
│         ├──── 用户点击卡片事件 ──→ router/message 事件         │
│         │                                                    │
│         ├──── 应用主动更新 ──→ formProvider.updateForm()       │
│         │                                                    │
│         ├──── onFormEvent ──→ 处理卡片 message 事件            │
│         │                                                    │
│         ├──── onVisibilityChange ──→ 卡片可见性变化             │
│         │                                                    │
│  ┌──────┴───────┐                                            │
│  │ onRemoveForm │ ← 用户删除卡片时触发                         │
│  └──────────────┘                                            │
│                                                              │
└──────────────────────────────────────────────────────────────┘

静态卡片开发

静态卡片使用 ArkTS 声明式语法开发,但组件和 API 有严格限制。系统负责渲染,性能更优。

// WidgetStatic.ets — 静态天气小卡片(1×2)
@Entry
@Component
struct WidgetStatic {
  // 卡片数据通过 @Local 装饰器接收
  @Local temperature: string = '--';
  @Local weather: string = '加载中';
  @Local city: string = '';
  @Local weatherIcon: string = '🌤';
  @Local updateTime: string = '--:--';

  build() {
    Column() {
      // 上半部分:天气图标和温度
      Row() {
        Text(this.weatherIcon)
          .fontSize(28)
          .fontWeight(FontWeight.Bold)

        Blank()

        Text(this.temperature)
          .fontSize(32)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
      }
      .width('100%')
      .padding({ left: 12, right: 12 })

      // 下半部分:城市和天气描述
      Row() {
        Column() {
          Text(this.city)
            .fontSize(12)
            .fontColor('#FFFFFFCC')
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })

          Text(this.weather)
            .fontSize(14)
            .fontColor('#FFFFFF')
            .fontWeight(FontWeight.Medium)
        }
        .alignItems(HorizontalAlign.Start)

        Blank()

        Text(this.updateTime)
          .fontSize(10)
          .fontColor('#FFFFFF99')
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 8 })
    }
    .width('100%')
    .height('100%')
    .linearGradient({
      direction: GradientDirection.BottomRight,
      colors: [['#4FACFE', 0.0], ['#00F2FE', 1.0]]
    })
    .borderRadius(16)
    .padding(12)
    // 点击卡片跳转到主应用
    .onClick(() => {
      // 使用 router 事件跳转到指定页面
      postCardAction(this, {
        action: 'router',
        abilityName: 'EntryAbility',
        params: {
          targetPage: 'WeatherDetailPage'
        }
      });
    })
  }
}

静态卡片组件限制:

类别允许禁止
基础组件Text、Image、Column、Row、Stack、FlexList、Grid、Swiper、Tab
画布组件Canvas、Polyline、Circle
媒体组件Image(有限制)Video、Web
手势onClickonSwipe、onPinch、onRotate
动画animateTo、animation
自定义组件不支持不支持
状态管理@Local@State、@Prop、@Link
网络请求不支持-

动态卡片开发

动态卡片支持更丰富的组件和交互,应用侧渲染,可实现动画效果。

// WidgetCard.ets — 动态天气中卡片(2×2)
@Entry
@Component
struct WidgetCard {
  @Local temperature: string = '--';
  @Local weather: string = '加载中';
  @Local city: string = '';
  @Local weatherIcon: string = '🌤';
  @Local humidity: string = '--%';
  @Local wind: string = '--';
  @Local forecast: Array<{ day: string; icon: string; temp: string }> = [];
  @Local updateTime: string = '--:--';
  @Local isRefreshing: boolean = false;

  build() {
    Column() {
      // 顶部:城市 + 更新时间 + 刷新按钮
      Row() {
        Text(this.city)
          .fontSize(14)
          .fontColor('#FFFFFF')
          .fontWeight(FontWeight.Bold)

        Blank()

        Text(this.updateTime)
          .fontSize(10)
          .fontColor('#FFFFFF99')

        // 刷新按钮 — 使用 message 事件
        Text('↻')
          .fontSize(16)
          .fontColor('#FFFFFF')
          .margin({ left: 6 })
          .onClick(() => {
            this.isRefreshing = true;
            postCardAction(this, {
              action: 'message',
              params: {
                msgType: 'refresh',
                formId: 'current'
              }
            });
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })

      // 中间:天气图标 + 温度 + 描述
      Row() {
        Text(this.weatherIcon)
          .fontSize(48)
          .margin({ right: 12 })

        Column() {
          Text(this.temperature)
            .fontSize(42)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')

          Text(this.weather)
            .fontSize(14)
            .fontColor('#FFFFFFCC')
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)

        Blank()

        // 湿度和风力信息
        Column() {
          Row() {
            Text('💧')
              .fontSize(12)
            Text(this.humidity)
              .fontSize(12)
              .fontColor('#FFFFFFCC')
              .margin({ left: 4 })
          }

          Row() {
            Text('🌬')
              .fontSize(12)
            Text(this.wind)
              .fontSize(12)
              .fontColor('#FFFFFFCC')
              .margin({ left: 4 })
          }
          .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 8 })

      Blank()

      // 底部:未来天气预报
      Row() {
        ForEach(this.forecast, (item: { day: string; icon: string; temp: string }) => {
          Column() {
            Text(item.day)
              .fontSize(10)
              .fontColor('#FFFFFF99')
            Text(item.icon)
              .fontSize(16)
              .margin({ top: 2 })
            Text(item.temp)
              .fontSize(10)
              .fontColor('#FFFFFFCC')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
        }, (item: { day: string }, index: number) => `${item.day}_${index}`)
      }
      .width('100%')
      .padding({ left: 16, right: 16, bottom: 12 })
    }
    .width('100%')
    .height('100%')
    .linearGradient({
      direction: GradientDirection.Bottom,
      colors: [['#667eea', 0.0], ['#764ba2', 1.0]]
    })
    .borderRadius(24)
    // 点击主体跳转到详情页
    .onClick(() => {
      postCardAction(this, {
        action: 'router',
        abilityName: 'EntryAbility',
        params: {
          targetPage: 'WeatherDetailPage',
          city: this.city
        }
      });
    })
  }
}

动态卡片 vs 静态卡片:

维度静态卡片动态卡片
渲染方式系统 FormRenderer 渲染应用侧渲染,截图推送到系统
组件支持有限(基础组件)丰富(接近完整 ArkUI)
动画不支持支持
自定义组件不支持支持
性能高(系统渲染)较低(需截图传输)
内存占用较高
刷新频率高(可达分钟级)受限(建议 ≥ 30分钟)
适用场景信息展示为主需要动画和丰富交互

卡片数据交互

卡片数据交互是元服务开发的核心,涉及 formProvider、formInfo、formBindingData 三个关键模块。

1. formBindingData — 数据绑定

import formBindingData from '@ohos.app.form.formBindingData';

// ===== 创建卡片数据 =====

// 简单数据
const simpleData = formBindingData.createFormBindingData({
  title: '今日天气',
  temperature: '26°'
});

// 嵌套数据
const complexData = formBindingData.createFormBindingData({
  title: '待办事项',
  count: 3,
  items: [
    { id: 1, content: '完成开发', done: false },
    { id: 2, content: '提交代码', done: true },
    { id: 3, content: '写周报', done: false }
  ]
});

// 包含图片数据(通过 rawDataPath 引用 rawfile 资源)
const dataWithImage = formBindingData.createFormBindingData({
  title: '每日推荐',
  imageSrc: 'images/daily_cover.png',  // rawfile 中的路径
  description: '今天适合阅读'
});

2. formProvider — 卡片数据更新

import formProvider from '@ohos.app.form.formProvider';
import formBindingData from '@ohos.app.form.formBindingData';
import formInfo from '@ohos.app.form.formInfo';

// ===== 更新单个卡片 =====
function updateSingleCard(formId: string, data: Record<string, Object>): void {
  const formData = formBindingData.createFormBindingData(data);
  formProvider.updateForm(formId, formData)
    .then(() => {
      console.info(`[formProvider] updateForm success, formId: ${formId}`);
    })
    .catch((error: Error) => {
      console.error(`[formProvider] updateForm failed: ${JSON.stringify(error)}`);
    });
}

// ===== 批量更新卡片 =====
async function updateAllCards(newData: Record<string, Object>): Promise<void> {
  try {
    // 获取当前应用所有卡片信息
    const formInfos: Array<formInfo.FormInfo> = await formProvider.getAllFormsInfo();
    console.info(`[formProvider] total forms: ${formInfos.length}`);

    const formData = formBindingData.createFormBindingData(newData);

    for (const info of formInfos) {
      try {
        await formProvider.updateForm(info.id, formData);
        console.info(`[formProvider] updated form: ${info.id}`);
      } catch (e) {
        console.error(`[formProvider] update form ${info.id} failed: ${e}`);
      }
    }
  } catch (error) {
    console.error(`[formProvider] getAllFormsInfo failed: ${error}`);
  }
}

// ===== 请求卡片发布 =====
function requestFormPublish(want: Want, formBindingData: formBindingData.FormBindingData): void {
  formProvider.requestPublishForm(want, formBindingData)
    .then((formId: string) => {
      console.info(`[formProvider] requestPublishForm success, formId: ${formId}`);
    })
    .catch((error: Error) => {
      console.error(`[formProvider] requestPublishForm failed: ${error}`);
    });
}

// ===== 通知卡片可见性变化 =====
function notifyVisibilityChange(formIds: string[], visibility: formInfo.FormVisibility): void {
  formProvider.notifyFormsVisibilityUpdate(formIds, visibility)
    .then(() => {
      console.info('[formProvider] notifyFormsVisibilityUpdate success');
    })
    .catch((error: Error) => {
      console.error(`[formProvider] notifyFormsVisibilityUpdate failed: ${error}`);
    });
}

// ===== 通知卡片是否可更新 =====
function notifyFormsUpdate(formIds: string[], enableUpdate: boolean): void {
  formProvider.notifyFormsEnableUpdate(formIds, enableUpdate)
    .then(() => {
      console.info('[formProvider] notifyFormsEnableUpdate success');
    })
    .catch((error: Error) => {
      console.error(`[formProvider] notifyFormsEnableUpdate failed: ${error}`);
    });
}

3. formInfo — 卡片信息查询

import formInfo from '@ohos.app.form.formInfo';

// ===== 获取所有卡片信息 =====
async function getAllCardInfo(): Promise<void> {
  try {
    const formInfos: Array<formInfo.FormInfo> = await formProvider.getAllFormsInfo();
    for (const info of formInfos) {
      console.info(`===== Form Info =====`);
      console.info(`  formId: ${info.id}`);
      console.info(`  formName: ${info.name}`);
      console.info(`  bundleName: ${info.bundleName}`);
      console.info(`  moduleName: ${info.moduleName}`);
      console.info(`  dimension: ${info.dimension}`);         // 卡片尺寸
      console.info(`  formVisibleState: ${info.formVisibleState}`); // 可见状态
      console.info(`  specification: ${info.specification}`); // 规格信息
      console.info(`  isDynamic: ${info.isDynamic}`);         // 是否动态卡片
    }
  } catch (error) {
    console.error(`[formInfo] getAllFormsInfo failed: ${error}`);
  }
}

数据更新机制完整流程:

┌────────────────────────────────────────────────────────────────┐
│                    卡片数据更新机制                              │
├────────────────────────────────────────────────────────────────┤
│                                                                │
│  ┌──────────────┐     ┌──────────────┐     ┌──────────────┐   │
│  │  定时更新     │     │  条件更新     │     │  主动更新     │   │
│  │ (系统调度)   │     │ (事件触发)   │     │ (应用调用)   │   │
│  └──────┬───────┘     └──────┬───────┘     └──────┬───────┘   │
│         │                    │                    │            │
│         ▼                    ▼                    ▼            │
│  ┌─────────────────────────────────────────────────────┐      │
│  │  FormExtensionAbility.onUpdateForm(formId)           │      │
│  │  或                                                  │      │
│  │  formProvider.updateForm(formId, formData)            │      │
│  └─────────────────────┬───────────────────────────────┘      │
│                        │                                      │
│                        ▼                                      │
│  ┌─────────────────────────────────────────────────────┐      │
│  │  formBindingData.createFormBindingData(newData)       │      │
│  │  创建新的数据绑定对象                                   │      │
│  └─────────────────────┬───────────────────────────────┘      │
│                        │                                      │
│                        ▼                                      │
│  ┌─────────────────────────────────────────────────────┐      │
│  │  卡片管理服务 → 推送数据到卡片渲染进程                  │      │
│  └─────────────────────┬───────────────────────────────┘      │
│                        │                                      │
│                        ▼                                      │
│  ┌─────────────────────────────────────────────────────┐      │
│  │  卡片 UI 重新渲染(@Local 变量自动更新)               │      │
│  └─────────────────────────────────────────────────────┘      │
│                                                                │
└────────────────────────────────────────────────────────────────┘

卡片事件交互

卡片支持两种事件类型:router 事件(跳转页面)和 message 事件(发送消息)。

// ===== postCardAction 工具函数 =====
// 这是卡片与宿主应用通信的核心 API
import { postCardAction } from '@ohos.app.form.formBindingData';

// ===== 方式一:router 事件 — 跳转到 Ability 页面 =====
@Entry
@Component
struct WidgetWithRouter {
  @Local title: string = '点击查看详情';

  build() {
    Column() {
      Text(this.title)
        .fontSize(16)
        .fontColor('#333333')
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .onClick(() => {
      // router 事件:拉起指定 Ability 并传递参数
      postCardAction(this, {
        action: 'router',
        abilityName: 'EntryAbility',    // 目标 Ability 名称
        params: {                        // 传递给 Ability 的参数
          targetPage: 'DetailPage',
          itemId: '12345',
          from: 'widget'
        }
      });
    })
  }
}

// ===== 方式二:message 事件 — 发送消息给 FormExtension =====
@Entry
@Component
struct WidgetWithMessage {
  @Local todoList: Array<{ content: string; done: boolean }> = [];
  @Local remaining: number = 0;

  build() {
    Column() {
      Text(`待办事项 (${this.remaining})`)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)

      // 待办列表
      ForEach(this.todoList, (item: { content: string; done: boolean }, index: number) => {
        Row() {
          Text(item.done ? '✅' : '⬜')
            .fontSize(14)
            .onClick(() => {
              // message 事件:向 FormExtensionAbility 发送消息
              postCardAction(this, {
                action: 'message',
                params: {
                  action: 'toggleTodo',
                  index: index,
                  currentDone: item.done
                }
              });
            })

          Text(item.content)
            .fontSize(12)
            .fontColor(item.done ? '#999999' : '#333333')
            .decoration({ type: item.done ? TextDecorationType.LineThrough : TextDecorationType.None })
        }
        .width('100%')
        .padding({ top: 4, bottom: 4 })
      }, (item: { content: string }, index: number) => `${item.content}_${index}`)
    }
    .width('100%')
    .height('100%')
    .padding(12)
  }
}

在宿主 Ability 中接收 router 事件:

// EntryAbility.ets — 宿主 Ability 接收卡片跳转
import UIAbility from '@ohos.app.ability.UIAbility';
import window from '@ohos.window';

export default class EntryAbility extends UIAbility {

  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    console.info('[EntryAbility] onCreate');

    // 检查是否从卡片跳转而来
    if (want.parameters) {
      const fromWidget = want.parameters['ohos.extra.param.key.form_identity'] !== undefined;
      const targetPage = want.parameters['targetPage'] as string;
      const itemId = want.parameters['itemId'] as string;

      if (fromWidget) {
        console.info(`[EntryAbility] Launched from widget, targetPage: ${targetPage}`);
        // 存储目标页面信息,在 onWindowStageCreate 中使用
        this.launchFromWidget = true;
        this.targetPage = targetPage;
        this.widgetParams = want.parameters;
      }
    }
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    console.info('[EntryAbility] onWindowStageCreate');

    // 根据来源决定加载哪个页面
    if (this.launchFromWidget && this.targetPage) {
      // 从卡片跳转,加载对应详情页
      windowStage.loadContent(`pages/${this.targetPage}`, (err, data) => {
        if (err.code) {
          console.error(`[EntryAbility] loadContent failed: ${JSON.stringify(err)}`);
          return;
        }
        console.info('[EntryAbility] loadContent success from widget');
      });
    } else {
      // 正常启动,加载首页
      windowStage.loadContent('pages/Index', (err, data) => {
        if (err.code) {
          console.error(`[EntryAbility] loadContent failed: ${JSON.stringify(err)}`);
          return;
        }
      });
    }
  }

  private launchFromWidget: boolean = false;
  private targetPage: string = '';
  private widgetParams: Record<string, Object> = {};
}

卡片定时更新与条件更新

// ===== 定时更新 =====
// 在 form_config.json 中配置:
// "updateEnabled": true,
// "scheduledUpdateTime": "10:30",  // 每日定时更新时间
// "updateDuration": 1,             // 每隔1小时检查更新

// ===== 条件更新 — 在应用中主动触发 =====
import formProvider from '@ohos.app.form.formProvider';
import formBindingData from '@ohos.app.form.formBindingData';
import backgroundTaskManager from '@ohos.resourceschedule.backgroundTaskManager';

// 数据管理类 — 统一管理卡片数据更新
class WidgetDataManager {
  private static instance: WidgetDataManager;
  private formIds: Set<string> = new Set();
  private updateInterval: number = -1;

  static getInstance(): WidgetDataManager {
    if (!WidgetDataManager.instance) {
      WidgetDataManager.instance = new WidgetDataManager();
    }
    return WidgetDataManager.instance;
  }

  // 注册卡片 formId
  registerForm(formId: string): void {
    this.formIds.add(formId);
  }

  // 注销卡片 formId
  unregisterForm(formId: string): void {
    this.formIds.delete(formId);
  }

  // ===== 1. 即时更新 — 数据变化时立即更新 =====
  async updateWidgetImmediately(formId: string, data: Record<string, Object>): Promise<void> {
    try {
      const formData = formBindingData.createFormBindingData(data);
      await formProvider.updateForm(formId, formData);
      console.info(`[WidgetDataManager] immediate update success: ${formId}`);
    } catch (error) {
      console.error(`[WidgetDataManager] immediate update failed: ${error}`);
    }
  }

  // ===== 2. 条件更新 — 满足条件时更新 =====
  async conditionalUpdate(weatherData: Record<string, Object>): Promise<void> {
    const currentTemp = weatherData.temperature as string;
    const lastTemp = this.getLastTemperature();

    // 温度变化超过 2 度时才更新卡片
    if (Math.abs(parseFloat(currentTemp) - parseFloat(lastTemp)) >= 2) {
      await this.updateAllWidgets(weatherData);
      this.saveLastTemperature(currentTemp);
    } else {
      console.info('[WidgetDataManager] temperature change < 2, skip update');
    }
  }

  // ===== 3. 批量更新所有卡片 =====
  async updateAllWidgets(data: Record<string, Object>): Promise<void> {
    const formData = formBindingData.createFormBindingData(data);
    for (const formId of this.formIds) {
      try {
        await formProvider.updateForm(formId, formData);
      } catch (error) {
        console.error(`[WidgetDataManager] update ${formId} failed: ${error}`);
        // 更新失败可能是卡片已删除,移除 formId
        this.formIds.delete(formId);
      }
    }
  }

  // ===== 4. 长时任务 + 定时轮询(后台持续更新) =====
  startPeriodicUpdate(intervalMs: number = 30 * 60 * 1000): void {
    // 申请长时任务
    try {
      backgroundTaskManager.startBackgroundRunning(
        this.context,
        'weatherUpdate',
        backgroundTaskManager.BackgroundMode.DATA_TRANSFER
      );
      console.info('[WidgetDataManager] background task started');
    } catch (error) {
      console.error(`[WidgetDataManager] startBackgroundRunning failed: ${error}`);
    }

    // 启动定时器
    this.updateInterval = setInterval(async () => {
      try {
        const latestData = await this.fetchWeatherFromServer();
        await this.updateAllWidgets(latestData);
      } catch (error) {
        console.error(`[WidgetDataManager] periodic update failed: ${error}`);
      }
    }, intervalMs);
  }

  stopPeriodicUpdate(): void {
    if (this.updateInterval !== -1) {
      clearInterval(this.updateInterval);
      this.updateInterval = -1;
    }

    // 取消长时任务
    try {
      backgroundTaskManager.stopBackgroundRunning(this.context);
      console.info('[WidgetDataManager] background task stopped');
    } catch (error) {
      console.error(`[WidgetDataManager] stopBackgroundRunning failed: ${error}`);
    }
  }

  private context: Context = undefined as unknown as Context;

  // ===== 模拟网络请求 =====
  private async fetchWeatherFromServer(): Promise<Record<string, Object>> {
    // 实际项目中使用 @ohos.net.http 请求天气 API
    return {
      temperature: `${(20 + Math.random() * 15).toFixed(0)}°`,
      weather: '多云',
      city: '北京',
      humidity: `${(40 + Math.random() * 30).toFixed(0)}%`,
      updateTime: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
    };
  }

  private getLastTemperature(): string {
    // 从偏好设置获取上次温度
    return '25°';
  }

  private saveLastTemperature(temp: string): void {
    // 保存到偏好设置
  }
}

// 导出单例
export default WidgetDataManager.getInstance();

更新策略对比:

更新方式触发时机频率限制适用场景实现方式
定时更新系统调度最快1小时数据变化缓慢form_config.json 配置
条件更新数据变化时无限制数据关键性高formProvider.updateForm
即时更新用户操作后无限制用户主动触发formProvider.updateForm
长时任务后台持续需申请权限实时性要求高BackgroundTask + Timer

卡片尺寸适配

不同尺寸的卡片需要适配不同的布局和展示内容。

// MultiSizeWidget.ets — 多尺寸适配卡片
@Entry
@Component
struct MultiSizeWidget {
  @Local temperature: string = '26°';
  @Local weather: string = '晴';
  @Local city: string = '北京';
  @Local weatherIcon: string = '☀️';
  @Local humidity: string = '45%';
  @Local wind: string = '东南风 3级';
  @Local forecast: Array<{ day: string; icon: string; high: string; low: string }> = [
    { day: '明天', icon: '⛅', high: '28°', low: '20°' },
    { day: '后天', icon: '🌧', high: '24°', low: '18°' },
    { day: '大后天', icon: '☀️', high: '30°', low: '22°' }
  ];

  build() {
    // 使用 Grid 布局适配不同尺寸
    // 1*2 = 2 列, 2*2 = 4 列, 2*4 = 4 列 2 行, 4*4 = 8 列 4 行
    Column() {
      if (this.dimension === 2) {
        // 1×2 小卡片:最精简信息
        this.buildSmallCard()
      } else if (this.dimension === 4) {
        // 2×2 中卡片:基本信息 + 额外指标
        this.buildMediumCard()
      } else if (this.dimension === 6) {
        // 2×4 大卡片:详细信息 + 预报
        this.buildLargeCard()
      } else {
        // 4×4 超大卡片:全功能展示
        this.buildExtraLargeCard()
      }
    }
    .width('100%')
    .height('100%')
  }

  // ===== 1×2 小卡片 =====
  @Builder
  buildSmallCard() {
    Column() {
      Text(this.weatherIcon)
        .fontSize(24)
      Text(this.temperature)
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
      Text(this.city)
        .fontSize(10)
        .fontColor('#FFFFFFCC')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
    .linearGradient({
      direction: GradientDirection.BottomRight,
      colors: [['#4FACFE', 0.0], ['#00F2FE', 1.0]]
    })
    .borderRadius(16)
    .onClick(() => {
      postCardAction(this, {
        action: 'router',
        abilityName: 'EntryAbility',
        params: { targetPage: 'WeatherPage' }
      });
    })
  }

  // ===== 2×2 中卡片 =====
  @Builder
  buildMediumCard() {
    Column() {
      Row() {
        Text(this.city)
          .fontSize(12)
          .fontColor('#FFFFFF')
        Blank()
        Text(this.weather)
          .fontSize(12)
          .fontColor('#FFFFFFCC')
      }
      .width('100%')
      .padding({ left: 12, right: 12 })

      Row() {
        Text(this.weatherIcon)
          .fontSize(36)
        Text(this.temperature)
          .fontSize(40)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .margin({ left: 8 })
      }
      .margin({ top: 8 })

      Row() {
        Text(`💧 ${this.humidity}`)
          .fontSize(10)
          .fontColor('#FFFFFFCC')
        Text(`🌬 ${this.wind}`)
          .fontSize(10)
          .fontColor('#FFFFFFCC')
          .margin({ left: 12 })
      }
      .width('100%')
      .padding({ left: 12 })
      .margin({ top: 8 })
    }
    .width('100%')
    .height('100%')
    .linearGradient({
      direction: GradientDirection.Bottom,
      colors: [['#667eea', 0.0], ['#764ba2', 1.0]]
    })
    .borderRadius(24)
    .padding(12)
    .onClick(() => {
      postCardAction(this, {
        action: 'router',
        abilityName: 'EntryAbility',
        params: { targetPage: 'WeatherPage' }
      });
    })
  }

  // ===== 2×4 大卡片 =====
  @Builder
  buildLargeCard() {
    Column() {
      // 上半部分:当前天气
      Row() {
        Column() {
          Text(this.city)
            .fontSize(14)
            .fontColor('#FFFFFF')
            .fontWeight(FontWeight.Bold)
          Row() {
            Text(this.weatherIcon)
              .fontSize(32)
            Text(this.temperature)
              .fontSize(36)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .margin({ left: 8 })
          }
          .margin({ top: 4 })
          Text(this.weather)
            .fontSize(12)
            .fontColor('#FFFFFFCC')
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)

        Blank()

        Column() {
          Text(`💧 湿度 ${this.humidity}`)
            .fontSize(10)
            .fontColor('#FFFFFFCC')
          Text(`🌬 ${this.wind}`)
            .fontSize(10)
            .fontColor('#FFFFFFCC')
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%')
      .padding({ left: 16, right: 16 })

      Blank()

      // 下半部分:3天预报
      Row() {
        ForEach(this.forecast, (item: { day: string; icon: string; high: string; low: string }) => {
          Column() {
            Text(item.day)
              .fontSize(10)
              .fontColor('#FFFFFF99')
            Text(item.icon)
              .fontSize(20)
              .margin({ top: 4 })
            Text(`${item.high}/${item.low}`)
              .fontSize(10)
              .fontColor('#FFFFFFCC')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
        }, (item: { day: string }, index: number) => `${item.day}_${index}`)
      }
      .width('100%')
      .padding({ left: 16, right: 16, bottom: 12 })
    }
    .width('100%')
    .height('100%')
    .linearGradient({
      direction: GradientDirection.Bottom,
      colors: [['#f093fb', 0.0], ['#f5576c', 1.0]]
    })
    .borderRadius(24)
    .padding(12)
    .onClick(() => {
      postCardAction(this, {
        action: 'router',
        abilityName: 'EntryAbility',
        params: { targetPage: 'WeatherPage' }
      });
    })
  }

  // ===== 4×4 超大卡片 =====
  @Builder
  buildExtraLargeCard() {
    Column() {
      // 标题栏
      Row() {
        Text(this.city)
          .fontSize(18)
          .fontColor('#FFFFFF')
          .fontWeight(FontWeight.Bold)
        Blank()
        Text('↻')
          .fontSize(18)
          .fontColor('#FFFFFF')
          .onClick(() => {
            postCardAction(this, {
              action: 'message',
              params: { msgType: 'refresh' }
            });
          })
      }
      .width('100%')
      .padding({ left: 20, right: 20, top: 16 })

      // 当前天气大展示
      Row() {
        Text(this.weatherIcon)
          .fontSize(64)
        Column() {
          Text(this.temperature)
            .fontSize(56)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Text(this.weather)
            .fontSize(16)
            .fontColor('#FFFFFFCC')
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 16 })
        Blank()
        // 详细指标网格
        Column() {
          Row() {
            Text('💧')
              .fontSize(14)
            Text(`湿度 ${this.humidity}`)
              .fontSize(12)
              .fontColor('#FFFFFFCC')
              .margin({ left: 4 })
          }
          Row() {
            Text('🌬')
              .fontSize(14)
            Text(this.wind)
              .fontSize(12)
              .fontColor('#FFFFFFCC')
              .margin({ left: 4 })
          }
          .margin({ top: 6 })
          Row() {
            Text('🌡')
              .fontSize(14)
            Text('体感 28°')
              .fontSize(12)
              .fontColor('#FFFFFFCC')
              .margin({ left: 4 })
          }
          .margin({ top: 6 })
          Row() {
            Text('🛡')
              .fontSize(14)
            Text('AQI 良')
              .fontSize(12)
              .fontColor('#FFFFFFCC')
              .margin({ left: 4 })
          }
          .margin({ top: 6 })
        }
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')
      .padding({ left: 20, right: 20 })
      .margin({ top: 8 })

      Blank()

      // 底部预报区域
      Row() {
        ForEach(this.forecast, (item: { day: string; icon: string; high: string; low: string }) => {
          Column() {
            Text(item.day)
              .fontSize(12)
              .fontColor('#FFFFFF99')
            Text(item.icon)
              .fontSize(28)
              .margin({ top: 6 })
            Text(item.high)
              .fontSize(14)
              .fontColor('#FFFFFF')
              .margin({ top: 4 })
            Text(item.low)
              .fontSize(12)
              .fontColor('#FFFFFF99')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
        }, (item: { day: string }, index: number) => `${item.day}_${index}`)
      }
      .width('100%')
      .padding({ left: 20, right: 20, bottom: 16 })
    }
    .width('100%')
    .height('100%')
    .linearGradient({
      direction: GradientDirection.BottomRight,
      colors: [['#0c3483', 0.0], ['#a2b6df', 0.5], ['#6b8cce', 1.0]]
    })
    .borderRadius(32)
    .onClick(() => {
      postCardAction(this, {
        action: 'router',
        abilityName: 'EntryAbility',
        params: { targetPage: 'WeatherPage' }
      });
    })
  }

  // dimension 需在 onAddForm 时从 want 中获取并存入数据
  @Local dimension: number = 4; // 默认中卡片
}

尺寸适配设计原则:

尺寸信息密度交互复杂度设计要点
1×2极低仅展示最核心单一信息
2×21-2 个操作核心信息 + 少量指标
2×42-3 个操作核心信息 + 详情 + 操作
4×43-5 个操作完整信息 + 丰富操作

实战案例一:天气卡片

完整的天气元服务,包含数据获取、卡片更新、多尺寸适配。

// ===== 1. 天气数据模型 =====
// model/WeatherModel.ets
export interface WeatherInfo {
  city: string;
  temperature: string;
  weather: string;
  weatherIcon: string;
  humidity: string;
  wind: string;
  aqi: string;
  feelsLike: string;
  forecast: Array<DailyForecast>;
  updateTime: string;
}

export interface DailyForecast {
  date: string;
  dayLabel: string;
  weatherIcon: string;
  weatherDesc: string;
  highTemp: string;
  lowTemp: string;
}

// ===== 2. 天气 API 服务 =====
// service/WeatherService.ets
import http from '@ohos.net.http';
import { WeatherInfo } from '../model/WeatherModel';

export class WeatherService {
  private baseUrl: string = 'https://api.weather.example.com/v1';

  // 根据城市获取天气数据
  async getWeatherByCity(city: string): Promise<WeatherInfo> {
    const httpRequest = http.createHttp();
    try {
      const response = await httpRequest.request(
        `${this.baseUrl}/weather?city=${encodeURIComponent(city)}`,
        {
          method: http.RequestMethod.GET,
          header: { 'Content-Type': 'application/json' },
          connectTimeout: 10000,
          readTimeout: 10000
        }
      );

      if (response.responseCode === 200) {
        const result = JSON.parse(response.result as string) as Record<string, Object>;
        return this.parseWeatherData(result);
      } else {
        throw new Error(`HTTP ${response.responseCode}`);
      }
    } finally {
      httpRequest.destroy();
    }
  }

  // 解析 API 返回数据为卡片数据格式
  private parseWeatherData(raw: Record<string, Object>): WeatherInfo {
    const data = raw.data as Record<string, Object>;
    const forecastRaw = data.forecast as Array<Record<string, Object>>;

    return {
      city: data.city as string,
      temperature: `${data.temperature}°`,
      weather: data.weather as string,
      weatherIcon: this.getWeatherIcon(data.weatherCode as number),
      humidity: `${data.humidity}%`,
      wind: `${data.windDirection} ${data.windLevel}级`,
      aqi: data.aqi as string,
      feelsLike: `${data.feelsLike}°`,
      forecast: forecastRaw.map((item, index) => ({
        date: item.date as string,
        dayLabel: this.getDayLabel(index),
        weatherIcon: this.getWeatherIcon(item.weatherCode as number),
        weatherDesc: item.weather as string,
        highTemp: `${item.high}°`,
        lowTemp: `${item.low}°`
      })),
      updateTime: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
    };
  }

  private getWeatherIcon(code: number): string {
    const iconMap: Record<number, string> = {
      100: '☀️', 101: '⛅', 102: '☁️', 103: '🌧',
      104: '⛈', 105: '🌨', 106: '🌫', 107: '🌪'
    };
    return iconMap[code] || '🌤';
  }

  private getDayLabel(offset: number): string {
    if (offset === 0) return '今天';
    if (offset === 1) return '明天';
    if (offset === 2) return '后天';
    return `${offset}天后`;
  }
}

export default new WeatherService();

// ===== 3. 天气卡片 FormExtensionAbility =====
// widget/WeatherWidgetAbility.ets
import FormExtensionAbility from '@ohos.app.form.FormExtensionAbility';
import formBindingData from '@ohos.app.form.formBindingData';
import formProvider from '@ohos.app.form.formProvider';
import formInfo from '@ohos.app.form.formInfo';
import WeatherService from '../service/WeatherService';
import preferences from '@ohos.data.preferences';

const PREF_NAME = 'weather_widget_prefs';
const KEY_CITY = 'default_city';

export default class WeatherWidgetAbility extends FormExtensionAbility {
  private weatherService = WeatherService;

  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;
    const dimension = want.parameters?.['ohos.extra.param.key.form_dimension'] as number;

    console.info(`[WeatherWidget] onAddForm: ${formId}, name: ${formName}, dim: ${dimension}`);

    // 返回初始数据(使用缓存或默认数据)
    const defaultData: Record<string, Object> = {
      city: '北京',
      temperature: '--°',
      weather: '加载中...',
      weatherIcon: '🌤',
      humidity: '--%',
      wind: '--',
      aqi: '--',
      feelsLike: '--°',
      forecast: [],
      updateTime: '--:--',
      dimension: dimension
    };

    // 异步获取真实天气数据,获取到后更新卡片
    this.fetchAndUpdateWeather(formId, '北京');

    return formBindingData.createFormBindingData(defaultData);
  }

  onUpdateForm(formId: string): void {
    console.info(`[WeatherWidget] onUpdateForm: ${formId}`);
    this.fetchAndUpdateWeather(formId, '北京');
  }

  onRemoveForm(formId: string): void {
    console.info(`[WeatherWidget] onRemoveForm: ${formId}`);
  }

  onFormEvent(formId: string, message: string): void {
    console.info(`[WeatherWidget] onFormEvent: ${formId}, msg: ${message}`);
    try {
      const msg = JSON.parse(message) as Record<string, string>;
      if (msg.msgType === 'refresh') {
        this.fetchAndUpdateWeather(formId, msg.city || '北京');
      }
    } catch (e) {
      console.error(`[WeatherWidget] parse message error: ${e}`);
    }
  }

  // 获取天气数据并更新卡片
  private async fetchAndUpdateWeather(formId: string, city: string): Promise<void> {
    try {
      const weatherInfo = await this.weatherService.getWeatherByCity(city);
      const formData = formBindingData.createFormBindingData(weatherInfo);
      await formProvider.updateForm(formId, formData);
      console.info(`[WeatherWidget] update success: ${formId}`);
    } catch (error) {
      console.error(`[WeatherWidget] fetchAndUpdateWeather failed: ${error}`);
      // 使用缓存数据兜底
      this.updateWithCachedData(formId);
    }
  }

  // 使用缓存数据兜底
  private async updateWithCachedData(formId: string): Promise<void> {
    try {
      const prefs = await preferences.getPreferences(this.context, PREF_NAME);
      const cachedData = await prefs.get('cached_weather', '{}') as string;
      if (cachedData !== '{}') {
        const data = JSON.parse(cachedData) as Record<string, Object>;
        const formData = formBindingData.createFormBindingData(data);
        await formProvider.updateForm(formId, formData);
      }
    } catch (e) {
      console.error(`[WeatherWidget] cache fallback failed: ${e}`);
    }
  }
}

实战案例二:待办卡片

// ===== 待办数据模型 =====
// model/TodoModel.ets
export interface TodoItem {
  id: string;
  content: string;
  done: boolean;
  priority: 'high' | 'medium' | 'low';
  dueDate?: string;
}

export interface TodoCardData {
  title: string;
  todoList: Array<TodoItem>;
  total: number;
  completed: number;
  remaining: number;
  progress: number;
  updateTime: string;
}

// ===== 待办卡片 UI =====
// widget/pages/TodoWidget.ets
@Entry
@Component
struct TodoWidget {
  @Local title: string = '今日待办';
  @Local todoList: Array<{ id: string; content: string; done: boolean; priority: string }> = [];
  @Local total: number = 0;
  @Local completed: number = 0;
  @Local remaining: number = 0;
  @Local progress: number = 0;
  @Local updateTime: string = '--:--';

  build() {
    Column() {
      // 顶部标题 + 进度
      Row() {
        Text(this.title)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1a1a2e')

        Blank()

        // 进度指示器
        Stack() {
          // 背景圆环(用 Row 模拟进度条)
          Row()
            .width(32)
            .height(32)
            .borderRadius(16)
            .border({ width: 3, color: '#E0E0E0' })

          Text(`${this.progress}%`)
            .fontSize(8)
            .fontColor('#666666')
            .fontWeight(FontWeight.Bold)
        }
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })

      // 进度条
      Row() {
        Row()
          .width(`${this.progress}%`)
          .height('100%')
          .borderRadius(2)
          .linearGradient({
            direction: GradientDirection.Right,
            colors: [['#4CAF50', 0.0], ['#8BC34A', 1.0]]
          })
      }
      .width('100%')
      .height(4)
      .borderRadius(2)
      .backgroundColor('#E8E8E8')
      .margin({ left: 16, right: 16, top: 8 })

      // 待办列表
      Column() {
        ForEach(
          this.todoList.slice(0, 4),  // 最多展示4条
          (item: { id: string; content: string; done: boolean; priority: string }, index: number) => {
            Row() {
              // 勾选框
              Text(item.done ? '✅' : '⬜')
                .fontSize(14)
                .onClick(() => {
                  // 发送切换待办状态的消息
                  postCardAction(this, {
                    action: 'message',
                    params: {
                      action: 'toggleTodo',
                      todoId: item.id,
                      currentDone: item.done
                    }
                  });
                })

              // 优先级标记
              Text(item.priority === 'high' ? '🔴' : item.priority === 'medium' ? '🟡' : '🟢')
                .fontSize(8)
                .margin({ left: 4 })

              // 待办内容
              Text(item.content)
                .fontSize(12)
                .fontColor(item.done ? '#999999' : '#333333')
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .decoration({
                  type: item.done ? TextDecorationType.LineThrough : TextDecorationType.None
                })
                .margin({ left: 4 })
                .layoutWeight(1)
            }
            .width('100%')
            .padding({ top: 6, bottom: 6 })
          },
          (item: { id: string }, index: number) => item.id
        )
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 8 })

      Blank()

      // 底部操作栏
      Row() {
        Text(`${this.remaining} 项待完成`)
          .fontSize(10)
          .fontColor('#999999')

        Blank()

        // 快速添加按钮
        Row() {
          Text('+ 添加')
            .fontSize(12)
            .fontColor('#4CAF50')
            .fontWeight(FontWeight.Medium)
        }
        .padding({ left: 8, right: 8, top: 4, bottom: 4 })
        .borderRadius(12)
        .backgroundColor('#E8F5E9')
        .onClick(() => {
          postCardAction(this, {
            action: 'router',
            abilityName: 'EntryAbility',
            params: {
              targetPage: 'TodoAddPage',
              action: 'add'
            }
          });
        })
      }
      .width('100%')
      .padding({ left: 16, right: 16, bottom: 12 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(24)
    .shadow({ radius: 8, color: '#1a000000', offsetY: 2 })
  }
}

// ===== 待办卡片 FormExtensionAbility =====
// widget/TodoWidgetAbility.ets
import FormExtensionAbility from '@ohos.app.form.FormExtensionAbility';
import formBindingData from '@ohos.app.form.formBindingData';
import formProvider from '@ohos.app.form.formProvider';
import relationalStore from '@ohos.data.relationalStore';

const STORE_CONFIG: relationalStore.StoreConfig = {
  name: 'TodoDatabase.db',
  securityLevel: relationalStore.SecurityLevel.S1
};

export default class TodoWidgetAbility extends FormExtensionAbility {
  private rdbStore: relationalStore.RdbStore | null = null;

  async onAddForm(want: Want): Promise<formBindingData.FormBindingData> {
    const formId = want.parameters?.['ohos.extra.param.key.form_identity'] as string;
    const dimension = want.parameters?.['ohos.extra.param.key.form_dimension'] as number;

    // 初始化数据库
    await this.initDatabase();

    // 查询待办数据
    const todoData = await this.queryTodoData();

    return formBindingData.createFormBindingData({
      ...todoData,
      dimension: dimension
    });
  }

  onUpdateForm(formId: string): void {
    this.queryTodoData().then(data => {
      const formData = formBindingData.createFormBindingData(data);
      formProvider.updateForm(formId, formData);
    }).catch(error => {
      console.error(`[TodoWidget] onUpdateForm failed: ${error}`);
    });
  }

  onRemoveForm(formId: string): void {
    console.info(`[TodoWidget] onRemoveForm: ${formId}`);
  }

  async onFormEvent(formId: string, message: string): Promise<void> {
    try {
      const msg = JSON.parse(message) as Record<string, Object>;
      const action = msg.action as string;

      if (action === 'toggleTodo') {
        const todoId = msg.todoId as string;
        const currentDone = msg.currentDone as boolean;

        // 切换待办完成状态
        await this.toggleTodoStatus(todoId, !currentDone);

        // 更新卡片数据
        this.onUpdateForm(formId);
      }
    } catch (e) {
      console.error(`[TodoWidget] onFormEvent error: ${e}`);
    }
  }

  // 初始化数据库
  private async initDatabase(): Promise<void> {
    if (this.rdbStore) return;

    this.rdbStore = await relationalStore.getRdbStore(this.context, STORE_CONFIG);

    // 创建待办表
    const createSql = `
      CREATE TABLE IF NOT EXISTS todo (
        id TEXT PRIMARY KEY,
        content TEXT NOT NULL,
        done INTEGER DEFAULT 0,
        priority TEXT DEFAULT 'medium',
        due_date TEXT,
        created_at INTEGER
      )
    `;
    await this.rdbStore.executeSql(createSql);
  }

  // 查询待办数据
  private async queryTodoData(): Promise<Record<string, Object>> {
    if (!this.rdbStore) {
      return this.getDefaultTodoData();
    }

    try {
      const predicates = new relationalStore.RdbPredicates('todo');
      predicates.orderByDesc('priority')
        .orderByAsc('done')
        .limitAs(10);

      const resultSet = await this.rdbStore.query(predicates, ['id', 'content', 'done', 'priority']);
      const todoList: Array<Record<string, Object>> = [];

      while (resultSet.goToNextRow()) {
        todoList.push({
          id: resultSet.getString(0),
          content: resultSet.getString(1),
          done: resultSet.getLong(2) === 1,
          priority: resultSet.getString(3)
        });
      }
      resultSet.close();

      const total = todoList.length;
      const completed = todoList.filter(item => item.done as boolean).length;
      const remaining = total - completed;
      const progress = total > 0 ? Math.round((completed / total) * 100) : 0;

      return {
        title: '今日待办',
        todoList,
        total,
        completed,
        remaining,
        progress,
        updateTime: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
      };
    } catch (error) {
      console.error(`[TodoWidget] queryTodoData error: ${error}`);
      return this.getDefaultTodoData();
    }
  }

  // 切换待办状态
  private async toggleTodoStatus(todoId: string, done: boolean): Promise<void> {
    if (!this.rdbStore) return;

    const valueBucket: relationalStore.ValuesBucket = {
      done: done ? 1 : 0
    };

    const predicates = new relationalStore.RdbPredicates('todo');
    predicates.equalTo('id', todoId);

    await this.rdbStore.update(valueBucket, predicates);
  }

  // 默认数据
  private getDefaultTodoData(): Record<string, Object> {
    return {
      title: '今日待办',
      todoList: [
        { id: '1', content: '完成项目文档', done: false, priority: 'high' },
        { id: '2', content: '代码评审', done: true, priority: 'medium' },
        { id: '3', content: '周报提交', done: false, priority: 'low' }
      ],
      total: 3,
      completed: 1,
      remaining: 2,
      progress: 33,
      updateTime: '--:--'
    };
  }
}

实战案例三:快捷操作卡片

// QuickActionWidget.ets — 快捷操作面板卡片(2×4)
@Entry
@Component
struct QuickActionWidget {
  @Local userName: string = '用户';
  @Local currentStep: number = 3862;
  @Local stepGoal: number = 8000;
  @Local heartRate: number = 72;
  @Local nextMeeting: string = '14:00 产品评审';

  build() {
    Column() {
      // 顶部:用户问候
      Row() {
        Text('👋')
          .fontSize(20)
        Text(`${this.userName},下午好`)
          .fontSize(14)
          .fontColor('#1a1a2e')
          .fontWeight(FontWeight.Medium)
          .margin({ left: 6 })
        Blank()
        Text(this.getCurrentDate())
          .fontSize(10)
          .fontColor('#999999')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })

      // 中间:健康数据
      Row() {
        // 步数
        Column() {
          Text('🏃')
            .fontSize(20)
          Text(`${this.currentStep}`)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FF6B6B')
          Text(`/ ${this.stepGoal} 步`)
            .fontSize(8)
            .fontColor('#999999')
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .onClick(() => {
          postCardAction(this, {
            action: 'router',
            abilityName: 'EntryAbility',
            params: { targetPage: 'HealthPage' }
          });
        })

        // 心率
        Column() {
          Text('❤️')
            .fontSize(20)
          Text(`${this.heartRate}`)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4ECDC4')
          Text('bpm')
            .fontSize(8)
            .fontColor('#999999')
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .onClick(() => {
          postCardAction(this, {
            action: 'router',
            abilityName: 'EntryAbility',
            params: { targetPage: 'HeartRatePage' }
          });
        })

        // 日程
        Column() {
          Text('📅')
            .fontSize(20)
          Text(this.nextMeeting.split(' ')[0])
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#45B7D1')
          Text(this.nextMeeting.split(' ')[1])
            .fontSize(8)
            .fontColor('#999999')
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .onClick(() => {
          postCardAction(this, {
            action: 'router',
            abilityName: 'EntryAbility',
            params: { targetPage: 'SchedulePage' }
          });
        })
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 12 })

      Blank()

      // 底部:快捷操作按钮
      Row() {
        this.actionButton('📱', '扫码', 'scanAction')
        this.actionButton('🎵', '音乐', 'musicAction')
        this.actionButton('🏠', '家居', 'iotAction')
        this.actionButton('💳', '支付', 'payAction')
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceAround)
      .padding({ left: 16, right: 16, bottom: 12 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(24)
    .shadow({ radius: 8, color: '#1a000000', offsetY: 2 })
  }

  @Builder
  actionButton(icon: string, label: string, actionName: string) {
    Column() {
      Text(icon)
        .fontSize(22)
      Text(label)
        .fontSize(10)
        .fontColor('#666666')
        .margin({ top: 2 })
    }
    .padding(8)
    .borderRadius(12)
    .backgroundColor('#F5F5F5')
    .onClick(() => {
      postCardAction(this, {
        action: 'router',
        abilityName: 'EntryAbility',
        params: {
          targetPage: 'QuickActionPage',
          action: actionName
        }
      });
    })
  }

  private getCurrentDate(): string {
    const now = new Date();
    const month = now.getMonth() + 1;
    const day = now.getDate();
    const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
    return `${month}月${day}日 周${weekdays[now.getDay()]}`;
  }
}

免安装元服务分发

┌──────────────────────────────────────────────────────────────┐
│                 元服务分发与发现机制                            │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌────────────────────────────────────────────────────┐      │
│  │               应用市场(AppGallery)                 │      │
│  │  ┌──────────┐ ┌──────────┐ ┌──────────┐          │      │
│  │  │ 服务推荐  │ │ 精品服务  │ │ 分类浏览  │          │      │
│  │  └────┬─────┘ └────┬─────┘ └────┬─────┘          │      │
│  └───────┼─────────────┼────────────┼────────────────┘      │
│          │             │            │                        │
│          ▼             ▼            ▼                        │
│  ┌────────────────────────────────────────────────────┐      │
│  │              服务发现引擎                            │      │
│  │  ┌──────────┐ ┌──────────┐ ┌──────────┐          │      │
│  │  │ 场景推荐  │ │ 位置推荐  │ │ 习惯推荐  │          │      │
│  │  │(时间/场景)│ │(LBS/ Beacon)│ │(用户画像) │          │      │
│  │  └────┬─────┘ └────┬─────┘ └────┬─────┘          │      │
│  └───────┼─────────────┼────────────┼────────────────┘      │
│          │             │            │                        │
│          ▼             ▼            ▼                        │
│  ┌────────────────────────────────────────────────────┐      │
│  │              免安装运行时                             │      │
│  │  ┌──────────────────────────────────────────────┐  │      │
│  │  │  按需加载 → 沙箱运行 → 资源回收                  │  │      │
│  │  └──────────────────────────────────────────────┘  │      │
│  └────────────────────────────────────────────────────┘      │
│                                                              │
│  触达入口:                                                   │
│  ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐    │
│  │ 桌面  │ │负一屏 │ │ 服务  │ │ 语音  │ │ 扫码  │ │ 分享  │    │
│  │ 卡片  │ │ 推荐  │ │ 中心  │ │ 助手  │ │ 入口  │ │ 链接  │    │
│  └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘    │
│                                                              │
└──────────────────────────────────────────────────────────────┘

分发配置:

// app.json5 — 应用级配置
{
  "app": {
    "bundleName": "com.example.weatherservice",
    "vendor": "example",
    "versionCode": 1000000,
    "versionName": "1.0.0",
    "icon": "$media:app_icon",
    "label": "$string:app_name",
    // 元服务关键配置
    "minAPIVersion": 10,
    "targetAPIVersion": 12,
    "apiReleaseType": "Release",
    "debug": false,
    "installationFree": true  // 标记为免安装元服务
  }
}

服务发现配置:

// module.json5 中的 skills 配置,用于服务发现
{
  "extensionAbilities": [
    {
      "name": "WidgetAbility",
      "type": "form",
      "skills": [
        {
          "actions": ["action.weather.view"],        // 服务动作
          "entities": ["entity.weather.current"]      // 服务实体
        }
      ],
      "uri": "weather://current"  // 服务 URI,用于深度链接
    }
  ]
}

元服务测试与调试

// ===== 1. 卡片单元测试 =====
// test/WidgetAbility.test.ets
import { describe, it, expect } from '@ohos/hypium';
import formBindingData from '@ohos.app.form.formBindingData';

export default function widgetAbilityTest() {
  describe('WidgetAbility', () => {
    it('should_create_form_binding_data', 0, () => {
      const testData = {
        temperature: '26°',
        weather: '晴',
        city: '北京'
      };

      const formData = formBindingData.createFormBindingData(testData);
      expect(formData).not.toBeNull();
    });

    it('should_handle_different_dimensions', 0, () => {
      const dimensions = [2, 4, 6, 16]; // 1×2, 2×2, 2×4, 4×4

      dimensions.forEach(dim => {
        const data = {
          dimension: dim,
          temperature: '26°',
          weather: '晴'
        };

        const formData = formBindingData.createFormBindingData(data);
        expect(formData).not.toBeNull();
      });
    });
  });
}

// ===== 2. 卡片 UI 测试(模拟不同尺寸渲染) =====
// test/WidgetUITest.ets
import { describe, it, expect } from '@ohos/hypium';

export default function widgetUITest() {
  describe('WidgetUI', () => {
    it('small_card_should_show_minimal_info', 0, () => {
      // 1×2 卡片应只展示温度和图标
      const smallCardData = {
        dimension: 2,
        temperature: '26°',
        weatherIcon: '☀️'
      };
      expect(smallCardData.temperature).toContain('°');
      expect(smallCardData.weatherIcon).not.toBe('');
    });

    it('large_card_should_show_forecast', 0, () => {
      // 2×4 卡片应包含预报数据
      const largeCardData = {
        dimension: 6,
        temperature: '26°',
        forecast: [
          { day: '明天', icon: '⛅', temp: '22°/28°' },
          { day: '后天', icon: '🌧', temp: '18°/24°' }
        ]
      };
      expect(largeCardData.forecast.length).toBeGreaterThan(0);
    });
  });
}

// ===== 3. 卡片数据交互测试 =====
// test/WidgetDataTest.ets
import { describe, it, expect, beforeEach } from '@ohos/hypium';
import formProvider from '@ohos.app.form.formProvider';
import formBindingData from '@ohos.app.form.formBindingData';

export default function widgetDataTest() {
  describe('WidgetDataInteraction', () => {
    const testFormId = 'test_form_001';

    it('should_update_form_data', 0, async () => {
      const newData = {
        temperature: '28°',
        weather: '多云',
        updateTime: '10:30'
      };

      const formData = formBindingData.createFormBindingData(newData);

      // 注意:测试环境中 formProvider.updateForm 可能需要 mock
      try {
        await formProvider.updateForm(testFormId, formData);
      } catch (e) {
        // 测试环境可能无法真正更新卡片,验证数据格式即可
        expect(formData).not.toBeNull();
      }
    });

    it('should_handle_empty_data_gracefully', 0, () => {
      const emptyData = {};
      const formData = formBindingData.createFormBindingData(emptyData);
      expect(formData).not.toBeNull();
    });

    it('should_handle_large_data', 0, () => {
      // 测试大数据量场景
      const largeTodoList = Array.from({ length: 50 }, (_, i) => ({
        id: `todo_${i}`,
        content: `待办事项 ${i + 1}`,
        done: i % 2 === 0,
        priority: i < 10 ? 'high' : i < 30 ? 'medium' : 'low'
      }));

      const data = {
        title: '大待办列表',
        todoList: largeTodoList,
        total: 50,
        completed: 25,
        remaining: 25
      };

      const formData = formBindingData.createFormBindingData(data);
      expect(formData).not.toBeNull();
    });
  });
}

调试技巧:

# 1. 使用 hdc 命令管理卡片
hdc shell aa dump -l  # 查看正在运行的 Ability 列表

# 2. 查看卡片信息
hdc shell bm dump -n com.example.weatherservice  # 查看应用/元服务信息

# 3. 强制更新卡片(调试用)
hdc shell aa force-update-form <formId>

# 4. 查看日志
hdc hilog | grep "WidgetAbility"  # 过滤卡片相关日志

# 5. 安装元服务(免安装模式下测试)
hdc install --no-stream <hap-path>  # 安装但不安装到应用列表

# 6. 卸载元服务
hdc shell bm uninstall -n com.example.weatherservice

卡片设计规范与最佳实践

设计规范:

规范项要求说明
圆角16vp(小卡片)/ 24vp(中卡片)/ 32vp(大卡片)与系统卡片风格统一
内边距最少 12vp,推荐 16vp防止内容贴边
字号标题 14-16fp / 正文 12fp / 辅助 10fp保证可读性
颜色主色+辅色+背景色 3色体系视觉层次清晰
深浅色必须同时支持 Light/Dark 模式colorMode: “auto”
图片使用矢量图或 3x 位图适配不同分辨率
交互最小点击区域 44×44vp防止误触
动画动态卡片中适度使用不影响性能

最佳实践:

// ===== 1. 数据缓存策略 =====
// 避免卡片加载时显示空白,始终提供兜底数据
class CardDataCache {
  private cache: Map<string, Record<string, Object>> = new Map();

  // 获取数据:先返回缓存,异步更新后刷新
  getDataWithCache(formId: string, fetchFn: () => Promise<Record<string, Object>>): Record<string, Object> {
    // 同步返回缓存数据
    if (this.cache.has(formId)) {
      const cached = this.cache.get(formId)!;
      // 异步获取最新数据并更新
      fetchFn().then(newData => {
        this.cache.set(formId, newData);
        const formData = formBindingData.createFormBindingData(newData);
        formProvider.updateForm(formId, formData);
      }).catch(err => {
        console.error(`[Cache] fetch failed, using cache: ${err}`);
      });
      return cached;
    }

    // 无缓存时返回默认数据
    const defaultData = this.getDefaultData();
    this.cache.set(formId, defaultData);

    // 异步获取真实数据
    fetchFn().then(newData => {
      this.cache.set(formId, newData);
      const formData = formBindingData.createFormBindingData(newData);
      formProvider.updateForm(formId, formData);
    }).catch(err => {
      console.error(`[Cache] initial fetch failed: ${err}`);
    });

    return defaultData;
  }

  private getDefaultData(): Record<string, Object> {
    return {
      temperature: '--°',
      weather: '加载中...',
      weatherIcon: '🌤',
      city: '--',
      updateTime: '--:--'
    };
  }
}

// ===== 2. 更新节流策略 =====
// 避免频繁更新卡片,节省系统资源
class UpdateThrottler {
  private lastUpdateTime: Map<string, number> = new Map();
  private minInterval: number = 5 * 60 * 1000; // 最小5分钟间隔

  async throttledUpdate(formId: string, updateFn: () => Promise<void>): Promise<void> {
    const now = Date.now();
    const lastUpdate = this.lastUpdateTime.get(formId) || 0;

    if (now - lastUpdate < this.minInterval) {
      console.info(`[Throttler] skip update, too frequent: ${formId}`);
      return;
    }

    try {
      await updateFn();
      this.lastUpdateTime.set(formId, now);
    } catch (error) {
      console.error(`[Throttler] update failed: ${error}`);
    }
  }
}

// ===== 3. 错误处理策略 =====
// 卡片数据获取失败时的优雅降级
async function safeUpdateCard(formId: string, dataFn: () => Promise<Record<string, Object>>): Promise<void> {
  try {
    const data = await dataFn();
    const formData = formBindingData.createFormBindingData(data);
    await formProvider.updateForm(formId, formData);
  } catch (error) {
    console.error(`[SafeUpdate] primary update failed: ${error}`);

    // 降级:使用缓存数据
    try {
      const cachedData = await getFromCache(formId);
      if (cachedData) {
        // 在数据中标记为离线状态
        cachedData.isOffline = true;
        const formData = formBindingData.createFormBindingData(cachedData);
        await formProvider.updateForm(formId, formData);
      }
    } catch (cacheError) {
      console.error(`[SafeUpdate] cache fallback also failed: ${cacheError}`);
    }
  }
}

// ===== 4. 深浅色适配 =====
// 卡片页面中根据系统深浅色切换样式
@Entry
@Component
struct AdaptiveWidget {
  @Local isDarkMode: boolean = false;
  @Local temperature: string = '26°';

  // 主题颜色配置
  private get themeColors(): Record<string, string> {
    return this.isDarkMode ? {
      background: '#1a1a2e',
      textPrimary: '#FFFFFF',
      textSecondary: '#FFFFFF99',
      cardBg: '#2d2d44',
      accent: '#4FACFE'
    } : {
      background: '#FFFFFF',
      textPrimary: '#1a1a2e',
      textSecondary: '#666666',
      cardBg: '#F5F5F5',
      accent: '#4FACFE'
    };
  }

  build() {
    Column() {
      Text(this.temperature)
        .fontSize(32)
        .fontColor(this.themeColors.textPrimary)
    }
    .width('100%')
    .height('100%')
    .backgroundColor(this.themeColors.background)
    .borderRadius(24)
  }
}

// ===== 5. 卡片性能优化 =====
// 减少卡片内存占用,提升渲染性能
// - 静态卡片优先:信息展示类场景首选静态卡片
// - 控制图片大小:卡片图片压缩到最小必要尺寸
// - 减少数据量:卡片数据只传必要字段,避免大对象
// - 懒加载:卡片初始化时使用默认数据,异步加载真实数据
// - 避免频繁更新:使用节流策略,最小更新间隔 5 分钟

性能优化对照表:

优化项优化前优化后收益
卡片类型选择全部用动态卡片展示类用静态卡片内存降低 50%+
图片资源原始尺寸 PNG压缩 WebP/矢量图体积降低 70%+
数据传输完整数据对象仅必要字段传输量降低 60%+
更新频率每分钟更新5分钟节流+条件更新CPU 占用降低 80%+
初始化等待网络数据默认数据+异步更新首屏时间降低 90%+

元服务发布流程

┌──────────────────────────────────────────────────────────────┐
│                   元服务发布流程                               │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  1. 开发调试                                                  │
│     DevEco Studio → 编译 → 模拟器/真机调试                     │
│         │                                                    │
│         ▼                                                    │
│  2. 签名配置                                                  │
│     生成签名证书 → 配置签名信息 → 生成 HAP/APP 包               │
│         │                                                    │
│         ▼                                                    │
│  3. 发布准备                                                  │
│     ┌─────────────────────────────────────┐                  │
│     │  检查清单:                           │                  │
│     │  □ installationFree = true           │                  │
│     │  □ 包体积 ≤ 10MB                     │                  │
│     │  □ 卡片配置正确(form_config.json)   │                  │
│     │  □ 深浅色适配完成                     │                  │
│     │  □ 多尺寸适配完成                     │                  │
│     │  □ 隐私声明配置                       │                  │
│     │  □ 无障碍适配                         │                  │
│     └─────────────────────────────────────┘                  │
│         │                                                    │
│         ▼                                                    │
│  4. 上架审核                                                  │
│     AGC 控制台 → 创建元服务 → 上传包 → 填写信息 → 提交审核      │
│         │                                                    │
│         ▼                                                    │
│  5. 审核通过 → 发布上架 → 用户触达即用                          │
│                                                              │
└──────────────────────────────────────────────────────────────┘
# 发布构建命令
# 构建 HAP 包(调试)
hvigorw assembleHap --mode module -p product=default

# 构建 APP 包(发布)
hvigorw assembleApp --mode release -p product=default

# 检查包体积
# 元服务包体积限制 ≤ 10MB
ls -lh entry/build/default/outputs/default/entry-default-signed.hap

常见问题与解决方案

问题原因解决方案
卡片添加后空白数据未正确绑定检查 onAddForm 返回的 formBindingData 是否包含 UI 引用的字段
卡片不更新formId 无效或更新间隔太短检查 formId,确认 updateDuration 配置
卡片点击无反应router/message 事件配置错误检查 postCardAction 的 abilityName 和 params
动态卡片渲染异常组件超出卡片支持范围检查是否使用了卡片不支持的组件或 API
免安装失败包体积超限压缩资源,确保 HAP 包 ≤ 10MB
深浅色不切换colorMode 配置错误form_config.json 中 colorMode 设为 “auto”
卡片内存过高动态卡片过多或图片过大改用静态卡片,压缩图片资源
卡片数据不同步formId 未持久化在 onAddForm 时持久化 formId,启动时恢复

相关链接

面试题

1. 元服务与普通应用的核心区别是什么?什么场景下应选择元服务?

答案:

元服务与普通应用的核心区别体现在五个维度:

  1. 安装方式:元服务免安装即用即走,普通应用需下载安装。元服务的 installationFree 配置为 true,用户通过卡片、搜索、扫码等入口直接触达,无需等待下载。

  2. 入口形态:元服务无桌面图标,以服务卡片为主要入口,嵌入桌面、负一屏、服务中心等位置;普通应用以桌面图标为入口。

  3. 包体积:元服务限制 ≤ 10MB(推荐 ≤ 2MB),只能承载轻量功能;普通应用无严格限制。

  4. 分发逻辑:元服务走”服务分发”,系统基于场景、位置、习惯主动推荐;普通应用走”应用分发”,用户主动搜索下载。

  5. 后台运行:元服务无长驻后台能力,受系统严格管控;普通应用可申请后台运行。

适用元服务的场景

  • 信息速览类(天气、股价、快递)
  • 快捷操作类(扫码支付、打卡、计时器)
  • 服务直达类(点餐、打车、查路线)
  • IoT 控制类(智能家居快捷操作)
  • 不适合:需要深度交互、大量数据展示、长驻后台的功能(如即时通讯、视频播放、大型游戏)

2. 静态卡片和动态卡片有什么区别?如何选择?

答案:

维度静态卡片动态卡片
渲染方式系统 FormRenderer 渲染应用侧渲染,截图推送到系统
组件支持仅基础组件(Text、Image、Column、Row、Stack、Flex)接近完整 ArkUI 组件集
动画不支持支持
自定义组件不支持支持
性能高(系统原生渲染)较低(需截图传输)
内存占用较高
刷新频率高(可达分钟级)受限(建议 ≥ 30分钟)
状态管理仅 @Local@Local + 其他

选择建议

  • 优先选静态卡片:信息展示为主、不需要动画、更新频率高、需要多卡片并存
  • 选动态卡片:需要动画效果、需要复杂交互、需要自定义组件、展示内容丰富且更新频率低

实际项目中,大多数天气、待办、快捷操作卡片应使用静态卡片,仅当需要动画或复杂布局时才用动态卡片。

3. 卡片数据更新的方式有哪些?各自的触发机制和限制是什么?

答案:

卡片数据更新有四种方式:

  1. 定时更新:在 form_config.json 中配置 updateEnabled: truescheduledUpdateTimeupdateDuration。系统按配置的时间间隔调用 FormExtensionAbility.onUpdateForm()。限制:最快间隔 1 小时,不适合实时性高的场景。

  2. 条件更新:应用侧在数据变化时主动调用 formProvider.updateForm(formId, formData) 推送新数据。无频率限制,但需应用进程存活。适合数据关键性高的场景(如股价变动、新消息)。

  3. message 事件更新:用户在卡片上操作触发 postCardActionmessage 事件,FormExtensionAbility.onFormEvent() 接收消息后调用 formProvider.updateForm() 更新。适合用户主动触发的刷新。

  4. 长时任务更新:申请 backgroundTaskManager.startBackgroundRunning() 长时任务,在后台持续轮询数据并更新卡片。需要申请权限,系统可能拒绝,适合实时性要求极高的场景。

实际开发中推荐组合使用:定时更新兜底 + 条件更新保实时 + message 事件响应用户操作。

4. 解释 FormExtensionAbility 的完整生命周期,每个回调的触发时机和作用。

答案:

FormExtensionAbility 的生命周期回调:

  1. onAddForm(want):用户添加卡片时触发。从 want.parameters 中获取 formIdformNamedimension 等信息,必须返回 formBindingData.FormBindingData 对象作为卡片初始数据。这是卡片首次数据的唯一来源。

  2. onUpdateForm(formId):定时更新触发时调用。系统根据 form_config.jsonupdateDurationscheduledUpdateTime 调度。开发者在此获取最新数据并调用 formProvider.updateForm() 推送更新。

  3. onRemoveForm(formId):用户删除卡片时触发。用于清理与该卡片关联的资源(如 formId 映射、缓存数据、定时器等)。

  4. onFormEvent(formId, message):卡片 UI 中通过 postCardAction 发送 message 事件时触发。用于处理卡片上的交互操作(如刷新、切换状态等),在回调中解析 message 内容执行相应逻辑。

  5. onVisibilityChange(formIds, visibility):卡片在桌面上的可见性发生变化时触发(如用户切换桌面页)。可用于优化:卡片不可见时暂停更新节省资源。

  6. onConnect(want) / onDisconnect(want):其他组件连接/断开与 FormExtensionAbility 的连接时触发,返回 RemoteObject 用于跨进程通信。

5. 卡片中如何实现用户交互?router 事件和 message 事件的区别和适用场景?

答案:

卡片交互通过 postCardAction API 实现两种事件:

router 事件

  • 作用:拉起指定的 UIAbility,并传递参数
  • 使用方式:postCardAction(this, { action: 'router', abilityName: 'EntryAbility', params: {...} })
  • 在 UIAbility 的 onCreate(want) 中通过 want.parameters 接收参数
  • 适用场景:点击卡片跳转到详情页、打开特定功能页面

message 事件

  • 作用:向 FormExtensionAbility 发送消息,不拉起 UI
  • 使用方式:postCardAction(this, { action: 'message', params: {...} })
  • FormExtensionAbility.onFormEvent(formId, message) 中接收处理
  • 适用场景:卡片内操作(切换待办状态、刷新数据、点赞收藏等)不需要跳转页面的轻量交互

关键区别

  • router 会启动一个新的 Ability 实例(或恢复已有实例),有 UI 展示;message 仅在后台处理,无 UI 变化
  • router 适合”查看更多”,message 适合”快捷操作”
  • 一个卡片中可以同时使用两种事件:主体用 router 跳转,按钮用 message 操作

6. 如何实现卡片的多尺寸适配?设计时需要注意什么?

答案:

多尺寸适配需要从配置和 UI 两个层面处理:

配置层面: 在 form_config.json 中为同一个卡片名称配置 supportDimensions 数组,支持多种尺寸:

"supportDimensions": ["1*2", "2*2", "2*4", "4*4"]

UI 层面: 在卡片页面中根据 dimension 值(2/4/6/16 对应四种尺寸)条件渲染不同布局:

  • 1×2(dimension=2):仅展示最核心的单一信息
  • 2×2(dimension=4):核心信息 + 少量辅助指标
  • 2×4(dimension=6):详细信息 + 操作按钮 + 预报/列表
  • 4×4(dimension=16):全功能展示 + 多操作区

设计要点

  1. 信息优先级递进:小卡片展示最关键信息,大卡片逐步增加详情
  2. 交互复杂度递增:小卡片仅 router 跳转,大卡片可嵌入 message 操作
  3. 字号适配:小卡片用小字号(10-14fp),大卡片可用大字号(16-56fp)
  4. 圆角适配:小卡片 16vp,中卡片 24vp,大卡片 32vp
  5. 深浅色必须同时适配所有尺寸
  6. 避免在大卡片中简单放大小卡片布局,应重新设计信息密度

7. 免安装元服务的分发机制是怎样的?如何让用户发现和使用元服务?

答案:

免安装元服务的分发机制核心是”服务分发”而非”应用分发”:

分发渠道

  1. 应用市场服务推荐:AppGallery 中的服务专区,用户浏览即可使用
  2. 负一屏推荐:基于时间、位置、场景在负一屏推荐相关服务
  3. 服务中心:系统级服务中心聚合各类元服务
  4. 桌面卡片:用户长按桌面添加卡片,即自动获取元服务
  5. 语音助手:通过小艺语音指令触发元服务
  6. 扫码入口:扫描二维码直接打开元服务
  7. 分享链接:通过分享链接传播元服务

发现机制

  • 场景推荐:系统根据当前时间、地点、活动等场景主动推荐(如到达机场推荐值机服务)
  • 位置推荐:基于 LBS 和 Beacon 推荐附近服务(如商场内推荐店铺服务)
  • 习惯推荐:基于用户使用习惯和画像推荐(如每天早上推荐天气卡片)
  • 社交推荐:好友分享、热门排行等社交维度

技术实现

  • module.json5 中配置 installationFree: true 标记免安装
  • 配置 skills 定义服务的 action 和 entity,用于服务匹配
  • 配置 uri 支持深度链接,扫码/分享链接直接打开
  • 免安装运行时负责按需加载、沙箱运行、资源回收

8. 开发元服务卡片时有哪些常见坑?如何避免?

答案:

常见坑及解决方案

  1. 卡片添加后空白

    • 原因:onAddForm 返回的 formBindingData 中字段名与 UI 中 @Local 变量名不一致
    • 解决:确保数据 key 与 @Local 变量名完全匹配,包括大小写
  2. 卡片数据不更新

    • 原因:formId 未持久化,应用重启后丢失,无法调用 updateForm
    • 解决:在 onAddForm 时将 formId 持久化到 preferences,启动时恢复并重新绑定更新
  3. 静态卡片使用了不支持组件导致崩溃

    • 原因:静态卡片组件白名单严格,List/Grid/Swiper 等均不支持
    • 解决:对照卡片组件支持列表逐一检查,展示列表改用 Column + ForEach
  4. 深浅色模式不生效

    • 原因:form_config.jsoncolorMode 配置为 "light""dark" 硬编码
    • 解决:设为 "auto" 跟随系统,UI 中使用条件判断切换颜色
  5. 卡片更新过于频繁被系统限流

    • 原因:短时间内多次调用 formProvider.updateForm
    • 解决:实现更新节流,最小间隔 5 分钟;条件更新时判断数据是否真的变化
  6. 包体积超限导致免安装失败

    • 原因:资源文件过大,HAP 包超过 10MB
    • 解决:压缩图片为 WebP 格式,使用矢量图,移除不必要资源,启用代码混淆和压缩
  7. 卡片点击事件无法传递参数

    • 原因:postCardActionparams 中包含不支持序列化的类型(如函数、Symbol)
    • 解决:params 中只传基本类型(string、number、boolean),复杂对象转为 JSON 字符串
  8. 动态卡片截图模糊

    • 原因:卡片 window.designWidth 设置过小,渲染分辨率不足
    • 解决:designWidth 设为 720,autoDesignWidth 设为 true,确保图片资源为 3x