App小组件
App 小组件是一种用户界面组件,可以在用户的主屏幕(对于移动设备)或通知中心(在某些操作系统中)上显示应用的关键信息和提供快速访问功能的途径,而无需打开应用本身。小组件的主要作用和优势可以概括如下:
- 即时信息访问:不打开应用即可获取应用提供的相关信息,例如展示天气信息
- 便捷的交互:不打开应用即可快速使用应用提供的能力,例如控制播放/暂停
- 个性化:用户通常可以根据个人喜好调整小组件的大小和内容,实现更个性化的屏幕布局。
- 提升应用的可见性:小组件能够增加应用的主屏幕存在感,间接提升app的DAU等指标。
Android 从 1.5 版本(Cupcake,2009年发布)开始支持小组件。开发者可以为应用创建自定义小组件,用户可以将这些小组件添加到他们的主屏幕上。iOS 也在 2020 年发布的 iOS 14 版本中跟进,引入了新的小组件功能,允许用户将小组件添加到主屏幕上。至此,世界上最大的两个移动端操作系统都完成了对小组件的支持。
作为后起之秀的鸿蒙系统,自然也没落下对小组件的支持,不过鸿蒙中的小组件改名换姓成为了服务卡片。本文就从开发鸿蒙服务卡片开始,尝试窥探鸿蒙宇宙的「冰山一角」。
鸿蒙服务卡片简介
鸿蒙的服务卡片架构包含以下4部分:
- 卡片使用方:显示卡片内容的宿主应用,控制卡片在宿主中展示的位置,当前鸿蒙系统仅支持系统应用作为卡片使用方(举个🌰:桌面)。
- 卡片提供方:提供卡片显示内容的应用,控制卡片的显示内容、控件布局以及控件点击事件。
- 卡片管理服务:用于管理系统中所添加卡片的常驻代理服务,提供formProvider接口能力,同时提供卡片对象的管理与使用以及卡片周期性刷新等能力。
- 卡片渲染服务:用于管理卡片渲染实例,渲染实例与卡片使用方上的卡片组件一一绑定。
服务卡片的ui页面开发支持两种开发形式:
- 使用声明式范式的ArkTS UI开发的卡片,简称
ArkTS卡片 - 使用类Web范式JS UI开发的卡片,简称
JS卡片
两种卡片形式实现原理和场景支持上均有所差异,相比于JS卡片,
ArkTS 卡片额外支持了自定义动效、自定义绘制、逻辑代码执行。很明显ArkTS卡片的功能更加丰富,官方也更推荐我们使用ArkTS卡片。所以下文的服务卡片特指使用ArkTS卡片。
接下来,我们就来开发一个服务卡片,体验一下鸿蒙服务卡片的开发流程。
创建一个服务卡片
按照上图步骤创建完服务卡片后,项目工程文件中会新增以下三个文件:
EntryFromAbility.ts、WidgetCard.ets、以及form_config.json。
- EntryFromAbility.ts:卡片扩展模块,提供卡片创建、销毁、刷新等生命周期回调。
- WidgetCard.ets:卡片UI页面文件
- form_config.json:卡片配置文件,配置卡片(WidgetCard.ets)相关信息。
不急,这三个文件我们一个一个来看,先来看下配置文件form_config.json
配置服务卡片
{
"forms": [
{
// 卡片名称,最大长度为127字节
"name": "widget",
// 卡片的展示名称,最小1字节,最大30字节
"displayName": "2*2 Widget",
// 卡片描述,用户创建服务卡片时可见,最大255字节
"description": "This is a service widget.",
// 卡片UI代码文件路径
"src": "./ets/widget/pages/WidgetCard.ets",
// 卡片类型,当前仅两种值:arkts卡片为arkts,js卡片为hml
"uiSyntax": "arkts",
// 显示窗口的配置
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
// 卡片的主题样式,三个值:
// auto - 跟随系统日夜间
// dark - 深色主题
// light - 浅色主题
"colorMode": "auto",
// 是否为默认卡片,一个UIAbility只能有一个默认卡片
"isDefault": true,
// 是否支持周期性刷新(周期性刷新包含定时刷新和定点刷新)
"updateEnabled": true,
// 卡片的定点刷新的时刻,采用24小时制,精确到分钟
"scheduledUpdateTime": "10:30",
// 卡片定时刷新的更新周期,单位为30分钟,取值为自然数
"updateDuration": 1,
// 卡片的默认外观规格
"defaultDimension": "2*2",
// 卡片支持的外观规格
"supportDimensions": [
"2*2"
],
// 卡片的配置跳转链接,URI格式
"formConfigAbility": "ability://EntryAbility",
// 卡片是否支持卡片代理刷新
"dataProxyEnabled": false,
// 是否为动态卡片
"isDynamic": true,
// 是否支持卡片使用方设置此卡片的背景透明度
"transparencyEnabled": false,
// 卡片的自定义信息
"metadata": []
}
]
}
完整的卡片配置见官网开发文档: 服务卡片-配置卡片的配置文件。
动态卡片?静态卡片?都是啥,我该如何选择?
创建服务卡片和配置服务卡片时,都有一个选项让我们选择所创建的卡片是否为动态卡片,与之对应的则是静态卡片。与动态卡片相比,静态卡片整体的运行框架和渲染流程是一致的,主要区别在于,卡片渲染服务将卡片内容渲染完毕后,卡片使用方会使用最后一帧渲染的数据作为静态图片显示。
因此静态卡片也不支持使用动效能力,其次卡片渲染服务中的卡片实例会释放该卡片的所有运行资源以节省内存,频繁的刷新会导致静态卡片运行时资源不断的创建和销毁,增加卡片功耗。
所以在使用时,根据业务需求灵活选择即可:需要频繁刷新卡片UI的,就选择动态卡片,反之无脑选择静态卡片。
管理卡片生命周期
FormExtensionAbility是鸿蒙系统中的卡片拓展模块,提供了卡片的创建、销毁、刷新等生命周期回调。创建服务卡片时生成的EntryFromAbility类就实现了这一接口:
const TAG: string = 'EntryFormAbility';
const DOMAIN_NUMBER: number = 0xFF00;
export default class EntryFormAbility extends FormExtensionAbility {
onAddForm(want: Want): formBindingData.FormBindingData {
hilog.info(DOMAIN_NUMBER, TAG, '[EntryFormAbility] onAddForm');
if (want.parameters) {
// 卡片的唯一标识ID,需持久化存储,更新卡片数据时通过ID更新
const formId = want.parameters[formInfo.FormParam.IDENTITY_KEY] as string
// 将卡片ID使用 dataPreferences.Preferences 就行持久化存储
FormPreferencesUtil.getInstance().addFormId(this.context, formId)
}
// 使用方创建卡片时触发,提供方需要返回卡片数据绑定类
let obj: Record<string, string> = {
'title': 'titleOnAddForm',
'detail': 'detailOnAddForm'
};
let formData: formBindingData.FormBindingData = formBindingData.createFormBindingData(obj);
return formData;
}
onCastToNormalForm(formId: string): void {
// 使用方将临时卡片转换为常态卡片触发,提供方需要做相应的处理
hilog.info(DOMAIN_NUMBER, TAG, '[EntryFormAbility] onCastToNormalForm');
}
onUpdateForm(formId: string): void {
// 若卡片支持定时更新/定点更新/卡片使用方主动请求更新功能,则提供方需要重写该方法以支持数据更新
hilog.info(DOMAIN_NUMBER, TAG, '[EntryFormAbility] onUpdateForm');
let obj: Record<string, string> = {
'title': 'titleOnUpdateForm',
'detail': 'detailOnUpdateForm'
};
let formData: formBindingData.FormBindingData = formBindingData.createFormBindingData(obj);
formProvider.updateForm(formId, formData).catch((error: BusinessError) => {
hilog.info(DOMAIN_NUMBER, TAG, '[EntryFormAbility] updateForm, error:' + JSON.stringify(error));
});
}
onChangeFormVisibility(newStatus: Record<string, number>): void {
// 使用方发起可见或者不可见通知触发,提供方需要做相应的处理,仅系统应用生效
hilog.info(DOMAIN_NUMBER, TAG, '[EntryFormAbility] onChangeFormVisibility');
}
onFormEvent(formId: string, message: string): void {
// 若卡片支持触发事件,则需要重写该方法并实现对事件的触发
hilog.info(DOMAIN_NUMBER, TAG, '[EntryFormAbility] onFormEvent');
// ...
}
onRemoveForm(formId: string): void {
// 删除卡片实例数据
hilog.info(DOMAIN_NUMBER, TAG, '[EntryFormAbility] onRemoveForm');
// 删除之前持久化的卡片实例数据
FormPreferencesUtil.getInstance().removeFormId(this.context, formId)
}
onConfigurationUpdate(config: Configuration) {
// 当前formExtensionAbility存活时更新系统配置信息时触发的回调。
// 需注意:formExtensionAbility创建后5秒内无操作将会被清理。
hilog.info(DOMAIN_NUMBER, TAG, '[EntryFormAbility] onConfigurationUpdate:' + JSON.stringify(config));
}
onAcquireFormState(want: Want) {
// 卡片提供方接收查询卡片状态通知接口,默认返回卡片初始状态。
return formInfo.FormState.READY;
}
}
⚠️需要注意:
- formId的处理
formId作为卡片的唯一标识ID,我们与服务卡片的交互都需通过它来完成,所以需在onAddForm中将其持久化存储,更新卡片数据时通过ID更新卡片,在onRemoveForm中删除之前持久化的卡片实例数据。
- FormExtensionAbility进程不能常驻后台
FormExtensionAbility在生命周期调度完成后会继续存在5秒,如5秒内没有新的生命周期回调触发则进程自动退出,所以卡片生命周期中不能处理长耗时任务。针对长耗时任务,需拉起主应用处理。
这里出现了几个陌生的面孔:
- formProvider:提供卡片提供方相关的接口能力,可通过该模块提供接口实现更新卡片、设置卡片更新时间、获取卡片信息、请求发布卡片等。
- formInfo:提供了卡片信息(卡片ID等)和状态等相关类型和枚举
- formBindingData:提供卡片数据绑定的能力,包括FormBindingData对象的创建、相关信息的描述。
开发卡片页面
卡片页面的开发和普通页面开发没什么太大的区别,最大的不同就是某些组件和接口无法用于ArkTS卡片,具体可查看官网文档(只有标识“支持在ArkTS卡片中使用”的组件和接口可用于ArkTS卡片)。
同时服务卡片提供了一个特殊的组件FormLink,同于静态卡片内部和提供方应用间的交互,当前支持router、message和call三种类型的事件。
ArkTS卡片还存在以下限制:
- 当导入模块时,仅支持导入标识“支持在ArkTS卡片中使用”的模块。
- 仅支持声明式范式的部分组件、事件、动效、数据管理、状态管理和API能力。
- 卡片的事件处理和使用方的事件处理是独立的,建议在使用方支持左右滑动的场景下卡片内容不要使用左右滑动功能的组件,以防手势冲突影响交互体验。
- 暂不支持导入共享包及使用native语言开发。
- 暂不支持极速预览、断点调试能力、热重载及设置超时任务(setTimeOut)等能力。
卡片的模块讲完,接下来进入实战,开发我们自己的第一个服务卡片。
开发「新年倒计时」服务卡片
第一个服务卡片,就做的喜庆一点,做什么呢,就做一个新春倒计时吧,大概长这样:
先来简单写一下布局文件:
@Entry
@Component
struct WidgetCard {
readonly ACTION_TYPE: string = 'router';
readonly MESSAGE: string = 'add detail';
readonly ABILITY_NAME: string = 'EntryAbility';
@LocalStorageProp('daysUntilNewYear') daysUntilNewYear: string = ''
build() {
FormLink({
action: this.ACTION_TYPE,
abilityName: this.ABILITY_NAME,
params: {
message: this.MESSAGE
}
}) {
Column() {
Text('2025新年')
.fontSize(18)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.fontColor($r('app.color.title_font'))
.fontWeight(FontWeight.Medium)
.maxLines(1)
.height('25%')
.backgroundColor('#6060ec')
.width('100%')
.textAlign(TextAlign.Center)
.fontWeight(FontWeight.Bold)
Text(this.daysUntilNewYear)
.fontSize(35)
.margin({ top: $r('app.float.item_margin_top') })
.textOverflow({ overflow: TextOverflow.Ellipsis })
.fontColor(Color.Black)
.fontWeight(FontWeight.Bold)
.maxLines(1)
Text('days')
.fontSize(15)
.margin({ top: $r('app.float.item_margin_top') })
.textOverflow({ overflow: TextOverflow.Ellipsis })
.fontColor(Color.Black)
.fontWeight(FontWeight.Bold)
.maxLines(1)
Text('目标日期 2025/01/29')
.fontSize(12)
.margin({ top: $r('app.float.item_margin_top') })
.textOverflow({ overflow: TextOverflow.Ellipsis })
.fontColor(Color.Black)
.fontWeight(FontWeight.Bold)
.maxLines(1)
.margin({ bottom: 12 })
}
.justifyContent(FlexAlign.SpaceBetween)
.width('100%')
.height('100%')
}
}
}
用户添加服务卡片时,在FormExtensionAbility的onAddForm回调中计算倒计时,保存卡片ID,并更新UI:
onAddForm(want: Want) {
if (want.parameters) {
const formId = want.parameters[formInfo.FormParam.IDENTITY_KEY] as string
FormPreferencesUtil.getInstance().addFormId(this.context, formId)
}
// 计算倒计时,返回卡片数据绑定类
const daysUntilNewYear = FormRepo.getInstance().calculateDaysUntilNewYear().toString()
let obj: Record<string, string> = {
'daysUntilNewYear': daysUntilNewYear,
};
let formData: formBindingData.FormBindingData = formBindingData.createFormBindingData(obj);
return formData;
}
FormRepo的代码如下:
export class FormRepo {
private static instance: FormRepo
readonly newYearDate = new Date(2025, 1, 29)
static getInstance(): FormRepo {
if (!FormRepo.instance) {
FormRepo.instance = new FormRepo()
}
return FormRepo.instance
}
private constructor() {}
calculateDaysUntilNewYear(): number {
systemDateTime.getTime()
const currentTime = new Date().getTime()
const timeDifference = this.newYearDate.getTime() - currentTime
const millisecondsPerDay = 1000 * 60 * 60 * 24;
const dayDifference = timeDifference / millisecondsPerDay;
return Math.floor(dayDifference);
}
}
「倒计时服务卡片」的重点在于需要在每天的零点定点更新倒计时,然后通过formProvider更新卡片的UI展示,所以我们首先要调整一下卡片的配置文件form_config.json,将
updateEnabled改为true,同时将scheduledUpdateTime调整为"00:00",即每日的零点整时刷新。
然后在FormExtensionAbility的中处理卡片的定点刷新逻辑:
onUpdateForm(formId: string) {
const daysUntilNewYear = FormRepo.getInstance().calculateDaysUntilNewYear().toString()
let obj: Record<string, string> = {
'daysUntilNewYear': daysUntilNewYear,
};
let formInfo: formBindingData.FormBindingData = formBindingData.createFormBindingData(obj);
formProvider.updateForm(formId, formInfo);
}
这样,一个简单的倒计时服务卡片就开发完成了~
一些问题/感受
鸿蒙系统目前还处于Beta阶段,所以难免会有些让人疑惑的问题(BUG🤔)
使用preferences存储formId无法正常获取
开发服务卡片时,发现了一个异常场景:
主应用启动后,添加服务卡片,此时通过preferences将formId进行持久化存储,然后在主应用通过preferences获取已添加卡片的formId无法正常获取,导致无法正常更新卡片数据。
杀掉主进程后再启动就又能正常获取到了🤷,尝试了多种办法,发现主应用在使用preferences获取formId前,调用removePreferencesFromCache也能正常获取到,再次🤷。
根据现象不难猜测,服务卡片和主应用属于两个不同进程,主进程不kill掉时会从cache获取,导致无法正常获取卡片进程内存储的数据(只是猜测,概不负责🤪)。
文档细节的缺失
作为一个新系统,鸿蒙的开发文档其实已经挺全了,但还是会缺少一些细节,比如FormExtensionAbility生命周期函数中有一个onCastToNormalForm,官网文档中注明了系统将临时卡片转换为常态卡片触发,提供方需要做相应的处理。
这里又提到了两个新名词:临时卡片和常态卡片,我翻遍了官方的文档,也没找到关于临时卡片/常态卡片 的定义,那我需要做什么相应的处理?🤷不造啊🤷。
后来在鸡脚旮旯里看到了这么一段说明:
通过本地数据库进行卡片信息的持久化时,建议先在onAddForm生命周期中通过TEMPORARY_KEY判断当前添加的卡片是否为常态卡片:如果是常态卡片,则直接进行卡片信息持久化;如果为临时卡片,则可以在卡片转为常态卡片(onCastToNormalForm)时进行持久化;同时需要在卡片销毁(onRemoveForm)时删除当前卡片存储的持久化信息,避免反复添加删除卡片导致数据库文件持续变大。
不过这还是没解答我们的疑问,什么是临时卡片/常态卡片?什么时候系统会创建临时卡片?什么时候临时卡片会转为常态卡片?
不过上面那段话也不是没作用,至少提醒我们处理卡片信息时注意区分临时卡片和常态卡片。所以,在开发鸿蒙软件时,要多翻、勤翻官网文档。
路漫漫其修远兮,吾将上下而求索,我是沈剑心,我们下次再见,salute!🫡
本文正在参加华为鸿蒙有奖征文征文活动