v83: 后台定期同步(四层调度,策略照 SyncCalendar)
用户原话:「另外,要有定期同步的功能,就是家书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 全中、已装机。
This commit is contained in:
@@ -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<boolean> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<FbBgSyncResult> {
|
||||
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<void> {
|
||||
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<FbBgSyncResult>, budgetMs: number): Promise<void> {
|
||||
return new Promise<void>((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<number> {
|
||||
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<void> {
|
||||
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();
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%')
|
||||
|
||||
@@ -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%')
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user