From 2b29f197e80ac60b10d0b49362ac304b5a9b7732 Mon Sep 17 00:00:00 2001 From: laoyang Date: Sat, 10 Oct 2026 12:49:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=90=8C=E6=AD=A5=E9=87=8D?= =?UTF-8?q?=E6=9E=84=EF=BC=9A=E7=A7=BB=E9=99=A4=E4=B8=8D=E5=90=88=E8=A7=84?= =?UTF-8?q?=E9=95=BF=E6=97=B6=E4=BB=BB=E5=8A=A1=EF=BC=8C=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=88=86=E5=B1=82=E8=B0=83=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:用户反馈"后台常驻其实没实现,过一段时间常驻通知就没了"。调研确认这是 系统强制管控、不是 bug:DATA_TRANSFER 长时任务有一致性校验,**超 10 分钟未更新 传输进度即被强制取消**。日历同步是"间歇跑、平时无进度",不符合该语义 → 必被取消, 且频繁触发校验失败可能被列入管控名单。另:workScheduler 请求 20 分钟低于系统 "应用活跃分组"下限(活跃≥2h),被静默压制 → 名义 20 分钟实际从未生效。 改为分层调度(目标:用户要看时数据一定最新,而非追求进程常驻): 1. **移除 dataTransfer 长时任务**:BackgroundSyncService 不再申请; module.json5 去掉 backgroundModes 与 ohos.permission.KEEP_BACKGROUND_RUNNING 2. **延迟任务周期 20 分钟 → 2 小时**(对齐系统下限,避免被压制);保留 isRepeat/isPersisted 3. **新增 BackgroundSyncRunner**:后台同步唯一执行体(抽取自 SyncWorkAbility), 供延迟任务/网络恢复/回到前台三处共用,避免逻辑漂移 4. **新增 NetworkSyncWatcher**:net.connection 监听,**从无网恢复到有网时立即补同步** (带 20s 去抖;仅在 netLost→netAvailable 时触发,单纯切网不重复同步) 5. **回到前台补同步**:UIAbility.onForeground 立即同步 + 取消短时任务归还配额 6. **退后台短时任务**:onBackground 用 requestSuspendDelay 兜住退后台那次同步; 回到前台 cancelSuspendDelay(配额仅 10 分钟/日,必须归还) 7. **设置页开关语义调整**:"后台持续同步(常驻通知)" → "后台自动同步", 文案如实说明"系统最短约 2 小时一次 + 回前台/网络恢复立即补同步"; 开关现在真正控制延迟任务的注册/取消 8. **官网文档同步**:sf/uh 改写后台同步说明;upa **移除 KEEP_BACKGROUND_RUNNING 权限行** (权限表必须与实际声明一致);已重建 404 并部署线上 注:applyEfficiencyResources 在当前 SDK 不可用,未采用。 --- .../main/ets/common/BackgroundSyncRunner.ets | 102 +++++++++ .../main/ets/common/BackgroundSyncService.ets | 213 ++++++++++-------- .../main/ets/common/NetworkSyncWatcher.ets | 80 +++++++ .../main/ets/entryability/EntryAbility.ets | 13 +- entry/src/main/ets/pages/SettingsPage.ets | 16 +- .../src/main/ets/syncwork/SyncWorkAbility.ets | 80 +------ entry/src/main/module.json5 | 6 - 7 files changed, 336 insertions(+), 174 deletions(-) create mode 100644 entry/src/main/ets/common/BackgroundSyncRunner.ets create mode 100644 entry/src/main/ets/common/NetworkSyncWatcher.ets diff --git a/entry/src/main/ets/common/BackgroundSyncRunner.ets b/entry/src/main/ets/common/BackgroundSyncRunner.ets new file mode 100644 index 0000000..440b127 --- /dev/null +++ b/entry/src/main/ets/common/BackgroundSyncRunner.ets @@ -0,0 +1,102 @@ +// entry/src/main/ets/common/BackgroundSyncRunner.ets +// ⭐ 后台同步的**唯一**执行体:延迟任务 / 短时任务 / 网络恢复 / 回到前台 都调它。 +// +// 设计要点(2026-10-10 定时同步方案调研结论): +// - 不再依赖"进程常驻"(系统不允许,dataTransfer 长时任务会被强制取消) +// - 而是让"最合适的机制在最合适的时机把数据对齐" +// - 本类只负责"跑一次完整同步",幂等(基于 etag 全量比对 + dirty 标记) +// +// ⚠️ 与 SyncEngine.syncAccount 的分工:本类不关心超时与互斥锁(由 SyncEngine 内部处理)。 +import { common } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { DavAccount, AccountStore, TYPE_CALDAV } from './AccountStore'; +import { SyncEngine } from './SyncEngine'; +import { CardDataService } from './CardDataService'; +import { ReminderService } from './ReminderService'; +import { SystemCalendarImport } from './SystemCalendarImport'; +import { SystemCalendarMirror } from './SystemCalendarMirror'; +import { MirrorInbound } from './MirrorInbound'; +import { AppSettings } from './AppSettings'; +import { LogUtil } from './LogUtil'; + +/** 一次后台同步的结果(供日志/埋点,不做 UI) */ +export class BackgroundSyncResult { + ok: number = 0; + attempted: number = 0; + failed: number = 0; +} + +export class BackgroundSyncRunner { + /** + * 跑一次完整同步。全流程 try-catch,**永不抛错**(后台路径没有 UI 承担异常)。 + * @param context 应用上下文 + * @param tag 日志标签,用于区分触发来源(deferred/transient/netRecover/foreground) + */ + static async runOnce(context: common.Context, tag: string): Promise { + const r = new BackgroundSyncResult(); + try { + const accounts: DavAccount[] = await AccountStore.loadAll(context); + if (accounts.length === 0) { + LogUtil.write(`后台同步[${tag}]:无账号,跳过`); + return r; + } + // 备份模式:先导入系统本地日历(幂等),再随同步推送 + try { + await SystemCalendarImport.importIfNeeded(context); + } catch (err) { + // 导入失败不影响正常同步 + } + // 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前) + try { + await MirrorInbound.reconcile(context); + } catch (err) { + // 对账失败不影响正常同步 + } + for (const acc of accounts) { + if (acc.type !== TYPE_CALDAV) { + continue; + } + r.attempted++; + try { + await SyncEngine.syncAccount(context, acc); + r.ok++; + } catch (err) { + const e = err as BusinessError; + // "已有同步进行中"不算失败(前台可能正在同步) + r.failed++; + LogUtil.write(`后台同步[${tag}] id=${acc.id} 失败: ${e.message}`); + } + } + // 全部账号同步成功才结束"一次性全量重拉"状态 + if (r.attempted > 0 && r.ok === r.attempted) { + await AppSettings.markFullRefetchDone(context); + } + await SyncEngine.settleLocalEvents(context); + await SyncEngine.pruneOrphanRows(context, accounts); + // ⚠️ 必须回写账号:syncAccount 会就地更新 acc(服务器日历本颜色、写权限等)。 + // 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失。 + await AccountStore.saveAll(context, accounts); + // 出站镜像:把服务器最新状态写进系统日历(自动化,无需手点) + try { + if (await AppSettings.getMirrorEnabled(context) && await SystemCalendarMirror.hasPermission()) { + const mr = await SystemCalendarMirror.syncNow(context); + LogUtil.write(`后台同步[${tag}] 自动镜像:本=${mr.books} 新增=${mr.added} 更新=${mr.updated} 删除=${mr.deleted}`); + } + } catch (err) { + // 镜像失败不影响同步 + } + // 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见 + try { + await CardDataService.pushToAllForms(context); + await ReminderService.refreshReminders(context as common.UIAbilityContext); + } catch (err) { + // 卡片/提醒刷新失败不影响同步结果 + } + LogUtil.write(`后台同步[${tag}] 完成:${r.ok}/${r.attempted} 个账号`); + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`后台同步[${tag}] 流程异常: ${e.message}`); + } + return r; + } +} \ No newline at end of file diff --git a/entry/src/main/ets/common/BackgroundSyncService.ets b/entry/src/main/ets/common/BackgroundSyncService.ets index b2a7b8c..1994ca1 100644 --- a/entry/src/main/ets/common/BackgroundSyncService.ets +++ b/entry/src/main/ets/common/BackgroundSyncService.ets @@ -1,115 +1,152 @@ // entry/src/main/ets/common/BackgroundSyncService.ets -// 后台同步管理: -// 1) 长时任务(DATA_TRANSFER):进程不被挂起,前台定时器在后台继续跑(需用户在设置中开启) -// 2) 延迟任务(workScheduler):App 被杀/重启后由系统按条件拉起兜底同步(最小周期 20 分钟) +// ⭐ 后台同步调度(2026-10-10 重构,依据《定时同步方案调研.md》) +// +// 为什么不再用长时任务(重要): +// 旧版申请 `dataTransfer` 长时任务想让进程常驻,但系统对 DATA_TRANSFER 有**一致性校验**—— +// "超过 10 分钟未更新传输进度 → 任务被强制取消"。日历同步是"间歇跑、平时无进度", +// 本质不符合"持续传输中"的语义 → 必被取消(常驻通知消失就是这个原因), +// 且频繁触发校验失败可能被系统列入管控名单。故**彻底移除**。 +// +// 现在的分层调度(每层各司其职): +// ① 前台:Index 里的 setInterval 按用户设定间隔同步(最准,已在前台实现) +// ② 退后台瞬间:**短时任务**(requestSuspendDelay)兜住"退后台后那一次" +// ③ 长期后台:**延迟任务**(workScheduler,≥2 小时,对齐系统活跃分组下限) +// ④ 网络恢复 / 回到前台:各补一次(性价比最高,见 NetworkSyncWatcher / Index.onForeground) +// +// ⚠️ 关键约束(踩过的坑,勿改): +// - workScheduler 的 repeatCycleTime **不得低于 2 小时**:系统按"应用活跃分组"压制 +// (活跃≥2h / 经常使用≥4h / 常用≥24h / 极少使用≥48h / 受限·从未使用禁止调度), +// 请求更短不报错但会被压制,形同虚设。 +// - WorkSchedulerExtensionAbility 单次回调**上限 2 分钟**,超时进程被杀。 +// - 短时任务配额:单日 10 分钟、单次最长 3 分钟(低电量 1 分钟)→ 只做短收尾,用完必须 cancel。 import { backgroundTaskManager } from '@kit.BackgroundTasksKit'; -import { wantAgent, common } from '@kit.AbilityKit'; +import { common } from '@kit.AbilityKit'; import { workScheduler } from '@kit.BackgroundTasksKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { AppSettings } from './AppSettings'; import { LogUtil } from './LogUtil'; const WORK_ID: number = 1001; +/** 延迟任务周期:2 小时(系统"活跃分组"下限,不可再短) */ +const DEFERRED_CYCLE_MS: number = 2 * 60 * 60 * 1000; export class BackgroundSyncService { - /** 申请长时任务(DATA_TRANSFER):申请成功后进程在后台不被挂起,失败抛错由调用方提示用户 */ - static async startContinuousTask(context: common.UIAbilityContext): Promise { - try { - const info: wantAgent.WantAgentInfo = { - wants: [{ - bundleName: context.abilityInfo.bundleName, - abilityName: 'EntryAbility' - }], - operationType: wantAgent.OperationType.START_ABILITY, - requestCode: 0 - }; - const agent = await wantAgent.getWantAgent(info); - await backgroundTaskManager.startBackgroundRunning( - context, backgroundTaskManager.BackgroundMode.DATA_TRANSFER, agent); - LogUtil.write('长时任务已申请:后台持续同步开启'); - } catch (err) { - const e = err as BusinessError; - LogUtil.write(`长时任务申请失败: ${e.code} - ${e.message}`); - throw new Error(`长时任务申请失败(错误码 ${e.code})`); - } - } - - /** 停止长时任务 */ - static async stopContinuousTask(context: common.UIAbilityContext): Promise { - try { - await backgroundTaskManager.stopBackgroundRunning(context); - LogUtil.write('长时任务已停止'); - } catch (err) { - const e = err as BusinessError; - LogUtil.write(`长时任务停止失败: ${e.code} - ${e.message}`); - } - } + /** 当前短时任务的 requestId(0 = 无);退后台申请、回前台取消 */ + private static transientId: number = 0; /** - * 注册延迟任务兜底同步:无论长时任务开关与否都注册。 - * 条件:有任意网络即执行;周期重复;重启后自动恢复。 - * 注:repeatCycleTime 各版本下限不同(20 分钟 ~ 2 小时),先用 20 分钟, - * 被系统拒绝(9700003 条件无效)时降级为 2 小时再试。 + * 注册延迟任务兜底同步:周期 2 小时、重启后自动恢复(isPersisted)。 + * 条件:有任意网络即执行。 + * ⚠️ 周期**不得低于 2 小时**(系统按"应用活跃分组"压制:活跃≥2h / 经常使用≥4h / 常用≥24h / + * 极少使用≥48h / 受限·从未使用禁止调度)。请求更短不报错但会被压制,形同虚设。 */ static scheduleDeferredSync(context: common.UIAbilityContext): void { - const build = (cycleMs: number): workScheduler.WorkInfo => { - const info: workScheduler.WorkInfo = { - workId: WORK_ID, - bundleName: context.abilityInfo.bundleName, - abilityName: 'SyncWorkAbility', - networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY, - isRepeat: true, - isPersisted: true, - repeatCycleTime: cycleMs - }; - return info; + const info: workScheduler.WorkInfo = { + workId: WORK_ID, + bundleName: context.abilityInfo.bundleName, + abilityName: 'SyncWorkAbility', + networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY, + isRepeat: true, + isPersisted: true, + repeatCycleTime: DEFERRED_CYCLE_MS }; try { - workScheduler.startWork(build(20 * 60 * 1000)); - LogUtil.write('延迟任务已注册:20 分钟兜底同步'); + workScheduler.startWork(info); + LogUtil.write('延迟任务已注册:2 小时兜底同步(系统活跃分组下限)'); } catch (err) { const e = err as BusinessError; - if (e.code === 9700003) { - try { - workScheduler.startWork(build(2 * 60 * 60 * 1000)); - LogUtil.write('延迟任务降级注册:2 小时兜底同步'); - return; - } catch (err2) { - const e2 = err2 as BusinessError; - LogUtil.write(`延迟任务注册失败(2h): ${e2.code} - ${e2.message}`); - return; - } - } LogUtil.write(`延迟任务注册失败: ${e.code} - ${e.message}`); } } - /** App 启动时统一入口:注册延迟任务 + 按设置恢复长时任务(恢复失败静默,不影响启动) */ - static async initOnLaunch(context: common.UIAbilityContext): Promise { - BackgroundSyncService.scheduleDeferredSync(context); - const enabled: boolean = await AppSettings.getBackgroundSync(context); - if (enabled) { - try { - await BackgroundSyncService.startContinuousTask(context); - } catch (err) { - // 启动时恢复失败不阻塞 App:下次开关切换或重启再试 - } + /** 取消延迟任务(用户关闭"后台自动同步"时调用) */ + static cancelDeferredSync(context: common.UIAbilityContext): void { + const info: workScheduler.WorkInfo = { + workId: WORK_ID, + bundleName: context.abilityInfo.bundleName, + abilityName: 'SyncWorkAbility', + networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY, + isRepeat: true, + isPersisted: true, + repeatCycleTime: DEFERRED_CYCLE_MS + }; + try { + workScheduler.stopWork(info, true); + LogUtil.write('延迟任务已取消(后台自动同步关闭)'); + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`延迟任务取消失败: ${e.code} - ${e.message}`); } } - /** 设置页开关切换:失败抛错由页面提示并回滚 UI */ - static async setBackgroundSync(context: common.UIAbilityContext, enabled: boolean): Promise { - if (enabled) { - // 先申请任务,成功才落盘设置(失败时设置页回滚开关) - await BackgroundSyncService.startContinuousTask(context); - await AppSettings.setBackgroundSync(context, true); - } else { - await AppSettings.setBackgroundSync(context, false); - try { - await BackgroundSyncService.stopContinuousTask(context); - } catch (err) { - // 停止失败(如任务本就不在运行)不影响设置保存 - } + /** + * ⭐ 退到后台时申请**短时任务**:让"刚退后台的这一次同步"能跑完。 + * 必须在**前台或退后台 5 秒内**调用,否则申请失败。 + * 单次最长 3 分钟(实际申请到的时长以返回值为准),单日总配额 10 分钟。 + */ + static requestTransientSync(reason: string): void { + if (BackgroundSyncService.transientId !== 0) { + return; // 已有短时任务在跑,不重复申请(重复申请会浪费配额) + } + try { + const info: backgroundTaskManager.DelaySuspendInfo = + backgroundTaskManager.requestSuspendDelay(reason, (): void => { + // 超时回调:配额用完,系统即将挂起;清状态,等下次前台/退后台再申请 + LogUtil.write('短时任务超时(配额用尽),后台同步将被挂起'); + BackgroundSyncService.transientId = 0; + }); + BackgroundSyncService.transientId = info.requestId; + LogUtil.write(`短时任务已申请:requestId=${info.requestId},可用 ${Math.round(info.actualDelayTime / 1000)} 秒`); + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`短时任务申请失败: ${e.code} - ${e.message}`); } } -} + + /** 回到前台时取消短时任务:不占用当日配额(很重要,配额只有 10 分钟) */ + static cancelTransientSync(): void { + if (BackgroundSyncService.transientId === 0) { + return; + } + try { + backgroundTaskManager.cancelSuspendDelay(BackgroundSyncService.transientId); + LogUtil.write('短时任务已取消(回到前台,归还配额)'); + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`短时任务取消失败: ${e.code} - ${e.message}`); + } + BackgroundSyncService.transientId = 0; + } + + /** 查询短时任务剩余时间(毫秒);无任务或无配额返回 0 */ + static async remainingTransientMs(): Promise { + if (BackgroundSyncService.transientId === 0) { + return 0; + } + try { + return await backgroundTaskManager.getRemainingDelayTime(BackgroundSyncService.transientId); + } catch (err) { + return 0; + } + } + + /** App 启动时统一入口:按用户设置注册/不注册延迟任务兜底同步 */ + static async initOnLaunch(context: common.UIAbilityContext): Promise { + const enabled: boolean = await AppSettings.getBackgroundSync(context); + if (enabled) { + BackgroundSyncService.scheduleDeferredSync(context); + } else { + LogUtil.write('后台自动同步未开启:不注册延迟任务'); + } + } + + /** 设置页开关切换:控制"后台自动同步"(延迟任务注册与否) */ + static async setBackgroundSync(context: common.UIAbilityContext, enabled: boolean): Promise { + await AppSettings.setBackgroundSync(context, enabled); + if (enabled) { + BackgroundSyncService.scheduleDeferredSync(context); + } else { + BackgroundSyncService.cancelDeferredSync(context); + } + } +} \ No newline at end of file diff --git a/entry/src/main/ets/common/NetworkSyncWatcher.ets b/entry/src/main/ets/common/NetworkSyncWatcher.ets new file mode 100644 index 0000000..7641274 --- /dev/null +++ b/entry/src/main/ets/common/NetworkSyncWatcher.ets @@ -0,0 +1,80 @@ +// entry/src/main/ets/common/NetworkSyncWatcher.ets +// ⭐ 网络恢复补同步:从"无网"变为"有网"时立刻同步一次。 +// +// 为什么这条最有价值(《定时同步方案调研.md》第 ④ 层): +// 延迟任务最短 2 小时、还会随应用活跃度降级,长时间在外(飞行模式/地铁/弱网)期间 +// 基本不会跑。用户一旦回到有网环境,**立刻对齐一次**,才能做到 +// "我要看的时候数据是最新的"。这比追求"进程常驻"实际得多。 +// +// 实现:net.connection 的 netAvailable / netLost 事件 + 去抖,避免网络抖动时连打多次。 +import { connection } from '@kit.NetworkKit'; +import { common } from '@kit.AbilityKit'; +import { BackgroundSyncRunner } from './BackgroundSyncRunner'; +import { LogUtil } from './LogUtil'; + +/** 网络恢复后的去抖间隔:避免弱网下反复"断开→恢复"导致连续同步 */ +const DEBOUNCE_MS: number = 20 * 1000; + +export class NetworkSyncWatcher { + private static netConn: connection.NetConnection | null = null; + /** 上一次触发同步的时间,用于去抖 */ + private static lastTriggerAt: number = 0; + /** 当前是否处于"无网"状态(只有从无网恢复才触发,避免频繁切换网络时误触发) */ + private static offline: boolean = false; + + /** 开始监听网络变化(幂等:重复调用不会重复注册) */ + static start(context: common.UIAbilityContext): void { + if (NetworkSyncWatcher.netConn !== null) { + return; + } + try { + const conn: connection.NetConnection = connection.createNetConnection(); + conn.on('netLost', (): void => { + NetworkSyncWatcher.offline = true; + LogUtil.write('网络断开:标记为无网状态'); + }); + conn.on('netAvailable', (): void => { + // ⭐ 只有"从无网恢复"才补同步:单纯切换 Wi-Fi/蜂窝时不重复触发 + if (!NetworkSyncWatcher.offline) { + return; + } + NetworkSyncWatcher.offline = false; + NetworkSyncWatcher.triggerDebounced(context, 'netRecover'); + }); + conn.register((): void => { + LogUtil.write('网络监听已注册(网络恢复补同步)'); + }); + NetworkSyncWatcher.netConn = conn; + } catch (err) { + LogUtil.write(`网络监听注册失败:${(err as Error).message}`); + } + } + + /** 停止监听(一般不需要,App 存活期间持续有效) */ + static stop(): void { + if (NetworkSyncWatcher.netConn === null) { + return; + } + try { + NetworkSyncWatcher.netConn.unregister((err: object): void => { + // 忽略取消失败 + }); + } catch (err) { + // 忽略 + } + NetworkSyncWatcher.netConn = null; + } + + /** 去抖后触发一次同步 */ + private static triggerDebounced(context: common.UIAbilityContext, tag: string): void { + const now: number = Date.now(); + if (now - NetworkSyncWatcher.lastTriggerAt < DEBOUNCE_MS) { + LogUtil.write(`网络恢复补同步:距上次过快(<20s),跳过`); + return; + } + NetworkSyncWatcher.lastTriggerAt = now; + LogUtil.write('网络已恢复 → 触发补同步'); + // 不 await:事件回调里不能阻塞;BackgroundSyncRunner 内部全 try-catch,不会抛出 + BackgroundSyncRunner.runOnce(context, tag); + } +} \ 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 7d182de..ebf20a9 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -2,6 +2,8 @@ import { AbilityConstant, ConfigurationConstant, UIAbility, Want, Configuration, import { hilog } from '@kit.PerformanceAnalysisKit'; import { window } from '@kit.ArkUI'; import { BackgroundSyncService } from '../common/BackgroundSyncService'; +import { NetworkSyncWatcher } from '../common/NetworkSyncWatcher'; +import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner'; import { LogUtil } from '../common/LogUtil'; const DOMAIN = 0x0000; @@ -12,8 +14,11 @@ export default class EntryAbility extends UIAbility { // 文件日志初始化:所有页面的同步日志统一写入 filesDir/sync.log LogUtil.init(this.context); LogUtil.write('---- App 启动 ----'); - // 后台同步:注册延迟任务兜底 + 按设置恢复长时任务 + // 后台同步:注册延迟任务兜底(2 小时)+ 网络恢复补同步监听 + // ⚠️ 不再申请"长时任务":dataTransfer 类型有 10 分钟无进度即被取消的一致性校验, + // 日历同步不符合该语义、必被取消(详见 BackgroundSyncService 顶部注释)。 BackgroundSyncService.initOnLaunch(this.context); + NetworkSyncWatcher.start(this.context); // 服务卡片"添加"按钮跳转:把 widgetAction=add 透传给主页面(在 AppStorage 中暂存) this.handleWidgetParam(want); // ⚠️ 这里**不再申请任何权限**(含通知权限)。 @@ -189,9 +194,15 @@ export default class EntryAbility extends UIAbility { onForeground(): void { hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground'); + // ⭐ 回到前台:① 归还短时任务配额 ② 立刻补同步一次(用户真正要看时数据必须最新) + BackgroundSyncService.cancelTransientSync(); + BackgroundSyncRunner.runOnce(this.context, 'foreground'); } onBackground(): void { hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground'); + // ⭐ 退到后台:申请短时任务,兜住"退后台后那一次"同步能跑完 + // (必须在退后台 5 秒内申请;单次最长 3 分钟,单日配额 10 分钟) + BackgroundSyncService.requestTransientSync('正在同步日历数据'); } } \ No newline at end of file diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index 5d7fd52..d58caa3 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -566,23 +566,17 @@ struct SettingsPage { if (this.context === undefined) { return; } - // 后台持续同步依赖常驻通知 → 在用户主动开启时才申请通知权限(不随启动自动弹窗) - if (value) { - await this.ensureNotifyPermission(); - } try { await BackgroundSyncService.setBackgroundSync( this.context as common.UIAbilityContext, value); this.getUIContext().getPromptAction().showToast({ - message: value - ? '后台持续同步已开启,通知栏将显示常驻通知' - : '后台持续同步已关闭' + message: value ? '后台自动同步已开启' : '后台自动同步已关闭' }); } catch (err) { this.backgroundSync = !value; // 失败回滚 UI const msg: string = (err as BusinessError).message !== '' ? (err as BusinessError).message : '请重试'; - this.getUIContext().getPromptAction().showToast({ message: `开启失败:${msg}` }); + this.getUIContext().getPromptAction().showToast({ message: `设置失败:${msg}` }); } } @@ -1197,15 +1191,15 @@ struct SettingsPage { .backgroundColor($r('app.color.card_bg')) .border({ width: 1, color: $r('app.color.shadow_color') }) - // 后台持续同步(长时任务) + // 后台自动同步 Column({ space: 8 }) { Row({ space: 10 }) { Column({ space: 2 }) { - Text('后台持续同步') + Text('后台自动同步') .fontSize(15) .fontWeight(FontWeight.Medium) .fontColor($r('app.color.text_primary')) - Text('开启后申请后台长时任务,App 退到后台也按设定间隔继续同步(通知栏会显示常驻通知)') + Text('开启后,退到后台仍会定时同步(系统最短约 2 小时一次);回到前台或网络恢复时会立即补同步一次') .fontSize(12) .fontColor($r('app.color.text_secondary')) } diff --git a/entry/src/main/ets/syncwork/SyncWorkAbility.ets b/entry/src/main/ets/syncwork/SyncWorkAbility.ets index e957e81..e40a6f8 100644 --- a/entry/src/main/ets/syncwork/SyncWorkAbility.ets +++ b/entry/src/main/ets/syncwork/SyncWorkAbility.ets @@ -1,16 +1,13 @@ // entry/src/main/ets/syncwork/SyncWorkAbility.ets -// 延迟任务兜底同步:App 进程被杀/设备重启后,系统在满足条件(有网络)时拉起本扩展执行同步 -// 注意:必须幂等——同步本身基于 ETag 全量比对,天然幂等;执行完必须 stopWork,否则系统按超时处理 +// 延迟任务兜底同步:App 进程被杀/设备重启后,系统在满足条件(有网络)时拉起本扩展执行同步。 +// +// ⚠️ 约束(勿改): +// - 单次回调**上限 2 分钟**,超时进程被系统终止 → 逻辑必须轻量(本类只调 Runner,不做事后处理)。 +// - 周期由 BackgroundSyncService 设为 2 小时(系统活跃分组下限),此处不再重复设置。 +// - 必须幂等:同步基于 etag 全量比对 + dirty 标记,天然幂等。 +// - 必须主动 stopWork,否则系统按超时处理。 import { WorkSchedulerExtensionAbility, workScheduler } from '@kit.BackgroundTasksKit'; -import { BusinessError } from '@kit.BasicServicesKit'; -import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore'; -import { SyncEngine } from '../common/SyncEngine'; -import { CardDataService } from '../common/CardDataService'; -import { ReminderService } from '../common/ReminderService'; -import { SystemCalendarImport } from '../common/SystemCalendarImport'; -import { SystemCalendarMirror } from '../common/SystemCalendarMirror'; -import { MirrorInbound } from '../common/MirrorInbound'; -import { AppSettings } from '../common/AppSettings'; +import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner'; import { LogUtil } from '../common/LogUtil'; export default class SyncWorkAbility extends WorkSchedulerExtensionAbility { @@ -26,65 +23,12 @@ export default class SyncWorkAbility extends WorkSchedulerExtensionAbility { private async doSync(workInfo: workScheduler.WorkInfo): Promise { try { - const context = this.context; - const accounts: DavAccount[] = await AccountStore.loadAll(context); - // 备份模式:先导入系统本地日历(幂等),再随同步推送 - try { - await SystemCalendarImport.importIfNeeded(context); - } catch (err) { - // 导入失败不影响正常同步 - } - // 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前) - try { - await MirrorInbound.reconcile(context); - } catch (err) { - // 对账失败不影响正常同步 - } - let ok: number = 0; - let attempted: number = 0; - for (const acc of accounts) { - if (acc.type !== TYPE_CALDAV) { - continue; - } - attempted++; - try { - // 超时保护由 syncAccount 内部处理(全量重拉 10 分钟 / 常规 5 分钟),不再套外层超时 - await SyncEngine.syncAccount(context, acc); - ok++; - } catch (err) { - const e = err as BusinessError; - LogUtil.write(`延迟任务同步 id=${acc.id} 失败: ${e.message}`); - } - } - // 全部账号同步成功才结束"一次性全量重拉"状态 - if (attempted > 0 && ok === attempted) { - await AppSettings.markFullRefetchDone(context); - } - await SyncEngine.settleLocalEvents(context); - await SyncEngine.pruneOrphanRows(context, accounts); - // ⚠️ 必须回写账号:syncAccount 会就地更新 acc(服务器日历本颜色、写权限等)。 - // 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失, - // 界面下次冷启动仍用旧颜色(表现为"同步之后颜色还是不对")。 - await AccountStore.saveAll(context, accounts); - // 出站镜像:把服务器最新状态写进系统日历(自动化,无需手点) - try { - if (await AppSettings.getMirrorEnabled(context) && await SystemCalendarMirror.hasPermission()) { - const mr = await SystemCalendarMirror.syncNow(context); - LogUtil.write(`延迟任务自动镜像:本=${mr.books} 新增=${mr.added} 更新=${mr.updated} 删除=${mr.deleted}`); - } - } catch (err) { - // 镜像失败不影响同步 - } - // 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见 - await CardDataService.pushToAllForms(context); - await ReminderService.refreshReminders(context); - LogUtil.write(`延迟任务同步完成:${ok} 个账号`); - } catch (err) { - const e = err as BusinessError; - LogUtil.write(`延迟任务同步失败: ${e.message}`); + // 完整的后台同步流程(导入/对账/逐账号同步/镜像/卡片/提醒)统一由 Runner 实现, + // 与"网络恢复补同步"、"回到前台补同步"共用同一套逻辑,避免三处实现漂移。 + await BackgroundSyncRunner.runOnce(this.context, 'deferred'); } finally { // 必须主动结束,否则系统按 120 秒超时处理 workScheduler.stopWork(workInfo); } } -} +} \ No newline at end of file diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index eeabe8f..243968e 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -12,9 +12,6 @@ { "name": "ohos.permission.INTERNET" }, - { - "name": "ohos.permission.KEEP_BACKGROUND_RUNNING" - }, { "name": "ohos.permission.PUBLISH_AGENT_REMINDER" }, @@ -83,9 +80,6 @@ "startWindowBackground": "$color:start_window_background", "exported": true, "orientation": "auto_rotation_restricted", - "backgroundModes": [ - "dataTransfer" - ], "skills": [ { "entities": [