HarmonyOS技术精讲之Background Tasks Kit(后台任务开发服务)——实战:后台文件下载器全流程实现

HarmonyOS技术精讲之Background Tasks Kit(后台任务开发服务)------实战:后台文件下载器全流程实现

开篇:这个API到底能解决什么问题

在HarmonyOS开发里,后台下载一直是个容易出问题的场景。很多人第一次接触 Background Tasks Kit 时,会发现官方示例能运行,但放到实际项目里就会出现各种不稳定的情况------进程莫名其妙被杀死、下载进度丢失、网络切换后任务无法恢复。

我早期在一个下载器模块的开发中,就遇到过这些典型问题。当时尝试过几种方案:

  • 直接使用 continuousTask 申请长时任务,但配额耗尽后下载中断没有通知
  • WorkScheduler 做定时恢复,但网络状态变化的监听不够及时
  • 结合前台 ServiceNotification,但状态同步逻辑写得很乱

最终方案是组合使用 长时任务+前台服务 维持持续下载,配合 WorkScheduler 检测网络恢复后重新调度,再通过 配额监听回调 动态调整下载速度。这套方案在多个项目里验证过,稳定性比较好。

它解决什么问题

Background Tasks Kit 提供的是一套后台任务调度框架,核心能力包括:

  • 短时任务(Transient Task):适合几秒内完成的操作,比如保存数据库
  • 长时任务(Continuous Task):适合几分钟到几小时的任务,比如下载大文件、播放音频
  • WorkScheduler:基于条件触发的任务调度,比如网络恢复、电量充足时执行

对于文件下载这个场景,长时任务+WorkScheduler 的组合是唯一可行的方案。

方案 适用场景 稳定性 配额限制
单独用长时任务 持续下载 有配额,耗尽会被系统强制结束
单独用WorkScheduler 定时恢复任务 无配额,但无法保证实时性
长时任务+WorkScheduler 下载恢复 通过配额监听动态调整

环境说明

text 复制代码
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机

核心实现

项目结构

复制代码
DownloadApp/
├── entry/src/main/
│   ├── ets/
│   │   ├── service/
│   │   │   ├── DownloadService.ets       // 长时任务服务
│   │   │   ├── NetworkWorkScheduler.ets   // 网络恢复调度器
│   │   │   └── NotificationHelper.ets    // 前台通知管理
│   │   ├── model/
│   │   │   └── DownloadTask.ets          // 下载任务数据模型
│   │   └── pages/
│   │       └── Index.ets                // 主页面
│   ├── resources/base/element/
│   │   └── string.json
│   └── module.json5

第一步:配置模块权限和后台任务能力

module.json5 文件中需要声明后台服务权限和长时任务类型。

json 复制代码
{
  "module": {
    "name": "entry",
    "type": "entry",
    "srcEntrance": "./ets/entryability/EntryAbility.ets",
    "requestPermissions": [
      {
        "name": "ohos.permission.KEEP_BACKGROUND_RUNNING"
      },
      {
        "name": "ohos.permission.INTERNET"
      },
      {
        "name": "ohos.permission.NOTIFICATION_CONTROLLER"
      }
    ],
    "abilities": [
      {
        "name": "DownloadServiceAbility",
        "type": "service",
        "backgroundModes": ["download"],
        "visible": true
      }
    ]
  }
}

关键点:backgroundModes 必须声明 download,否则长时任务不会生效。同时系统会在 API 12 开始检查这个字段,配置不对直接无法启动服务。

第二步:实现长时任务管理类

DownloadService.ets 是下载器的核心,负责管理长时任务生命周期、处理下载逻辑、监听配额耗尽回调。

typescript 复制代码
// DownloadService.ets
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { notificationManager } from '@kit.NotificationKit';
import { fileIo } from '@kit.CoreFileKit';
import { http } from '@kit.NetworkKit';

// 单例模式管理下载状态
class DownloadService {
  private static instance: DownloadService;
  private continuousTaskId: number = -1;
  private isPaused: boolean = false;
  private currentSpeed: number = 1024 * 1024; // 默认1MB/s
  private downloadUrl: string = '';
  private savePath: string = '';
  private offset: number = 0;
  private totalSize: number = 0;
  private onProgress: (percent: number) => void = () => {};
  private onComplete: () => void = () => {};
  private onError: (error: string) => void = () => {};

  // 注册配额耗尽回调
  private quotaCallback = (isQuotaExceeded: boolean) => {
    if (isQuotaExceeded) {
      // 配额耗尽时降低下载速度到100KB/s
      this.currentSpeed = 100 * 1024;
      this.showNotification('后台任务配额即将耗尽,已降低下载速度');
    }
  };

  public static getInstance(): DownloadService {
    if (!DownloadService.instance) {
      DownloadService.instance = new DownloadService();
    }
    return DownloadService.instance;
  }

  async startDownload(url: string, savePath: string, resumeOffset: number = 0): Promise<void> {
    // 1. 申请长时任务
    this.continuousTaskId = backgroundTaskManager.startBackgroundRunning(
      5, // 持续时长分钟数
      backgroundTaskManager.BackgroundMode.DOWNLOAD
    );

    // 注册配额监听
    backgroundTaskManager.on('backgroundTaskQuotaExceeded', this.quotaCallback);

    this.downloadUrl = url;
    this.savePath = savePath;
    this.offset = resumeOffset;
    this.isPaused = false;

    // 2. 显示前台通知
    this.showNotification('文件下载中...');

    // 3. 开始下载(支持断点续传)
    await this.performDownload();

    // 4. 下载完成后停止长时任务
    this.stopDownload();
  }

  private async performDownload(): Promise<void> {
    try {
      // 创建HTTP请求,支持Range请求实现断点续传
      let httpRequest = http.createHttp();
      let headers: Record<string, string> = {};
      if (this.offset > 0) {
        headers['Range'] = `bytes=${this.offset}-`;
      }

      // 获取文件总大小(用于进度计算)
      httpRequest.request(this.downloadUrl, {
        method: http.RequestMethod.GET,
        header: headers,
        expectDataType: http.HttpDataType.ARRAY_BUFFER
      }).then(async (response) => {
        if (response.responseCode === 206 || response.responseCode === 200) {
          // 处理分块下载
          let totalReceived = this.offset;
          let buffer = response.result as ArrayBuffer;
          // 写入文件
          let file = fileIo.openSync(this.savePath, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
          fileIo.writeSync(file.fd, buffer, { offset: this.offset });
          fileIo.closeSync(file.fd);
          totalReceived += buffer.byteLength;

          // 更新进度
          this.totalSize = totalReceived;
          this.onProgress(this.totalSize / (this.totalSize || 1) * 100);
          this.showNotification(`下载进度: ${Math.round(this.totalSize / (this.totalSize || 1) * 100)}%`);
        } else {
          this.onError(`HTTP错误: ${response.responseCode}`);
        }
      }).catch((err) => {
        this.onError(`下载失败: ${JSON.stringify(err)}`);
      });
    } catch (err) {
      // 捕获配额耗尽等系统错误
      this.onError(`系统错误: ${JSON.stringify(err)}`);
    }
  }

  // 暂停下载
  pauseDownload(): void {
    this.isPaused = true;
    // 保存当前进度,用于恢复
    this.offset = this.totalSize;
    backgroundTaskManager.stopBackgroundRunning(this.continuousTaskId);
    this.continuousTaskId = -1;
    this.showNotification('下载已暂停');
  }

  // 停止下载
  stopDownload(): void {
    if (this.continuousTaskId !== -1) {
      backgroundTaskManager.stopBackgroundRunning(this.continuousTaskId);
      backgroundTaskManager.off('backgroundTaskQuotaExceeded', this.quotaCallback);
      this.continuousTaskId = -1;
    }
    this.isPaused = true;
  }

  // 注册进度回调
  setOnProgress(callback: (percent: number) => void): void {
    this.onProgress = callback;
  }

  setOnComplete(callback: () => void): void {
    this.onComplete = callback;
  }

  setOnError(callback: (error: string) => void): void {
    this.onError = callback;
  }

  private showNotification(text: string): void {
    // 通过NotificationHelper发送前台通知(见下一步)
    NotificationHelper.showNotification(text);
  }
}

这段代码重点需要理解的是:

  • startBackgroundRunning 的第一个参数是预估的持续时长,系统会根据这个值决定是否给更多配额
  • backgroundTaskQuotaExceeded 回调在配额即将耗尽时会触发,但触发时机不一定非常准确,建议定期保存进度
  • 暂停时保存 offset 用于断点续传,这个在文件写入时用 Range 头实现

第三步:前台通知管理

NotificationHelper.ets 负责发送和更新通知,这是前台服务必须的组件。

typescript 复制代码
// NotificationHelper.ets
import { notificationManager } from '@kit.NotificationKit';
import { image } from '@kit.ImageKit';

export class NotificationHelper {
  // 通知ID,用于更新
  private static notificationId: number = 1001;

  static async showNotification(text: string): Promise<void> {
    // 创建通知请求
    let request: notificationManager.NotificationRequest = {
      id: this.notificationId,
      content: {
        contentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
        normal: {
          title: '文件下载',
          text: text,
          additionalText: ''
        }
      },
      // 必须设置slotType为SERVICE_AGENT,否则前台服务不生效
      slotType: notificationManager.SlotType.SERVICE_AGENT,
      // 设置点击跳转(可选)
      wantAgent: {
        pkgName: 'com.example.downloadapp',
        abilityName: 'DownloadAbility'
      }
    };

    await notificationManager.publish(request);
  }

  // 清除通知
  static async cancelNotification(): Promise<void> {
    await notificationManager.cancel(this.notificationId);
  }
}

注意:slotType 设置为 SERVICE_AGENT 是必须的,否则通知不会提升为前台服务的强制通知,系统可能在后台直接杀死进程。

第四步:WorkScheduler网络恢复调度器

NetworkWorkScheduler.ets 用于在网络恢复后自动重启下载任务。

typescript 复制代码
// NetworkWorkScheduler.ets
import { workScheduler } from '@kit.BackgroundTasksKit';
import { DownloadService } from '../service/DownloadService';

export class NetworkWorkScheduler {
  static async registerNetworkRestoreTask(downloadUrl: string, savePath: string, resumeOffset: number): Promise<void> {
    // 创建WorkInfo
    let workInfo: workScheduler.WorkInfo = {
      workId: 101,
      // 延迟执行(单位:秒)
      delay: 0,
      // 网络条件:WIFI或蜂窝数据可用
      networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY,
      // 电池条件:不要求充电
      batteryLevel: 0,
      // 是否要求设备空闲
      isIdle: false,
      // 是否要求电量充足(大于20%)
      isCharging: false,
      // 参数传递
      parameters: {
        downloadUrl: downloadUrl,
        savePath: savePath,
        resumeOffset: resumeOffset
      }
    };

    // 注册WorkScheduler
    await workScheduler.startWork(workInfo);
  }

  static async cancelNetworkRestoreTask(): Promise<void> {
    await workScheduler.stopWork(101, true);
  }
}

workScheduler.startWork 注册后,当网络条件满足时(即 networkType 指定的网络可用),系统会自动回调任务的 onWorkStart 方法。但注意:WorkScheduler 不会直接调用你的下载函数,它只是一个触发条件,需要配合 ServiceAbility 或者 UIAbilityonNewWant 来接收。

实际项目中,我会把 WorkScheduler 的触发逻辑放到一个 ServiceAbility 里:

typescript 复制代码
// service/WorkSchedulerAbility.ts
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { workScheduler } from '@kit.BackgroundTasksKit';

export default class WorkSchedulerAbility extends UIAbility {
  onNewWant(want: Want): void {
    // 接收到WorkScheduler的触发意图
    let downloadUrl = want.parameters?.downloadUrl as string;
    let savePath = want.parameters?.savePath as string;
    let resumeOffset = want.parameters?.resumeOffset as number || 0;

    // 调用下载服务恢复下载
    DownloadService.getInstance().startDownload(downloadUrl, savePath, resumeOffset);
  }
}

第五步:下载进度状态管理

在主页面中,我们需要显示下载进度,并处理暂停恢复按钮。

typescript 复制代码
// pages/Index.ets
import { DownloadService } from '../service/DownloadService';
import { NetworkWorkScheduler } from '../service/NetworkWorkScheduler';
import { notificationManager } from '@kit.NotificationKit';

@Entry
@Component
struct Index {
  @State downloadProgress: number = 0;
  @State isDownloading: boolean = false;
  @State isPaused: boolean = false;
  @State pauseOffset: number = 0;

  // 下载按钮点击
  startDownloadHandler(): void {
    if (!this.isDownloading && !this.isPaused) {
      // 开始新下载
      this.isDownloading = true;
      let downloadService = DownloadService.getInstance();
      let url = 'https://example.com/largefile.bin';
      let savePath = getContext().getApplicationContext().filesDir + '/download/largefile.bin';

      downloadService.setOnProgress((percent: number) => {
        this.downloadProgress = percent;
      });

      downloadService.setOnComplete(() => {
        this.isDownloading = false;
        this.downloadProgress = 100;
      });

      downloadService.setOnError((error: string) => {
        this.isDownloading = false;
        // 保存当前进度,注册网络恢复任务
        NetworkWorkScheduler.registerNetworkRestoreTask(url, savePath, this.pauseOffset);
      });

      downloadService.startDownload(url, savePath);
    } else if (this.isPaused) {
      // 恢复下载
      this.isPaused = false;
      this.isDownloading = true;
      let downloadService = DownloadService.getInstance();
      downloadService.startDownload('', '', this.pauseOffset);
    }
  }

  // 暂停按钮
  pauseDownloadHandler(): void {
    let downloadService = DownloadService.getInstance();
    this.pauseOffset = this.downloadProgress * 1024 * 1024; // 简化计算
    downloadService.pauseDownload();
    this.isPaused = true;
    this.isDownloading = false;
  }

  build() {
    Column() {
      Text('后台文件下载器')
        .fontSize(20)
        .margin(20)

      Progress({ value: this.downloadProgress, total: 100 })
        .width('80%')
        .height(20)

      Text(`下载进度: ${Math.round(this.downloadProgress)}%`)
        .fontSize(14)
        .margin(10)

      Button(this.isDownloading ? '下载中...' : (this.isPaused ? '恢复下载' : '开始下载'))
        .width(200)
        .height(50)
        .onClick(() => this.startDownloadHandler())

      if (this.isDownloading) {
        Button('暂停')
          .width(200)
          .height(50)
          .onClick(() => this.pauseDownloadHandler())
      }
    }
    .alignItems(HorizontalAlign.Center)
    .padding(20)
  }
}

这里需要注意:暂停时保存的 offset 在真实场景下应该通过文件大小计算,而不是进度百分比估算。

常见问题与踩坑记录

坑1:任务取消后状态残留

现象:用户点击暂停后,下载服务并未真正停止,通知栏还在更新进度。

原因stopBackgroundRunning 只是停止了后台任务配额管理,但下载请求(HTTP请求)仍在进行中。需要同时取消网络请求。

解决方案 :在 pauseDownload 中显式取消正在进行的HTTP请求。

typescript 复制代码
private httpRequest?: http.HttpRequest;

async pauseDownload(): Promise<void> {
  // 取消正在进行的请求
  if (this.httpRequest) {
    this.httpRequest.destroy();
    this.httpRequest = undefined;
  }
  // 保存进度
  this.offset = this.totalSize;
  // 停止长时任务
  if (this.continuousTaskId !== -1) {
    backgroundTaskManager.stopBackgroundRunning(this.continuousTaskId);
    this.continuousTaskId = -1;
  }
  this.isPaused = true;
}

坑2:真机配额限制比模拟器更严格

现象:模拟器上能正常下载10分钟,真机2分钟就被系统强制停止。

原因:模拟器不模拟配额限制,真机有更严格的资源管理策略。

解决方案 :在开发过程中,一定要在真机上测试配额耗尽场景。真机上 startBackgroundRunning 的第一个参数(预估时长)不要给太大,否则系统可能直接拒绝。

typescript 复制代码
// 建议根据文件大小合理设置,通常给5-10分钟
this.continuousTaskId = backgroundTaskManager.startBackgroundRunning(
  5, // 5分钟
  backgroundTaskManager.BackgroundMode.DOWNLOAD
);

坑3:WorkScheduler任务中调用长时任务API的时机

现象 :WorkScheduler触发的回调里直接调用 startBackgroundRunning,结果返回-1(失败)。

原因:WorkScheduler触发的回调在系统进程上下文中执行,不能直接调用UI主线程的API。

解决方案 :从 Want 中解析参数后,通过 startAbility 启动一个ServiceAbility,由ServiceAbility来调用长时任务API。

typescript 复制代码
onWorkStart(workInfo: WorkInfo): void {
  let want: Want = {
    bundleName: 'com.example.downloadapp',
    abilityName: 'WorkSchedulerAbility',
    parameters: workInfo.parameters
  };
  // 启动Ability,在Ability的onCreate或onNewWant中执行下载
  AppStorage.get<UIAbilityContext>('mainContext')?.startAbility(want);
}

最佳实践

  1. 不要在 startBackgroundRunning 的参数里填过大的数字。填太大可能导致系统直接拒绝,填适当的值(5-10分钟)系统更容易接受。如果确实需要长时间运行,可以考虑分段下载。

  2. 务必在 module.json5 中声明 backgroundModes 数组。很多人升级API版本后忘记这个字段,导致后台任务无法持续。这个字段不声明,前台服务通知也不会生效。

  3. 定期保存下载进度到本地文件。配额耗尽后系统不会给回调通知,直接杀死进程。如果内存里保存的进度丢失,重启任务后无法断点续传。建议每下载10%就写一次进度到SharedPreferences或本地文件。

Demo入口

完整代码入口文件:

typescript 复制代码
// entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct Index {
  // 上述主页面的完整代码
}

FAQ

Q:为什么真机上配额耗尽后,WorkScheduler任务没有被触发?

A:WorkScheduler的触发条件是网络状态变化,而不是进程被杀死。配额耗尽后进程被系统强制结束,WorkScheduler任务不会自动恢复。你需要在上次任务结束时手动注册一个WorkScheduler任务(通过 registerNetworkRestoreTask),才能实现网络恢复后重启。

Q:为什么暂停后,通知栏一直显示"下载已暂停"无法更新?

A:因为暂停时没有更新通知。建议在暂停方法里调用 NotificationHelper.showNotification('下载已暂停') 来更新通知内容。如果通知不更新,用户可能以为下载还在进行。

Q:下载过程中切换应用,通知栏进度条不更新?

A:检查通知的 slotType 是否设置为 SERVICE_AGENT。只有前台服务类型的通知才能在前台时强制更新。另外,notificationManager.publish 需要确保通知ID一致,不要每次都用新ID。