/** * 家书 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 = {}; 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 => { return FB_CARD_FORMS.clearOffset(this.context, formId); }).catch((err: BusinessError): void => { hilog.warn(DOMAIN, TAG, '注销卡片失败:%{public}s', err.message); }); } }