HarmonyOS 内存泄漏排查实战:生命周期、监听器、定时器与引用链
HarmonyOS 内存泄漏排查实战:生命周期、监听器、定时器与引用链
内存泄漏通常不会立刻崩溃,而是越用越慢、页面切换后内存不降、后台一段时间后被系统回收。HarmonyOS 应用里常见泄漏来自页面销毁后监听器未移除、定时器未清理、全局单例持有页面 Context、异步任务回调引用旧页面。本文用可复现、可采样、可释放的方式讲排查流程。

1. 泄漏排查先构造复现场景
没有稳定复现,就没有可靠修复。建议用“进入页面 20 次、退出页面 20 次、执行同一操作”的方式观察内存曲线。

| 泄漏来源 | 表现 | 修复方向 |
|---|---|---|
| 监听器 | 页面销毁后仍回调 | onDestroy 移除 |
| 定时器 | 页面退出仍执行 | 记录 timerId 并清理 |
| 全局缓存 | Context 被持有 | 只存必要数据 |
| 异步回调 | 请求回来更新旧页面 | 销毁标记 |
2. Profiler 资料和工程目录
可参考 DevEco Profiler、性能优化总览。
entry/src/main/ets/common/memory/
LeakScenario.ets
MemoryProbe.ets
DisposeRegistry.ets
LeakReport.ets
SafeAsyncTask.ets
3. LeakScenario 固定复现动作
export interface LeakStep {
name: string
repeat: number
}
export const RoutePageLeakScenario: LeakStep[] = [
{ name: 'open_route_page', repeat: 20 },
{ name: 'start_location_watch', repeat: 20 },
{ name: 'close_route_page', repeat: 20 }
]
复现场景要写下来,修复前后用同一套动作对比。
4. DisposeRegistry 统一释放资源
export type DisposeAction = () => void
export class DisposeRegistry {
private actions: DisposeAction[] = []
add(action: DisposeAction): void {
this.actions.push(action)
}
disposeAll(): void {
this.actions.forEach(action => action())
this.actions = []
}
}
监听器、定时器、订阅都登记进统一释放器,页面销毁时一次清理。
5. 定时器必须可清理
export class TimerOwner {
private timerId: number = -1
start(): void {
this.timerId = setInterval(() => {
console.info('tick')
}, 1000)
}
stop(): void {
if (this.timerId >= 0) {
clearInterval(this.timerId)
this.timerId = -1
}
}
}
定时器没有 ID 就无法清理,这是泄漏的常见原因。
6. 异步任务避免回调旧页面

export class SafeAsyncTask {
private destroyed = false
destroy(): void {
this.destroyed = true
}
async run(load: () => Promise<string>, update: (value: string) => void): Promise<void> {
const value = await load()
if (!this.destroyed) {
update(value)
}
}
}
页面销毁后,异步结果不应再更新 UI。
7. MemoryProbe 记录采样数据
export interface MemorySample {
scene: string
usedMB: number
at: number
}
export class MemoryProbe {
private samples: MemorySample[] = []
record(scene: string, usedMB: number): void {
this.samples.push({ scene, usedMB, at: Date.now() })
}
growth(): number {
if (this.samples.length < 2) return 0
return this.samples[this.samples.length - 1].usedMB - this.samples[0].usedMB
}
}
采样数据可以辅助 Profiler 结果,方便写修复前后对比。
8. 泄漏修复验收动作
| 场景 | 操作 | 预期结果 |
|---|---|---|
| 页面循环 | 进入退出 20 次 | 内存不持续上涨 |
| 监听器 | 页面销毁后触发事件 | 无旧页面回调 |
| 定时器 | 退出页面后等待 | 不再打印 tick |
| 异步请求 | 退出后返回 | 不更新旧 UI |
| 全局缓存 | 切换账号 | 不持有旧 Context |
export function assertMemoryGrowth(growthMB: number): void {
if (growthMB > 20) throw new Error(`疑似内存增长过高:${growthMB}MB`)
}
9. 内存泄漏排查表
排查顺序建议固定:复现场景、采样曲线、引用链、释放点、复测。
| 现象 | 优先查看 | 处理建议 |
|---|---|---|
| 页面退出内存不降 | 监听器和定时器 | onDestroy 清理 |
| 旧页面仍收事件 | EventBus 订阅 | 统一释放器 |
| 切账号串数据 | 全局单例 | 不持有 Context |
| 异步回调报错 | destroyed 标记 | 回调前判断 |
| 修复后不确定 | 同场景复测 | 对比增长曲线 |
建议把每个页面的可释放资源登记成清单。清单不是为了替代 Profiler,而是为了让代码审查时能看到“哪些资源需要释放、释放点在哪里”。
export interface DisposableResource {
name: string
type: 'listener' | 'timer' | 'subscription' | 'async_task'
disposed: boolean
}
export class LeakReport {
constructor(private readonly resources: DisposableResource[]) {}
undisposed(): DisposableResource[] {
return this.resources.filter(item => !item.disposed)
}
assertClean(): void {
const leaks = this.undisposed()
if (leaks.length > 0) {
throw new Error(`存在未释放资源:${leaks.map(item => item.name).join(',')}`)
}
}
}
页面退出时先跑资源清单,再结合 Profiler 看内存曲线。如果清单已经干净但内存仍增长,再继续查全局单例、缓存池和闭包引用。
复测时不要只进入页面一次。建议按下面的动作重复三轮:
| 步骤 | 操作 | 观察 |
|---|---|---|
| 进入 | 打开目标页面并触发主要功能 | 记录内存 |
| 退出 | 返回上一页并等待回收 | 监听器不再回调 |
| 重复 | 连续 20 次 | 内存曲线趋于稳定 |
| 对比 | 修复前后同场景 | 增长幅度明显收敛 |
内存排查要警惕“看起来已经释放”的假象。页面对象不再显示,不代表引用链已经断开;定时器停止打印,也不代表订阅已经移除。可靠证据是页面退出后旧对象不再被全局管理器、事件总线、闭包或异步任务持有。
| 引用来源 | 典型代码味道 | 处理方式 |
|---|---|---|
| 全局 Manager | 保存页面对象 | 只保存 ID 或轻量状态 |
| EventBus | 注册后没有 off | 统一进入 DisposeRegistry |
| Timer | 没保存 timerId | 页面销毁时 clear |
| Promise 回调 | 回调里直接更新 UI | 加 destroyed 判断 |
修复后不要只看一次 GC 后的瞬时值。内存曲线有波动是正常的,关键看多轮进入退出后是否持续抬升。如果每轮都比上一轮更高,才是需要继续追的泄漏信号。
专项验收建议把资源释放和内存曲线分开看。资源释放清单证明代码路径执行了,内存曲线证明运行结果收敛了,两者缺一不可。只看清单可能漏掉闭包引用,只看曲线又很难定位到具体释放点。
| 验收项 | 证明内容 |
|---|---|
| 释放清单为空 | 页面登记资源都已释放 |
| 旧页面无回调 | 监听器和异步任务已断开 |
| 多轮内存稳定 | 没有持续增长趋势 |
| 切账号无旧数据 | 全局缓存没有持有旧对象 |
如果释放清单为空但内存仍持续上涨,下一步应检查缓存容量、图片资源和全局单例;如果内存稳定但仍有旧页面回调,说明释放路径存在遗漏,不能只看曲线就结束。
最终提交修复时,建议附上修复前后同一复现场景的内存曲线、未释放资源清单和引用链截图。这样评审者能判断修复是否真的切断了引用,而不是只降低了一次内存峰值。
如果仍然接近阈值,建议把“修复前对象数量、修复后对象数量、剩余引用来源”写入复盘表。内存问题越具体,下一次评审越容易判断同类问题是否已经被系统性解决;否则团队只会反复记住“某页面有泄漏”,却不知道真正需要改的是监听器、定时器、缓存还是单例引用。
内存引用链复现场景:给读者一组可执行核验
内存泄漏要验证多轮进入退出。一次退出后内存下降不代表没有泄漏,旧页面回调和单例引用仍可能存在。
| 核验维度 | 读者需要准备的证据 |
|---|---|
| 输入 | 页面入口、用户动作、关键参数 |
| 过程 | 日志、状态变化、异常分支 |
| 输出 | UI 表现、回调结果、持久化结果 |
| 回归 | 同场景重复执行后的结果 |
interface LeakReplayCase {
pageName: any
enterTimes: any
aliveObjects: any
staleCallbacks: any
}
const replay90: LeakReplayCase = {
pageName: 'sample',
enterTimes: 'sample',
aliveObjects: 'sample',
staleCallbacks: 'sample',
}
function assertReplay90(item: LeakReplayCase): void {
if (item.enterTimes >= 10 && item.aliveObjects > 2) throw new Error('多轮退出后仍有页面对象存活')
}
这组核验要求多轮进入退出后再判断内存状态,避免把一次回收误认为泄漏已经修复。
内存增长回放表:把文章方法变成可复现动作
内存泄漏要用多轮操作验证。建议连续进入退出目标页面 20 次,并记录旧页面对象、订阅数、定时器数和回调次数。
| 回放动作 | 核验方式 |
|---|---|
| 页面对象回落 | 准备输入、执行操作、记录结果、给出结论 |
| 订阅归零 | 准备输入、执行操作、记录结果、给出结论 |
| 定时器清理 | 准备输入、执行操作、记录结果、给出结论 |
| 旧回调为零 | 准备输入、执行操作、记录结果、给出结论 |
内存泄漏要避免一次性结论。读者可以连续进入退出目标页面二十次,每五次记录对象数量、订阅数量、定时器数量和旧回调次数。如果曲线持续抬升,就继续看引用链;如果对象回落但旧回调存在,就优先查事件总线和异步任务。这样排查更接近真实线上使用。
内存修复的落地边界:不要把边界留给读者猜
内存治理要防止只修表面。定时器、事件总线、异步回调、单例缓存都可能持有页面。读者落地时建议每种引用来源都写释放规则,并在页面销毁时统一执行。
| 落地项 | 处理要求 |
|---|---|
| 定时器必须 clear | 需要有明确输入、处理边界和失败兜底 |
| 订阅必须 off | 需要有明确输入、处理边界和失败兜底 |
| 回调先判断页面状态 | 需要有明确输入、处理边界和失败兜底 |
| 单例不持有 UI 对象 | 需要有明确输入、处理边界和失败兜底 |
这类边界写清楚后,读者不需要猜哪些逻辑属于页面、哪些属于服务、哪些属于发布前验收。文章的价值也会从“讲了一个功能”变成“给了一套可迁移的工程判断”。
内存联调步骤:按真实路径走一遍
内存联调建议按引用来源拆。先查定时器是否销毁,再查事件订阅是否解绑,再查异步回调是否判断页面状态,最后查单例是否持有页面对象。每一轮进入退出后记录活动资源数量,而不是只看内存总量。这样才能把泄漏定位到具体引用来源。
这一步的意义是让读者拿到文章后可以直接复现,而不是只理解概念。技术文章如果能把“输入、动作、日志、结果、失败兜底”写完整,读者照着做时出错概率会低很多。
内存验收补充:补上容易漏掉的边界
补充一个半完成场景:页面发起网络请求后立即返回,响应回来时如果仍然更新 UI,就说明异步回调持有了页面引用。验收时要记录页面销毁标记、回调触发次数和旧页面对象数量,这比正常返回更容易暴露泄漏。
内存联调还要覆盖异常退出。用户可能在请求未完成时返回、在弹窗显示时关闭页面、在上传中切后台。读者应在这些半完成状态下退出页面,确认异步回调不会继续更新已销毁页面。这个场景比正常进入退出更容易暴露引用链残留。
这类补充不是为了增加篇幅,而是为了让读者在真实项目里少踩坑:正常路径一般最容易跑通,异常路径、退出路径和恢复路径才是质量差距所在。
内存收尾核验:补齐最后一个真实场景
如果页面包含图片、地图或 Web 组件,还要单独记录这些重资源的释放时间。普通对象释放了,不代表底层资源已经释放。读者可以把重资源释放作为独立验收项,避免内存曲线看似稳定但资源句柄仍然残留。
这个核验点建议和引用链截图一起归档。
这个复盘动作建议加上负责人和修复提交。内存问题往往跨页面复发,如果只记录“已释放”,下次仍然要重新排查;如果记录引用来源和修复提交,就能快速判断是否属于同类问题。
最后再补一个复盘动作:每次修复内存问题后,把引用来源、释放点和复测轮次写进问题单。下一次同类页面出现增长时,可以直接对照历史修复项,而不是重新从所有监听器和定时器开始排查。
10. 小结:内存修复要靠引用证据
内存泄漏排查不能凭感觉。先固定复现场景,再用 Profiler 和采样记录看增长,找到监听器、定时器、全局引用或异步回调,最后用同一场景复测。只有增长曲线收敛,修复才算闭环。
更多推荐


所有评论(0)