纯血鸿蒙后台长时任务与断点续传实战:从「10 分钟被回收」到可恢复的下载流水线
纯血鸿蒙后台长时任务与断点续传实战:从「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();
}
这段代码在应用处于前台时表现完全正常。一旦切到后台,会出现三类症状:
- 进度条卡住:
task.on('progress')不再回调。原因是进程被挂起,CPU 与定时器全部停止,网络请求的处理回调自然也不会执行。 - 任务「消失」但文件残留在半截:进程被回收,
onDestroy或者onSaveState没有保证一定被调用,内存里的「已下载字节数」随之消失,而磁盘上留着一个 137MB 的残缺文件——下次启动如果按「文件已存在就跳过」的逻辑判断,用户拿到的就是一个永远打不开的包。 - 申请了长时任务,仍然在某个时刻被取消:这是最让人困惑的一类。任务确实申请成功了,也跑了很久,但系统在某次检查后把任务取消了。根本原因是用了
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 等。三处必须一一对应,缺一处就会申请失败:
module.json5里abilities[].backgroundModes声明的类型;- 申请时传入的
BackgroundMode; 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 下由系统托管,与应用生命周期解耦:应用被杀,系统侧任务仍然继续,并且内置断点续传能力。
关键配置只有四个字段,但每一个都有明确语义:
| 字段 | 取值 | 理由 |
|---|---|---|
mode | request.agent.Mode.BACKGROUND | 后台模式,系统托管;FOREGROUND 跟随应用进程 |
overwrite | false | 允许续写而不是覆盖,写 true 会让续传退化成从头开始 |
gauge | true | 开启进度回调,这也是 DATA_TRANSFER 长时任务上报进度的数据来源 |
network | request.agent.Network.ANY | 不限制网络类型,避免 Wi-Fi 切换蜂窝时任务失败 |
恢复时的正确顺序是三步,不能颠倒:
- 用持久化的
tid调request.agent.getTask(context, tid)查系统侧是否还有这个任务; - 任务存在且处于暂停态 →
resume();运行中的任务绝不能重复create或重复start; - 任务已被系统清理(
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 标准语义,没有鸿蒙特有的东西,但有三个返回状态必须都处理:
206 Partial Content且Content-Range与请求的偏移一致 → 追加写入,正常续传;200 OK→ 服务端忽略了Range(文件已变或服务器不支持),必须清空本地文件从头写,不能继续 append;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 或本地偏移续传 |
落到工程上,记住五条:
- 下载/上传优先用系统代理
request.agent的Mode.BACKGROUND,它与应用生命周期解耦,本身就带续传能力;自己用http拼只适用于有特殊鉴权/加密需求的场景。 overwrite必须为false,否则每次恢复都从头开始。tid才是恢复依据,不是进度条数字,也不是内存里的Task对象——进程一死,它们全部消失。- 本地文件长度不等于可信续传偏移,必须配合
ETag/Content-Range校验,三个 HTTP 状态(206/200/416)都要有分支。 - 检查点要周期性写并
flush,不要赌onDestroy和onSaveState会给你机会。
最后一点提醒:如果是大文件(模型包、离线地图、安装包),建议 request.agent 托管 + 周期检查点双管齐下,并在每次恢复前用 HEAD 校验 ETag。多花一次请求,换掉的是「用户拿到一个永远打不开的包」这种最难排查的线上问题。
更多推荐




所有评论(0)