HarmonyOS7 元服务的两张桌面卡片与双通道刷新
文章目录
前言
「诗境」在桌面放了两张卡片:2×2 的学习进度卡和 4×4 的每日一诗卡。它们跑在 FormExtensionAbility 所在的那个进程里,和应用主进程互不相识 —— 用户在应用里收藏一首诗,桌面上那个数字要变,只能靠应用主动把数据推过去。这套推送机制加上卡片的被动接收方式,构成了这一整套「双通道」。

六条配置决定卡片长什么样
两张卡片都声明在同一个 profile 里:
{
"forms": [
{
"name": "progressWidget",
"displayName": "$string:form_progress_name",
"description": "$string:form_progress_desc",
"src": "./ets/widget/pages/ProgressWidgetCard.ets",
"uiSyntax": "arkts",
"isDefault": true,
"updateEnabled": true,
"scheduledUpdateTime": "08:00",
"defaultDimension": "2*2",
"supportDimensions": ["2*2"],
"isDynamic": true,
"colorMode": "auto",
"previewImages": ["$media:progressWidget_2x2"]
},
{
"name": "dailyPoemWidget",
...
"defaultDimension": "4*4",
"supportDimensions": ["4*4"],
"isDynamic": true,
"colorMode": "auto",
"previewImages": ["$media:dailyPoemWidget_4x4"]
}
]
}
两处差异只在尺寸和 isDefault 上:
| 字段 | 进度卡 | 每日一诗卡 | 作用 |
|---|---|---|---|
name | progressWidget | dailyPoemWidget | 代码里按名字决定推哪套数据 |
defaultDimension | 2*2 | 4*4 | 加卡时默认尺寸 |
supportDimensions | ["2*2"] | ["4*4"] | 只支持一种,不提供缩放 |
isDefault | true | false | 长按图标时默认展示进度卡 |
scheduledUpdateTime | 08:00 | 08:00 | 每天系统拉一次 |
isDynamic | true | true | 允许 updateForm 主动推数据 |
updateEnabled | true | true | 允许定时刷新 |
colorMode | auto | auto | 跟随系统深浅色 |
isDynamic: true 是主动刷新的前提。没有它,formProvider.updateForm 推过去的数据不会生效,卡片只会在 scheduledUpdateTime 那一刻被系统叫起来重新拉一次。
supportDimensions 只写一个值,意味着这两张卡不能缩放 —— 2×2 就是 2×2,4×4 就是 4×4。这省掉了「同一张卡在四种尺寸下都要好看」的适配工作。卡片内部所有尺寸都按目标尺寸的可用高宽设计:进度卡按最小高度 148vp 排布,固定内容高度压在 140vp 以内,剩下的余量交给 Blank() 消化。
ExtensionAbility 那一侧的声明在 module.json5 里:
"extensionAbilities": [
{
"name": "FormAbility",
"srcEntry": "./ets/formability/FormAbility.ets",
"type": "form",
"description": "$string:formability_desc",
"icon": "$media:icon",
"label": "$string:formability_label",
"exported": true,
"metadata": [
{
"name": "ohos.extension.form",
"resource": "$profile:form_config"
}
]
}
]
type: "form" 加上 ohos.extension.form 这条 metadata,把 profile 与 Ability 连起来。exported: true 让系统能找到它 —— 卡片是系统代为渲染的,Ability 必须对外可见。
数据怎么进到卡片里
这是整块最容易写错的地方。卡片不是普通页面,它的数据不由父组件传参,而是 FormBindingData 通过 LocalStorage 注入。所以卡片组件必须同时具备两样东西:一个自己的 LocalStorage 实例,和 @Entry 上的那个参数。
let storageDailyPoem = new LocalStorage();
@Entry(storageDailyPoem)
@Component
struct DailyPoemWidgetCard {
@LocalStorageProp('dateText') dateText: string = '';
@LocalStorageProp('sentence1') sentence1: string = '每日一诗加载中…';
@LocalStorageProp('sentence2') sentence2: string = '';
@LocalStorageProp('title') title: string = '';
@LocalStorageProp('dynasty') dynasty: string = '';
@LocalStorageProp('author') author: string = '';
@LocalStorageProp('translation') translation: string = '';
@LocalStorageProp('streakDays') streakDays: number = 0;
@LocalStorageProp('collectedCount') collectedCount: number = 0;
LocalStorage 实例声明在 struct 之外,是模块级变量 —— 卡片被系统渲染时这个文件会被独立加载,实例随之创建。@Entry(storageDailyPoem) 把这个实例登记为卡片的默认存储,框架才知道从哪里取注入数据。
九个 @LocalStorageProp(进度卡那边是八个)的属性名必须与 WidgetRefreshService 里那个 interface 的字段名逐字一致。这条对应关系没有任何编译期检查,写错了就是一个永远显示默认值的字段。
两张卡片的文件头都留了同一条注释:
* 数据接收(关键):动态卡片的数据由 FormBindingData 通过 LocalStorage 注入,
* 必须使用 @Entry(storage) + @LocalStorageProp 接收(官方动态卡片数据通道),
* 属性名与 WidgetRefreshService.buildProgressCardData 的输出字段一一对应;
* 使用 @Local/@ComponentV2 接收不到卡片数据,会导致卡片始终显示默认值。
注释的最后一句给的是一个明确结论:@Local 与 @ComponentV2 都收不到卡片数据。V2 的装饰器体系在这里不适用 —— 卡片运行时的注入通道只认 LocalStorage,而 @LocalStorageProp 是 V1 的装饰器。所以两张卡都写成 @Component(V1),哪怕应用主体全是 @ComponentV2。这是整个项目里唯一两块 V1 代码,原因不是偏好,是通道限制。
@LocalStorageProp 是单向的:LocalStorage 里的值会同步到组件属性,组件改它不会回写。卡片本来也不该改数据 —— 它的交互只有「点击拉起应用」。单向正好。
默认值不只是类型占位,它还承担「首帧」:
@LocalStorageProp('sentence1') sentence1: string = '每日一诗加载中…';
卡片被加到桌面时,onAddForm 返回的是一个空对象:
// 必须同步返回,不能声明为 async
onAddForm(want: Want): formBindingData.FormBindingData {
return formBindingData.createFormBindingData({});
空数据意味着卡片会先用默认值渲染一遍,几百毫秒后 prepareData() 完成、updateForm 推来真数据,再刷一次。所以「每日一诗加载中…」是用户真的会看到的一帧。默认值写具体文案而不是空串,就是为了这一帧不至于是一片空白。
dateText、streakDays 这类字段默认 ''、0,首帧会短暂显示成空白日期与 0 天。这几个字段的变化频率低、视觉权重小,不值得为它们单独设计占位文案。

两套数据,两种性格
进度卡的八个字段全是标量:
buildProgressCardData(): ProgressCardData {
// 统一使用 StorageService 的词库校验口径,保证卡片与页面数字绝对一致
const store: StorageService = StorageService.getInstance();
const u: UserState = store.userState;
const masteredCount: number = store.getMasteredPoemIds().length;
const collectedCount: number = store.getCollectedPoemIds().length;
const levelIdx: number = LevelSystem.getLevelIndex(masteredCount);
const gap: number = LevelSystem.getNextLevelGap(masteredCount);
const levelTip: string = gap > 0
? `再学会 ${gap} 首升级「${LevelSystem.getNextLevelName(masteredCount)}」`
: '已达最高等级 · 诗仙本仙';
const gradeName: string = (u.selectedGrade >= 1 && u.selectedGrade <= 6)
? `${u.selectedGrade}年级`
: '未选年级';
const data: ProgressCardData = {
levelIdx: levelIdx,
levelName: LevelSystem.getLevel(masteredCount).name,
gradeName: gradeName,
collectedCount: collectedCount,
masteredCount: masteredCount,
streakDays: u.streakDays,
levelProgress: LevelSystem.getProgress(masteredCount),
levelTip: levelTip
};
return data;
}
八个字段在卡片上被拆成四种呈现:levelIdx → 本地查表拿徽章资源,levelName / gradeName / levelTip → 直接显示文字,collectedCount / masteredCount / streakDays → 数字,levelProgress → 百分比与进度条宽度。
这个函数是「把业务状态压平成可序列化字段」的边界。它做完之后,卡片侧不需要任何计算 —— levelProgress 已经是 0 到 1 的比值,卡片只做 Math.round(x * 100) 转百分比;levelTip 已经是拼好的整句,卡片只负责 maxLines(1) 加省略号。
每日一诗卡的九个字段里有五个来自同一首诗:
if (poem !== null) {
const sentences: string[] = poem.getSentences();
if (sentences.length > 0) {
sentence1 = sentences[0];
}
if (sentences.length > 1) {
sentence2 = sentences[1];
}
title = poem.title;
dynasty = poem.dynasty;
author = poem.author;
if (poem.translation.length > 0) {
const firstPart: string = poem.translation.split('。')[0];
translation = firstPart.length > 24 ? firstPart.substring(0, 24) + '…' : firstPart + '。';
}
}
getSentences() 是 Poem 模型上的方法,按标点把正文切成句子。卡片只取前两句 —— 4×4 的卡片高度有限,超过两句会挤掉底部的统计与按钮。
译文处理是两步:先按第一个句号切出第一句,再判断长度。超过 24 字截断加省略号,否则补一个句号。这里有个细节:split('。')[0] 拿到的是不含句号的部分,所以短句要手动补 。,长句截断后不再补(因为它已经被省略号替代了)。两个分支都以「用户看到一句完整的话」为目标,而不是简单地 substring(0, 24)。
卡片侧对应地用条件渲染处理空值:
if (this.sentence2.length > 0) {
Text(this.sentence2)
.fontSize(22)
五言绝句有两句、七言律诗有八句,取前两句对绝大多数诗都成立;但万一某首诗正文只有一句(或者切句失败),sentence2 是空串,那一行 Text 整个不挂载,布局靠 justifyContent(FlexAlign.Center) 自动居中。同理译文那一块也是包在 if (this.translation.length > 0) 里的。
主动推送:300ms 防抖 + 两条枚举通道
卡片自己不会去读数据。应用进程在数据变化后必须主动推:
/** 应用进程数据变更后防抖刷新全部卡片(短时间内多次变更合并为一次刷新) */
scheduleRefresh(context: common.Context): void {
if (this.refreshTimer >= 0) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(() => {
this.refreshTimer = -1;
this.refreshAllForms(context);
}, REFRESH_DEBOUNCE_MS);
}
REFRESH_DEBOUNCE_MS 是 300。计时器句柄用 -1 表示「当前没有待执行的刷新」—— 每次调度前先看有没有旧的,有就清掉。回调里第一件事是把句柄置回 -1,这样刷新执行完之后再来一次调度会重新计时,而不是误清一个已经执行过的 id。
防抖针对的是一个具体场景:用户连点收藏按钮。每点一次 toggleCollect 都会 persist 再 notifyWidgetChange,如果每次都立刻 updateForm,三次点击就是三次系统调用。系统对卡片的主动刷新有频次配额,超了会被静默拒绝 —— 表现是「后面几次操作卡片不动了」。300ms 的窗口把连点合并成一次。
refreshAllForms 用两条通道枚举「桌面上到底有哪些卡片」:
async refreshAllForms(context: common.Context): Promise<void> {
const refreshed: Set<string> = new Set<string>();
try {
// 主通道:系统 API 枚举桌面全部已加桌卡片(含本次修复前添加、formId 未持久化的旧卡片)
const runningForms: formInfo.RunningFormInfo[] = await formProvider.getPublishedRunningFormInfos();
for (let i = 0; i < runningForms.length; i++) {
const info: formInfo.RunningFormInfo = runningForms[i];
await this.updateForm(info.formId, info.formName);
refreshed.add(info.formId);
}
hilog.info(DOMAIN, TAG, 'refreshAllForms by runningForms, count=%{public}d', runningForms.length);
} catch (err) {
hilog.error(DOMAIN, TAG, 'getPublishedRunningFormInfos failed: %{public}s', JSON.stringify(err));
}
try {
// 兜底通道:刷新持久化映射中未被系统 API 覆盖的卡片
const infos: Record<string, string> = await this.getFormInfoMap(context);
const formIds: string[] = Object.keys(infos);
for (let i = 0; i < formIds.length; i++) {
if (!refreshed.has(formIds[i])) {
await this.updateForm(formIds[i], infos[formIds[i]]);
}
}
} catch (err) {
hilog.error(DOMAIN, TAG, 'refreshAllForms fallback failed: %{public}s', JSON.stringify(err));
}
}
主通道 getPublishedRunningFormInfos() 是系统 API,一次拿到所有已加桌卡片,连 formName 都带回来,不用查表。它能覆盖「旧版本添加、没写过映射」的卡片 —— 这类卡片的 formId 只存在于系统里。
兜底通道读那张持久化的 widget_forms_store。它存在的理由是「主通道可能整体失败」:getPublishedRunningFormInfos 抛错时,第一个 try 被跳过,如果只有这一条路,所有卡片就都不刷了。两个 try 分开写,前一个失败不影响后一个。
Set 去重保证同一次刷新里每张卡只被推一次。兜底循环里的 if (!refreshed.has(...)) 是关键:没有它,主通道刷过的卡片会被再推一遍,白耗配额。
updateForm 按名字分派:
/** 按卡片名称推送最新数据到指定卡片 */
async updateForm(formId: string, formName: string): Promise<void> {
if (formId.length === 0) {
return;
}
const name: string = formName.length > 0 ? formName : PROGRESS_FORM_NAME;
try {
const data: Object = name === DAILY_FORM_NAME
? this.buildDailyPoemCardData()
: this.buildProgressCardData();
await formProvider.updateForm(formId, formBindingData.createFormBindingData(data));
} catch (err) {
hilog.error(DOMAIN, TAG, 'updateForm %{public}s failed: %{public}s', formId, JSON.stringify(err));
}
}
「名字为空就按进度卡推」这条兜底在三处出现(saveFormInfo、refreshAllForms、这里),指向同一个常量。之所以允许名字为空,是因为 onAddForm 从 want.parameters 里取 formName,参数缺失时只能拿到空串。
updateForm 的失败被吞掉只记日志。一张卡推送失败不该影响其他卡片的刷新循环。
谁在什么时候触发推送
三个触发点:
| 触发点 | 时机 | 覆盖场景 |
|---|---|---|
SplashPage.aboutToAppear | 每次冷启动 | 补刷旧卡片,修正上次退出后错过的变更 |
persistListener | 每次 persist 成功之后 | 应用内任何写操作 |
FormAbility.onUpdateForm | 系统每天 08:00 | 应用未打开时的定时刷新 |
启动时那次是「补课」:
// 注册 persist 回调:学习数据每次落盘后,实时刷新桌面服务卡片(收藏/已学会统计)
StorageService.getInstance().setPersistListener((): void => {
WidgetRefreshService.getInstance().scheduleRefresh(ctx);
});
// 每次启动主动同步一次桌面卡片(覆盖修复前添加、数据已过期的旧卡片)
WidgetRefreshService.getInstance().scheduleRefresh(ctx);
注册回调之后立刻主动刷一次。回调管的是「本次运行期间的变化」,主动刷管的是「上次运行遗留的差异」。典型场景是用户昨天在应用里学会了三首诗、卡片没刷成就退出了,今天打开应用 —— 这次主动刷新把三首的差额补上。
setPersistListener 是回调注入而不是直接调用,为的是避免 StorageService 反过来 import WidgetRefreshService 形成循环依赖。StorageService 只持有一个 (() => void) | null 字段,谁需要谁注册。
FormAbility.onUpdateForm 走的是另一条路:
onUpdateForm(formId: string): void {
const widget: WidgetRefreshService = WidgetRefreshService.getInstance();
this.prepareData().then(async () => {
const infos: Record<string, string> = await widget.getFormInfoMap(this.context);
const formName: string = infos[formId] !== undefined ? infos[formId] : '';
await widget.updateForm(formId, formName);
}).catch((e: Object) => {
hilog.error(DOMAIN, TAG, 'update failed: %{public}s', JSON.stringify(e));
});
}
系统只传了一个 formId,没有卡片名。所以要靠持久化映射反查 —— 查不到就传空串,updateForm 里那条「空名字按进度卡推」会接管。对每日一诗卡来说这会导致推错数据,但映射正常情况下一定存在(加卡时就写了),查不到属于异常。
onAddForm 为什么不能是 async
// 必须同步返回,不能声明为 async
onAddForm(want: Want): formBindingData.FormBindingData {
const params: Record<string, Object> = want.parameters as Record<string, Object>;
let formId: string = '';
let formName: string = '';
...
const widget: WidgetRefreshService = WidgetRefreshService.getInstance();
// 持久化 formId → 卡片名,供应用进程主动刷新
widget.saveFormInfo(this.context, formId, formName);
// 异步加载词库与学习进度,完成后主动刷新,不阻塞返回
this.prepareData().then(() => {
return widget.updateForm(formId, formName);
}).catch((e: Object) => {
hilog.error(DOMAIN, TAG, 'prepare failed: %{public}s', JSON.stringify(e));
});
return formBindingData.createFormBindingData({});
}
onAddForm 的返回值就是卡片的初始数据,系统等不了 await。所以它同步返回空对象,把「加载数据 + 推一次」全部甩到 then 里。卡片会先显示默认值,随后被推上真数据。
saveFormInfo 用 void 方式调用(不 await),因为它写的是另一张表,失败也不影响这次渲染。如果 await 它,onAddForm 就变成异步了。
prepareData 做了两层缓存:
private prepareData(): Promise<void> {
const repo: PoemRepository = PoemRepository.getInstance();
if (repo.isLoaded()) {
return StorageService.getInstance().init(this.context, true);
}
if (this.preparationPromise === null) {
this.preparationPromise = repo.load(this.context)
.then(() => StorageService.getInstance().init(this.context, true))
.then(() => {
this.preparationPromise = null;
});
}
return this.preparationPromise;
}
词库加载是一次 59518 字节的 rawfile 读取加 JSON.parse,卡片进程里可能被 onAddForm 与 onUpdateForm 连续触发两次。isLoaded() 短路掉「已加载」的情况,preparationPromise 合并并发 —— 两次调用拿到同一个 Promise,只加载一次。
第三个 then 把 preparationPromise 置回 null。这一步让失败之后还能重试:如果 Promise 被 reject,preparationPromise 若留在原地,后续调用会一直复用那个已失败的 Promise,永远不再尝试加载。置回 null 之后,下一次 prepareData 会重新发起。
注意 StorageService.init(this.context, true) 的第二个参数 true —— 卡片进程以只读模式读同一张表。这一层决定了卡片拿到的数据是最新的,同时卡片进程永远不会写回。
点击卡片之后
两张卡片的点击行为完全一致,都是拉起元服务:
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: {}
});
})
params 是空对象,也就是「打开应用,不指定页面」。启动后走正常的启动流程:SplashPage 判断有没有选过年级,选过就进主页面。
没有做到「点进度卡的收藏数字直接进收藏页」这种深链,因为 action: 'router' 拉起的是整个元服务,路由参数需要 EntryAbility.onCreate 里再解析一次 want,多一层不产生价值的转发。卡片的作用是提示与唤起,不是导航。
onFormEvent 是空实现:
onFormEvent(formId: string, message: string): void {
// 当前卡片均使用 router 拉起元服务,message 事件预留
}
它对应的是卡片里 action: 'message' 那种不拉起应用、只给 Ability 发消息的交互。两张卡都没用,函数留着是因为 FormExtensionAbility 的接口里它在那里。
两条通道的分工
把「数据怎么进去」和「什么时候推一次」放在一起对照:
| 维度 | 数据注入通道 | 主动刷新通道 |
|---|---|---|
| 载体 | FormBindingData → LocalStorage | formProvider.updateForm |
| 接收方式 | @Entry(storage) + @LocalStorageProp | 同上,一次推送就是一次重新注入 |
| 谁发起 | 卡片渲染时框架 | 应用进程(或卡片进程的 onAddForm) |
| 触发时机 | 加卡、每次 updateForm、每天 08:00 | 数据变更(300ms 防抖)、冷启动、定时 |
| 字段契约 | interface 字段名与属性名逐字对应 | 同左 |
| 失败表现 | 字段停在默认值 | 卡片停在上次的数据,等下次触发 |
两条通道其实是同一条链路的两个方向:updateForm 把 FormBindingData 塞进卡片的 LocalStorage,@LocalStorageProp 再把值同步到组件属性。所以接收侧的可靠性决定了推送侧有没有意义 —— 属性名写错,推得再勤也只显示默认值;推送失效,属性名再对也永远停在首帧。这条链路上只有三个约束是硬的:卡片必须是 @Component 加 @Entry(storage) 加 @LocalStorageProp(V2 装饰器在这条通道上收不到数据),推送必须防抖(配额有限,连点会撞上限被静默拒绝),枚举卡片要两条通道并用(系统 API 覆盖旧卡片,持久化映射在主通道失败时兜底)。
更多推荐
所有评论(0)