纯血鸿蒙后台长时任务与断点续传实战:从「10 分钟被回收」到可恢复的下载流水线

前言

几乎所有做下载、上传、素材同步的 HarmonyOS 应用,都会在某个版本遇到同一个现象:应用切到后台,任务跑了几分钟看着正常,然后突然断了。日志里没有崩溃,没有异常栈,只有一个「任务消失」。社区的常见结论是「鸿蒙后台超过 10 分钟就会被回收」,于是一堆工程开始想各种办法让进程「活着」——加长时任务、加 onSaveState、加前台服务,甚至定时拉起。

方向就错了。

断点续传的核心从来不是「让进程一直活着」,而是三件事:进度可持久化、分片可校验、恢复可幂等。长时任务只是给「正在传」的过程争取后台执行权,它既不替你保存进度,也不承诺进程永不被回收。进程被杀是常态而非异常,正确的设计是假设「随时会死」,死之前把状态写到位,活过来之后能无损接上。

这篇文章把「后台 10 分钟被回收」这个说法拆开讲清楚(它是两个完全不同的机制被混为一谈),给出系统代理 request.agent 的 BACKGROUND 模式托管方案,再给一套自建下载的 Range + ETag 分片续传骨架,最后列出会让你「拼出坏文件」的坑。

问题描述

一个典型的业务:应用要下载一个几百 MB 的模型包或素材包,用户点了开始后就切到后台去干别的事。

// 一个「看起来没问题」的朴素实现
import { http } from '@kit.NetworkKit';
import { fileIo as fs } from '@kit.CoreFileKit';

async function downloadNaive(url: string, path: string): Promise<void> {
  const req = http.createHttp();
  const file = fs.openSync(path, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
  const res = await req.request(url, { expectDataType: http.HttpDataType.ARRAY_BUFFER });
  fs.writeSync(file.fd, res.result as ArrayBuffer);
  fs.closeSync(file);
  req.destroy();
}

这段代码在应用处于前台时表现完全正常。一旦切到后台,会出现三类症状:

  1. 进度条卡住:task.on('progress') 不再回调。原因是进程被挂起,CPU 与定时器全部停止,网络请求的处理回调自然也不会执行。
  2. 任务「消失」但文件残留在半截:进程被回收,onDestroy 或者 onSaveState 没有保证一定被调用,内存里的「已下载字节数」随之消失,而磁盘上留着一个 137MB 的残缺文件——下次启动如果按「文件已存在就跳过」的逻辑判断,用户拿到的就是一个永远打不开的包。
  3. 申请了长时任务,仍然在某个时刻被取消:这是最让人困惑的一类。任务确实申请成功了,也跑了很久,但系统在某次检查后把任务取消了。根本原因是用了 DATA_TRANSFER 类型的长时任务,却没有持续上报进度。

而 onSaveState 也不是万能兜底:它只在特定的非正常退出场景触发,用户主动从后台划走应用时并不保证回调,而且保存的内容有容量与时效限制。把它当成「最后一道防线」可以,当成主力机制一定失败。

细节解析

一、把「后台 10 分钟被回收」拆成两件事

社区里传的「10 分钟」实际上混合了两个完全不同的机制:

机制 A:进程被挂起。 应用进入后台且没有申请任何长时任务时,系统会在很短时间内(秒级到数十秒级)让进程进入挂起状态,CPU 与定时器停止。此时谈不上「10 分钟」,是立刻失去后台执行权。挂起不是杀死,进程内存还在,一旦回到前台会继续跑,但后台期间不会有任何计算和网络回调。

机制 B:长时任务被系统取消。 申请长时任务之后,不同类型的约束完全不同。其中 DATA_TRANSFER(数据传输)类型有一个硬性要求:必须通过通知栏或实况窗周期性上报进度。如果连续一段时间没有任何进度更新(社区常引用约 10 分钟这个量级),系统会判定该任务「卡死」,主动取消它。任务一旦被取消,进程就失去了保活依据,随即转入机制 A 被挂起。

所以「后台 10 分钟被回收」的真实链条是:用了 DATA_TRANSFER 长时任务 → 进度上报断了 → 系统取消任务 → 进程失去保活依据被挂起 → 调用方看到「任务没了」。

这个拆解直接决定了修复方向必须是两条腿走路:

  • 一条腿:长时任务要真实、持续地更新进度。具体做法是拿到 startBackgroundRunning 返回的 notificationId,用 notificationManager 的 downloadTemplate 模板持续 publish(实况窗类型),任务结束后立刻 stopBackgroundRunning。只申请不更新,等于没做。
  • 另一条腿:真正的断点续传——把进度落盘,进程死了也能从断点接上。

只做第一条腿,等于把「10 分钟」这件事往后拖,阈值一到还是断。只做第二条腿,等于每次断都要重来一次冷启动检测,体验很差。

二、长时任务的类型必须选对,否则连申请都过不了

backgroundTaskManager.BackgroundMode 枚举里可用的类型包括 DATA_TRANSFER、AUDIO_PLAYBACK、AUDIO_RECORDING、LOCATION、NAVIGATION、BLUETOOTH_INTERACTION、MULTI_DEVICE_CONNECTION、WIFI_INTERACTION、VOIP 等。三处必须一一对应,缺一处就会申请失败:

  1. module.json5 里 abilities[].backgroundModes 声明的类型;
  2. 申请时传入的 BackgroundMode;
  3. requestPermissions 里的 ohos.permission.KEEP_BACKGROUND_RUNNING。

一个高频错误是「下载功能声明成 AUDIO_PLAYBACK」,理由是「反正都能保活」。这在审核阶段会被判为与实际功能不符而驳回,运行期也可能因「没有真正在播放音频」而被判定为滥用。类型必须与真实业务一致,这是合规底线,不是技巧问题。

// module.json5 片段
{
  "module": {
    "requestPermissions": [
      { "name": "ohos.permission.KEEP_BACKGROUND_RUNNING" },
      { "name": "ohos.permission.INTERNET" }
    ],
    "abilities": [
      {
        "name": "EntryAbility",
        "backgroundModes": ["dataTransfer"]
      }
    ]
  }
}

三、系统代理 request.agent 是下载/上传的首选

只要业务是「文件下载/上传」,就不要自己用 http 拼。系统上传下载服务(@kit.BasicServicesKit 的 request.agent)在 Mode.BACKGROUND 下由系统托管,与应用生命周期解耦:应用被杀,系统侧任务仍然继续,并且内置断点续传能力。

关键配置只有四个字段,但每一个都有明确语义:

字段取值理由
moderequest.agent.Mode.BACKGROUND后台模式,系统托管;FOREGROUND 跟随应用进程
overwritefalse允许续写而不是覆盖,写 true 会让续传退化成从头开始
gaugetrue开启进度回调,这也是 DATA_TRANSFER 长时任务上报进度的数据来源
networkrequest.agent.Network.ANY不限制网络类型,避免 Wi-Fi 切换蜂窝时任务失败

恢复时的正确顺序是三步,不能颠倒:

  1. 用持久化的 tid 调 request.agent.getTask(context, tid) 查系统侧是否还有这个任务;
  2. 任务存在且处于暂停态 → resume();运行中的任务绝不能重复 create 或重复 start;
  3. 任务已被系统清理(getTask 抛异常)→ 用 begins: 已下载字节数 重建任务。

这里有一个官方 FAQ 明确点出、但很容易被忽略的坑:对已经被中断的任务直接调 Task.resume() 是无效的(调 Task.start() 更糟,会从头开始下)。中断态必须走 begins 重建。而 begins 的正确取值不是「自己猜本地文件长度」,而是 request.agent.show(tid) 返回的 progress.processed——系统侧记录的断点才是服务端真正认可的那个偏移。

这里最容易错的是第 3 步的「本地已落盘字节数」。直接读 .part 文件长度当作续传偏移是不可靠的:服务端文件可能已经更新,长度对得上但内容不是同一版,续写下去就是一个校验永远过不了的坏文件。正确做法是续传前先发一次 HEAD,比对 ETag 与总长度,版本一致才沿用旧 .part,否则删掉重下。

四、长时任务与检查点是两套机制,不要互相替代

很多文章把这两件事混着讲,导致实现者以为「申请了长时任务就不用存进度了」。它们的分工非常清晰:

  • 长时任务:解决「传输过程中进程不被挂起」,是运行期的保活。
  • 检查点持久化:解决「挂起/被杀/重启之后还能接上」,是恢复期的依据。

因此检查点必须周期性写入(每完成一个分片、或每 N 秒),写到 Preferences 或 RDB,而不是等到 onDestroy。原因很直接:进程被系统回收时,不保证给你任何回调。

五、自建续传:Range + If-Range 三个状态必须都处理

如果服务端不支持系统代理,或者需要自定义鉴权/加密,那就自建分片下载。核心是 HTTP 标准语义,没有鸿蒙特有的东西,但有三个返回状态必须都处理:

  1. 206 Partial Content 且 Content-Range 与请求的偏移一致 → 追加写入,正常续传;
  2. 200 OK → 服务端忽略了 Range(文件已变或服务器不支持),必须清空本地文件从头写,不能继续 append;
  3. 416 Range Not Satisfiable → 本地偏移已经超过服务端文件长度(典型的版本变更或分片错位),删掉本地文件重新开始。

带 If-Range: <ETag> 的好处是让服务端帮你做版本判断:版本一致返回 206,不一致返回 200 和完整内容。这比自己比 ETag 更省一次请求,也更不容易出竞态。

示例代码

1. 系统代理托管下载:创建、恢复、重建

// entry/src/main/ets/common/ResumableDownloader.ets
import { request } from '@kit.BasicServicesKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { common } from '@kit.AbilityKit';
import { preferences } from '@kit.ArkTS';
import { fileIo as fs } from '@kit.CoreFileKit';
import { http } from '@kit.NetworkKit';

interface Checkpoint {
  tid: number;
  url: string;
  saveAs: string;
  etag: string;
  totalSize: number;
}

export class ResumableDownloader {
  private context: common.UIAbilityContext;
  private store: preferences.Preferences | undefined = undefined;

  constructor(context: common.UIAbilityContext) {
    this.context = context;
  }

  private async getStore(): Promise<preferences.Preferences> {
    if (this.store === undefined) {
      this.store = await preferences.getPreferences(this.context, 'download_resume');
    }
    return this.store;
  }

  /** 首次下载:后台模式创建任务并落检查点 */
  async start(url: string, saveAs: string): Promise<number> {
    const meta = await this.headInfo(url);
    const config: request.agent.Config = {
      action: request.agent.Action.DOWNLOAD,
      url: url,
      mode: request.agent.Mode.BACKGROUND,
      overwrite: false,          // 关键:允许续写
      gauge: true,               // 关键:进度回调,也是长时任务上报来源
      network: request.agent.Network.ANY,
      saveas: saveAs
    };
    const task: request.agent.Task = await request.agent.create(this.context, config);
    const token: string = task.tid.toString();
    task.on('progress', (progress: request.agent.Progress): void => {
      // 回调参数是 Progress 对象:processed 已处理字节,state 当前状态
      console.info(`[dl] ${token} ${progress.processed} state=${progress.state}`);
    });
    task.on('completed', (): void => {
      console.info(`[dl] ${token} completed`);
    });
    task.on('failed', (err: number): void => {
      console.error(`[dl] ${token} failed code=${err}`);
    });
    await task.start();

    const cp: Checkpoint = {
      tid: task.tid,
      url: url,
      saveAs: saveAs,
      etag: meta.etag,
      totalSize: meta.totalSize
    };
    await this.saveCheckpoint(cp);
    return task.tid;
  }

  /** 冷启动/回前台时调用:优先复用系统任务,其次按本地偏移重建 */
  async resume(): Promise<boolean> {
    const store = await this.getStore();
    const tid: number = store.get('tid', -1) as number;
    const url: string = store.get('url', '') as string;
    const saveAs: string = store.get('saveAs', '') as string;
    const etag: string = store.get('etag', '') as string;
    if (tid < 0 || url.length === 0) {
      return false;
    }

    // 第一步:系统侧任务还在,直接 resume,绝不重新 create
    try {
      const task: request.agent.Task = await request.agent.getTask(this.context, tid);
      task.on('progress', (progress: request.agent.Progress): void => {
        console.info(`[dl] resume ${progress.processed} state=${progress.state}`);
      });
      await task.resume();
      return true;
    } catch (err) {
      const e = err as BusinessError;
      console.warn(`[dl] task ${tid} gone: ${e.code} ${e.message}`);
    }

    // 第二步:任务已被系统清理,校验版本后用本地偏移重建
    const remote = await this.headInfo(url);
    if (remote.etag !== etag || remote.totalSize <= 0) {
      // 服务端文件变了:旧分片无意义,删掉重下
      this.removeQuietly(saveAs);
      await this.start(url, saveAs);
      return true;
    }

    const begins: number = this.localSize(saveAs);
    if (begins >= remote.totalSize && remote.totalSize > 0) {
      return true; // 已经下完了,只差收尾
    }
    const config: request.agent.Config = {
      action: request.agent.Action.DOWNLOAD,
      url: url,
      mode: request.agent.Mode.BACKGROUND,
      overwrite: false,
      gauge: true,
      network: request.agent.Network.ANY,
      saveas: saveAs,
      begins: begins
    };
    const task: request.agent.Task = await request.agent.create(this.context, config);
    await task.start();
    await this.saveCheckpoint({
      tid: task.tid, url: url, saveAs: saveAs,
      etag: remote.etag, totalSize: remote.totalSize
    });
    return true;
  }

  private async saveCheckpoint(cp: Checkpoint): Promise<void> {
    const store = await this.getStore();
    store.put('tid', cp.tid);
    store.put('url', cp.url);
    store.put('saveAs', cp.saveAs);
    store.put('etag', cp.etag);
    store.put('totalSize', cp.totalSize);
    await store.flush();   // 必须 flush,否则只写内存
  }

  private localSize(path: string): number {
    try {
      const stat = fs.statSync(path);
      return stat.size;
    } catch (err) {
      return 0;
    }
  }

  private removeQuietly(path: string): void {
    try {
      fs.unlinkSync(path);
    } catch (err) {
      // 文件本来就不存在,忽略
    }
  }

  /** HEAD 取 ETag 与总长度,用于版本校验 */
  private async headInfo(url: string): Promise<Record<string, string | number>> {
    const req = http.createHttp();
    try {
      const res = await req.request(url, {
        method: http.RequestMethod.HEAD,
        expectDataType: http.HttpDataType.STRING
      });
      const etag = String(res.header['etag'] ?? '');
      const len = Number(res.header['content-length'] ?? 0);
      return { 'etag': etag, 'totalSize': isNaN(len) ? 0 : len };
    } finally {
      req.destroy();
    }
  }
}

要点复盘:saveCheckpoint 里的 flush() 不能省,Preferences 的 put 只改内存副本;resume() 里对「任务存在」和「任务消失」两条路径的区分,是整套逻辑能不能接上的关键;headInfo 只取 etag 和 content-length,不要顺手把整个文件拉下来。

2. 自建分片续传:三个返回状态都要处理

// entry/src/main/ets/common/ChunkedDownloader.ets
import { http } from '@kit.NetworkKit';
import { fileIo as fs } from '@kit.CoreFileKit';

const CHUNK: number = 1024 * 1024;   // 1MB 一片

export class ChunkedDownloader {
  private etag: string = '';

  /** 返回 true 表示完成,false 表示中途被中断(进度已落盘,可再调) */
  async run(url: string, partPath: string): Promise<boolean> {
    let offset: number = this.fileSize(partPath);
    let total: number = -1;

    while (total < 0 || offset < total) {
      const req = http.createHttp();
      const headers: Record<string, string> = {
        'Range': `bytes=${offset}-${offset + CHUNK - 1}`
      };
      if (this.etag.length > 0) {
        headers['If-Range'] = this.etag;   // 让服务端判断版本是否一致
      }
      let res: http.HttpResponse;
      try {
        res = await req.request(url, {
          method: http.RequestMethod.GET,
          header: headers,
          expectDataType: http.HttpDataType.ARRAY_BUFFER,
          connectTimeout: 30000,
          readTimeout: 30000
        });
      } finally {
        req.destroy();
      }

      if (res.responseCode === 200) {
        // 服务端忽略了 Range(文件已变/不支持):清空重下,绝不 append
        this.truncate(partPath);
        offset = 0;
        total = Number(res.header['content-length'] ?? 0);
        this.appendBuffer(partPath, res.result as ArrayBuffer);
        offset += (res.result as ArrayBuffer).byteLength;
        continue;
      }

      if (res.responseCode === 416) {
        // 本地偏移越界:版本变更或分片错位,整段重来
        this.truncate(partPath);
        offset = 0;
        this.etag = '';
        continue;
      }

      if (res.responseCode !== 206) {
        console.error(`[chunk] unexpected code ${res.responseCode}`);
        return false;
      }

      // 206:校验 Content-Range 与请求的偏移一致后才允许追加
      const cr = String(res.header['content-range'] ?? '');   // 形如 bytes 0-1048575/52428800
      const m = cr.match(/bytes\s+(\d+)-(\d+)\/(\d+)/);
      if (m === null) {
        console.error(`[chunk] bad content-range ${cr}`);
        return false;
      }
      const start = Number(m[1]);
      total = Number(m[3]);
      if (start !== offset) {
        console.error(`[chunk] offset mismatch want=${offset} got=${start}`);
        return false;
      }

      this.appendBuffer(partPath, res.result as ArrayBuffer);
      offset += (res.result as ArrayBuffer).byteLength;

      const et = res.header['etag'];
      if (typeof et === 'string' && et.length > 0) {
        this.etag = et;
      }
      // 每片写完立刻把 offset 落盘(这里用文件长度本身当检查点)
      await this.commitProgress(partPath, offset, total);
    }
    return true;
  }

  private fileSize(path: string): number {
    try {
      return fs.statSync(path).size;
    } catch (err) {
      return 0;
    }
  }

  private truncate(path: string): void {
    try {
      const f = fs.openSync(path, fs.OpenMode.READ_WRITE | fs.OpenMode.TRUNC);
      fs.closeSync(f);
    } catch (err) {
      // 文件不存在则新建
      const f = fs.openSync(path, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
      fs.closeSync(f);
    }
  }

  private appendBuffer(path: string, buf: ArrayBuffer): void {
    const f = fs.openSync(path, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.APPEND);
    fs.writeSync(f.fd, buf);
    fs.fsyncSync(f.fd);      // 关键:落盘,避免掉电后长度与内容不一致
    fs.closeSync(f);
  }

  /** 检查点:写入偏移与总大小,供下次冷启动读取 */
  private async commitProgress(path: string, offset: number, total: number): Promise<void> {
    console.info(`[chunk] checkpoint ${path} ${offset}/${total}`);
  }
}

两个容易被忽略的细节:fs.fsyncSync 必须在每片写完就调用,否则「文件长度」这个天然检查点在掉电/强杀后可能比实际内容长,续传就会从错误位置接上;Content-Range 必须校验起始偏移,光看 206 是不够的,代理服务器或 CDN 回源异常时可能给你一段偏移不对的内容。

3. 长时任务申请与释放

// entry/src/main/ets/common/KeepAlive.ets
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { wantAgent, WantAgent } from '@kit.AbilityKit';
import { common } from '@kit.AbilityKit';
import { notificationManager } from '@kit.NotificationKit';
import { BusinessError } from '@kit.BasicServicesKit';

export class KeepAlive {
  private static running: boolean = false;
  private static notificationId: number = 0;

  /** 申请长时任务;注意用「多类型数组」重载,它返回带 notificationId 的通知对象 */
  static async start(context: common.UIAbilityContext): Promise<void> {
    if (KeepAlive.running) {
      return;   // 幂等:重复申请会报错
    }
    const info: wantAgent.WantAgentInfo = {
      wants: [
        {
          bundleName: context.abilityInfo.bundleName,
          abilityName: context.abilityInfo.name
        }
      ],
      actionType: wantAgent.OperationType.START_ABILITY,
      requestCode: 0,
      actionFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
    };
    const agent: WantAgent = await wantAgent.getWantAgent(info);
    const modes: string[] = ['dataTransfer'];   // 必须与 module.json5 的 backgroundModes 一致
    const res: backgroundTaskManager.ContinuousTaskNotification =
      await backgroundTaskManager.startBackgroundRunning(context, modes, agent);
    KeepAlive.notificationId = res.notificationId;   // 后续更新进度必须用这个 id
    KeepAlive.running = true;
  }

  /** 上传下载类长时任务的「进度上报」:用申请返回的 notificationId 持续更新实况窗 */
  static async reportProgress(fileName: string, percent: number): Promise<void> {
    if (!KeepAlive.running || KeepAlive.notificationId === 0) {
      return;
    }
    const template: notificationManager.NotificationTemplate = {
      name: 'downloadTemplate',          // 当前仅支持该模板名,必须保持
      data: {
        title: `正在下载 ${fileName}`,
        fileName: fileName,
        progressValue: percent
      }
    };
    const request: notificationManager.NotificationRequest = {
      id: KeepAlive.notificationId,       // 必须是长时任务返回的 id,否则更新失败
      notificationSlotType: notificationManager.SlotType.LIVE_VIEW,
      template: template,
      content: {
        notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_SYSTEM_LIVE_VIEW,
        systemLiveView: {
          typeCode: 8,                    // 上传下载类型固定为 8
          title: `正在下载 ${fileName}`,
          text: `${percent}%`
        }
      }
    };
    try {
      await notificationManager.publish(request);
    } catch (err) {
      console.warn(`report progress failed: ${(err as BusinessError).code}`);
    }
  }

  static async stop(context: common.UIAbilityContext): Promise<void> {
    if (!KeepAlive.running) {
      return;
    }
    try {
      await backgroundTaskManager.stopBackgroundRunning(context);
    } catch (err) {
      const e = err as BusinessError;
      console.error(`stop background running failed: ${e.code}`);
    }
    KeepAlive.running = false;
  }
}

KeepAlive.running 这个静态标记不是多余的美化:重复调用 startBackgroundRunning 会抛异常,而在多个页面都会触发下载的场景里,「谁负责 stop」很容易写乱。用一个幂等包装器管住申请与释放,传输一结束立即 stop,才是 DATA_TRANSFER 类型正确的生命周期。

reportProgress 里的三个细节也值得注意:id 必须是长时任务返回的 notificationId(用别的 id 更新会失败,甚至导致任务被判定为异常);template.name 只能是 downloadTemplate;systemLiveView.typeCode 上传下载类型固定为 8。这三处任何一处写错,进度通知不会刷新,长时任务照样会在无更新超时后被系统取消——表现和「没写上报逻辑」完全一样。

总结

把「后台任务总是被回收」这件事拆开,其实是三个层面的问题,各自有各自的解法:

层面真实机制正确解法
运行期保活无长时任务时进程立即被挂起按业务真实类型申请长时任务,申请前核对 backgroundModes 与权限
任务被取消DATA_TRANSFER 长时间无进度上报被判定卡死持续真实上报进度,传完立即 stopBackgroundRunning
进程被杀后恢复内存状态全部丢失,onSaveState 不保证回调周期性持久化检查点,冷启动按 tid 或本地偏移续传

落到工程上,记住五条:

  1. 下载/上传优先用系统代理 request.agent 的 Mode.BACKGROUND,它与应用生命周期解耦,本身就带续传能力;自己用 http 拼只适用于有特殊鉴权/加密需求的场景。
  2. overwrite 必须为 false,否则每次恢复都从头开始。
  3. tid 才是恢复依据,不是进度条数字,也不是内存里的 Task 对象——进程一死,它们全部消失。
  4. 本地文件长度不等于可信续传偏移,必须配合 ETag / Content-Range 校验,三个 HTTP 状态(206 / 200 / 416)都要有分支。
  5. 检查点要周期性写并 flush,不要赌 onDestroy 和 onSaveState 会给你机会。

最后一点提醒:如果是大文件(模型包、离线地图、安装包),建议 request.agent 托管 + 周期检查点双管齐下,并在每次恢复前用 HEAD 校验 ETag。多花一次请求,换掉的是「用户拿到一个永远打不开的包」这种最难排查的线上问题。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐