《HarmonyOS技术精讲-Basic Services Kit》上传下载进阶:多任务并发与后台长时任务

在这里插入图片描述

多文件传输,不只是调个API那么简单

HarmonyOS NEXT 开发里,request 模块的上传下载 API 本身设计得比较完整。单文件、单任务场景下,直接调 request.downloadFile()request.uploadFile() 就能跑通。官方文档给的示例也确实能运行。

但实际生产环境从来不这么简单。用户可能一次性选中 20 个文件批量下载,也可能在下载过程中切到后台再切回来。直接默认实现,会出现 CPU 瞬间飙满、页面卡死、后台任务被系统回收、多个文件同时写同一路径报错等各种问题。

这篇文章要解决的问题,就是如何构建一个稳定的多文件传输管理器,让系统在可控的并发数下完成任务,并且在应用进入后台后能继续执行。

先搞清楚几个核心概念

任务队列 vs 并发控制

很多人的第一反应是“开多个线程”。HarmonyOS NEXT 的 request.download 本身就是异步的,不涉及传统多线程概念。真正的瓶颈在于同时发起的任务数量

如果一次性发起 20 个下载任务,系统会同时建立 20 个连接、分配 20 个回调、占用 20 份内存。设备性能有限的场景下(比如折叠屏、平板)会明显感受到卡顿。

所以需要一套任务队列机制:把任务放进队列,按优先级排序,按最大并发数一个一个取出执行。

后台长时任务

HarmonyOS NEXT 的 @ohos.resourceschedule.backgroundTaskManager 提供了后台长时任务能力。当应用进入后台后,如果没有声明后台长时任务,系统会在几秒内挂起所有网络操作。

这意味着用户切出去回微信,再切回来,发现下载进度已经停了。后台挂起是默认行为,不是 bug。要解决这个问题,必须在下载开始时申请长时任务,下载结束后释放。

网络变更处理

移动设备上网络状态经常波动:WiFi 断开自动切蜂窝、信号弱、飞行模式切换。@ohos.net.connection 可以监听网络状态变化,在网络恢复时自动重试失败的任务。

环境说明

DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机 / 平板

核心实现:多任务并发下载管理器

整体设计思路是这样的:

  1. 定义一个 DownloadTask 数据结构,包含任务ID、URL、保存路径、状态、优先级等信息
  2. 实现 DownloadManager 单例,维护一个任务队列、并发控制计数器、后台任务管理器
  3. 每次添加任务时,按优先级插入队列,然后检查当前并发数是否小于最大值,如果是则取出队列头部任务执行
  4. 每个任务完成后,减少并发计数器,检查队列,执行下一个任务
  5. 下载开始时申请后台长时任务,所有任务完成后释放

第一步:定义任务状态和数据结构

这部分比较基础,但要注意一点:状态枚举的值不要用魔法数字,ArkTS 里枚举有严格的类型推断,用字符串更安全。

// src/utils/DownloadTypes.ets

export enum TaskStatus {
  PENDING = 'pending',       // 等待中
  DOWNLOADING = 'downloading', // 下载中
  PAUSED = 'paused',         // 已暂停
  COMPLETED = 'completed',   // 已完成
  FAILED = 'failed'          // 失败
}

export interface DownloadTask {
  taskId: string;           // 唯一标识,用 nanoid 或 Date.now()
  url: string;              // 下载地址
  filePath: string;         // 保存路径
  priority: number;         // 优先级,数值越小优先级越高
  status: TaskStatus;       // 当前状态
  progress: number;         // 进度 0-100
  failedReason?: string;    // 失败原因
}

这里用 taskId 作为任务的唯一标识,不要直接用 URL,因为同一个 URL 可能需要下载多次。

第二步:实现任务队列管理器

核心逻辑在 DownloadManager 类里。用 @Observed 装饰器包装,以便 UI 能实时监听任务列表变化。

// src/utils/DownloadManager.ets

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

@Observed
export class DownloadManager {
  // 单例
  private static instance: DownloadManager | null = null;
  
  // 最大并发数,这里固定为3
  private maxConcurrent: number = 3;
  
  // 当前活跃的任务数量
  private activeCount: number = 0;
  
  // 等待队列
  private queue: DownloadTask[] = [];
  
  // 当前正在执行的任务列表(方便取消)
  private runningTasks: Map<string, request.DownloadTask> = new Map();
  
  // 是否已申请后台长时任务
  private hasBackgroundTask: boolean = false;
  
  // 对外暴露的任务列表,UI 可以绑定
  public taskList: DownloadTask[] = [];

  public static getInstance(): DownloadManager {
    if (DownloadManager.instance === null) {
      DownloadManager.instance = new DownloadManager();
    }
    return DownloadManager.instance;
  }

  /**
   * 添加下载任务
   * @param url 下载地址
   * @param filePath 保存路径
   * @param priority 优先级(越小越高)
   * @returns 任务ID
   */
  public addDownload(url: string, filePath: string, priority: number = 5): string {
    const taskId = `download_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
    const task: DownloadTask = {
      taskId: taskId,
      url: url,
      filePath: filePath,
      priority: priority,
      status: TaskStatus.PENDING,
      progress: 0
    };
    
    // 按优先级插入队列
    this.insertIntoQueue(task);
    // 更新UI列表
    this.taskList = [...this.queue];  // 这里只是简化写法,实际可以维护两个列表
    
    // 尝试执行下一个任务
    this.processNext();
    
    return taskId;
  }

  private insertIntoQueue(task: DownloadTask): void {
    // 从优先级最高的开始处理,数值越小优先级越高
    let inserted = false;
    for (let i = 0; i < this.queue.length; i++) {
      if (this.queue[i].priority > task.priority) {
        this.queue.splice(i, 0, task);
        inserted = true;
        break;
      }
    }
    if (!inserted) {
      this.queue.push(task);
    }
  }

  private async processNext(): Promise<void> {
    // 如果当前活跃任务已达上限,或者队列为空,直接返回
    if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
      return;
    }

    // 从队列头部取出下一个待执行任务
    const nextTask = this.queue.shift();
    if (!nextTask) return;

    // 标记状态
    nextTask.status = TaskStatus.DOWNLOADING;
    this.activeCount++;

    // 如果没有申请后台长时任务,申请一个
    await this.ensureBackgroundTask();

    try {
      // 创建下载任务
      const downloadTask = await request.downloadFile(
        getContext(this),
        {
          url: nextTask.url,
          filePath: nextTask.filePath
        }
      );

      // 存入运行中列表
      this.runningTasks.set(nextTask.taskId, downloadTask);

      // 监听进度
      downloadTask.on('progress', (progress: request.Progress) => {
        nextTask.progress = progress.totalSize > 0 
          ? Math.round(progress.transferredSize / progress.totalSize * 100) 
          : 0;
        // UI 自动刷新,因为 taskList 是 @Observed 装饰的对象
        AppStorage.setOrCreate('downloadTaskList', this.taskList);
      });

      // 监听完成
      downloadTask.on('complete', () => {
        this.finishTask(nextTask, TaskStatus.COMPLETED);
      });

      // 监听失败
      downloadTask.on('fail', (err: BusinessError) => {
        nextTask.failedReason = err.message;
        this.finishTask(nextTask, TaskStatus.FAILED);
      });

    } catch (error) {
      nextTask.failedReason = (error as BusinessError).message;
      this.finishTask(nextTask, TaskStatus.FAILED);
    }
  }

  private finishTask(task: DownloadTask, status: TaskStatus): void {
    task.status = status;
    this.runningTasks.delete(task.taskId);
    this.activeCount--;

    // 更新UI列表
    AppStorage.setOrCreate('downloadTaskList', this.taskList);

    // 检查是否所有任务都已完成
    if (this.queue.length === 0 && this.activeCount === 0) {
      this.releaseBackgroundTask();
    }

    // 执行下一个任务
    this.processNext();
  }

  private async ensureBackgroundTask(): Promise<void> {
    if (this.hasBackgroundTask) return;
    
    try {
      // 请求后台长时任务,类型为 DATA_TRANSFER(数据传输)
      await backgroundTaskManager.startBackgroundRunning(
        getContext(this),
        backgroundTaskManager.BackgroundMode.DATA_TRANSFER
      );
      this.hasBackgroundTask = true;
    } catch (error) {
      console.error('申请后台长时任务失败:', error);
      // 即使申请失败,任务仍然继续,只是后台会被挂起
    }
  }

  private releaseBackgroundTask(): void {
    if (!this.hasBackgroundTask) return;
    
    try {
      backgroundTaskManager.stopBackgroundRunning(getContext(this));
      this.hasBackgroundTask = false;
    } catch (error) {
      console.error('释放后台长时任务失败:', error);
    }
  }

  /**
   * 取消某个任务
   */
  public async cancelTask(taskId: string): Promise<void> {
    const runningTask = this.runningTasks.get(taskId);
    if (runningTask) {
      await runningTask.remove();
      this.runningTasks.delete(taskId);
      this.activeCount--;
      this.finishTask(
        { taskId: '', url: '', filePath: '', priority: 0, status: TaskStatus.PENDING, progress: 0 },
        TaskStatus.FAILED
      );
    }
    // 如果任务在队列中,直接从队列移除
    const idx = this.queue.findIndex(item => item.taskId === taskId);
    if (idx > -1) {
      this.queue.splice(idx, 1);
    }
  }
}

注意事项

  • getContext(this) 在 ArkTS 中会获取当前组件的上下文,但在 Service 等非 UI 场景下可能不适用。建议在 addDownload 时传入上下文,或者把上下文存为单例属性
  • AppStorage.setOrCreate 用于跨组件通信,但频繁写入可能影响性能。生产环境中建议在回调里只更新任务对象本身,利用 @Observed 自动更新 UI
  • backgroundTaskManager.startBackgroundRunning 需要在 module.json5 中声明权限和后台模式

第三步:在页面中使用

// src/pages/DownloadPage.ets

import { DownloadManager } from '../utils/DownloadManager';
import { DownloadTask, TaskStatus } from '../utils/DownloadTypes';

@Entry
@Component
struct DownloadPage {
  @StorageLink('downloadTaskList') taskList: DownloadTask[] = [];
  
  private manager: DownloadManager = DownloadManager.getInstance();

  build() {
    Column() {
      Button('添加3个下载任务')
        .onClick(() => {
          // 模拟3个不同优先级的下载任务
          this.manager.addDownload(
            'https://example.com/file1.zip',
            '/data/storage/el2/base/haps/entry/files/file1.zip',
            1  // 高优先级
          );
          this.manager.addDownload(
            'https://example.com/file2.zip',
            '/data/storage/el2/base/haps/entry/files/file2.zip',
            5  // 中优先级
          );
          this.manager.addDownload(
            'https://example.com/file3.zip',
            '/data/storage/el2/base/haps/entry/files/file3.zip',
            10 // 低优先级
          );
        })

      List() {
        ForEach(this.taskList, (task: DownloadTask) => {
          ListItem() {
            Row() {
              Text(task.url.substring(0, 20) + '...')
              Text(this.statusText(task.status))
              ProgressBar({ value: task.progress })
            }
          }
        })
      }
    }
  }

  private statusText(status: TaskStatus): string {
    switch (status) {
      case TaskStatus.PENDING: return '等待';
      case TaskStatus.DOWNLOADING: return '下载中';
      case TaskStatus.COMPLETED: return '完成';
      case TaskStatus.FAILED: return '失败';
      default: return '';
    }
  }
}

常见问题 1:页面返回后任务回调丢失

现象:用户点击开始下载,切到其他页面或按Home键回到桌面,再回到应用,发现下载进度不更新了。

原因on('progress')on('complete') 回调是在当前页面的生命周期上下文里注册的。页面销毁后,这些回调对应的函数引用可能被垃圾回收,或者因为上下文丢失而不再触发。

解决方案

// 在 DownloadManager 中,回调绑定到全局上下文,而不是局部函数
// 或者使用静态方法,避免绑定 this 导致上下文依赖

private onProgress(task: DownloadTask): (progress: request.Progress) => void {
  return (progress: request.Progress) => {
    task.progress = progress.totalSize > 0 
      ? Math.round(progress.transferredSize / progress.totalSize * 100) 
      : 0;
    // 这里不使用 AppStorage 频繁更新,而是通过 EventBus 或状态管理框架
    // 实际项目中推荐使用 @SingleInstance + @Observed
  };
}

更稳定的做法是把 DownloadManager 设计成 @SingleInstance 装饰的类,并作为全局状态存储在 AppStorageLocalStorage 中,让 UI 组件通过 @StorageLink 绑定,这样即使页面重建也能自动恢复。

常见问题 2:后台长时任务申请失败

现象startBackgroundRunning 调用后直接抛出异常,或者在开发者选项里开启了“不保留活动”后任务被杀死。

原因

  1. module.json5 中没有声明后台模式权限
  2. 用户手动关闭了应用的后台运行权限
  3. 系统资源紧张时主动回收

解决方案

module.json5 中声明:

{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.KEEP_BACKGROUND_RUNNING"
      }
    ],
    "backgroundModes": ["dataTransfer"]
  }
}

申请失败时,应该有降级方案:

private async ensureBackgroundTask(): Promise<void> {
  if (this.hasBackgroundTask) return;
  
  try {
    await backgroundTaskManager.startBackgroundRunning(
      getContext(this),
      backgroundTaskManager.BackgroundMode.DATA_TRANSFER
    );
    this.hasBackgroundTask = true;
  } catch (error) {
    // 降级方案:使用前台服务代替,或者提示用户不要切后台
    console.warn('后台任务申请失败,下载将在前台才能正常进行');
    // 可以考虑弹一个 Toast 提示用户
  }
}

常见问题 3:同时写同一个路径导致文件损坏

现象:用户反复点击下载同一个文件,或者两个任务配置了相同的 filePath,结果文件内容被互相覆盖,下载完成后校验失败。

原因request.downloadFile 不会检查目标路径是否已被占用,也不会做原子写入。如果两个任务同时写同一个文件,最后一个写入的会覆盖之前的。

解决方案:在 addDownload 方法中添加路径冲突检测:

public addDownload(url: string, filePath: string, priority: number = 5): string {
  // 检查队列中是否已存在相同路径的任务
  const existsInQueue = this.queue.some(t => t.filePath === filePath);
  const existsInRunning = Array.from(this.runningTasks.keys()).some(id => {
    const task = this.taskList.find(t => t.taskId === id);
    return task && task.filePath === filePath;
  });
  
  if (existsInQueue || existsInRunning) {
    console.warn(`路径 ${filePath} 已被占用,任务添加失败`);
    return '';
  }
  
  // 后续逻辑同前
}

最佳实践

1. 不要频繁更新 UI 状态

on('progress') 回调的频率很高,在小文件中可能每秒触发几十次。每次回调都调用 AppStorage.setOrCreate@State 赋值,会导致 ArkUI 频繁重渲染。

建议做法:节流,只更新整数百分比变动。

private lastProgress: number = 0;

private onProgress(task: DownloadTask): (progress: request.Progress) => void {
  return (progress: request.Progress) => {
    const newProgress = progress.totalSize > 0 
      ? Math.round(progress.transferredSize / progress.totalSize * 100) 
      : 0;
    // 只在新进度与上次相差 5% 以上时才更新 UI
    if (Math.abs(newProgress - this.lastProgress) >= 5) {
      task.progress = newProgress;
      // 更新 UI
      this.lastProgress = newProgress;
    }
  };
}

2. 异常重试要区分类型

网络错误(超时、DNS解析失败)可以重试,但 404、403、文件已存在等错误重试没有意义。建议在 finishTask 中只对特定错误码重试。

private finishTask(task: DownloadTask, status: TaskStatus): void {
  if (status === TaskStatus.FAILED && task.failedReason?.includes('timeout')) {
    // 网络超时,重试 3 次
    if ((task as any).retryCount || 0 < 3) {
      (task as any).retryCount = ((task as any).retryCount || 0) + 1;
      task.status = TaskStatus.PENDING;
      this.queue.unshift(task);  // 插回队列头部
      this.processNext();
      return;
    }
  }
  // 正常结束逻辑
}

3. 优先级和并发数的关系要保证

高优先级任务应该优先执行,但不应该抢占正在执行的低优先级任务。当前实现里,优先级只在插入队列时生效,一旦开始执行就不会被中途打断。这个设计比较合理,因为中断正在下载的任务代价太大(部分写入的文件需要清理)。

FAQ

Q:为什么 maxConcurrent 设置成 3,但任务还是同时下载 3 个以上?

A:请检查 processNext 方法是否在调用 downloadFile 之前就返回了。如果异步调用的 on(‘complete’) 回调中有 processNext(),而 on(‘fail’) 中也有,可能导致 activeCount 计数错误。建议在 finishTask 中统一调用 processNext

Q:后台下载完成后,能不能弹一个通知给用户?

A:可以。使用 @ohos.notificationManagerfinishTask 中发送本地通知。但注意,通知也需要在 module.json5 中声明权限,并且需要用户授权。HarmonyOS NEXT 中对通知权限管理比较严格。

Q:真机测试时后台任务正常,但模拟器上总是失败,为什么?

A:模拟器通常不具备完整的后台运行环境,特别是针对硬件相关的后台任务。建议以真机运行为准,模拟器只用于 UI 和基本逻辑调试。

Logo

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

更多推荐