一次 HarmonyOS 后台任务未释放问题的定位与治理

有一次测试同学提了个 bug:退出某个页面以后,Log 里还在刷定时器输出的日志。一开始我以为是页面没退干净,重新进了一次,发现日志刷得更猛了——原来每次进页面都注册了一组新的 Timer 和监听,退出的时候没清理,进几次就叠加几份。
这个问题不影响功能,也不会崩溃,但它会持续占 CPU 和内存,时间长了整个应用都变慢。而且这种问题特别容易被忽略,因为它不像闪退那样一进来就能看到,得反复进出页面好几次才会暴露出来。
这篇就讲讲这个 bug 是怎么一步步定位的,以及修完以后怎么确保以后不会再犯。
一、问题不是卡顿,而是页面退出后日志还在刷
现象很简单:打开一个实时数据展示页面,页面里有个定时器每隔一秒刷一次数据,同时注册了一个事件监听接收后台推送。退出页面以后,Log 里 timer tick 和 listener callback 的日志还在一直打。
第一反应是页面 aboutToDisappear 里漏了清理。但我去看了代码,确实在 aboutToDisappear 里调了 clearInterval 和 unregister,为什么还在跑?
后来加了日志才发现:页面里创建了两个 Timer 实例,一个在 onPageShow 里创建,一个在 onPageHide 里清理。但 onPageShow 每次页面显示都会触发,也就是说反复进出页面会创建多个 Timer,而 aboutToDisappear 只在页面真正销毁时才触发一次。如果页面是被压到后台而不是销毁,Timer 就一直在跑。
这里的关键问题不是"漏写了清理代码",而是没有搞清楚页面生命周期和任务生命周期的关系。页面 hide 不等于页面 destroy,任务创建的时机和清理的时机必须对应起来。
二、沿着引用关系找到真正持有任务的对象
搞清楚现象以后,下一步是找到谁在持有这些任务。
ArkTS 的页面里,Timer 是通过 setInterval 创建的,它返回一个 timerId。监听是通过 emitter.on 或者 eventHub.on 注册的,返回一个 unsubscribe 函数。这些对象如果不手动清理,垃圾回收不会帮你回收——因为 Timer 本身就有自己的执行线程,只要它在跑,就会持有回调函数的引用,而回调函数又持有页面上下文。
我顺着引用关系查了一遍:Timer 回调里调用了 this.updateData(),this 就是页面对象。也就是说,只要 Timer 还在跑,页面对象就不会被回收。页面都退出了,但页面对象还在内存里,它的成员变量、它持有的 Timer、它注册的监听,全都活着。
这就是为什么反复进出页面问题会越来越严重:每次进入都创建新的 Timer 和监听,退出时如果没完全清理,旧的页面对象连同它的任务一起留在内存里。进三次,就有三组 Timer 在跑。
找到根因以后,修复方向就清楚了:不能只在 aboutToDisappear 里清理,要在页面 hide 的时候就把不需要后台继续跑的任务停掉;同时确保所有注册和清理都收口到一个地方,不能散落在各个生命周期函数里。
三、页面生命周期和任务生命周期为什么对不上

HarmonyOS 的页面生命周期有好几个阶段:aboutToAppear(页面创建)、onPageShow(页面显示到前台)、onPageHide(页面切到后台)、aboutToDisappear(页面销毁)。这几个阶段什么时候触发,和开发者直觉可能不太一样。
从一个页面跳到另一个页面,前一个页面会走 onPageHide,但不会走 aboutToDisappear。也就是说,它还活着,只是不在前台了。如果你在 onPageShow 里创建 Timer,在 onPageHide 里清理,那这俩是配对的,问题不大。但如果你在 aboutToAppear 里创建 Timer,却只在 aboutToDisappear 里清理,那从这个页面跳走的时候 Timer 就一直在后台跑。
还有一种情况:UIAbility 的生命周期和页面生命周期也不一样。应用切到后台的时候,所有页面都会走 onPageHide,但 UIAbility 走 onBackground。如果你在 UIAbility 的 onForeground 里注册了全局监听,那这个监听在应用整个生命周期里都存在,不受页面切换影响。
搞清楚这些对应关系以后,就知道不同类型的任务应该放在哪个生命周期里管理:前台需要的实时更新放在 onPageShow 里启动、onPageHide 里停止;只需要在页面存活期间运行的任务放在 aboutToAppear 里创建、aboutToDisappear 里清理;全局监听放在 UIAbility 层统一管理。
四、Timer、监听和异步任务分别怎么收口
修复的时候我把所有任务管理收口到了一个 PageTaskManager.ets 里。这个管理器负责记录页面创建了哪些 Timer、注册了哪些监听,页面 hide 的时候统一清理。
下面这段代码放在 TaskRegistry.ets 里,提供统一的注册和清理接口。页面在 onPageShow 里通过这个接口创建 Timer 和注册监听,onPageHide 里调 stopAll() 一次性清理。它还做了重复注册保护——同一个监听如果已经注册过,不会再注册一次。
export class TaskRegistry {
private timers: number[] = [];
private unsubscribers: Array<() => void> = [];
private registeredKeys: Set<string> = new Set();
startInterval(callback: () => void, interval: number): number {
const id = setInterval(callback, interval);
this.timers.push(id);
return id;
}
registerListener(key: string, subscribe: () => () => void): void {
if (this.registeredKeys.has(key)) {
console.warn(`[TaskRegistry] listener "${key}" already registered, skip`);
return;
}
const unsubscribe = subscribe();
this.unsubscribers.push(unsubscribe);
this.registeredKeys.add(key);
}
stopAll(): void {
this.timers.forEach((id) => clearInterval(id));
this.timers = [];
this.unsubscribers.forEach((fn) => fn());
this.unsubscribers = [];
this.registeredKeys.clear();
console.info('[TaskRegistry] all timers and listeners stopped');
}
}
这段代码要解决的问题:页面里所有 Timer 和监听都通过 TaskRegistry 统一管理,不再散落在各个生命周期函数里;stopAll() 一次把 Timer 全部 clear、监听全部注销、已注册标记全部清空;registerListener 做了 key 去重,防止重复注册同一个监听。
实际使用时要注意:stopAll() 在 onPageHide 里调用,意味着切到后台时所有实时任务都停掉。如果有些任务确实需要在后台继续运行(比如前台服务的下载进度),不能放在这个 Registry 里管理,要单独用支持后台运行的方式。另外,setInterval 的 timerId 类型需要对照当前 API 版本确认,不同版本可能是 number 还是对象类型。页面重新 onPageShow 的时候,需要重新调 startInterval 和 registerListener,不能假设它们会自动恢复。
五、修完以后怎么验证没有重复注册和残留任务
修复完不能只看代码觉得"应该好了",得实际验证。
验证方法很直接:反复进出同一个页面十次,每次进入后等几秒,退出后看 Log 里还有没有 timer tick 在打。如果退出后日志立刻停了,说明清理生效了;如果还在打,说明有任务没被 stopAll 覆盖到。
还要检查有没有重复注册。在 registerListener 里加了重复保护以后,反复进出页面时 Log 里应该只出现一次注册日志,不会出现"already registered, skip"的警告。如果出现了警告,说明页面 onPageShow 被调了多次但 onPageHide 没被正确触发。
最后用 Profiler 看一下内存。反复进出页面多次以后,如果内存曲线是阶梯式上涨,说明还有对象没释放;如果涨上去以后稳住了,说明清理到位了。

这种任务未释放的问题,说到底不是某段代码写错了,而是任务的创建和清理没有一一对应。把所有任务收口到一个管理器里,创建和清理都走同一个入口,比在各个生命周期函数里分别管要可靠得多。
更多推荐




所有评论(0)