diff --git a/entry/src/main/ets/common/AppSettings.ets b/entry/src/main/ets/common/AppSettings.ets index 2f45343..cd0aa01 100644 --- a/entry/src/main/ets/common/AppSettings.ets +++ b/entry/src/main/ets/common/AppSettings.ets @@ -20,6 +20,10 @@ export class AppSettings { private static readonly KEY_MANUAL_READONLY: string = 'manual_readonly_keys'; // 手动标记只读的 calKey private static readonly KEY_MUTED_REMINDER: string = 'muted_reminder_keys'; // 提醒静音的 calKey private static readonly KEY_REMINDER_TICK: string = 'reminder_tick'; // 应用内提醒上次检查时间戳 + // ⭐ 卡片刷新驱动的后台同步:上次执行时间戳(用于节流)。 + // 服务卡片每 30 分钟被系统刷新一次(form_config 的 updateDuration=1), + // 借这个时机顺带跑一次后台同步 —— 得到"约 30 分钟一次"的定期同步。 + private static readonly KEY_CARD_SYNC_AT: string = 'card_sync_at'; private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成 private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图 private static readonly KEY_DISPLAY_STYLE: string = 'display_style'; // 日程显示方式:'timeline' | 'list' @@ -323,6 +327,29 @@ export class AppSettings { } } + /** ⭐ 卡片刷新驱动的后台同步:上次执行时间戳(0 = 从未执行) */ + static async getCardSyncAt(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + return await store.get(AppSettings.KEY_CARD_SYNC_AT, 0) as number; + } catch (err) { + return 0; + } + } + + static async setCardSyncAt(context: common.Context, ts: number): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + await store.put(AppSettings.KEY_CARD_SYNC_AT, ts); + await store.flush(); + } catch (err) { + const e = err as BusinessError; + console.error(`保存卡片同步时间失败: ${e.message}`); + } + } + /** * 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。 * 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。 diff --git a/entry/src/main/ets/common/BackgroundSyncRunner.ets b/entry/src/main/ets/common/BackgroundSyncRunner.ets index 440b127..5b0f7e5 100644 --- a/entry/src/main/ets/common/BackgroundSyncRunner.ets +++ b/entry/src/main/ets/common/BackgroundSyncRunner.ets @@ -27,6 +27,64 @@ export class BackgroundSyncResult { } export class BackgroundSyncRunner { + /** + * ⭐ 卡片的系统定时刷新间隔(与 form_config.json 的 updateDuration=1 保持一致,单位 30 分钟)。 + * ⚠️ 两处必须同步修改:改这里也要改 form_config.json,否则节流窗口对不上。 + */ + static readonly CARD_TICK_MS: number = 30 * 60 * 1000; + /** 距上次同步不足该时长则跳过(略小于卡片周期,避免系统刷新漂移导致被误跳过) */ + private static readonly CARD_MIN_GAP_MS: number = 25 * 60 * 1000; + /** + * 卡片进程的软超时:官方规定 FormExtensionAbility 生命周期回调后进程**仅存续 10 秒**, + * 超过就会被回收(异步任务被强制中止)。这里主动在 9 秒收手, + * 让"这一次同步"**干净地放弃**而不是被硬杀在半途 —— 幂等同步中断本就不损坏数据, + * 主动放弃还能保住"时间戳已写入、下轮再试"的一致性。 + */ + private static readonly CARD_SOFT_TIMEOUT_MS: number = 9 * 1000; + + /** + * ⭐⭐ 由**服务卡片定时刷新**(`onUpdateForm`)驱动的后台同步(0.0.8)。 + * + * 为什么用它:系统给普通应用的后台调度很受限 —— + * `workScheduler` 的周期被"应用活跃分组"压制到 **≥2 小时**; + * 而服务卡片配置了 `updateDuration: 1`(**单位 30 分钟**),系统会**每 30 分钟** + * 触发一次 `onUpdateForm()`,且不受活跃分组压制(每天上限 50 次)。 + * 借这个时机跑一次完整同步,即可得到"约 30 分钟一次"的定期后台同步。 + * + * ⚠️ 节流:系统刷新本身可能有漂移、也可能因故重入 → 用时间戳挡一道, + * 保证两次真实同步之间至少间隔 `CARD_MIN_GAP_MS`。 + * ⚠️ 本方法**永不抛错**(后台路径没有 UI 承担异常)。 + */ + static async runOnceThrottledByCard(context: common.Context): Promise { + try { + const last: number = await AppSettings.getCardSyncAt(context); + const now: number = Date.now(); + if (last > 0 && now - last < BackgroundSyncRunner.CARD_MIN_GAP_MS) { + const mins: number = Math.round((now - last) / 60000); + LogUtil.write(`卡片驱动同步:距上次仅 ${mins} 分钟,跳过(节流 ${BackgroundSyncRunner.CARD_MIN_GAP_MS / 60000} 分钟)`); + return; + } + // 先写时间戳:避免同步执行期间卡片再次刷新导致并发重入 + await AppSettings.setCardSyncAt(context, now); + LogUtil.write('卡片驱动同步:开始(卡片 30 分钟定时刷新触发)'); + // 软超时:9 秒内没跑完就主动放弃(进程本来也只有 10 秒窗口) + const worker: Promise = + BackgroundSyncRunner.runOnce(context, 'cardTick').then((): void => {}); + const guard: Promise = new Promise((_res: () => void, rej: (e: Error) => void) => { + setTimeout((): void => rej(new Error('卡片进程窗口不足,本次同步放弃')), + BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS); + }); + try { + await Promise.race([worker, guard]); + } catch (err) { + const e = err as Error; + LogUtil.write(`卡片驱动同步提前收手:${e.message}(幂等,下轮会重试)`); + } + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`卡片驱动同步异常: ${e.message}`); + } + } /** * 跑一次完整同步。全流程 try-catch,**永不抛错**(后台路径没有 UI 承担异常)。 * @param context 应用上下文 diff --git a/entry/src/main/ets/entryformability/EntryFormAbility.ets b/entry/src/main/ets/entryformability/EntryFormAbility.ets index 29c17e2..d323d3a 100644 --- a/entry/src/main/ets/entryformability/EntryFormAbility.ets +++ b/entry/src/main/ets/entryformability/EntryFormAbility.ets @@ -4,6 +4,7 @@ import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKi import { Want } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { CardDataService, CardData } from '../common/CardDataService'; +import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner'; import { LogUtil } from '../common/LogUtil'; export default class EntryFormAbility extends FormExtensionAbility { @@ -42,8 +43,28 @@ export default class EntryFormAbility extends FormExtensionAbility { LogUtil.init(this.context); LogUtil.write(`系统触发卡片更新: formId=${formId}`); CardDataService.registerForm(this.context, formId); - // 保留用户翻到的日期(系统定时刷新不应把用户翻走的日期拽回今天) + // ⭐ ① 先用**本地缓存**立刻刷新卡片(几毫秒,不依赖网络) + // 保留用户翻到的日期(系统定时刷新不应把用户翻走的日期拽回今天) this.pushData(formId, CardDataService.getDayOffset(formId)); + // ⭐ ② 再借这次系统刷新顺带跑一次后台同步(见 runOnceThrottledByCard:带节流、永不抛错) + // 同步成功后再推一次,让卡片显示刚同步回来的最新数据。 + // + // ⚠️ 关键约束(依据官方 FormExtensionAbility 生命周期文档): + // "FormExtensionAbility 进程不能常驻后台,生命周期回调中无法处理长时间的任务, + // 生命周期调度完成后会继续存在 **10 秒**……可能需要 10 秒以上才能完成的业务逻辑, + // 建议拉起主应用进行处理。" + // ⇒ 本处同步**可能被进程回收中断**。这是**可接受**的,因为: + // · 同步是幂等的(基于 etag 增量比对 + dirty 标记),中断不会损坏任何数据; + // · 中断只是"这一次没同步成",下个 30 分钟卡片再刷新时会重试; + // · 正常网络下 1~3 个账号的**增量**同步通常数秒内完成,落在 10 秒窗口内。 + // ⇒ 因此它是"**尽力而为地提高后台同步频率**",与 workScheduler 2 小时兜底、 + // 回前台/网络恢复补同步共同构成多层保障,**不承诺 30 分钟必然同步成功**。 + // (不采用"拉起主应用同步"是因为会每 30 分钟弹出一次界面,体验不可接受。) + BackgroundSyncRunner.runOnceThrottledByCard(this.context).then((): void => { + this.pushData(formId, CardDataService.getDayOffset(formId)); + }).catch((err: BusinessError): void => { + LogUtil.write(`卡片驱动同步失败: ${err.message}`); + }); } onRemoveForm(formId: string): void {