一次鸿蒙后台任务的翻车与自救【鸿蒙心迹】
标题
那是一个测试的鸿蒙应用。内测一切正常,骑友使用甩出截图:他骑了 40 分钟,记录页面上赫然写着 8 分钟,中间整整 32 分钟是空白。
我拿着手机骑楼下绕了一圈复现:一锁屏,应用就被"冻"住了。锁屏、切桌面、开别的应用——只要退到后台,进程被系统挂起,定时器不走了,GPS 定位停了,等我解锁回来,它才"诈尸"继续跑。用户的 40 分钟骑行, App 只"醒着"8 分钟。
之前写 Android 时处理过前台服务,我以为鸿蒙大差不差,结果人家把规则写得明明白白:后台不是你的。这篇把这个坑从根因到解法一次讲清,附一个完整可跑通的"骑行记录"Demo——所有 API 我都对着官方文档逐条核过。
一、先搞懂:你的应用为什么会被"冻住"
在鸿蒙上,返回主界面、锁屏、切应用,都会让应用退至后台。系统为了省电和流畅,会对后台应用做挂起或终止。关键在挂起的定义:进程挂起后,无法使用软件资源(公共事件、定时器)和硬件资源(CPU、网络、GPS、蓝牙)——我那 32 分钟的空白,就是定时器和 GPS 同时被掐了。
不是不能在后台干活,是要按规矩申请。Background Tasks Kit 给了四种规范内的后台任务,先对号入座:
| 类型 | 适合 | 举例 | 关键约束 |
|---|---|---|---|
| 短时任务 | 实时性高、耗时短 | 退后台前快速保存笔记 | 单次最大 3 分钟,单日配额默认 10 分钟,低电量单次 1 分钟 |
| 长时任务 | 长时间运行、用户可感知 | 后台播放音乐、导航、骑行记录 | 要申请对应类型,通知栏常驻,系统做一致性校验 |
| 延迟任务 | 实时性不高、可推迟 | 后台同步数据 | 系统统一排队调度 |
| 代理提醒 | 退后台/进程终止后也要提醒 | 闹钟、倒计时、日历 | 系统代理执行 |
骑行记录 = 长时间 + 用户可感知 + 持续定位 → 长时任务(location 类型),没有第二个选项。短时任务那 3 分钟配额,撑不起任何一次骑行。
二、完整 Demo:一个锁屏后还在骑的"骑行记录"
效果:点"开始骑行"→ 通知栏出现常驻通知 → 锁屏/切走任意久 → 回来,计时和距离连续无断层。删掉通知或点"结束",一切归位。
1. module.json5:声明权限 + 后台模式
{
"module": {
// ...
"requestPermissions": [
{
// 申请长时任务的入场券,normal 级权限,声明即授予
"name": "ohos.permission.KEEP_BACKGROUND_RUNNING"
},
{
// 定位需要这两个 user_grant 权限,必须带 reason 和 usedScene
"name": "ohos.permission.APPROXIMATELY_LOCATION",
"reason": "$string:location_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
},
{
"name": "ohos.permission.LOCATION",
"reason": "$string:location_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
}
],
"abilities": [
{
// ...模板其余内容保持不变
"backgroundModes": [
"location" // 长时任务类型,和代码里申请的类型一致
]
}
]
}
}
别忘了在 resources/base/element/string.json 里补一个 location_reason(比如"记录骑行轨迹需要获取您的位置")——user_grant 权限没 reason,应用直接装不上。
2. Index.ets:整个替换页面代码
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { geoLocationManager } from '@kit.LocationKit';
import { abilityAccessCtrl, Permissions, wantAgent, WantAgent, common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
/** 球面两点间距离(米),骑行距离靠它累加 */
function distanceBetween(a: geoLocationManager.Location,
b: geoLocationManager.Location): number {
const rad = Math.PI / 180;
const dLat = (b.latitude - a.latitude) * rad;
const dLon = (b.longitude - a.longitude) * rad;
const s = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(a.latitude * rad) * Math.cos(b.latitude * rad) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
return 6371000 * 2 * Math.asin(Math.sqrt(s));
}
function formatTime(sec: number): string {
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
const two = (n: number): string => (n < 10 ? '0' : '') + n;
return `${two(h)}:${two(m)}:${two(s)}`;
}
@Entry
@Component
struct Index {
@State elapsedText: string = '00:00:00';
@State distanceText: string = '0.00';
@State isRiding: boolean = false;
private startTs: number = 0; // 时间戳计时法的锚点
private timerId: number = -1; // 只负责刷新 UI
private totalMeters: number = 0;
private lastLocation: geoLocationManager.Location | undefined = undefined;
aboutToAppear(): void {
// API 15+:监听长时任务被系统/用户取消(比如用户删了常驻通知)
backgroundTaskManager.on('continuousTaskCancel',
(info: backgroundTaskManager.ContinuousTaskCancelInfo) => {
console.warn(`continuous task canceled, id=${info.id}, reason=${info.reason}`);
this.getUIContext().getPromptAction()
.showToast({ message: '后台保护已被取消,骑行记录可能中断' });
});
}
aboutToDisappear(): void {
backgroundTaskManager.off('continuousTaskCancel');
}
private requestLocationPermission = async (): Promise<boolean> => {
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
const atManager = abilityAccessCtrl.createAtManager();
const perms: Array<Permissions> = [
'ohos.permission.APPROXIMATELY_LOCATION',
'ohos.permission.LOCATION'
];
const result = await atManager.requestPermissionsFromUser(context, perms);
return result.authResults.every((r: number) => r === 0);
};
/** 申请长时任务:通知栏会出现常驻通知,点击可拉回应用 */
private startContinuousTask = async (context: common.UIAbilityContext): Promise<void> => {
const wantAgentInfo: wantAgent.WantAgentInfo = {
wants: [
{ bundleName: 'com.demo.ridetrack', abilityName: 'EntryAbility' }
],
actionType: wantAgent.OperationType.START_ABILITY,
requestCode: 0,
actionFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
};
const wantAgentObj: WantAgent = await wantAgent.getWantAgent(wantAgentInfo);
const res: backgroundTaskManager.ContinuousTaskNotification =
await backgroundTaskManager.startBackgroundRunning(context, ['location'], wantAgentObj);
console.info(`continuous task started, notificationId=${res.notificationId}`);
};
private onLocation = (location: geoLocationManager.Location): void => {
if (this.lastLocation !== undefined) {
this.totalMeters += distanceBetween(this.lastLocation, location);
this.distanceText = (this.totalMeters / 1000).toFixed(2);
}
this.lastLocation = location;
};
private startRide = async (): Promise<void> => {
if (this.isRiding) {
return;
}
const granted = await this.requestLocationPermission();
if (!granted) {
this.getUIContext().getPromptAction().showToast({ message: '需要定位权限才能记录骑行' });
return;
}
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
// 先申请长时任务,再开始定位——保证退后台时业务已经在跑(一致性校验)
try {
await this.startContinuousTask(context);
} catch (err) {
const e = err as BusinessError;
console.error(`startBackgroundRunning failed: ${e.code}, ${e.message}`);
return;
}
// 场景设为轨迹追踪,5 秒上报一次
const request: geoLocationManager.LocationRequest = {
scenario: geoLocationManager.LocationRequestScenario.TRAJECTORY_TRACKING,
timeInterval: 5
};
geoLocationManager.on('locationChange', request, this.onLocation);
// 时间戳计时法:锚点记一次,展示层只管刷 UI
this.startTs = Date.now();
this.timerId = setInterval(() => {
const sec = Math.floor((Date.now() - this.startTs) / 1000);
this.elapsedText = formatTime(sec);
}, 1000);
this.isRiding = true;
};
private stopRide = async (): Promise<void> => {
if (!this.isRiding) {
return;
}
geoLocationManager.off('locationChange', this.onLocation);
this.lastLocation = undefined;
clearInterval(this.timerId);
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
try {
await backgroundTaskManager.stopBackgroundRunning(context);
console.info('continuous task stopped');
} catch (err) {
const e = err as BusinessError;
console.error(`stopBackgroundRunning failed: ${e.code}, ${e.message}`);
}
this.isRiding = false;
};
build() {
Column({ space: 24 }) {
Text('骑行记录').fontSize(24).fontWeight(FontWeight.Bold)
Text(this.elapsedText)
.fontSize(56)
.fontWeight(FontWeight.Bold)
.fontColor('#0A59F7')
Text(`已骑 ${this.distanceText} 公里`)
.fontSize(20)
.fontColor('#666666')
Button(this.isRiding ? '结束骑行' : '开始骑行')
.width(220)
.height(52)
.fontSize(18)
.type(ButtonType.Capsule)
.backgroundColor(this.isRiding ? '#E84026' : '#0A59F7')
.onClick(() => {
if (this.isRiding) {
this.stopRide();
} else {
this.startRide();
}
})
Text('提示:开始后可锁屏或切到其他应用,\n回来后计时与距离应保持连续')
.fontSize(13)
.fontColor('#999999')
.textAlign(TextAlign.Center)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#F5F7FA')
}
}
把代码里的
bundleName换成你自己工程的包名(AppScope/app.json5里那个)。
3. 三个设计点,比代码本身值钱
- 时间戳计时,别靠 setInterval 累加。
startTs记一次锚点,每次展示时用Date.now() - startTs算。挂起期间setInterval不跑没关系——锚点不会说谎,回到前台的第一秒就能算出真实时长。业务状态靠时间戳,UI 只是展示层,这一刀切干净,后台问题少一半。 - 先申请长时任务,再开定位。 顺序反了的话,退后台瞬间系统做一致性校验:申请了 location 却没在定位,判定"申请未使用",照样挂起。
- 监听
continuousTaskCancel(API 15+)。 用户把通知栏那条常驻通知往下一划,系统会自动停掉长时任务——用户不是在删通知,是在关你的后台保护。接住这个回调,要么提示用户,要么重新申请,别让记录悄悄断掉。
三、跑通清单(五步,对照组很重要)
- Run 到模拟器(Background Tasks Kit 支持模拟器)或真机,点"开始骑行",授权定位;
- 确认通知栏出现常驻通知——这就是长时任务的"身份证";
- 锁屏或退回桌面,等 2~3 分钟(真机出门骑一圈更好),解锁回来:计时连续、距离正常累加,没有断层;
- 对照组:把
startContinuousTask那行注释掉重跑,锁屏几分钟再回来——断层重现,你就亲眼见到了那位骑友的 8 分钟; - 下滑删掉常驻通知,看日志里
continuous task canceled回调触发;再点通知本体,应用被拉回前台(wantAgent生效)。
四、踩坑实录
坑 1:报 201 权限拒绝。 ohos.permission.KEEP_BACKGROUND_RUNNING 忘了在 requestPermissions 里声明。它是 normal 级,声明即得,但必须声明。
坑 2:backgroundModes 写成 "LOCATION"。 配置文件里填的是配置项小写驼峰(location、audioPlayback、dataTransfer……),不是枚举名大写。写错不会报错,但退后台照样被挂起,极难排查。
坑 3:报 9800005(一致性校验失败)。 申请了某类型长时任务,后台却没干对应的事。系统会检测"申请与实际业务是否匹配":申请了音视频播放却在后台沉默、申请了定位却没定位——都会被挂起甚至终止。申请什么,就做什么。
坑 4:后台播音乐没接 AVSession,被静音冻结。 STREAM_USAGE_MUSIC/MOVIE/AUDIOBOOK/GAME 类型的播放,必须同时接入 AVSession + 申请 audioPlayback 长时任务,缺一个,退后台就被静音并冻结,回前台才恢复。停止时还要先停音频再停长时任务,顺序反了会被强制终止。
坑 5:拿短时任务跑长业务。 短时任务单次最大 3 分钟、单日默认 10 分钟,低电量下单次只有 1 分钟,超时前 6 秒才有回调。它是"退后台前抢救保存"用的,不是保活工具。
坑 6:API 20 及之前,一个 UIAbility 同时只能有一个长时任务。 又要导航又要放歌的(location + audioPlayback),API 21+ 可以一个 UIAbility 申请最多 10 个;老版本要么合并场景,要么拆多个 UIAbility。
坑 7:托管给系统的下载,退后台还是会被挂起。 用 @ohos.request 托管的下载/上传,即使申请了 dataTransfer 长时任务也会被挂起;自己搬数据的传输才有效,且进度通知首次更新超过 10 分钟没更新,任务会被取消。
坑 8:任务做完了不取消。 用户点了暂停,长时任务还挂着——应用退后台会被系统挂起(判定"业务已结束")。规范动作:暂停/结束时同步 stopBackgroundRunning,下次开始再重新申请。
五、回头看
修完给那位骑友,之后在群里发了张新截图:42 分钟,一秒没丢。
这件事给我最大的冲击不是某个 API,而是鸿蒙把后台资源的逻辑摆得非常直白:前台是用户的,后台是公共的。你想在公共的资源上继续干活,就得亮明身份(申请对应类型)、名实相符(一致性校验)、用完归还(及时 stop)。违反了,轻则挂起,重则终止,还有一页明明白白的违规处罚标准。
从 Android 时代八仙过海的保活黑科技,到鸿蒙的"申请—使用—归还",一开始我是不适应的——少了很多"操作空间"。但写完这个 Demo 我想通了:保活大战里没有赢家,用户手机烫手、电量跳水,最后骂的是整个生态。把"后台能干什么"变成明规则,逼着开发者把功夫花在把前台体验做好——这才是这条规则真正想改变的事。
更多推荐


所有评论(0)