鸿蒙元服务与卡片开发
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 + ArkUI | ArkTS + ArkUI / Java |
| 使用场景 | 快捷服务、信息展示、轻交互 | 复杂功能、深度使用 |
服务卡片类型:
| 类型 | 说明 | 渲染方式 | 数据更新 | 交互能力 |
|---|---|---|---|---|
| 静态卡片(Static Widget) | 固定布局,数据驱动更新 | 系统渲染 | 定时/条件触发 | router/message 事件 |
| 动态卡片(Dynamic Widget) | 支持动态 UI 和动画 | 应用渲染 | 实时更新 | 丰富交互 |
卡片尺寸规格:
| 尺寸 | 网格 | 宽高(px) | 典型用途 |
|---|---|---|---|
| 小卡片 | 1×2 | 2×4 网格 | 单一信息展示(天气图标+温度) |
| 中卡片 | 2×2 | 4×4 网格 | 信息+操作(天气概要) |
| 大卡片 | 2×4 | 4×8 网格 | 丰富信息+多操作(天气详情+预报) |
| 超大卡片 | 4×4 | 8×8 网格 | 复合信息面板(天气+日历+待办) |
Why — 为什么
适用场景:
- 信息速览:天气、日程、股价、快递状态等无需打开 App 即可查看
- 快捷操作:扫码支付、运动打卡、计时器等一键触达
- 服务直达:点餐、打车、查路线等核心功能免安装使用
- 智能推荐:基于场景、位置、习惯主动推荐服务
- IoT 控制:智能家居设备状态展示与快捷控制
- 企业办公:待办审批、考勤打卡、会议提醒等轻量办公
与 iOS Widget / Android App Widget 的对比:
| 维度 | 鸿蒙服务卡片 | iOS Widget | Android App Widget |
|---|---|---|---|
| 免安装 | 支持(元服务形态) | 不支持 | 不支持 |
| 卡片类型 | 静态+动态 | 静态为主 | 静态 |
| 渲染方式 | 系统/应用双渲染 | 系统 WidgetKit 渲染 | 系统 RemoteViews 渲染 |
| 动画支持 | 动态卡片支持 | 有限(SwiftUI 动画) | 不支持 |
| 交互方式 | router + message | Deep Link | PendingIntent |
| 数据更新 | 定时 + 条件 + 实时 | Timeline 预加载 | 定时 + 通知触发 |
| 尺寸规格 | 4 种(1×2/2×2/2×4/4×4) | 3 种(小/中/大) | 自由(minResizeWidth/Height) |
| 开发语言 | ArkTS | SwiftUI | Kotlin/Java + XML |
| 桌面位置 | 桌面 + 负一屏 + 服务中心 | 桌面 + 今日视图 | 桌面 |
| 分发方式 | 服务分发(免安装) | App Store | Google 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、Flex | List、Grid、Swiper、Tab |
| 画布组件 | 无 | Canvas、Polyline、Circle |
| 媒体组件 | Image(有限制) | Video、Web |
| 手势 | onClick | onSwipe、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×2 | 低 | 1-2 个操作 | 核心信息 + 少量指标 |
| 2×4 | 中 | 2-3 个操作 | 核心信息 + 详情 + 操作 |
| 4×4 | 高 | 3-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,启动时恢复 |
相关链接
- 鸿蒙系统架构与开发入门 — 鸿蒙整体架构与开发环境搭建
- ArkUI声明式开发 — ArkUI 声明式语法与组件体系
- 鸿蒙状态管理 — 状态管理机制与卡片数据绑定
- 鸿蒙数据持久化与网络 — 偏好设置、关系型数据库、网络请求
面试题
1. 元服务与普通应用的核心区别是什么?什么场景下应选择元服务?
答案:
元服务与普通应用的核心区别体现在五个维度:
-
安装方式:元服务免安装即用即走,普通应用需下载安装。元服务的
installationFree配置为true,用户通过卡片、搜索、扫码等入口直接触达,无需等待下载。 -
入口形态:元服务无桌面图标,以服务卡片为主要入口,嵌入桌面、负一屏、服务中心等位置;普通应用以桌面图标为入口。
-
包体积:元服务限制 ≤ 10MB(推荐 ≤ 2MB),只能承载轻量功能;普通应用无严格限制。
-
分发逻辑:元服务走”服务分发”,系统基于场景、位置、习惯主动推荐;普通应用走”应用分发”,用户主动搜索下载。
-
后台运行:元服务无长驻后台能力,受系统严格管控;普通应用可申请后台运行。
适用元服务的场景:
- 信息速览类(天气、股价、快递)
- 快捷操作类(扫码支付、打卡、计时器)
- 服务直达类(点餐、打车、查路线)
- IoT 控制类(智能家居快捷操作)
- 不适合:需要深度交互、大量数据展示、长驻后台的功能(如即时通讯、视频播放、大型游戏)
2. 静态卡片和动态卡片有什么区别?如何选择?
答案:
| 维度 | 静态卡片 | 动态卡片 |
|---|---|---|
| 渲染方式 | 系统 FormRenderer 渲染 | 应用侧渲染,截图推送到系统 |
| 组件支持 | 仅基础组件(Text、Image、Column、Row、Stack、Flex) | 接近完整 ArkUI 组件集 |
| 动画 | 不支持 | 支持 |
| 自定义组件 | 不支持 | 支持 |
| 性能 | 高(系统原生渲染) | 较低(需截图传输) |
| 内存占用 | 低 | 较高 |
| 刷新频率 | 高(可达分钟级) | 受限(建议 ≥ 30分钟) |
| 状态管理 | 仅 @Local | @Local + 其他 |
选择建议:
- 优先选静态卡片:信息展示为主、不需要动画、更新频率高、需要多卡片并存
- 选动态卡片:需要动画效果、需要复杂交互、需要自定义组件、展示内容丰富且更新频率低
实际项目中,大多数天气、待办、快捷操作卡片应使用静态卡片,仅当需要动画或复杂布局时才用动态卡片。
3. 卡片数据更新的方式有哪些?各自的触发机制和限制是什么?
答案:
卡片数据更新有四种方式:
-
定时更新:在
form_config.json中配置updateEnabled: true、scheduledUpdateTime、updateDuration。系统按配置的时间间隔调用FormExtensionAbility.onUpdateForm()。限制:最快间隔 1 小时,不适合实时性高的场景。 -
条件更新:应用侧在数据变化时主动调用
formProvider.updateForm(formId, formData)推送新数据。无频率限制,但需应用进程存活。适合数据关键性高的场景(如股价变动、新消息)。 -
message 事件更新:用户在卡片上操作触发
postCardAction的message事件,FormExtensionAbility.onFormEvent()接收消息后调用formProvider.updateForm()更新。适合用户主动触发的刷新。 -
长时任务更新:申请
backgroundTaskManager.startBackgroundRunning()长时任务,在后台持续轮询数据并更新卡片。需要申请权限,系统可能拒绝,适合实时性要求极高的场景。
实际开发中推荐组合使用:定时更新兜底 + 条件更新保实时 + message 事件响应用户操作。
4. 解释 FormExtensionAbility 的完整生命周期,每个回调的触发时机和作用。
答案:
FormExtensionAbility 的生命周期回调:
-
onAddForm(want):用户添加卡片时触发。从
want.parameters中获取formId、formName、dimension等信息,必须返回formBindingData.FormBindingData对象作为卡片初始数据。这是卡片首次数据的唯一来源。 -
onUpdateForm(formId):定时更新触发时调用。系统根据
form_config.json的updateDuration和scheduledUpdateTime调度。开发者在此获取最新数据并调用formProvider.updateForm()推送更新。 -
onRemoveForm(formId):用户删除卡片时触发。用于清理与该卡片关联的资源(如 formId 映射、缓存数据、定时器等)。
-
onFormEvent(formId, message):卡片 UI 中通过
postCardAction发送message事件时触发。用于处理卡片上的交互操作(如刷新、切换状态等),在回调中解析 message 内容执行相应逻辑。 -
onVisibilityChange(formIds, visibility):卡片在桌面上的可见性发生变化时触发(如用户切换桌面页)。可用于优化:卡片不可见时暂停更新节省资源。
-
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):全功能展示 + 多操作区
设计要点:
- 信息优先级递进:小卡片展示最关键信息,大卡片逐步增加详情
- 交互复杂度递增:小卡片仅 router 跳转,大卡片可嵌入 message 操作
- 字号适配:小卡片用小字号(10-14fp),大卡片可用大字号(16-56fp)
- 圆角适配:小卡片 16vp,中卡片 24vp,大卡片 32vp
- 深浅色必须同时适配所有尺寸
- 避免在大卡片中简单放大小卡片布局,应重新设计信息密度
7. 免安装元服务的分发机制是怎样的?如何让用户发现和使用元服务?
答案:
免安装元服务的分发机制核心是”服务分发”而非”应用分发”:
分发渠道:
- 应用市场服务推荐:AppGallery 中的服务专区,用户浏览即可使用
- 负一屏推荐:基于时间、位置、场景在负一屏推荐相关服务
- 服务中心:系统级服务中心聚合各类元服务
- 桌面卡片:用户长按桌面添加卡片,即自动获取元服务
- 语音助手:通过小艺语音指令触发元服务
- 扫码入口:扫描二维码直接打开元服务
- 分享链接:通过分享链接传播元服务
发现机制:
- 场景推荐:系统根据当前时间、地点、活动等场景主动推荐(如到达机场推荐值机服务)
- 位置推荐:基于 LBS 和 Beacon 推荐附近服务(如商场内推荐店铺服务)
- 习惯推荐:基于用户使用习惯和画像推荐(如每天早上推荐天气卡片)
- 社交推荐:好友分享、热门排行等社交维度
技术实现:
module.json5中配置installationFree: true标记免安装- 配置
skills定义服务的 action 和 entity,用于服务匹配 - 配置
uri支持深度链接,扫码/分享链接直接打开 - 免安装运行时负责按需加载、沙箱运行、资源回收
8. 开发元服务卡片时有哪些常见坑?如何避免?
答案:
常见坑及解决方案:
-
卡片添加后空白
- 原因:
onAddForm返回的formBindingData中字段名与 UI 中@Local变量名不一致 - 解决:确保数据 key 与
@Local变量名完全匹配,包括大小写
- 原因:
-
卡片数据不更新
- 原因:
formId未持久化,应用重启后丢失,无法调用updateForm - 解决:在
onAddForm时将 formId 持久化到 preferences,启动时恢复并重新绑定更新
- 原因:
-
静态卡片使用了不支持组件导致崩溃
- 原因:静态卡片组件白名单严格,List/Grid/Swiper 等均不支持
- 解决:对照卡片组件支持列表逐一检查,展示列表改用 Column + ForEach
-
深浅色模式不生效
- 原因:
form_config.json中colorMode配置为"light"或"dark"硬编码 - 解决:设为
"auto"跟随系统,UI 中使用条件判断切换颜色
- 原因:
-
卡片更新过于频繁被系统限流
- 原因:短时间内多次调用
formProvider.updateForm - 解决:实现更新节流,最小间隔 5 分钟;条件更新时判断数据是否真的变化
- 原因:短时间内多次调用
-
包体积超限导致免安装失败
- 原因:资源文件过大,HAP 包超过 10MB
- 解决:压缩图片为 WebP 格式,使用矢量图,移除不必要资源,启用代码混淆和压缩
-
卡片点击事件无法传递参数
- 原因:
postCardAction的params中包含不支持序列化的类型(如函数、Symbol) - 解决:params 中只传基本类型(string、number、boolean),复杂对象转为 JSON 字符串
- 原因:
-
动态卡片截图模糊
- 原因:卡片
window.designWidth设置过小,渲染分辨率不足 - 解决:
designWidth设为 720,autoDesignWidth设为 true,确保图片资源为 3x
- 原因:卡片