鸿蒙元服务与卡片开发

本文讲解 HarmonyOS NEXT 的元服务与服务卡片开发:元服务的免安装特性与包体积约束、FormExtensionAbility 的完整生命周期回调、form_config.json 配置项含义、卡片 ArkTS UI 的组件子集与数据绑定、定时与定点两类刷新机制,以及 postCardAction 的三种交互方式。文中给出可运行代码与两张对照表,并汇总卡片刷新配额、组件受限等常见坑。

开篇:元服务是什么

元服务(Atomic Service)是 HarmonyOS 特有的一种应用形态,它的核心卖点是"免安装、服务直达":用户不需要在应用市场里完整下载安装,而是通过负一屏、桌面卡片、小艺建议、扫码等入口直接触达服务能力。

对开发者来说,元服务不是一个全新的技术栈,而是一套约束更严的打包与分发形态。它仍然使用 ArkTS 与 ArkUI,仍然跑在 Stage 模型上,但对包体积、启动速度、权限使用都提出了更高要求。而服务卡片(Form)则是元服务最常被用户看到的载体。

本文覆盖元服务的定位、服务卡片的完整开发链路与刷新机制。如果你还不熟悉 Stage 模型,建议先看 鸿蒙应用与页面生命周期 。

一、元服务与传统应用的区别

1.1 免安装与服务直达

传统应用的分发单位是完整安装包,用户先安装再打开。元服务的分发单位是服务能力本身,系统在需要时按需拉起,用完即走。这意味着元服务必须做到秒开,任何在主线程上的重量级初始化都会直接毁掉体验。

1.2 包体积与能力限制

元服务对包体积有硬性限制,这直接决定了技术选型:不能把大模型、大资源、大量第三方库塞进去。

维度传统应用元服务
安装方式应用市场完整安装免安装,按需拉起
包体积相对宽松严格受限,需精简资源
入口桌面图标卡片、负一屏、扫码、小艺
后台能力支持长时任务受限,优先短时任务
签名要求普通发布签名元服务专用签名与上架流程
典型场景综合型应用单点服务,如查快递、点餐

选型建议很直接:如果你的功能需要长时间后台运行、需要大量本地资源、或者是一个综合型平台,就不要硬做成元服务,老老实实做传统应用。

二、服务卡片形态

2.1 静态卡片与动态卡片

静态卡片的内容在创建时确定,之后不再变化,适合展示"今日汇率"“待办数量"这类快照信息。动态卡片支持通过 formBindingData 更新内容,也支持响应交互事件,适合展示实时数据。

2.2 卡片尺寸规格

卡片尺寸用 supportDimensions 声明,系统会按用户选择的规格渲染。

规格典型比例建议展示内容
1x2小单个数字或状态
2x2中两到三行摘要
2x4中长列表前三条
4x4大图文混合面板

一张卡片应当同时声明多种规格,并针对每种规格给出不同的布局,否则在小尺寸下会出现内容被截断。

2.3 卡片与页面的关系

卡片本身不是页面,它不能承载导航栈,也做不了页面转场动画。卡片承担的是"信息露出加一键触达”,真正的交互仍然发生在元服务的页面里。因此设计卡片时要克制:一屏之内只讲一件事,把操作收敛到一个主按钮,多出来的信息留给点击后的页面去展开。

还有一个容易被忽略的区别:卡片存在"临时卡片"与"常驻卡片"两种状态。用户从服务入口临时拉起的是临时卡片,被固化到桌面后才是常驻卡片,两者的切换会触发 onCastToNormalForm。如果业务需要在卡片被固化时做持久化,就必须在这个回调里处理,而不是在 onAddForm 里。

三、FormExtensionAbility 生命周期

卡片的业务逻辑写在 FormExtensionAbility 中,它是一个独立于 UIAbility 的扩展能力,运行在卡片自己的进程里。

3.1 onAddForm

用户把卡片添加到桌面时触发,返回值必须是通过 formBindingData.createFormBindingData 构造的数据对象。

import { formBindingData, FormExtensionAbility, formInfo } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const TAG: string = 'EntryFormAbility';

export default class EntryFormAbility extends FormExtensionAbility {
  onAddForm(want: Want): formBindingData.FormBindingData {
    hilog.info(0x0000, TAG, 'onAddForm');
    const data: Record<string, Object> = {
      'title': '今日待办',
      'count': 3
    };
    return formBindingData.createFormBindingData(data);
  }

  onCastToNormalForm(formId: string): void {
    hilog.info(0x0000, TAG, `onCastToNormalForm: ${formId}`);
  }

  onUpdateForm(formId: string): void {
    hilog.info(0x0000, TAG, `onUpdateForm: ${formId}`);
    const data: Record<string, Object> = {
      'title': '今日待办',
      'count': 5
    };
    const binding = formBindingData.createFormBindingData(data);
    formProvider.updateForm(formId, binding).catch((err: Error) => {
      hilog.error(0x0000, TAG, `updateForm failed: ${err.message}`);
    });
  }

  onFormEvent(formId: string, message: string): void {
    hilog.info(0x0000, TAG, `onFormEvent: ${message}`);
  }

  onRemoveForm(formId: string): void {
    hilog.info(0x0000, TAG, `onRemoveForm: ${formId}`);
  }
}

3.2 各回调的触发时机

回调触发时机典型用途
onAddForm卡片被添加到桌面返回初始数据
onCastToNormalForm临时卡片转为常驻卡片持久化卡片状态
onUpdateForm到达刷新时间拉取最新数据并更新
onFormEvent卡片发起 message 交互处理卡片内事件
onRemoveForm卡片被移除清理关联资源

3.3 关于进程隔离

FormExtensionAbility 与主应用的 UIAbility 运行在不同进程,两者不能直接共享内存对象。所有跨进程的数据交换都必须经过 formBindingData 序列化,这一点在调试时最容易踩坑:在卡片里打印的主应用全局变量永远是初始值。

四、form_config.json 配置

卡片的元信息在 resources/base/profile/form_config.json 中声明,字段含义如下。

{
  "forms": [
    {
      "name": "todoCard",
      "displayName": "$string:card_name",
      "description": "$string:card_desc",
      "src": "./ets/widget/pages/TodoCard.ets",
      "uiSyntax": "arkts",
      "isDefault": true,
      "colorMode": "auto",
      "supportDimensions": ["2x2", "2x4"],
      "defaultDimension": "2x2",
      "updateEnabled": true,
      "updateDuration": 1,
      "scheduledUpdateTime": "10:30"
    }
  ]
}

几个容易搞错的点:src 是相对模块根目录的路径,写错会在添加卡片时直接报错;updateDuration 的单位是 30 分钟,值为 1 表示每 30 分钟刷新一次;scheduledUpdateTime 是定点刷新时间,格式为 HH:mm;uiSyntax 在 API 12 之后固定为 arkts。

4.1 module.json5 中的注册

卡片能力还必须在模块的 module.json5 里注册,否则 FormExtensionAbility 不会被系统识别,添加卡片时会提示找不到能力。

{
  "module": {
    "name": "entry",
    "extensionAbilities": [
      {
        "name": "EntryFormAbility",
        "srcEntry": "./ets/entryformability/EntryFormAbility.ets",
        "label": "$string:EntryFormAbility_label",
        "description": "$string:EntryFormAbility_desc",
        "type": "form",
        "metadata": [
          {
            "name": "ohos.extension.form",
            "resource": "$profile:form_config"
          }
        ]
      }
    ]
  }
}

type 必须写成 form,metadata 里的 resource 指向 resources/base/profile/form_config.json,写成 $profile:form_config 时省略扩展名。这是卡片开发中最容易被漏掉的一步,而且漏掉后 IDE 不会给任何提示。

五、卡片 UI 编写

5.1 支持的组件子集

卡片 UI 虽然也是 ArkTS 文件,但它不是完整页面,只能用有限的组件与属性。可用的基础组件包括 Text、Image、Divider、Column、Row、Stack、List、Grid、Progress、Button 等,容器与布局能力基本齐全,但自定义绘制、动画、Web、Video 等重量级能力不可用。

@Entry
@Component
struct TodoCard {
  @State title: string = '今日待办';
  @State count: number = 0;

  build() {
    Column({ space: 8 }) {
      Text(this.title)
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
      Text(`${this.count} 项`)
        .fontSize(24)
        .fontColor('#FF6B00')
      Divider()
      Button('查看全部')
        .fontSize(14)
        .onClick(() => {
          postCardAction(this, {
            action: 'router',
            abilityName: 'EntryAbility',
            params: { page: 'pages/TodoList' }
          });
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .padding(12)
  }
}

5.2 formBindingData 数据绑定

卡片里的 @State 初值会被 onAddForm 与 onUpdateForm 返回的绑定数据覆盖,键名必须与状态变量名一致。注意绑定数据只支持 string、number、boolean 等基础类型,复杂对象要先序列化成字符串再在卡片侧解析。

5.3 卡片的图片与资源限制

卡片对资源的处理比普通页面严格。Image 组件在卡片里只能加载应用内的媒体资源(通过 $r('app.media.xxx'))或网络图片,不能像页面那样做复杂的解码与裁剪链式调用。像素级处理、动态生成图形、SVG 动画这些能力都不在卡片的能力范围内。

能力页面卡片
本地图片资源支持支持
网络图片支持支持,受内存限制
自定义绘制支持不支持
组件动画支持受限
视频与 Web支持不支持

实践建议是把卡片用到的图片单独压缩一份小尺寸版本放进 resources/base/media,不要把页面的高清图直接复用给卡片,否则卡片内存占用会迅速逼近上限。

六、卡片刷新机制

刷新是卡片开发中最容易出问题的地方,因为系统对刷新频率有配额限制,超限会被静默丢弃。

刷新方式触发来源频率限制适用场景
定时刷新updateDuration最小 30 分钟周期性数据,如日程
定点刷新scheduledUpdateTime每天固定时刻早报、打卡提醒
下次刷新setFormNextRefreshTime最短 5 分钟单次延后刷新
主动刷新requestForm受配额约束主应用数据变更后同步
卡片内触发postCardAction message受配额约束用户点击卡片刷新

调用 setFormNextRefreshTime 需要先通过 formProvider.setFormNextRefreshTime(formId, minutes) 设置,注意参数单位是分钟而不是毫秒,这是最常见的参数误用。

6.1 刷新调用的写法

主应用侧刷新卡片只有两个入口,一个是"立刻刷",一个是"稍后刷"。

import { formProvider } from '@kit.FormKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

// 让指定卡片在 10 分钟后刷新一次
export function scheduleNextRefresh(formId: string): void {
  formProvider.setFormNextRefreshTime(formId, 10).then(() => {
    hilog.info(0x0000, 'Form', 'next refresh scheduled');
  }).catch((err: Error) => {
    hilog.error(0x0000, 'Form', `schedule failed: ${err.message}`);
  });
}

// 立即请求刷新
export function refreshNow(formId: string): void {
  formProvider.requestForm(formId).catch((err: Error) => {
    hilog.error(0x0000, 'Form', `requestForm failed: ${err.message}`);
  });
}

requestForm 是异步的,它只是"请求"刷新,实际刷新时机由系统决定,因此不要假设调用之后卡片内容立刻变了。如果需要严格保证数据一致,正确做法是先把数据写进持久化存储,再发起刷新请求。

七、卡片交互 postCardAction

postCardAction 是卡片唯一的交互出口,三种 action 对应三种能力。

action目标关键参数用途
router拉起应用页面abilityName、params点击卡片跳转详情
call拉起后台method、params触发后台任务
message通知卡片自身params卡片内局部刷新

router 会直接打开元服务的页面,call 会拉起后台的 UIAbility 但不进入前台,message 会把事件回传给 FormExtensionAbility.onFormEvent。

// call:拉起后台任务,不进入前台
postCardAction(this, {
  action: 'call',
  abilityName: 'EntryAbility',
  params: { method: 'syncData' }
});

// message:回传给 FormExtensionAbility.onFormEvent
postCardAction(this, {
  action: 'message',
  params: { event: 'refresh' }
});

选择 action 的原则是看用户意图:想看到新页面就用 router,只想让后台干活就用 call,只想让卡片自己变一下就用 message。混用会导致点击行为与预期不符,尤其是 call 很容易被误当成 router 使用。

八、卡片与主应用通信

由于进程隔离,主应用想更新卡片只能通过 formProvider 系列接口:主应用拿到 formId 后调用 formProvider.updateForm 或 formProvider.requestForm。获取 formId 的常用途径有两个,一是通过 formProvider.getFormsInfo 查询已添加的卡片,二是把 formId 持久化到 Preferences 中。

一个稳妥的模式是:主应用在数据变更后不直接刷新卡片,而是写入 Preferences 并调用 requestForm,由卡片进程在 onUpdateForm 中重新读取数据。这样即便主应用进程被回收,卡片也能拿到最新数据。

8.1 主应用侧更新卡片

import { formBindingData, formProvider } from '@kit.FormKit';

export async function pushToCard(formId: string, count: number): Promise<void> {
  const data: Record<string, Object> = {
    'title': '今日待办',
    'count': count
  };
  const binding = formBindingData.createFormBindingData(data);
  await formProvider.updateForm(formId, binding);
}

这段代码看起来简单,但要注意 formId 的来源:它由系统在 onAddForm 时生成并传入,主应用并不能凭空构造。因此需要在 onAddForm 里把 formId 写入 Preferences 或数据库,主应用再读出来使用。多个卡片实例会对应多个 formId,只更新其中一个是不够的,通常需要遍历全部已添加的卡片逐个刷新。

多设备场景下卡片的布局需要针对不同尺寸做适配,这部分策略与 一次开发多端部署与响应式适配 里讲的断点思路一致。卡片点击后拉起页面的跳转方式,可以参考 鸿蒙页面路由与 Navigation 组件 。卡片作为"局部视图加数据绑定"的组合方式,与小程序自定义组件的组织思路有相通之处。

九、常见坑清单

  • 卡片 UI 里使用了不支持的组件或属性,编译期报错或运行时白屏。
  • form_config.json 的 src 路径写错,添加卡片时直接失败。
  • updateDuration 填了分钟数,实际单位是 30 分钟。
  • 把复杂对象直接放进绑定数据,序列化失败。
  • 在卡片里访问主应用的全局单例,拿到的是过期值。
  • 刷新频率超出系统配额,更新被静默丢弃且无报错。
  • 卡片进程里做耗时操作,导致卡片加载超时被系统回收。
  • postCardAction 的 params 里传了不可序列化的值。
  • 元服务包体积超限,上架时被驳回。
  • 忘记在 module.json5 中注册 FormExtensionAbility。
  • 把 formId 当成可跨进程长期持有的稳定标识,实际它随卡片实例变化。

9.1 调试卡片的实用技巧

调试卡片比调试普通页面麻烦,因为卡片进程独立且生命周期由系统控制。几个经验:其一,日志统一用 hilog 并带上固定 TAG,console.log 在卡片进程里有时抓不到;其二,修改 form_config.json 后必须重新安装应用,热重载不会更新卡片元信息;其三,卡片刷新不生效时先用 formProvider.getFormsInfo 确认卡片是否已被系统正确识别,再排查刷新配额。

小结

元服务与卡片的开发难点不在 API,而在约束:包体积要小、启动要快、刷新有配额、进程之间不能共享内存。把这四条约束记牢,剩下的就是按生命周期回调把数据流串起来。落地时优先采用"主应用写数据、卡片进程读数据"的解耦模式,避免跨进程共享状态的诱惑;刷新策略上能用定点刷新就不用高频定时刷新,既省电也不会撞配额。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「鸿蒙开发」更多文章

  1. 鸿蒙 ohpm 包管理与 Hypium 测试框架
  2. ArkUI 动画体系与手势交互
  3. 鸿蒙应用安全:权限模型与 HUKS 密钥管理