开篇:元服务是什么
元服务(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,而在约束:包体积要小、启动要快、刷新有配额、进程之间不能共享内存。把这四条约束记牢,剩下的就是按生命周期回调把数据流串起来。落地时优先采用"主应用写数据、卡片进程读数据"的解耦模式,避免跨进程共享状态的诱惑;刷新策略上能用定点刷新就不用高频定时刷新,既省电也不会撞配额。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。