From bd3afb7bd1b93b815d10f1333b4a549950b50bbc Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 14 Oct 2026 16:51:56 +0800 Subject: [PATCH] =?UTF-8?q?0.0.8=EF=BC=9A=E5=8D=A1=E7=89=87=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=B0=9D=E8=AF=95=E7=94=A8=E7=9F=AD=E6=97=B6=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=BB=B6=E9=95=BF=E7=AA=97=E5=8F=A3=EF=BC=8810=20?= =?UTF-8?q?=E7=A7=92=20=E2=86=92=20=E6=9C=80=E5=A4=9A=2060=20=E7=A7=92?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:上一步实现了"卡片 30 分钟刷新驱动同步",但受 FormExtensionAbility "回调后进程仅存续 10 秒"的硬限制,同步可能被回收掐断。 用户建议:能不能调一个后台进程、或让异步同步在 10 秒后继续跑一会儿? 调研结论(已核实官方文档): 1. ❌ ServiceExtensionAbility(独立后台服务)—— 三方应用**根本不能用**: 官方"仅系统应用可使用",需 Full SDK + AllowAppUsePrivilegeExtension 特权 + 设备白名单。 2. ❌ 从卡片进程 startAbility 拉起主应用 —— 会被拦:官方《组件启动规则》 "位于后台的 UIAbility 应用,启动组件需校验 ohos.permission.START_ABILITIES_FROM_BACKGROUND, **该权限仅系统应用可申请**"。 (卡片 postCardAction 的 `call` 事件确实能"后台拉起 UIAbility 且不弹界面", 但**只能由用户点击卡片触发**,系统定时刷新的 onUpdateForm 用不上。) 3. ✅ **短时任务 `requestSuspendDelay`** —— 可行路径:让进程在后台多活一会儿, 单次最长 3 分钟(低电量 1 分钟),实际时长以 actualDelayTime 为准。 本次改动(BackgroundSyncRunner): - 新增 `acquireCardWindow()`:尝试申请短时任务, · **容错尝试**:官方规定应在"应用在前台"或"onBackground 回调"里调用, 卡片进程是否符合**未知** → 抛错 / 时长为 0 时**优雅退回 9 秒软超时**,不阻塞流程; · 留 **15 秒余量**提前收手(保证能干净地 cancelSuspendDelay, 否则超时不取消会被系统强制终止进程并浪费配额); · 加 **60 秒/次总上限**(CARD_MAX_TOTAL_MS)。 - 新增 `releaseCardWindow()`:无论成败都在 finally 里释放,归还配额。 - `runOnceThrottledByCard()` 改为按"实际可用窗口"设软超时,并记录窗口秒数到日志。 ⚠️ 配额账(关键取舍): 短时任务**单日总配额仅 10 分钟**,且**只在被挂起后计时**(10 秒内跑完不计费)。 · 正常网络下增量同步几秒完成 → 多落在 10 秒窗口内 → 消耗极少; · 网络慢才会吃配额;吃满 10 分钟后自动降级为 9 秒软超时,不报错; · ⚠️ **不能吃光配额**:EntryAbility.onBackground 也申请短时任务(兜"退后台那一次同步"), 故卡片侧设 60 秒/次上限 + 15 秒提前释放,避免把它饿死。 验证:编译通过(BUILD SUCCESSFUL);窗口预算逻辑离线用例通过 (申请被拒/时长为0 → 9 秒;低电量 → 45 秒;正常 3 分钟 → 被 60 秒上限截断;40 秒 → 25 秒)。 --- .../main/ets/common/BackgroundSyncRunner.ets | 88 +++++++++++++++++-- 1 file changed, 80 insertions(+), 8 deletions(-) diff --git a/entry/src/main/ets/common/BackgroundSyncRunner.ets b/entry/src/main/ets/common/BackgroundSyncRunner.ets index 5b0f7e5..3dee6c4 100644 --- a/entry/src/main/ets/common/BackgroundSyncRunner.ets +++ b/entry/src/main/ets/common/BackgroundSyncRunner.ets @@ -8,6 +8,7 @@ // // ⚠️ 与 SyncEngine.syncAccount 的分工:本类不关心超时与互斥锁(由 SyncEngine 内部处理)。 import { common } from '@kit.AbilityKit'; +import { backgroundTaskManager } from '@kit.BackgroundTasksKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { DavAccount, AccountStore, TYPE_CALDAV } from './AccountStore'; import { SyncEngine } from './SyncEngine'; @@ -35,12 +36,26 @@ export class BackgroundSyncRunner { /** 距上次同步不足该时长则跳过(略小于卡片周期,避免系统刷新漂移导致被误跳过) */ private static readonly CARD_MIN_GAP_MS: number = 25 * 60 * 1000; /** - * 卡片进程的软超时:官方规定 FormExtensionAbility 生命周期回调后进程**仅存续 10 秒**, - * 超过就会被回收(异步任务被强制中止)。这里主动在 9 秒收手, - * 让"这一次同步"**干净地放弃**而不是被硬杀在半途 —— 幂等同步中断本就不损坏数据, - * 主动放弃还能保住"时间戳已写入、下轮再试"的一致性。 + * 卡片进程的软超时(**未申请到短时任务时**):官方规定 FormExtensionAbility + * 生命周期回调后进程**仅存续 10 秒**,超过就被回收(异步任务被强制中止)。 + * 这里主动在 9 秒收手,让"这一次同步"**干净地放弃**而不是被硬杀在半途。 */ private static readonly CARD_SOFT_TIMEOUT_MS: number = 9 * 1000; + /** + * 申请到短时任务后的软超时:短时任务单次最长 3 分钟(低电量 1 分钟), + * 但实际时长以 `actualDelayTime` 为准 → 留 15 秒余量提前收手,保证能干净地 + * `cancelSuspendDelay`(否则超时不取消会被系统强制终止进程、并浪费当日配额)。 + */ + private static readonly CARD_EXT_SOFT_RESERVE_MS: number = 15 * 1000; + /** + * 卡片驱动同步可用的**总时长上限**:即使申请到短时任务也不超过这个值。 + * 理由:短时任务**单日总配额仅 10 分钟**,30 分钟一次的话每次平均只能分到约 20 秒, + * 全用光会让"退后台那一次同步"(EntryAbility.onBackground 申请)无配额可用。 + */ + private static readonly CARD_MAX_TOTAL_MS: number = 60 * 1000; + + /** 本次卡片同步申请的短时任务 requestId(0 = 未申请到) */ + private static cardTransientId: number = 0; /** * ⭐⭐ 由**服务卡片定时刷新**(`onUpdateForm`)驱动的后台同步(0.0.8)。 @@ -66,25 +81,82 @@ export class BackgroundSyncRunner { } // 先写时间戳:避免同步执行期间卡片再次刷新导致并发重入 await AppSettings.setCardSyncAt(context, now); - LogUtil.write('卡片驱动同步:开始(卡片 30 分钟定时刷新触发)'); - // 软超时:9 秒内没跑完就主动放弃(进程本来也只有 10 秒窗口) + // ⭐ 尝试申请**短时任务**,把 10 秒窗口延长到最多 3 分钟。 + // ⚠️ 官方规定 requestSuspendDelay 应在"应用处于前台"或"onBackground 回调"里调用; + // 卡片进程(FormExtensionAbility)是否符合**未知** → 故此处**容错尝试**: + // 成功则按实际时长延长,失败(抛错/时长为 0)则退回 9 秒软超时。 + // ⚠️ 配额:短时任务单日总配额 10 分钟、单次最长 3 分钟(低电量 1 分钟), + // 且**被挂起后才计时**(10 秒内跑完不消耗)→ 对我们很划算。 + const windowMs: number = await BackgroundSyncRunner.acquireCardWindow(); + const budget: number = Math.min(windowMs, BackgroundSyncRunner.CARD_MAX_TOTAL_MS); + LogUtil.write(`卡片驱动同步:开始(卡片 30 分钟定时刷新触发,可用窗口 ${Math.round(budget / 1000)} 秒)`); + // 软超时:预算内没跑完就主动放弃(幂等同步,中断不损坏数据,下轮会重试) 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); + setTimeout((): void => rej(new Error('本次同步时间预算用尽,主动收手')), + budget); }); try { await Promise.race([worker, guard]); } catch (err) { const e = err as Error; LogUtil.write(`卡片驱动同步提前收手:${e.message}(幂等,下轮会重试)`); + } finally { + // 无论成败都释放短时任务:① 归还当日配额 ② 避免超时不取消被系统强杀进程 + BackgroundSyncRunner.releaseCardWindow(); } } catch (err) { const e = err as BusinessError; LogUtil.write(`卡片驱动同步异常: ${e.message}`); } } + + /** + * 为卡片同步申请短时任务,返回**实际可用窗口(毫秒)**。 + * 申请失败/异常/时长为 0 → 返回 0,由调用方退回默认的 9 秒软超时(不阻塞流程)。 + */ + private static async acquireCardWindow(): Promise { + try { + const info: backgroundTaskManager.DelaySuspendInfo = + backgroundTaskManager.requestSuspendDelay('正在同步日历数据', (): void => { + // 超时回调:配额即将用尽,清掉本地记录(系统会自行处理挂起) + LogUtil.write('卡片同步的短时任务即将超时'); + BackgroundSyncRunner.cardTransientId = 0; + }); + BackgroundSyncRunner.cardTransientId = info.requestId; + const actual: number = info.actualDelayTime ?? 0; + if (actual <= 0) { + // 拿到了 requestId 但没有实际时长 → 视为不可用 + BackgroundSyncRunner.releaseCardWindow(); + LogUtil.write('卡片同步:短时任务时长为 0,退回 9 秒软超时'); + return BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS; + } + // 留 15 秒余量提前收手,保证能干净地取消 + const usable: number = Math.max(actual - BackgroundSyncRunner.CARD_EXT_SOFT_RESERVE_MS, + BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS); + LogUtil.write(`卡片同步:短时任务已获批,实际时长 ${Math.round(actual / 1000)} 秒`); + return usable; + } catch (err) { + // 例如"未在前台/退后台时机申请"被拒 —— 退回 9 秒软超时,不影响流程 + const e = err as BusinessError; + LogUtil.write(`卡片同步:短时任务申请失败(${e.code ?? ''} ${e.message}),退回 9 秒软超时`); + return BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS; + } + } + + /** 释放卡片同步的短时任务(幂等) */ + private static releaseCardWindow(): void { + if (BackgroundSyncRunner.cardTransientId === 0) { + return; + } + try { + backgroundTaskManager.cancelSuspendDelay(BackgroundSyncRunner.cardTransientId); + } catch (err) { + // 取消失败无需处理 + } + BackgroundSyncRunner.cardTransientId = 0; + } /** * 跑一次完整同步。全流程 try-catch,**永不抛错**(后台路径没有 UI 承担异常)。 * @param context 应用上下文