《HarmonyOS技术精讲-Basic Services Kit》上传下载进阶:多任务并发与后台长时任务
《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) 及以上
目标设备:手机 / 平板
核心实现:多任务并发下载管理器
整体设计思路是这样的:
- 定义一个
DownloadTask数据结构,包含任务ID、URL、保存路径、状态、优先级等信息 - 实现
DownloadManager单例,维护一个任务队列、并发控制计数器、后台任务管理器 - 每次添加任务时,按优先级插入队列,然后检查当前并发数是否小于最大值,如果是则取出队列头部任务执行
- 每个任务完成后,减少并发计数器,检查队列,执行下一个任务
- 下载开始时申请后台长时任务,所有任务完成后释放
第一步:定义任务状态和数据结构
这部分比较基础,但要注意一点:状态枚举的值不要用魔法数字,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自动更新 UIbackgroundTaskManager.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 装饰的类,并作为全局状态存储在 AppStorage 或 LocalStorage 中,让 UI 组件通过 @StorageLink 绑定,这样即使页面重建也能自动恢复。
常见问题 2:后台长时任务申请失败
现象:startBackgroundRunning 调用后直接抛出异常,或者在开发者选项里开启了“不保留活动”后任务被杀死。
原因:
module.json5中没有声明后台模式权限- 用户手动关闭了应用的后台运行权限
- 系统资源紧张时主动回收
解决方案:
在 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.notificationManager 在 finishTask 中发送本地通知。但注意,通知也需要在 module.json5 中声明权限,并且需要用户授权。HarmonyOS NEXT 中对通知权限管理比较严格。
Q:真机测试时后台任务正常,但模拟器上总是失败,为什么?
A:模拟器通常不具备完整的后台运行环境,特别是针对硬件相关的后台任务。建议以真机运行为准,模拟器只用于 UI 和基本逻辑调试。
更多推荐



所有评论(0)