HarmonyOS WorkScheduler 延迟任务:约束条件、幂等执行与失败续跑

请添加图片描述

“每天凌晨两点上传一次数据”听起来像后台需求,直接交给 WorkScheduler 却很可能得到错误预期:设备可能关机、无网、低电或处于高负载,系统不会承诺在指定秒级时刻唤醒应用。延迟任务解决的是“当网络、电量、充电、存储等条件合适时执行一段非实时工作”,不是替代闹钟,也不是让应用在后台常驻。

本文以“在 Wi-Fi 且充电时分批同步离线笔记”为例,建立 WorkInfo 注册、WorkSchedulerExtensionAbility 回调、检查点幂等、两分钟收口和失败续跑。读完后可以清楚判断什么时候该用延迟任务,什么时候应改用短时任务、长时任务或代理提醒。

1. 先判断业务是否真的适合延迟调度

适合的任务通常具备三个特点:用户不等待即时结果、触发时间允许漂移、工作能被切成小批次。离线索引维护、非紧急同步、缓存整理都属于这一类。

export interface DeferredJobDecision {
  requiresExactTime: boolean;
  userCanSeeProgress: boolean;
  canWaitForConstraints: boolean;
  canSplitIntoBatches: boolean;
}

export function shouldUseWorkScheduler(value: DeferredJobDecision): boolean {
  return !value.requiresExactTime
    && !value.userCanSeeProgress
    && value.canWaitForConstraints
    && value.canSplitIntoBatches;
}

需要准点通知的业务考虑代理提醒;用户主动开始并持续感知的上传下载考虑长时任务;前台切后台后只需短暂收尾的工作考虑短时任务。类型选择错误,后面再多重试代码也无法保证体验。

2. 接口版本与硬性约束

本文以 Stage 模型、ArkTS、HarmonyOS SDK API 23 为基线,使用 @kit.BackgroundTasksKit。WorkScheduler 首批接口从 API 9 起提供,earliestStartTime 从 API 22 起提供。官方接口约束中有几项必须先写进设计:

约束 工程含义
仅 Stage 模型可用 FA 工程不能照搬示例
workIdbundleNameabilityName 必填 注册信息必须与应用配置一致
至少设置一个触发条件 不能创建完全无约束的后台工作
参数只支持 number/string/boolean 复杂对象应传业务键,不传整段 JSON
循环间隔至少 2 小时 不适合分钟级轮询
单次后台运行应小于 2 分钟 工作必须分批并主动收口

“满足约束”也不等于立即执行,系统还会综合性能、功耗和温度等状态安排时机。

3. 在 module.json5 声明专用 ExtensionAbility

调度回调落到 WorkSchedulerExtensionAbility,它需要在模块配置中声明为 workScheduler 类型。名称必须与后续 WorkInfo.abilityName 完全一致。

{
  "module": {
    "name": "entry",
    "type": "entry",
    "srcEntry": "./ets/MyAbilityStage.ets",
    "extensionAbilities": [
      {
        "name": "NoteSyncWorkExtension",
        "srcEntry": "./ets/workscheduler/NoteSyncWorkExtension.ets",
        "type": "workScheduler",
        "exported": false
      }
    ]
  }
}

该扩展只服务本应用的系统调度,不需要被外部应用直接调用,因此保持 exported: false。实际字段支持范围以当前工程模板和 SDK 配置声明为准。

4. WorkInfo 表达条件,不表达执行时间承诺

示例要求 Wi-Fi、充电,并允许设备重启后恢复注册。earliestStartTime 只表示从申请时刻起最早多久可以触发,不是倒计时结束就必须执行。

import { workScheduler } from '@kit.BackgroundTasksKit';

const NOTE_SYNC_WORK_ID: number = 12001;

export function buildNoteSyncWork(bundleName: string): workScheduler.WorkInfo {
  return {
    workId: NOTE_SYNC_WORK_ID,
    bundleName,
    abilityName: 'NoteSyncWorkExtension',
    networkType: workScheduler.NetworkType.NETWORK_TYPE_WIFI,
    isCharging: true,
    chargerType: workScheduler.ChargingType.CHARGING_PLUGGED_ANY,
    isPersisted: true,
    isRepeat: true,
    repeatCycleTime: 2 * 60 * 60 * 1000,
    earliestStartTime: 15 * 60 * 1000,
    parameters: {
      jobType: 'note_delta_sync',
      batchSize: 50,
      schemaVersion: 1
    }
  };
}

循环任务间隔取官方允许的下限,生产项目应按真实业务放宽。batchSize 只是提示值,回调还需校验范围,不能直接信任注册参数。

5. 延迟任务闭环从注册到检查点结束

请添加图片描述

完整闭环不是“注册后等回调”:业务先定义条件和批次;系统把任务放入队列;约束满足时启动 Extension;执行层根据检查点处理未完成部分;成功保存新游标,失败保留旧游标,等待后续调度继续。

import { BusinessError } from '@kit.BasicServicesKit';
import { workScheduler } from '@kit.BackgroundTasksKit';

export function registerNoteSync(bundleName: string): void {
  try {
    workScheduler.startWork(buildNoteSyncWork(bundleName));
    console.info('note sync work registered');
  } catch (reason) {
    const error = reason as BusinessError;
    console.error(`register work failed: code=${error.code}, message=${error.message}`);
  }
}

startWork() 返回 void,成功表示任务已加入执行队列,不表示同步已经完成。页面提示应写“已安排后台同步”,而不是“同步成功”。

6. onWorkStart 只负责接收调度并启动受控工作

扩展回调签名返回 void。不要在回调里启动没有边界的常驻循环,也不要依赖 Extension 进程永久存活。下面把一次运行交给独立执行器,并捕获所有异常。

import WorkSchedulerExtensionAbility from '@ohos.WorkSchedulerExtensionAbility';
import { workScheduler } from '@kit.BackgroundTasksKit';

export default class NoteSyncWorkExtension extends WorkSchedulerExtensionAbility {
  private readonly runner: NoteBatchRunner = new NoteBatchRunner();

  onWorkStart(work: workScheduler.WorkInfo): void {
    console.info(`work start: ${work.workId}`);
    void this.runner.run(work).catch((reason: Object) => {
      console.error(`work execution failed: ${JSON.stringify(reason)}`);
    });
  }

  onWorkStop(work: workScheduler.WorkInfo): void {
    console.info(`work stop: ${work.workId}`);
    this.runner.cancel(work.workId);
  }
}

onWorkStop() 是明确的停止信号,执行器的网络请求和批处理循环都应检查取消状态,而不是只打印一条日志。

7. 参数入口必须再次校验

注册发生在应用侧,回调可能在更晚时间甚至设备重启后发生。执行器不能假设所有参数仍符合当前版本,应核对任务 ID、类型、批量大小和 schema 版本。

interface NoteJobOptions {
  batchSize: number;
  schemaVersion: number;
}

function parseOptions(work: workScheduler.WorkInfo): NoteJobOptions {
  if (work.workId !== NOTE_SYNC_WORK_ID) throw new Error('unexpected work id');
  const params = work.parameters ?? {};
  if (params['jobType'] !== 'note_delta_sync') throw new Error('unexpected job type');

  const rawBatch = params['batchSize'];
  const rawSchema = params['schemaVersion'];
  const batchSize = typeof rawBatch === 'number' ? Math.round(rawBatch) : 20;
  const schemaVersion = typeof rawSchema === 'number' ? Math.round(rawSchema) : 0;
  if (batchSize < 1 || batchSize > 100) throw new Error('invalid batch size');
  if (schemaVersion !== 1) throw new Error('unsupported schema version');
  return { batchSize, schemaVersion };
}

只传基础类型可以降低跨进程序列化风险,但不代表值天然可信。业务版本升级时可通过 schemaVersion 拒绝旧任务,并由前台重新注册。

8. 幂等依赖稳定业务键和提交检查点

系统可能因失败、超时或循环规则再次触发同一工作。服务端请求应携带稳定幂等键,本地只在服务端确认成功后推进游标。

export interface SyncCheckpoint {
  cursor: number;
  lastCommittedBatch: string;
  updatedAt: number;
}

export function buildIdempotencyKey(
  deviceScope: string,
  fromCursor: number,
  toCursor: number
): string {
  return `${deviceScope}:${fromCursor}:${toCursor}`;
}

export interface CheckpointStore {
  load(): Promise<SyncCheckpoint>;
  commit(value: SyncCheckpoint): Promise<void>;
}

不要使用当前时间作为唯一幂等键,否则重试会生成新请求,服务端无法识别重复批次。检查点写入失败时保持旧游标,下一次仍会提交同一幂等键。

9. 单次执行预留收尾时间

官方规范要求延迟任务后台运行小于两分钟。执行器不应跑到最后一毫秒才停止,示例给自己 100 秒工作预算,剩余时间用于保存检查点和释放资源。

class ExecutionWindow {
  private readonly deadline: number;

  constructor(maxWorkMs: number = 100_000) {
    this.deadline = Date.now() + maxWorkMs;
  }

  canStartNextBatch(reserveMs: number = 10_000): boolean {
    return Date.now() + reserveMs < this.deadline;
  }

  remainingMs(): number {
    return Math.max(0, this.deadline - Date.now());
  }
}

实际预算还应考虑网络超时。单个请求超时时间不能比整个执行窗口还长,否则无法在停止信号到来时及时收口。

10. 批处理循环每一步都允许取消

执行器按检查点读取一批待同步笔记,成功后推进游标;无数据、预算不足或收到停止信号时结束。失败不在后台无限重试,而是保留检查点等待后续调度。

class NoteBatchRunner {
  private cancelled: Set<number> = new Set<number>();

  cancel(workId: number): void {
    this.cancelled.add(workId);
  }

  async run(work: workScheduler.WorkInfo): Promise<void> {
    const options = parseOptions(work);
    const window = new ExecutionWindow();
    this.cancelled.delete(work.workId);

    while (!this.cancelled.has(work.workId) && window.canStartNextBatch()) {
      const count = await this.syncNextBatch(options.batchSize);
      if (count === 0) break;
    }
  }

  private async syncNextBatch(batchSize: number): Promise<number> {
    // 从检查点读取待同步记录,上传成功后再提交新检查点。
    return Math.min(batchSize, 0);
  }
}

示例中的数据访问是接口占位,项目应接入自己的 Repository。关键语义是:每批独立提交、循环前后可取消、无数据立即退出。

11. 失败续跑不是在 Extension 内自旋重试

网络波动时立即重试一次可能有价值,但无限指数退避会消耗整个后台窗口。更稳的策略是按错误类别决定:认证错误停止并等待用户处理;网络错误保存游标;服务端重复响应按幂等结果处理;数据格式错误隔离单条记录。

type SyncFailure = 'network' | 'auth' | 'invalid_record' | 'server';

function retryInCurrentWindow(kind: SyncFailure, attempt: number): boolean {
  if (kind === 'auth' || kind === 'invalid_record') return false;
  return attempt < 2;
}

function retryDelayMs(attempt: number): number {
  return Math.min(1000 * 2 ** attempt, 5000);
}

当前窗口放弃后,不修改成功游标,下一次系统调度从检查点继续。这样重启、进程回收和重复触发都不会让已确认批次再次产生副作用。

12. 调度责任边界不让 Extension 承担长期状态

请添加图片描述

业务计划定义要完成的目标;WorkInfo 只携带基础参数与约束;系统调度器决定何时启动;Extension 在有限窗口内执行批次。长期游标存入持久层,不能只放在 Extension 成员变量里。

export interface DeferredSyncRepository {
  nextBatch(cursor: number, limit: number): Promise<Array<PendingNote>>;
  upload(notes: Array<PendingNote>, idempotencyKey: string): Promise<void>;
  loadCheckpoint(): Promise<SyncCheckpoint>;
  saveCheckpoint(value: SyncCheckpoint): Promise<void>;
}

export interface PendingNote {
  localId: string;
  revision: number;
  updatedAt: number;
}

把长期状态放进 Repository 后,Extension 被系统重新创建也能恢复进度。parameters 只携带任务类型和批量策略,不携带数百条待同步数据。

13. 注册、查询与取消要使用同一个任务定义

排查任务是否在队列中可以调用 getWorkStatus()obtainAllWorks()。取消时传入与注册一致的核心字段,并根据是否需要移除任务选择 needCancel

export async function describeNoteSync(): Promise<string> {
  try {
    const work = await workScheduler.getWorkStatus(NOTE_SYNC_WORK_ID);
    return `work=${work.workId}, repeat=${work.isRepeat}, persisted=${work.isPersisted}`;
  } catch (reason) {
    return `work not registered: ${JSON.stringify(reason)}`;
  }
}

export function removeNoteSync(bundleName: string): void {
  workScheduler.stopWork(buildNoteSyncWork(bundleName), true);
}

退出账号、关闭云同步或切换数据域时应取消旧任务,并清理对应检查点。只删除本地游标而保留调度任务,会让 Extension 后续再次启动。

14. 常见误区按调度语义排查

现象 根因方向 修正方式
到点没有立即运行 把 earliestStartTime 当闹钟 接受系统择机,准点业务用代理提醒
注册时报参数错误 缺少触发条件或名称不匹配 核对 WorkInfo 与 module.json5
每次都重复上传 幂等键不稳定、游标提前推进 服务端确认后再提交检查点
后台运行被终止 单批过大或循环没有时间边界 预留收尾时间并拆小批次
循环任务无法注册 间隔小于 2 小时 放宽周期或重新选择任务类型
设备重启后任务消失 未设置 isPersisted 对允许恢复的任务开启持久注册
关闭同步后仍被唤起 只清数据未取消工作 stopWork(..., true) 移除任务

先确认业务对“时间”的要求,再查系统错误码。延迟任务不会提供精确唤醒承诺,这是能力边界,不是偶发缺陷。

15. 真机验收清单与官方资料

[ ] 业务允许择机执行,并能拆成可重复的小批次
[ ] ExtensionAbility 名称与 WorkInfo.abilityName 完全一致
[ ] WorkInfo 至少设置一个真实触发条件
[ ] parameters 只包含 number、string、boolean
[ ] 循环周期不小于 2 小时
[ ] 每批请求使用稳定幂等键
[ ] 只有服务端确认后才推进本地检查点
[ ] onWorkStop 能阻止新批次并收口在途请求
[ ] 单次运行给保存状态和释放资源预留时间
[ ] 退出账号或关闭功能时移除任务与对应检查点
[ ] 真机覆盖无网、低电、重启、重复触发和执行超时

资料来源:

WorkScheduler 的稳定用法可以概括为:用约束描述合适时机,用幂等键描述同一批工作,用检查点描述已完成进度,用执行窗口约束后台成本。把它当成系统择机调度器,而不是精确计时器或常驻服务,离线同步才能在重启、断网和重复触发下继续推进而不制造重复副作用。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐