Files
FamilyBook_harmonyos/entry/src/main/ets/entryformability/FbFormAbility.ets
T

230 lines
12 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 FamilyBook · 服务卡片提供方(鸿蒙端 FormExtensionAbility)
*
* 用户 2026-10-20:「跟日程有关的桌面服务卡片,你就完全按照 synccalendar 来做就行。」
* ⇒ 生命周期与翻页照 `SyncCalendar/entry/src/main/ets/entryformability/EntryFormAbility.ets`:
* · `onAddForm` —— 添加到桌面时先塞"空壳",再异步推真实数据,并**登记**这张卡;
* · `onUpdateForm` —— 系统定时刷新(`updateDuration: 1` ⇒ 30 分钟);
* · `onFormEvent` —— 卡片上的 ‹ / › / 今 三个按钮(`action: 'message'`);
* · `onRemoveForm` —— 移出桌面(注销 + 清掉它的翻页偏移)。
*
* 🔴 四个必须记住的约束:
* ① **生命周期回调返回后进程只活约 10 秒**(官方文档)⇒ 回调里不能做长任务。
* 这里的做法与 SyncCalendar 一致:立刻返回/用本地缓存,真实网络请求放在
* `.then()` 里异步推 —— 就算被回收,也只是"这次没刷成",30 分钟后还会重试。
* ② `onAddForm` **必须同步返回** `FormBindingData`(不能是 Promise),否则桌面
* 直接显示"卡片加载失败"。
* ③ 数据构建失败**绝不能抛**:抛出去的表现是桌面一张白卡,用户完全不知道发生了什么。
* ⇒ 失败也返回"带一句人话"的壳(`FbCardData.hint`)。
* ④ **翻页 = 每张卡一份偏移量**(`FB_CARD_FORMS`),推数据时按各自偏移构建 ——
* 系统定时刷新**不能**把用户翻走的日期拽回今天。
*/
import { FormExtensionAbility, formBindingData, formProvider, formInfo } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { fbCardDataByKind, fbTaskCardComplete, FbTaskCardData } from '../data/FbCardMediaData';
import { FB_CARD_FORMS } from '../data/FbCardForms';
import { FB_BG_SYNC } from '../common/FbBackgroundSync';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbForm';
/** 卡片名 → 数据类型(v90 起不止日程卡)。
* ⚠️ 名字来自 `form_config.json` 的 `name` 字段(Want 里的 `form_name`),
* 两边改名必须同步 —— 改一边的表现是"新卡永远拿日程数据",编译不报错。 */
function kindOfFormName(name: string): string {
if (name.indexOf('FbMovieCard') === 0) {
return 'movies';
}
if (name.indexOf('FbTaskCard') === 0) {
return 'tasks';
}
return 'calendar';
}
export default class FbFormAbility extends FormExtensionAbility {
/**
* 重新算一遍并推给指定卡片。
*
* ⚠️ v90 起按类型分流:日程卡吃 `dayOffset`(翻页);
* 观影/任务卡没有"看哪一天"的概念,`dayOffset` 固定传 0。
*/
private push(formId: string, kind: string, dayOffset: number): void {
fbCardDataByKind(this.context, kind, dayOffset).then((data: Object): void => {
const binding: formBindingData.FormBindingData = formBindingData.createFormBindingData(data);
formProvider.updateForm(formId, binding).then((): void => {
hilog.info(DOMAIN, TAG, '卡片 %{public}s(%{public}s) 已更新(偏移 %{public}d)',
formId, kind, dayOffset);
}).catch((err: BusinessError): void => {
// 卡片可能已经被用户删了 —— 打一条日志就够,不要重试
hilog.warn(DOMAIN, TAG, '卡片 %{public}s 更新失败:%{public}s', formId, err.message);
});
}).catch((err: BusinessError): void => {
hilog.error(DOMAIN, TAG, '卡片数据构建失败:%{public}s', err.message);
});
}
/** 从 Want 里取卡片 id(`ohos.extra.param.key.form_identity`) */
private formIdOf(want: Want): string {
if (want.parameters === undefined) {
return '';
}
const raw: Object | undefined = want.parameters['ohos.extra.param.key.form_identity'];
return typeof raw === 'string' ? raw : '';
}
/** 从 Want 里取卡片名(`ohos.extra.param.key.form_name`,即 form_config 的 name) */
private formNameOf(want: Want): string {
if (want.parameters === undefined) {
return '';
}
const raw: Object | undefined = want.parameters[formInfo.FormParam.NAME_KEY];
return typeof raw === 'string' ? raw : '';
}
onAddForm(want: Want): formBindingData.FormBindingData {
const formId: string = this.formIdOf(want);
const kind: string = kindOfFormName(this.formNameOf(want));
hilog.info(DOMAIN, TAG, '添加卡片:%{public}s(%{public}s)', formId, kind);
if (formId !== '') {
// 异步:登记 + 补真实数据(不 await:见文件头约束 ①)
FB_CARD_FORMS.register(this.context, formId, kind).then((): void => {
this.push(formId, kind, 0);
}).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '卡片登记失败:%{public}s', err.message);
});
}
// 先给一份"正在取"的壳 —— 卡片上会显示空态,而不是白屏
const shell: Record<string, string> = {};
shell['state'] = 'loading';
shell['hint'] = '正在取数据…';
return formBindingData.createFormBindingData(shell);
}
onUpdateForm(formId: string): void {
hilog.info(DOMAIN, TAG, '系统定时刷新:%{public}s', formId);
/* 🔴🔴 v83(用户 2026-10-23「要有定期同步的功能,家书app在后台,也能自动同步数据」):
**借卡片定时刷新这个时机跑一轮后台同步。**
为什么只有这里能做到"约 30 分钟一次":
`workScheduler` 的周期被系统按"应用活跃分组"压制(活跃≥2h / 常用≥24h),
请求更短不报错但**不生效**;而卡片配了 `updateDuration: 1`(单位 30 分钟)后,
系统会每 30 分钟回调一次本方法,**不受活跃分组压制**(每天上限 50 次)。
⇒ 这是那条"打开 app 数据就是最新的"真正落地的通道。
⚠️ **必须先推卡片、再同步**:
推送只依赖本地缓存/一次轻量请求,几秒内就能完成;
同步要跑 12 个模块,很可能在"进程只活 10 秒"的窗口里被砍。
顺序反了就会变成"同步把时间用光、卡片这次没刷新"——
用户看到的是"卡片一直转圈",比慢一步更糟。
⚠️ **不 await**:`onUpdateForm` 返回后进程只剩约 10 秒,
await 只会把两者一起拖死。同步自己内部有节流与预算(见 FbBackgroundSync)。
⚠️ 节流:系统刷新可能漂移或重入,`runThrottledByCard` 内部用时间戳挡了一道,
并且**先写时间戳再跑**,避免卡片进程与主进程并发拉同一批数据。 */
// ⚠️ 必须按**这张卡自己的偏移**重建,否则定时刷新会把用户翻走的日期拽回今天
// 🔴 v90:偏移量只对日程卡有意义;观影/任务卡按类型直接推。
FB_CARD_FORMS.kindOf(this.context, formId).then((kind: string): void => {
if (kind !== 'calendar') {
this.push(formId, kind, 0);
return;
}
FB_CARD_FORMS.getOffset(this.context, formId).then((off: number): void => {
this.push(formId, kind, off);
}).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '读卡片偏移失败:%{public}s', err.message);
this.push(formId, kind, 0);
});
}).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '读卡片类型失败(按日程处理):%{public}s', err.message);
this.push(formId, 'calendar', 0);
});
/* ⚠️ 这一句必须放在**推送之后**(原因见上面的 ①:卡片刷新只有几秒的窗口,
不能被 12 个模块的同步挤掉)。两条都是"发起即返回",不会互相阻塞。 */
FB_BG_SYNC.runThrottledByCard(this.context).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '卡片驱动的后台同步失败(不影响卡片本身):%{public}s', err.message);
});
}
/**
* 🔴 v91:任务卡上点「完成」—— 把任务标成 done,重算后**推给所有任务卡**。
*
* ⚠️ 推所有任务卡而不是只推这一张:还有别的任务卡(2×2 的待办数、别的 4×4)
* 也显示这条任务,只推一张会出现"这张没了、那张还挂着"。
* ⚠️ message 里的 id 用正则抠:`{"taskDone":"12"}` / 嵌套 `{params:{…}}`
* 两种形态都能命中(与既有"子串匹配"取舍一致)。
*/
private markTaskDone(formId: string, message: string): void {
const m: RegExpMatchArray | null = message.match(/"taskDone"\s*:\s*"?(\d+)/);
const id: number = m !== null ? parseInt(m[1], 10) : 0;
if (id <= 0 || isNaN(id)) {
hilog.warn(DOMAIN, TAG, '任务卡「完成」消息里没有可用 id:%{public}s', message);
return;
}
fbTaskCardComplete(this.context, id).then((data: FbTaskCardData): void => {
const binding: formBindingData.FormBindingData = formBindingData.createFormBindingData(data);
// 先回这张卡(用户手指点的那张,必须立刻看到变化)
formProvider.updateForm(formId, binding).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '任务卡 %{public}s 回推失败:%{public}s', formId, err.message);
});
// 其余任务卡重算重推(数量不多,构建走同一份缓存口径)
FB_CARD_FORMS.pushAll(this.context).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '任务完成后全量重推失败:%{public}s', err.message);
});
}).catch((err: BusinessError): void => {
hilog.error(DOMAIN, TAG, '完成任务 %{public}d 流程失败:%{public}s', id, err.message);
});
}
onFormEvent(formId: string, message: string): void {
/* 卡片上的按钮都走 `action: 'message'`,`message` 是 JSON 字符串。
⚠️ 不解析 JSON:不同版本系统给的形态可能是 `{params:{...}}` 或直接 `{...}`,
子串匹配更稳(与 SyncCalendar 同一个取舍)。
⚠️ v90:只有日程卡有 ‹ / › / 今 三个按钮;观影/任务卡不发 message 事件,
收到也直接忽略(不把 message 撞进偏移量里)。
🔴 v91:任务卡多了「完成」按钮(`taskDone`),**必须最先分流**——
它不认卡片类型也不认偏移量,认不出类型时也不能丢。 */
hilog.info(DOMAIN, TAG, '卡片事件:%{public}s %{public}s', formId, message);
if (message.indexOf('taskDone') >= 0) {
this.markTaskDone(formId, message);
return;
}
FB_CARD_FORMS.kindOf(this.context, formId).then((kind: string): void => {
if (kind !== 'calendar') {
return;
}
FB_CARD_FORMS.getOffset(this.context, formId).then((cur: number): void => {
let next: number = cur;
if (message.indexOf('prev') >= 0) {
next = cur - 1;
} else if (message.indexOf('next') >= 0) {
next = cur + 1;
} else if (message.indexOf('today') >= 0) {
next = 0;
}
FB_CARD_FORMS.setOffset(this.context, formId, next).then((off: number): void => {
this.push(formId, kind, off);
}).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '卡片翻页失败:%{public}s', err.message);
this.push(formId, kind, cur);
});
}).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '读卡片偏移失败(按今天处理):%{public}s', err.message);
this.push(formId, kind, 0);
});
}).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '读卡片类型失败:%{public}s', err.message);
});
}
onRemoveForm(formId: string): void {
hilog.info(DOMAIN, TAG, '移除卡片:%{public}s', formId);
FB_CARD_FORMS.unregister(this.context, formId).then((): Promise<void> => {
return FB_CARD_FORMS.clearOffset(this.context, formId);
}).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '注销卡片失败:%{public}s', err.message);
});
}
}