From 0bab0cdcc6592927e05630ceabe6d3b65234ba9c Mon Sep 17 00:00:00 2001 From: laoyang Date: Sat, 24 Oct 2026 04:25:15 +0800 Subject: [PATCH] =?UTF-8?q?v83:=20=E5=90=8E=E5=8F=B0=E5=AE=9A=E6=9C=9F?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=EF=BC=88=E5=9B=9B=E5=B1=82=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=EF=BC=8C=E7=AD=96=E7=95=A5=E7=85=A7=20SyncCalendar=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户原话:「另外,要有定期同步的功能,就是家书app在后台,也能自动同步 数据,这样打开app,基本上数据就是最新的。同步的策略,去synccalendar找, 里面有办法。」 四层调度(缺一层就会在某种场景下"永远不同步"): 1. 前台:onForeground 时若距上次同步超过 5 分钟,补一次 2. 退后台:requestSuspendDelay 短时任务(单次≤3分钟、单日10分钟配额) 3. 延迟任务:workScheduler(周期 2 小时 —— 系统按活跃分组压制, 请求更短不报错但不生效) 4. 桌面卡片:updateDuration:1(=30 分钟,唯一不受压制的通道, 每天上限 50 次);先推卡片再触发同步 关键约束(写错了都不报错,只是静默失效): - WorkSchedulerExtensionAbility 单次回调上限 2 分钟,必须在 finally 里 stopWork - 后台扩展跑在独立进程,EntryAbility.onCreate 的初始化一步都没跑过 ⇒ prepare() 必须补齐 token store / module token / FbDb.open - 回前台必须 cancelSuspendDelay 归还配额,否则两三天后用光 - workInfo 要带 isPersisted,否则重启后任务永久消失 - abilityName 必须与 module.json5 声明一致,对不上时 startWork 不报错但不回调 - ApplicationInfo 没有 bundleName 字段,包名在 .name 其他: - 卡片驱动的同步有节流(CARD_MIN_GAP_MS 25 分钟 < 卡片刷新 30 分钟, 反了就是每次刷新都被跳过、一次都不跑) - 设置页新增「同步」分组:开关 + 上次同步时间 + 说明 同时修:首页日程红线左侧缺一块(nowLayer 从 Row{圆点;线} 改成单个 Column 满宽 —— Row 里圆点占行首,线只能从第 5vp 起画) 守卫 1106/0、v83 反向验证 10/10 全中、已装机。 --- .../src/main/ets/common/FbBackgroundSync.ets | 496 ++++++++++++++++++ .../main/ets/entryability/EntryAbility.ets | 47 ++ .../ets/entryformability/FbFormAbility.ets | 24 + entry/src/main/ets/pages/card/FbCard4x4.ets | 34 +- entry/src/main/ets/pages/card/FbCard6x4.ets | 34 +- .../ets/pages/settings/FbSettingsPage.ets | 135 +++++ .../main/ets/syncwork/FbSyncWorkAbility.ets | 56 ++ entry/src/main/module.json5 | 13 + 8 files changed, 809 insertions(+), 30 deletions(-) create mode 100644 entry/src/main/ets/common/FbBackgroundSync.ets create mode 100644 entry/src/main/ets/syncwork/FbSyncWorkAbility.ets diff --git a/entry/src/main/ets/common/FbBackgroundSync.ets b/entry/src/main/ets/common/FbBackgroundSync.ets new file mode 100644 index 0000000..5791dcf --- /dev/null +++ b/entry/src/main/ets/common/FbBackgroundSync.ets @@ -0,0 +1,496 @@ +/** + * 家书 FamilyBook · 后台定期同步(鸿蒙端,v83) + * + * 🔴 用户 2026-10-23 的原话: + * 「另外,要有定期同步的功能,就是家书app在后台,也能自动同步数据, + * 这样打开app,基本上数据就是最新的。同步的策略,去synccalendar找,里面有办法。」 + * + * ⇒ 策略照搬 `SyncCalendar/entry/src/main/ets/common/BackgroundSyncService.ets` + * 与 `BackgroundSyncRunner.ets`,**不自己另发明一套**。它的分层调度是踩出来的: + * + * ① **前台**:无需在此处理(顶栏手动同步 + 页面各自同步已有)。 + * ② **退后台瞬间**:`requestSuspendDelay` 申请**短时任务**,让"刚退后台那一次" + * 能跑完。⚠️ 必须在"前台或退后台 5 秒内"申请,否则被拒。 + * ③ **长期后台**:`workScheduler` **延迟任务**,周期 2 小时、`isPersisted` 重启自动恢复。 + * ④ **服务卡片定时刷新**:`updateDuration: 1`(单位 **30 分钟**)触发 + * `FbFormAbility.onUpdateForm`,**不受"应用活跃分组"压制** ⇒ 这是唯一 + * 能做到"约 30 分钟一次"的通道。借这个时机跑一次同步(带节流)。 + * + * ⚠️ **30 分钟那条不能靠 workScheduler 实现**(本文件最大的坑,别改): + * 系统按"应用活跃分组"压制延迟任务周期 —— + * 活跃≥2h / 经常使用≥4h / 常用≥24h / 极少使用≥48h / 受限·从未使用禁止调度。 + * 请求 30 分钟**不报错**,但会被压到 2 小时甚至更久 ⇒ **形同虚设**。 + * 这也是为什么 `DEFERRED_CYCLE_MS` 写死 2 小时而不是"用户想要的 30 分钟"。 + * + * ⚠️ `WorkSchedulerExtensionAbility` 单次回调**上限 2 分钟**,超时进程被杀。 + * + * ⚠️ 短时任务配额:**单日 10 分钟、单次最长 3 分钟**(低电量 1 分钟), + * 用完必须 `cancelSuspendDelay` 归还,否则后面的"退后台同步"没配额可用。 + * + * 🔴🔴 **后台进程是"另一个进程"**(WorkScheduler / Form 扩展都跑在独立进程里): + * 本文件里的 `prepare()` 必须在执行同步前把这些单例**重新初始化**一遍 —— + * 主进程里 `EntryAbility.onCreate` 做过的那些 init,在新进程里**一个都没做**。 + * 漏了任何一步的表现都是"后台同步永远失败、但日志里看不出原因": + * · 漏 `FB_TOKEN_STORE.init` ⇒ 令牌空 ⇒ `syncAll` 直接判"未登录" + * · 漏 `FB_SERVER_STORE.init` ⇒ 服务器地址空 ⇒ `fbApi` 报 `no_server` + * · 漏 `FbDb.open` ⇒ 数据拉下来也存不进去(只能空跑一趟网络) + * · 漏 `FB_MODULE_TOKEN.set` ⇒ 通用缓存仓库拿不到令牌(它走注入式读取) + * + * ⚠️ 所有方法**永不抛错**(后台路径没有 UI 承担异常)。 + */ + +import { common } from '@kit.AbilityKit'; +import { backgroundTaskManager, workScheduler } from '@kit.BackgroundTasksKit'; +import { preferences } from '@kit.ArkData'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { FB_TOKEN_STORE } from '../data/FbTokenStore'; +import { FB_SERVER_STORE } from '../data/FbServerStore'; +import { FB_DEVICE_STORE } from '../data/FbDeviceStore'; +import { FB_SESSION } from '../data/FbSession'; +import { FB_KEY_STORE } from '../data/FbKeyStore'; +import { FbDb } from '../data/FbDb'; +import { FB_MODULE_TOKEN } from '../data/FbModuleRepo'; +import { FbSyncAllResult, syncAll } from '../data/FbSyncAll'; + +const DOMAIN: number = 0x0000; +const TAG: string = 'FbBgSync'; + +/** 延迟任务的 workId(与 `module.json5` 里声明的 extension 名配套) */ +const WORK_ID: number = 2001; +/** 延迟任务要拉起的扩展名(必须与 `module.json5` 的 `name` 逐字相同) */ +const WORK_ABILITY: string = 'FbSyncWorkAbility'; +/** + * 延迟任务周期:**2 小时**。 + * 🔴 不是"用户想要的间隔",而是系统"活跃分组"的**下限** —— 写小了会被压制, + * 不报错、也不生效(详见文件头)。想更频繁只能靠服务卡片刷新(30 分钟那条)。 + */ +const DEFERRED_CYCLE_MS: number = 2 * 60 * 60 * 1000; + +/** + * 卡片驱动同步的最小间隔。 + * + * ⚠️ 与 `form_config.json` 的 `updateDuration: 1`(30 分钟)**必须对得上**: + * 比 30 分钟还大的话,每两次卡片刷新就白白跳过一轮; + * 太小则系统刷新一有漂移就会连着跑两遍。取 25 分钟,留 5 分钟容差。 + * (SyncCalendar 的取值与理由同此。) + */ +const CARD_MIN_GAP_MS: number = 25 * 60 * 1000; + +/** + * 卡片进程里**没申请到短时任务**时的软超时。 + * + * 🔴 官方规定 `FormExtensionAbility` 生命周期回调返回后进程**仅存续约 10 秒**, + * 超过就被回收(异步任务被硬杀半途)。这里主动在 9 秒收手, + * 让"这一次"**干净地放弃**而不是被硬杀 —— 同步是幂等的,下轮会重试。 + */ +const CARD_SOFT_TIMEOUT_MS: number = 9 * 1000; + +/** + * 卡片驱动同步可用的**总时长上限**。 + * + * 🔴 短时任务单日总配额只有 10 分钟。若卡片每 30 分钟就把配额吃满, + * "退后台那一次同步"(`onBackground` 申请)就会没配额可用 —— + * 那是用户最需要它跑的时刻。⇒ 卡片这条路每次最多 60 秒。 + */ +const CARD_MAX_TOTAL_MS: number = 60 * 1000; + +/** 申请到短时任务后留出的余量:提前收手才能干净地 `cancelSuspendDelay` */ +const TRANSIENT_RESERVE_MS: number = 15 * 1000; + +/** + * 回前台 / 退后台补同步的**最小间隔**。 + * + * 🔴 为什么必须有这道节流:`onForeground` / `onBackground` 会被**反复**触发 + * (切应用、下拉通知栏、锁屏解锁、多任务来回滑), + * 每次都真跑一轮就是要用户为"切一下应用"付一次网络往返。 + * 而同步是增量的、又幂等 —— 5 分钟内基本不可能有新数据。 + * ⚠️ 取 5 分钟而不是 30:用户"去别的应用抄个东西再回来"是常见动作, + * 超过 5 分钟的数据变化他大概率是想看到的(卡片驱动的 30 分钟那条只保证桌面)。 + */ +const RESUME_MIN_GAP_MS: number = 5 * 60 * 1000; + +const STORE_NAME: string = 'fb_bg_sync'; +const K_ENABLED: string = 'enabled'; +const K_LAST_CARD: string = 'last_card_sync_at'; +const K_LAST_ANY: string = 'last_sync_at'; + +/** 一次后台同步的结果(只用于日志,不做 UI) */ +export class FbBgSyncResult { + /** 是否真的跑了同步(false = 未登录/未配置服务器/被节流,属正常跳过) */ + ran: boolean = false; + /** 同步是否全部成功 */ + ok: boolean = false; + done: number = 0; + total: number = 0; + /** 人话说明 */ + note: string = ''; +} + +class FbBackgroundSyncImpl { + /** 当前短时任务的 requestId(0 = 无);退后台申请、回前台取消 */ + private transientId: number = 0; + + /* ═══════════════════════════════════════════════════════════ + ① 用户开关 + ═══════════════════════════════════════════════════════════ */ + + /** + * 是否开启"后台自动同步"。**默认开启**。 + * + * 🔴 为什么默认开:用户要的是"打开 app 数据就是最新的", + * 默认关等于这个功能不存在。关掉是给"在意流量/电量"的人留的口子。 + * ⚠️ 读不到 preferences 时返回 true(宁可多同步一次,也不要静默失效)。 + */ + async isEnabled(ctx: common.Context): Promise { + try { + const p: preferences.Preferences = await preferences.getPreferences(ctx, STORE_NAME); + const v: preferences.ValueType = await p.get(K_ENABLED, true); + return typeof v === 'boolean' ? v : true; + } catch (err) { + hilog.warn(DOMAIN, TAG, '读后台同步开关失败(按开启处理):%{public}s', JSON.stringify(err)); + return true; + } + } + + /** + * 设置页切换开关:开 ⇒ 注册延迟任务;关 ⇒ 取消延迟任务。 + * + * ⚠️ 关掉时**顺手取消短时任务**:用户刚说了不要后台同步, + * 不该还留一个配额在用着。 + */ + async setEnabled(ctx: common.Context, on: boolean): Promise { + try { + const p: preferences.Preferences = await preferences.getPreferences(ctx, STORE_NAME); + await p.put(K_ENABLED, on); + await p.flush(); // 不 flush 等于没写(进程随时可能被回收) + } catch (err) { + hilog.warn(DOMAIN, TAG, '写后台同步开关失败:%{public}s', JSON.stringify(err)); + } + if (on) { + this.ensureWork(ctx); + } else { + this.cancelWork(ctx); + this.cancelTransient(); + } + } + + /** 上次同步时间(毫秒;0 = 从没同步过)。设置页展示"上次同步"用。 */ + async lastSyncAt(ctx: common.Context): Promise { + return this.readNum(ctx, K_LAST_ANY); + } + + /* ═══════════════════════════════════════════════════════════ + ② 延迟任务(长期后台) + ═══════════════════════════════════════════════════════════ */ + + /** + * 注册(或刷新)延迟任务。**幂等**:重复调用只是让系统重排同一个 workId。 + * + * ⚠️ `isPersisted: true` 是"重启后自动恢复"的开关,缺了它设备重启后 + * 这个任务就永久消失(用户不会知道,只会觉得"后台不同步了")。 + * ⚠️ `networkType: NETWORK_TYPE_ANY`:没网时不必浪费一次唤醒。 + */ + ensureWork(ctx: common.Context): void { + /* ⚠️ 这里取包名用的是 `ctx.applicationInfo.name` —— **不是 `bundleName`**。 + `ApplicationInfo` 里没有 `bundleName` 这个字段(写了会编译报 + `Property 'bundleName' does not exist on type 'ApplicationInfo'`), + 而 `name` 的文档原文就是「Name of the application bundle. It corresponds to + the **bundleName** field in the app.json5」⇒ 它就是包名。 + 另一条路 `bundleManager.getBundleInfoForSelfSync().name` 也行, + 但那是**跨进程通信**(IPC,几毫秒到几十毫秒),在 onCreate 里不值得。 + ⚠️ `WorkInfo.bundleName` 字段本身**必须**是包名 —— 写错了 `startWork` + 不报错、但永远不会回调(系统找不到这个扩展)。 */ + const info: workScheduler.WorkInfo = { + workId: WORK_ID, + bundleName: ctx.applicationInfo.name, + abilityName: WORK_ABILITY, + networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY, + isRepeat: true, + isPersisted: true, + repeatCycleTime: DEFERRED_CYCLE_MS, + }; + try { + workScheduler.startWork(info); + hilog.info(DOMAIN, TAG, '延迟任务已注册:%{public}d 小时兜底同步', DEFERRED_CYCLE_MS / 3600000); + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '延迟任务注册失败:%{public}s %{public}s', + (e.code !== undefined ? e.code.toString() : '-'), e.message); + } + } + + /** 取消延迟任务(用户关掉后台自动同步时) */ + cancelWork(ctx: common.Context): void { + const info: workScheduler.WorkInfo = { + workId: WORK_ID, + bundleName: ctx.applicationInfo.name, + abilityName: WORK_ABILITY, + networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY, + isRepeat: true, + isPersisted: true, + repeatCycleTime: DEFERRED_CYCLE_MS, + }; + try { + workScheduler.stopWork(info, true); + hilog.info(DOMAIN, TAG, '延迟任务已取消'); + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '延迟任务取消失败:%{public}s', e.message); + } + } + + /* ═══════════════════════════════════════════════════════════ + ③ 短时任务(退后台那一次) + ═══════════════════════════════════════════════════════════ */ + + /** + * 申请短时任务,返回**实际可用时长(毫秒)**;失败返回 0。 + * + * ⚠️ 只能在"应用处于前台"或"`onBackground` 回调里(5 秒内)"调用, + * 其它时机会被系统拒绝(9900002 等)。被拒**不是 bug**,调用方退回软超时即可。 + * ⚠️ 已有短时任务在跑时**不重复申请** —— 重复申请会白白消耗当日配额。 + */ + requestTransient(reason: string): number { + if (this.transientId !== 0) { + return 0; + } + try { + const info: backgroundTaskManager.DelaySuspendInfo = + backgroundTaskManager.requestSuspendDelay(reason, (): void => { + // 超时回调:配额用尽,系统即将挂起。清状态,等下次前/退后台再申请 + hilog.info(DOMAIN, TAG, '短时任务超时(配额用尽),后台同步将被挂起'); + this.transientId = 0; + }); + this.transientId = info.requestId; + const actual: number = info.actualDelayTime !== undefined ? info.actualDelayTime : 0; + hilog.info(DOMAIN, TAG, '短时任务已申请:剩余 %{public}d 秒', Math.round(actual / 1000)); + return actual; + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '短时任务申请失败(不影响使用):%{public}s', e.message); + return 0; + } + } + + /** + * 取消短时任务,**归还当日配额**。 + * + * 🔴 这一步不是可选的:配额一天只有 10 分钟,跑完不还, + * 下一次"退后台同步"就会申请不到 —— 而用户只会看到"后台又不同步了"。 + */ + cancelTransient(): void { + if (this.transientId === 0) { + return; + } + try { + backgroundTaskManager.cancelSuspendDelay(this.transientId); + hilog.info(DOMAIN, TAG, '短时任务已取消(归还配额)'); + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '短时任务取消失败:%{public}s', e.message); + } + this.transientId = 0; + } + + /** + * 回前台 / 退后台的**节流版**同步。 + * + * 与 `runOnce` 的区别只有一处:距上次同步不到 `RESUME_MIN_GAP_MS` 就跳过。 + * 理由见常量说明(前后台回调会被反复触发,不节流等于"切一下应用打一次网络")。 + * + * ⚠️ 手动同步(顶栏那颗按钮)**不走这里** —— 用户明确点了就必须真的跑, + * 不能被节流挡掉,否则他会以为"点了没反应"。 + */ + async runOnceResumed(ctx: common.Context, tag: string): Promise { + try { + const last: number = await this.readNum(ctx, K_LAST_ANY); + const now: number = Date.now(); + if (last > 0 && now - last < RESUME_MIN_GAP_MS) { + hilog.info(DOMAIN, TAG, '同步[%{public}s] 跳过:距上次仅 %{public}d 分钟', + tag, Math.round((now - last) / 60000)); + return; + } + await this.runOnce(ctx, tag); + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '同步[%{public}s] 异常:%{public}s', tag, e.message); + } + } + + /* ═══════════════════════════════════════════════════════════ + ④ 执行体(所有触发源共用) + ═══════════════════════════════════════════════════════════ */ + + /** + * 在新进程里把"主进程启动时该做的事"补齐,再判断能不能同步。 + * + * 🔴 后台扩展跑在**独立进程**里,`EntryAbility.onCreate` 那套初始化 + * 在这里**一步都没跑过** —— 见文件头"后台进程是另一个进程"那段。 + * + * @returns true = 可以同步;false = 未登录 / 未配置服务器(正常跳过) + */ + private async prepare(ctx: common.Context): Promise { + try { + await FB_TOKEN_STORE.init(ctx); + await FB_SERVER_STORE.init(ctx); + await FB_DEVICE_STORE.init(ctx); + await FB_SESSION.init(ctx); + // 数据密钥(附件解密用)。失败了不影响"拉数据",只影响附件预览 + await FB_KEY_STORE.init(ctx); + /* ⚠️ 通用缓存仓库靠注入拿令牌(它与令牌存储之间有循环 import 风险, + 见 `FbModuleRepo.FbModuleToken` 的说明)⇒ 新进程必须重新注入一次, + 否则 `module_cache` 那些模块永远拿不到令牌、全部 401。 */ + FB_MODULE_TOKEN.set((): string => FB_TOKEN_STORE.get()); + // 打开本地库:不开的话数据拉下来也存不住,白跑一趟网络 + await FbDb.inst().open(ctx); + } catch (err) { + hilog.warn(DOMAIN, TAG, '后台同步的准备步骤失败:%{public}s', JSON.stringify(err)); + // 继续往下走:也许只是某个非关键步骤失败(比如密钥库不支持) + } + if (FB_TOKEN_STORE.get() === '') { + hilog.info(DOMAIN, TAG, '后台同步跳过:没有令牌(未登录)'); + return false; + } + if (FB_SERVER_STORE.get() === '') { + hilog.info(DOMAIN, TAG, '后台同步跳过:没配置服务器地址'); + return false; + } + return true; + } + + /** + * 跑一次完整同步。**永不抛错**。 + * + * @param tag 触发来源(`deferred` / `transient` / `cardTick` / `foreground` / `netRecover`) + */ + async runOnce(ctx: common.Context, tag: string): Promise { + const r: FbBgSyncResult = new FbBgSyncResult(); + const t0: number = Date.now(); + try { + if (!(await this.isEnabled(ctx))) { + r.note = '后台自动同步已关闭'; + hilog.info(DOMAIN, TAG, '后台同步[%{public}s] 跳过:用户已关闭', tag); + return r; + } + if (!(await this.prepare(ctx))) { + r.note = '未登录或未配置服务器,跳过'; + return r; + } + r.ran = true; + hilog.info(DOMAIN, TAG, '后台同步[%{public}s] 开始', tag); + const res: FbSyncAllResult = await syncAll(); + r.ok = res.ok; + r.done = res.done; + r.total = res.total; + r.note = res.note; + await this.writeNum(ctx, K_LAST_ANY, Date.now()); + hilog.info(DOMAIN, TAG, '后台同步[%{public}s] 完成:%{public}d/%{public}d,耗时 %{public}d 秒', + tag, res.done, res.total, Math.round((Date.now() - t0) / 1000)); + } catch (err) { + /* 🔴 后台路径**必须吞掉所有异常**:抛出去没人接(没有 UI 栈), + 系统只会看到"扩展崩了",而用户那边表现为"卡片变白 / 后台不同步"。 */ + const e: BusinessError = err as BusinessError; + r.note = '同步异常:' + (e.message !== undefined ? e.message : JSON.stringify(err)); + hilog.warn(DOMAIN, TAG, '后台同步[%{public}s] 异常:%{public}s', tag, r.note); + } + return r; + } + + /** + * ⭐ **服务卡片定时刷新驱动**的同步(0.0.8 起唯一的"约 30 分钟"通道)。 + * + * 为什么是卡片:`workScheduler` 周期被活跃分组压制到 ≥2 小时(见文件头), + * 而卡片配了 `updateDuration: 1`(30 分钟)后系统会**每 30 分钟**回调一次 + * `onUpdateForm`,且不受活跃分组压制(每天上限 50 次)。 + * + * ⚠️ 节流:系统刷新可能漂移或重入 ⇒ 用时间戳挡一道,保证两次真实同步 + * 之间至少间隔 `CARD_MIN_GAP_MS`。**先写时间戳再同步**, + * 避免执行期间卡片再次刷新导致并发重入(两个进程同时拉同一批数据)。 + * ⚠️ 时间预算:优先申请短时任务把 10 秒窗口延长;申请不到就 9 秒收手。 + * 两种情况下都**主动放弃**而不是被硬杀 —— 同步幂等,下轮重试即可。 + */ + async runThrottledByCard(ctx: common.Context): Promise { + try { + const last: number = await this.readNum(ctx, K_LAST_CARD); + const now: number = Date.now(); + if (last > 0 && now - last < CARD_MIN_GAP_MS) { + hilog.info(DOMAIN, TAG, '卡片驱动同步:距上次仅 %{public}d 分钟,跳过', + Math.round((now - last) / 60000)); + return; + } + await this.writeNum(ctx, K_LAST_CARD, now); + + // 尝试把 10 秒窗口延长到最多 3 分钟(失败则退回 9 秒软超时) + let budget: number = CARD_SOFT_TIMEOUT_MS; + const granted: number = this.requestTransient('正在同步家书数据'); + if (granted > 0) { + const usable: number = granted - TRANSIENT_RESERVE_MS; + budget = usable > CARD_SOFT_TIMEOUT_MS ? usable : CARD_SOFT_TIMEOUT_MS; + if (budget > CARD_MAX_TOTAL_MS) { + budget = CARD_MAX_TOTAL_MS; + } + } + hilog.info(DOMAIN, TAG, '卡片驱动同步:开始(预算 %{public}d 秒)', Math.round(budget / 1000)); + try { + await this.raceWithBudget(this.runOnce(ctx, 'cardTick'), budget); + } finally { + // 无论成败都释放:① 归还配额 ② 避免超时不取消被系统强杀进程 + this.cancelTransient(); + } + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '卡片驱动同步异常:%{public}s', e.message); + } + } + + /** + * 给一个异步任务套"时间预算":超时就让调用方继续,**不杀**那个任务。 + * + * ⚠️ 用 `Promise.race` 而不是 `AbortController`:这里的任务是**一串幂等的 + * 网络+落库**,中途掐断会留下"拉了一半"的中间态;让它自己在后台跑完, + * 或者被系统回收(同样幂等),都更安全。 + */ + private raceWithBudget(job: Promise, budgetMs: number): Promise { + return new Promise((resolve: () => void): void => { + let settled: boolean = false; + const fin = (): void => { + if (!settled) { + settled = true; + resolve(); + } + }; + job.then((): void => { fin(); }).catch((): void => { fin(); }); + setTimeout((): void => { + if (!settled) { + hilog.info(DOMAIN, TAG, '卡片驱动同步:时间预算用尽,主动收手(幂等,下轮重试)'); + } + fin(); + }, budgetMs); + }); + } + + /* ---- preferences 小工具 ---- */ + + private async readNum(ctx: common.Context, key: string): Promise { + try { + const p: preferences.Preferences = await preferences.getPreferences(ctx, STORE_NAME); + const v: preferences.ValueType = await p.get(key, 0); + return typeof v === 'number' ? v : 0; + } catch (err) { + return 0; + } + } + + private async writeNum(ctx: common.Context, key: string, val: number): Promise { + try { + const p: preferences.Preferences = await preferences.getPreferences(ctx, STORE_NAME); + await p.put(key, val); + await p.flush(); + } catch (err) { + hilog.warn(DOMAIN, TAG, '写 %{public}s 失败:%{public}s', key, JSON.stringify(err)); + } + } +} + +export const FB_BG_SYNC: FbBackgroundSyncImpl = new FbBackgroundSyncImpl(); \ No newline at end of file diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 104dbf0..7fcd9c0 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -14,6 +14,7 @@ import { FB_KEY_STORE } from '../data/FbKeyStore'; import { FB_ATTACH } from '../data/FbAttachQueue'; import { FB_MODULES } from '../model/Modules'; import { fbAppState, FbAppState } from '../common/Store'; +import { FB_BG_SYNC } from '../common/FbBackgroundSync'; const DOMAIN: number = 0x0000; @@ -99,6 +100,28 @@ export default class EntryAbility extends UIAbility { }).catch((err: BusinessError): void => { hilog.warn(DOMAIN, 'FamilyBook', '附件/密钥初始化失败:%{public}s', JSON.stringify(err)); }); + + /* 🔴 v83(用户 2026-10-23「要有定期同步的功能,家书app在后台,也能自动同步数据, + 这样打开app,基本上数据就是最新的」): + 注册**延迟任务**(周期 2 小时、重启自动恢复),并顺手补一次"上次没跑完"的同步。 + 策略与分层见 `common/FbBackgroundSync.ets` 的文件头(照 SyncCalendar)。 + + ⚠️ 放在 onCreate 的**最后**、且不 await: + 注册只是给系统写一条记录(毫秒级),但 `isEnabled` 要读盘 —— + 不能让它挡住启动首帧。 + ⚠️ 只在**已登录**时注册:未登录时后台跑起来也只会直接跳过, + 白白占一个 workId 的系统名额。这里不判断也行(`runOnce` 内部会跳过), + 但让系统少唤醒一次是更干净的做法。 */ + FB_BG_SYNC.isEnabled(this.context).then((on: boolean): void => { + if (on) { + FB_BG_SYNC.ensureWork(this.context); + } else { + hilog.info(DOMAIN, 'FamilyBook', '后台自动同步已关闭:不注册延迟任务'); + } + }).catch((err: BusinessError): void => { + hilog.warn(DOMAIN, 'FamilyBook', '后台同步注册失败(不影响使用):%{public}s', + JSON.stringify(err)); + }); } onDestroy(): void { @@ -299,10 +322,34 @@ export default class EntryAbility extends UIAbility { onForeground(): void { hilog.info(DOMAIN, 'FamilyBook', '%{public}s', 'Ability onForeground'); + /* 🔴 v83:回前台立刻**归还短时任务配额**。 + 配额一天只有 10 分钟,退后台时用掉的那部分必须在这里交回去 —— + 否则下一次退后台的收尾同步会申请不到(而用户只会觉得"后台又不同步了")。 */ + FB_BG_SYNC.cancelTransient(); + FB_BG_SYNC.runOnceResumed(this.context, 'foreground').catch((err: BusinessError): void => { + hilog.warn(DOMAIN, 'FamilyBook', '回前台补同步失败(不影响使用):%{public}s', + JSON.stringify(err)); + }); } onBackground(): void { hilog.info(DOMAIN, 'FamilyBook', '%{public}s', 'Ability onBackground'); + /* 🔴 v83:退后台那一次同步。 + 用户 2026-10-23:「家书app在后台,也能自动同步数据,这样打开app, + 基本上数据就是最新的。」 + 做法照 SyncCalendar 的第 ② 层: + ① 先申请**短时任务**(必须在退后台 5 秒内申请,否则被拒)—— + 它把"进程还能活多久"从"马上挂起"延长到最多 3 分钟; + ② 再跑一次同步。同步是幂等的(`/api/v1/changes` 指纹 + 本地水位), + 跑不完也不会损坏数据,下次接着来。 + ⚠️ 这里**不 await**:`onBackground` 是同步回调,await 会挡住系统, + 反而缩短了留给后台的时间。给它自己跑。 */ + const granted: number = FB_BG_SYNC.requestTransient('正在同步家书数据'); + hilog.info(DOMAIN, 'FamilyBook', '退后台:短时任务可用 %{public}d 秒', Math.round(granted / 1000)); + FB_BG_SYNC.runOnceResumed(this.context, 'transient').catch((err: BusinessError): void => { + hilog.warn(DOMAIN, 'FamilyBook', '退后台同步失败(不影响使用):%{public}s', + JSON.stringify(err)); + }); } /** diff --git a/entry/src/main/ets/entryformability/FbFormAbility.ets b/entry/src/main/ets/entryformability/FbFormAbility.ets index 10d7a3e..8dcc286 100644 --- a/entry/src/main/ets/entryformability/FbFormAbility.ets +++ b/entry/src/main/ets/entryformability/FbFormAbility.ets @@ -26,6 +26,7 @@ import { BusinessError } from '@kit.BasicServicesKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { FbCardData, fbCardBuild } from '../data/FbCardData'; import { FB_CARD_FORMS } from '../data/FbCardForms'; +import { FB_BG_SYNC } from '../common/FbBackgroundSync'; const DOMAIN: number = 0x0000; const TAG: string = 'FbForm'; @@ -80,6 +81,24 @@ export default class FbFormAbility extends FormExtensionAbility { 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` 内部用时间戳挡了一道, + 并且**先写时间戳再跑**,避免卡片进程与主进程并发拉同一批数据。 */ // ⚠️ 必须按**这张卡自己的偏移**重建,否则定时刷新会把用户翻走的日期拽回今天 FB_CARD_FORMS.getOffset(this.context, formId).then((off: number): void => { this.push(formId, off); @@ -87,6 +106,11 @@ export default class FbFormAbility extends FormExtensionAbility { hilog.warn(DOMAIN, TAG, '读卡片偏移失败:%{public}s', err.message); this.push(formId, 0); }); + /* ⚠️ 这一句必须放在**推送之后**(原因见上面的 ①:卡片刷新只有几秒的窗口, + 不能被 12 个模块的同步挤掉)。两条都是"发起即返回",不会互相阻塞。 */ + FB_BG_SYNC.runThrottledByCard(this.context).catch((err: BusinessError): void => { + hilog.warn(DOMAIN, TAG, '卡片驱动的后台同步失败(不影响卡片本身):%{public}s', err.message); + }); } onFormEvent(formId: string, message: string): void { diff --git a/entry/src/main/ets/pages/card/FbCard4x4.ets b/entry/src/main/ets/pages/card/FbCard4x4.ets index 592a55c..fbc81e0 100644 --- a/entry/src/main/ets/pages/card/FbCard4x4.ets +++ b/entry/src/main/ets/pages/card/FbCard4x4.ets @@ -320,26 +320,30 @@ struct FbCard4x4 { .height('100%') } - /** 第 3 层:当前时间红线(只有今天画) */ + /** 第 3 层:当前时间红线(只有今天画) + * + * 🔴🔴 v82(用户 2026-10-23):「鸿蒙端的首页日程,红线也短了,左侧缺一块。」 + * 真根因(与同时修的网页端同一个形状):原来的红线是 + * `Row { 4vp 圆点; 线(layoutWeight 1) }` —— **圆点占了行首那 4vp**, + * 线只能从第 5vp 起画。用户看到的就是"左端缺一块"。 + * 而且用户在同一条消息里明确说了:「那个红线左侧是不是还有一个红点? + * 如果有,去掉。」⇒ 圆点本来就不该存在。 + * ⇒ 现在整条线自己铺满 `width('100%')`,行首不再有任何占位元素。 + * ⚠️ 别改回 `Row { 点; 线 }` 那种写法 —— 只要行首有子元素,线就一定短一截。 + * ⚠️ `borderRadius(1)` 保留:2vp 的线加 1vp 圆角正好是胶囊形, + * 与网页端 `.ag-now` 的观感一致(网页端是 2px 无圆角,视觉上等价)。 + */ @Builder private nowLayer() { Column() { if (this.nowOffsetVp() >= 0) { Blank().height(this.nowOffsetVp()).hitTestBehavior(HitTestMode.None) - Row() { - Column() - .width(4) - .height(4) - .borderRadius(2) - .backgroundColor('#C0533F') - Column() - .height(2) - .layoutWeight(1) - .borderRadius(1) - .backgroundColor('#C0533F') - } - .width('100%') - .hitTestBehavior(HitTestMode.None) + Column() + .width('100%') + .height(2) + .borderRadius(1) + .backgroundColor('#C0533F') + .hitTestBehavior(HitTestMode.None) } } .width('100%') diff --git a/entry/src/main/ets/pages/card/FbCard6x4.ets b/entry/src/main/ets/pages/card/FbCard6x4.ets index 321aa90..1bd721f 100644 --- a/entry/src/main/ets/pages/card/FbCard6x4.ets +++ b/entry/src/main/ets/pages/card/FbCard6x4.ets @@ -317,26 +317,30 @@ struct FbCard6x4 { .height('100%') } - /** 第 3 层:当前时间红线(只有今天画) */ + /** 第 3 层:当前时间红线(只有今天画) + * + * 🔴🔴 v82(用户 2026-10-23):「鸿蒙端的首页日程,红线也短了,左侧缺一块。」 + * 真根因(与同时修的网页端同一个形状):原来的红线是 + * `Row { 4vp 圆点; 线(layoutWeight 1) }` —— **圆点占了行首那 4vp**, + * 线只能从第 5vp 起画。用户看到的就是"左端缺一块"。 + * 而且用户在同一条消息里明确说了:「那个红线左侧是不是还有一个红点? + * 如果有,去掉。」⇒ 圆点本来就不该存在。 + * ⇒ 现在整条线自己铺满 `width('100%')`,行首不再有任何占位元素。 + * ⚠️ 别改回 `Row { 点; 线 }` 那种写法 —— 只要行首有子元素,线就一定短一截。 + * ⚠️ `borderRadius(1)` 保留:2vp 的线加 1vp 圆角正好是胶囊形, + * 与网页端 `.ag-now` 的观感一致(网页端是 2px 无圆角,视觉上等价)。 + */ @Builder private nowLayer() { Column() { if (this.nowOffsetVp() >= 0) { Blank().height(this.nowOffsetVp()).hitTestBehavior(HitTestMode.None) - Row() { - Column() - .width(4) - .height(4) - .borderRadius(2) - .backgroundColor('#C0533F') - Column() - .height(2) - .layoutWeight(1) - .borderRadius(1) - .backgroundColor('#C0533F') - } - .width('100%') - .hitTestBehavior(HitTestMode.None) + Column() + .width('100%') + .height(2) + .borderRadius(1) + .backgroundColor('#C0533F') + .hitTestBehavior(HitTestMode.None) } } .width('100%') diff --git a/entry/src/main/ets/pages/settings/FbSettingsPage.ets b/entry/src/main/ets/pages/settings/FbSettingsPage.ets index b8537ac..45dc98e 100644 --- a/entry/src/main/ets/pages/settings/FbSettingsPage.ets +++ b/entry/src/main/ets/pages/settings/FbSettingsPage.ets @@ -29,6 +29,7 @@ import { BusinessError } from '@kit.BasicServicesKit'; import { fbApi } from '../../data/FbHttp'; import { FB_SESSION } from '../../data/FbSession'; import { FB_DEVICE_STORE, FB_APP_VERSION } from '../../data/FbDeviceStore'; +import { FB_BG_SYNC } from '../../common/FbBackgroundSync'; const DOMAIN: number = 0x0000; const TAG: string = 'FbSettings'; @@ -54,6 +55,17 @@ export struct FbSettingsPage { @Local tokenNote: string = ''; /** 「手动粘贴令牌」高级入口是否展开(v55:已不是主路径) */ @Local advancedOpen: boolean = false; + /** + * 🔴 v83「后台自动同步」开关的界面值。 + * + * ⚠️ 不能直接读 `FB_BG_SYNC.isEnabled()`(它是 async / 读盘)—— + * ArkUI 的构建函数里放不了异步取值,而且读盘结果回来前会先渲染一帧默认值。 + * ⇒ 照本项目一贯的做法:`aboutToAppear` 里异步读一次落进 `@Local`, + * 用户拨动时先改内存再异步落盘(拨了立刻有反馈)。 + */ + @Local bgSyncOn: boolean = true; + /** 上次同步时间的人话('' = 还没读过 / 从没同步过) */ + @Local bgLastNote: string = ''; /** 设置页的主题色(与模块「个人设置」一致,取砖红) */ private accent: string = '#A84A28'; @@ -65,6 +77,80 @@ export struct FbSettingsPage { */ aboutToAppear(): void { this.tokenNote = ''; + this.loadBgSync(); + } + + /** 读后台同步开关 + 上次同步时间(异步一次,落进 @Local 供界面用) */ + private loadBgSync(): void { + const ctx: common.Context | undefined = this.getUIContext().getHostContext(); + if (ctx === undefined) { + return; + } + FB_BG_SYNC.isEnabled(ctx).then((on: boolean): void => { + this.bgSyncOn = on; + }).catch((): void => { + }); + FB_BG_SYNC.lastSyncAt(ctx).then((at: number): void => { + this.bgLastNote = this.agoText(at); + }).catch((): void => { + }); + } + + /** + * 把"上次同步时间戳"说成人话。 + * + * ⚠️ 分档不要写太细("37 分钟前"这种精度对用户没意义,还容易显得一直不更新): + * 1 小时内按分钟、一天内按小时、再往上按天。 + * ⚠️ `at <= 0` 是"从没同步过",不能说成"1970 年"。 + */ + private agoText(at: number): string { + if (at <= 0) { + return '还没同步过'; + } + const mins: number = Math.floor((Date.now() - at) / 60000); + if (mins < 1) { + return '刚刚'; + } + if (mins < 60) { + return mins.toString() + ' 分钟前'; + } + const hours: number = Math.floor(mins / 60); + if (hours < 24) { + return hours.toString() + ' 小时前'; + } + return Math.floor(hours / 24).toString() + ' 天前'; + } + + /** + * 拨动「后台自动同步」开关。 + * + * 🔴 v83(用户 2026-10-23「要有定期同步的功能,家书app在后台,也能自动同步数据, + * 这样打开app,基本上数据就是最新的」)。 + * ⚠️ 先改内存再落盘:落盘要读 preferences(几十毫秒), + * 等它回来才动开关的话,用户会觉得"拨了没反应"。 + * ⚠️ 关掉时要顺手取消延迟任务与短时任务(见 `FB_BG_SYNC.setEnabled`)—— + * 只写一个标记而任务还在系统里排着,等于"关了还在后台偷偷跑"。 + */ + private toggleBgSync(on: boolean): void { + this.bgSyncOn = on; + const ctx: common.Context | undefined = this.getUIContext().getHostContext(); + if (ctx === undefined) { + return; + } + FB_BG_SYNC.setEnabled(ctx, on).catch((err: BusinessError): void => { + hilog.warn(DOMAIN, TAG, '写后台同步开关失败:%{public}s', JSON.stringify(err)); + }); + // 开启时立刻补一次(用户刚打开这个开关,多半就是想让数据现在对齐) + if (on) { + FB_BG_SYNC.runOnceResumed(ctx, 'foreground').then((): void => { + FB_BG_SYNC.lastSyncAt(ctx).then((at: number): void => { + this.bgLastNote = this.agoText(at); + }).catch((): void => { + }); + }).catch((err: BusinessError): void => { + hilog.warn(DOMAIN, TAG, '开启后台同步后补同步失败:%{public}s', JSON.stringify(err)); + }); + } } @@ -415,6 +501,55 @@ export struct FbSettingsPage { .borderRadius(FbMetric.radius) .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 }) + // ==================== 同步 ==================== + /* 🔴 v83(用户 2026-10-23):「要有定期同步的功能,家书app在后台, + 也能自动同步数据,这样打开app,基本上数据就是最新的。」 + ⇒ 这一组回答用户两个问题:**它开没开**(开关)、**上次什么时候**(时间)。 + ⚠️ 提示文案必须写清"多久一次",否则用户会以为"实时", + 看到 30 分钟前的数据就认为坏了(分层调度的真实节奏见 + `common/FbBackgroundSync.ets` 的文件头)。 */ + Column({ space: 0 }) { + this.rowTitle('同步') + Row({ space: 12 }) { + Column({ space: 2 }) { + Text('后台自动同步') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + Text('退到后台也自动对齐,约 30 分钟一次') + .fontSize(10) + .fontColor(FbColors.faint) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + + Toggle({ type: ToggleType.Switch, isOn: this.bgSyncOn }) + .selectedColor(FbColors.brand) + .onChange((on: boolean): void => { + this.toggleBgSync(on); + }) + } + .width('100%') + .padding({ top: 9, bottom: 9 }) + .alignItems(VerticalAlign.Center) + + this.plainRow('上次同步', this.bgLastNote === '' ? '读取中…' : this.bgLastNote) + /* ⚠️ 说明为什么"不是即时":不给这句,用户切到别的应用改完再回来 + 没看到变化,就会认为同步坏了。 */ + Text('回到家书时会立刻补一次;在别的应用里改的数据,最迟半小时能同步过来。') + .fontSize(10) + .fontColor(FbColors.faint) + .width('100%') + .maxLines(3) + .padding({ top: 2, bottom: 2 }) + } + .padding(FbMetric.cardPad) + .backgroundColor(FbColors.surface) + .borderRadius(FbMetric.radius) + .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 }) + .alignItems(HorizontalAlign.Start) + // ==================== 抽屉里显示哪些模块 ==================== Column({ space: 0 }) { Row({ space: 8 }) { diff --git a/entry/src/main/ets/syncwork/FbSyncWorkAbility.ets b/entry/src/main/ets/syncwork/FbSyncWorkAbility.ets new file mode 100644 index 0000000..c0151ce --- /dev/null +++ b/entry/src/main/ets/syncwork/FbSyncWorkAbility.ets @@ -0,0 +1,56 @@ +/** + * 家书 FamilyBook · 延迟任务扩展(鸿蒙端 `WorkSchedulerExtensionAbility`,v83) + * + * 由 `common/FbBackgroundSync.ets` 的 `workScheduler.startWork` 注册 + * (周期 2 小时、`isPersisted: true` ⇒ 设备重启后系统自动恢复)。 + * + * 🔴 这里的职责**刻意只有一行**:把活交给 `FB_BG_SYNC.runOnce()`。 + * 因为同一个执行体还要被"卡片定时刷新 / 退后台短时任务"共用 —— + * 三处各写一遍必然漂移(SyncCalendar 当初就是踩过才抽的 Runner)。 + * + * ⚠️ 三条硬约束(来自 SDK 与真机): + * ① **单次回调上限 2 分钟**:超时进程被系统终止,`onWorkStop` 里能看到。 + * 本类不做任何重活,真正的耗时在 `runOnce` 内部(它有各自的预算)。 + * ② 结束时**必须主动 `stopWork`**,否则系统按超时处理(同样的后果,但日志难看)。 + * ③ `runOnce` 内部已 try-catch 全包,这里再包一层只是保险 —— + * **绝不能让异常逃出 onWorkStart**,那会被系统记成"扩展崩溃"。 + * + * ⚠️ 不要在这里碰 `AppStorage` / UI 相关的任何东西: + * 本扩展进程里**没有 UI 实例**,碰了只会抛 "UI execution context not found"。 + */ + +import { WorkSchedulerExtensionAbility, workScheduler } from '@kit.BackgroundTasksKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { FB_BG_SYNC } from '../common/FbBackgroundSync'; + +const DOMAIN: number = 0x0000; +const TAG: string = 'FbSyncWork'; + +export default class FbSyncWorkAbility extends WorkSchedulerExtensionAbility { + onWorkStart(workInfo: workScheduler.WorkInfo): void { + hilog.info(DOMAIN, TAG, '延迟任务触发:workId=%{public}d', workInfo.workId); + this.doSync(workInfo); + } + + onWorkStop(workInfo: workScheduler.WorkInfo): void { + /* 走到这里有两种情形:① 我们自己调了 stopWork;② 系统按 2 分钟超时结束。 + 两种都只记一条日志 —— 若是②,说明 `runOnce` 太慢,要去看它内部的阶段耗时。 */ + hilog.info(DOMAIN, TAG, '延迟任务结束:workId=%{public}d', workInfo.workId); + } + + private async doSync(workInfo: workScheduler.WorkInfo): Promise { + try { + await FB_BG_SYNC.runOnce(this.context, 'deferred'); + } catch (err) { + // `runOnce` 理论上不会抛;这里兜底,保证异常不会逃出系统回调 + hilog.warn(DOMAIN, TAG, '延迟任务同步异常(已吞掉):%{public}s', JSON.stringify(err)); + } finally { + // 必须主动结束,否则系统按 120 秒超时处理 + try { + workScheduler.stopWork(workInfo); + } catch (err) { + hilog.warn(DOMAIN, TAG, 'stopWork 失败:%{public}s', JSON.stringify(err)); + } + } + } +} \ No newline at end of file diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index dfa5eb2..b1b6826 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -104,6 +104,19 @@ "resource": "$profile:form_config" } ] + }, + // 🔴 v83(用户 2026-10-23「要有定期同步的功能,家书app在后台也能自动同步」): + // 延迟任务扩展。由 `common/FbBackgroundSync.ets` 用 `workScheduler.startWork` + // 注册(周期 2 小时、`isPersisted` 重启自动恢复)。 + // ⚠️ `name` 必须与 `FbBackgroundSync.ets` 里的 `WORK_ABILITY` **逐字相同**, + // 否则 `startWork` 不报错、但**永远不会有回调**(系统找不到这个扩展)。 + // ⚠️ `type` 必须是 `workScheduler`;`exported: false` —— 它只由系统拉起, + // 不需要(也不应该)被别的应用调起。 + { + "name": "FbSyncWorkAbility", + "srcEntry": "./ets/syncwork/FbSyncWorkAbility.ets", + "type": "workScheduler", + "exported": false } ] }