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