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 和采样记录看增长,找到监听器、定时器、全局引用或异步回调,最后用同一场景复测。只有增长曲线收敛,修复才算闭环。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐