一次真实的 HarmonyOS 内存泄漏排查:现象、定位与修复
页面能正常打开,返回也没有报错,但只要连续进出十几次,应用就越来越卡,内存还一路往上涨。
更奇怪的是:代码里没有死循环,也没有一次性加载几百兆数据。手动触发 GC 后,内存只下降一点,很快又涨回去。
这篇文章就复盘一次很典型的 HarmonyOS 内存泄漏排查:我们怎么从“感觉有点卡”,一步步找到一条没有解除的事件监听,最终把长期上涨的内存压回稳定区间。
本文使用 ArkTS 和 ArkUI 组件生命周期讲解。为了方便公开,业务名称、对象数量和测试数据做了简化,但排查方法可以直接迁移到大多数 HarmonyOS 项目。不同 DevEco Studio 和 SDK 版本的分析界面可能略有差异,请以当前版本为准。
一、问题是怎么暴露出来的?
出问题的是一个“文章详情页”。页面本身并不复杂:
- 顶部显示标题和作者;
- 中间渲染富文本内容;
- 正文里可能有多张图片;
- 页面会监听全局字体大小变化;
- 每隔一段时间同步一次阅读进度。
正常使用一两次完全看不出问题。测试同事在做稳定性测试时,连续执行了下面的操作:
首页 → 打开文章详情 → 返回首页
→ 再打开文章详情 → 再返回
→ 重复 20 次
然后发现三个现象:
- 第一次打开很流畅,十几次后返回动画开始掉帧;
- 每次进入详情页,内存都会比上一次高一点;
- 退出详情页后,内存没有回到之前的水平。
我们在一台固定测试设备、同一个 Release 测试包上重复了三轮,得到了一组类似的数据:
| 操作阶段 | 应用总内存(约) | 现象 |
|---|---|---|
| 冷启动并稳定后 | 136 MB | 页面流畅 |
| 进出详情页 5 次 | 181 MB | 暂无明显卡顿 |
| 进出详情页 10 次 | 229 MB | 返回偶尔掉帧 |
| 进出详情页 20 次 | 337 MB | 图片加载明显变慢 |
| 离开页面并等待、触发 GC | 301 MB | 只回落少量 |
不同设备、图片尺寸和构建模式下,绝对数字会不同。真正值得警惕的不是“337 MB”这个数字,而是这种趋势:
完成相同操作后,内存基线持续抬高;
离开页面并触发 GC 后,本应销毁的对象仍然存在。
到这里,我们只能说“疑似泄漏”,还不能立刻下结论。
二、先别急着改代码:内存上涨不一定是泄漏
看到内存曲线往上走,很多人的第一反应是:“完了,泄漏了。”
但内存上涨至少可能来自四种情况:
1. 正常的业务占用
用户打开一篇包含高清图片的文章,内存比首页高很正常。只要离开页面后相关对象能够被回收,这不算泄漏。
2. 缓存策略
图片、网络响应或页面数据可能被缓存。缓存会提高内存基线,但它通常有容量上限、淘汰机制,并且是为了换取性能。
3. GC 还没有执行
JavaScript/ArkTS 对象不一定在失去引用的一瞬间就被回收。观察到内存暂时没下降,不代表对象永远回不去。
4. 真正的内存泄漏
页面已经退出,本该销毁的对象却仍被一个长生命周期对象引用。只要这条引用链不断开,GC 就会认为对象“还活着”。
可以把 GC 的判断简单理解成:
从根对象出发还能走到它吗?
能走到 → 对象仍然可达,不能回收
走不到 → 对象不可达,等待 GC 回收
所以,排查内存泄漏不是寻找“哪个对象最大”,而是寻找:
这个已经没用了的对象,为什么还能从某个长期存活的根对象走到?
三、第一步:把“偶现”变成“稳定复现”
内存问题最怕凭感觉排查。今天打开十次涨了,明天打开十次没涨;换个人操作,结果又不一样。没有稳定复现路径,后面的快照基本都在猜。
我们先固定了测试条件:
- 同一台设备;
- 同一个 Release 测试包;
- 同一篇包含 8 张图片的文章;
- 每次进入页面后等待图片加载完成;
- 返回首页后停留 2 秒;
- 每轮重复 20 次;
- 测试前清理应用进程并重新启动。
然后把页面出现和消失的次数也打进调试日志:
class DetailPageCounter {
private static appeared: number = 0
private static disappeared: number = 0
static onAppear(): void {
this.appeared++
console.info(`[DetailPage] appeared=${this.appeared}`)
}
static onDisappear(): void {
this.disappeared++
console.info(`[DetailPage] disappeared=${this.disappeared}`)
}
}
页面中记录生命周期:
@Component
struct ArticleDetailPage {
aboutToAppear(): void {
DetailPageCounter.onAppear()
}
aboutToDisappear(): void {
DetailPageCounter.onDisappear()
}
build() {
Column() {
Text('文章详情')
}
}
}
20 次操作后,“出现”和“消失”次数一致。这说明路由返回和组件生命周期确实执行了,但它不代表创建过的页面对象都已经被回收。生命周期计数也不能直接等同于当前存活实例数,真正的对象残留仍要以内存快照为准。
这也是第一个容易误判的地方:
aboutToDisappear() 被调用
≠
页面对象已经被 GC 回收
生命周期只是给了我们清理资源的机会,真正的清理工作还得自己做。
四、第二步:用内存快照确认“谁没有走”
接下来打开 DevEco Studio 的性能分析工具,连接测试设备,选择目标进程并观察 Memory。具体入口名称可能随版本调整,但核心操作差不多。
我们取了三份快照:
快照 A:冷启动稳定、第一次进入详情页之前
快照 B:进出详情页 10 次并等待 GC 后
快照 C:进出详情页 20 次并等待 GC 后
为什么要三份,而不是只看一份?
因为一份快照只能告诉你“现在有什么”,多份快照的差异才能告诉你“什么在持续增加”。
对比时,重点看了三类信息:
- 对象实例数是否随操作次数线性增长;
- 对象的 Retained Size 是否很大;
- 从 GC Root 到对象的引用路径是什么。
Shallow Size 和 Retained Size 有什么区别?
这是内存分析里很重要的一组概念。
Shallow Size:对象自身占用的内存。
Retained Size:如果这个对象被回收,连带可以释放的总内存。
一个事件回调函数自身可能只有几百字节,Shallow Size 很小。但它通过闭包引用了整个页面,页面又引用文章段落和图片对象,于是它的 Retained Size 可能非常大。
所以只按“对象自身大小”排序,很容易放过真正的幕后黑手。
快照中出现了什么?
对比 A、B、C 后,我们发现下面几类对象随着进出次数持续增加:
ArticleDetailPage 相关组件状态
ArticleBlock 数组
图片包装对象
字体变化监听回调
更关键的是:退出页面并触发 GC 后,它们仍然存在,而且数量基本与进入详情页的次数一致。
这已经不是正常缓存的样子了。
五、第三步:沿着引用链找到真正根因
继续查看其中一个残留页面对象的引用路径,最终看到了一条非常有价值的链路:
GC Root
↓
全局 FontScaleBus
↓
listeners: Set<FontScaleListener>
↓
匿名箭头函数
↓ 闭包捕获 this
ArticleDetailPage
↓
blocks / imageWrappers / 页面状态
到这里,根因已经很清楚了:
页面退出后,全局事件总线仍然保存着页面注册的回调;回调通过闭包引用页面,导致整棵页面对象树无法回收。
问题代码大致是这样:
type FontScaleListener = (scale: number) => void
interface ArticleBlock {
text: string
imageUri?: string
}
class FontScaleBus {
private static listeners: Set<FontScaleListener> = new Set()
static on(listener: FontScaleListener): void {
this.listeners.add(listener)
}
static off(listener: FontScaleListener): void {
this.listeners.delete(listener)
}
static emit(scale: number): void {
this.listeners.forEach((listener: FontScaleListener) => {
listener(scale)
})
}
static listenerCount(): number {
return this.listeners.size
}
}
页面注册监听:
@Component
struct ArticleDetailPage {
@State private fontScale: number = 1
@State private blocks: ArticleBlock[] = []
private progressTimer: number = -1
aboutToAppear(): void {
FontScaleBus.on((scale: number) => {
this.fontScale = scale
this.rebuildArticleLayout()
})
this.progressTimer = setInterval(() => {
this.syncReadingProgress()
}, 10000)
}
aboutToDisappear(): void {
// 开发者以为这行已经取消了监听
FontScaleBus.off((scale: number) => {
this.fontScale = scale
this.rebuildArticleLayout()
})
clearInterval(this.progressTimer)
}
private rebuildArticleLayout(): void {
// 根据字体大小重新计算正文布局
}
private syncReadingProgress(): void {
// 同步阅读进度
}
build() {
Column() {
Text('文章详情')
}
}
}
乍一看,注册时调用了 on(),退出时也调用了 off(),好像没什么问题。
但这两个箭头函数不是同一个对象:
const first = (value: number) => value + 1
const second = (value: number) => value + 1
console.info(`${first === second}`) // false
它们的代码长得一样,但引用不同。Set.delete() 根据对象引用匹配,因此退出页面时传入的新函数根本删不掉之前注册的回调。
结果就是:
进入 1 次 → 总线里留下 1 个回调
进入 5 次 → 总线里留下 5 个回调
进入 20 次 → 总线里留下 20 个回调
每个回调都抓着一份已经退出的页面状态。文章中的图片越多,被间接保留的内存就越大。
六、为什么一个小回调,能泄漏几百兆内存?
有读者会问:一个函数而已,能占多少内存?
函数本身确实不大,真正的问题在闭包。
箭头函数中访问了 this.fontScale 和 this.rebuildArticleLayout(),因此它需要记住当时的页面实例。只要回调还在,页面就仍然可达:
全局事件总线的生命周期:几乎等于整个应用
全局事件总线
└─ 回调
└─ 页面实例
├─ 正文数据
├─ 图片对象
├─ 页面状态
└─ 其他业务对象
这就是典型的“小对象牵住大对象”。
如果页面中包含 PixelMap、视频、相机或其他持有 Native 资源的对象,情况还会更复杂。JavaScript 堆快照里看到的包装对象可能很小,但它背后关联的 Native 内存很大。
因此分析时要结合两条线:
- ArkTS/JS 堆中的对象数量和引用链;
- 应用总内存或 Native 内存的变化趋势。
只看其中一条,都可能低估问题。
七、修复:注册和注销必须使用同一个函数引用
修复思路其实很直接:把回调保存成页面字段,注册和注销都使用这个字段。
@Component
struct ArticleDetailPage {
@State private fontScale: number = 1
@State private blocks: ArticleBlock[] = []
private progressTimer: number = -1
private subscribed: boolean = false
private readonly fontScaleListener: FontScaleListener =
(scale: number): void => {
this.fontScale = scale
this.rebuildArticleLayout()
}
aboutToAppear(): void {
if (!this.subscribed) {
FontScaleBus.on(this.fontScaleListener)
this.subscribed = true
}
if (this.progressTimer < 0) {
this.progressTimer = setInterval(() => {
this.syncReadingProgress()
}, 10000)
}
}
aboutToDisappear(): void {
if (this.subscribed) {
FontScaleBus.off(this.fontScaleListener)
this.subscribed = false
}
if (this.progressTimer >= 0) {
clearInterval(this.progressTimer)
this.progressTimer = -1
}
this.releasePageResources()
}
private releasePageResources(): void {
// 根据实际资源类型调用对应的释放接口
// 对象数组置空不是万能药,关键仍是断开长生命周期引用并释放资源
this.blocks = []
}
private rebuildArticleLayout(): void {
// 重新计算正文布局
}
private syncReadingProgress(): void {
// 同步阅读进度
}
build() {
Column() {
Text('文章详情')
}
}
}
这里做了四件事:
- 用
fontScaleListener保存唯一的回调引用; off()时传入同一个引用;- 用
subscribed防止重复注册; - 同时清理定时器和页面持有的其他资源。
修复后,我们还给事件总线增加了调试能力:
console.info(
`[FontScaleBus] listenerCount=${FontScaleBus.listenerCount()}`
)
正常情况下:
进入详情页:listenerCount=1
退出详情页:listenerCount=0
如果连续进出后数量不断增加,测试阶段就能很快发现。
八、别急着宣布胜利:修复后必须按原路径验证
内存问题的修复不能用“代码看起来对了”验收。我们重新执行了与问题发生时完全相同的测试:
冷启动 → 打开指定文章 → 图片加载完成 → 返回
→ 重复 20 次 → 等待并触发 GC → 再取快照
修复后的趋势大致如下:
| 操作阶段 | 修复前 | 修复后 |
|---|---|---|
| 冷启动稳定后 | 136 MB | 137 MB |
| 进出详情页 5 次 | 181 MB | 157 MB |
| 进出详情页 10 次 | 229 MB | 163 MB |
| 进出详情页 20 次 | 337 MB | 168 MB |
| 等待并触发 GC 后 | 301 MB | 149 MB |
修复后,页面加载过程中内存仍会短暂上涨,这是图片解码和布局带来的正常峰值;但退出页面并经过 GC 后,内存会回到相对稳定的区间,不再随着进入次数线性抬升。
快照对比也显示:
- 残留的页面相关对象不再按次数增加;
FontScaleBus中监听数量能够回到 0;- 旧页面到 GC Root 的异常引用链消失;
- 连续操作后的卡顿明显改善。
到这一步,才可以说泄漏真正修掉了。
九、排查过程中走过的三个弯路
真实的内存排查通常不会一次命中。下面几个误区特别常见。
弯路 1:一看到图片多,就认定是图片缓存
详情页有多张图片,最初大家自然怀疑图片缓存。我们把缓存容量调小后,峰值确实下降了一点,但每轮操作后的内存基线还在上涨。
这说明图片只是放大了结果,不是维持引用的根因。
判断方法很简单:
缓存通常会趋于一个上限;
泄漏往往随着操作次数持续增长。
当然,设计糟糕的“无限缓存”本身也可能变成泄漏式问题,所以最终还是要看淘汰策略和引用链。
弯路 2:疯狂给数组赋空值
有人建议退出页面时写:
this.blocks = []
这样可能减少页面间接持有的数据,但没有解决页面仍被全局回调引用的问题。页面实例依旧不会被回收,其他字段也可能继续残留。
清空数组可以是资源清理的一部分,但不是内存泄漏的通用解药。
弯路 3:只盯着总内存曲线
总内存会受 GC、图片解码、系统缓存和 Native 资源影响,曲线并不会像尺子一样平滑。
更可靠的证据组合是:
稳定复现路径
+ 多轮操作后的基线抬升
+ GC 后对象仍然存在
+ 快照中的实例数量增长
+ 清晰的 GC Root 引用链
证据互相印证,才不会把正常波动当成泄漏。
十、HarmonyOS 项目中还要重点检查哪些地方?
这次根因是事件监听,但同一类问题还经常出现在下面这些位置。
1. 定时器没有清理
@Component
struct CountdownPage {
@State private seconds: number = 60
private timerId: number = -1
aboutToAppear(): void {
this.timerId = setInterval(() => {
this.seconds--
}, 1000)
}
aboutToDisappear(): void {
if (this.timerId >= 0) {
clearInterval(this.timerId)
this.timerId = -1
}
}
build() {
Text(`${this.seconds}`)
}
}
定时器回调只要持续存在,就可能持续引用页面,还会在页面退出后不停执行。
2. 订阅和反订阅不成对
常见对象包括:
- 全局事件总线;
- 系统公共事件;
- 网络状态监听;
- 传感器监听;
- 键盘、窗口或显示变化监听;
- 数据仓库的观察者;
- Web 组件回调。
看到 on、subscribe、addListener 时,就应该顺手问一句:对应的 off、unsubscribe、removeListener 在哪里?
3. 长时间异步任务持有页面
普通的一次性 Promise 最终完成后通常会释放回调,但长轮询、持续下载、永不结束的任务或异常重试队列,可能长时间持有页面。
可以增加“页面是否仍活跃”的保护:
@Component
struct SearchPage {
@State private resultText: string = ''
private active: boolean = false
aboutToAppear(): void {
this.active = true
}
aboutToDisappear(): void {
this.active = false
// 如果底层能力支持取消,应同时取消网络请求或异步任务
}
private async search(): Promise<void> {
const result = await loadSearchResult()
if (!this.active) {
return
}
this.resultText = result
}
build() {
Text(this.resultText)
}
}
active 只能避免页面退出后继续更新 UI,它不一定能立即释放底层资源。能力支持取消时,仍应主动取消。
4. Native 资源只丢引用,没有主动释放
图片、媒体、相机、文件和其他系统资源,可能提供明确的 release()、close() 或 destroy() 方法。只把变量赋成 undefined,不一定等同于立即释放底层资源。
通用原则是:
谁创建,谁负责释放;
创建和释放尽量处于同一层;
释放方法重复调用也不应造成崩溃;
异常和提前返回路径也要执行清理。
具体资源应该调用哪个接口,要以相应 HarmonyOS API 文档为准。
5. 单例缓存没有上限
单例不是问题,没有边界的单例才是问题。
下面这种缓存会随着用户浏览不断增长:
class ArticleCache {
static data: Map<string, ArticleDetail> = new Map()
static put(id: string, detail: ArticleDetail): void {
this.data.set(id, detail)
}
}
至少需要考虑:
- 最大条目数或最大内存;
- 淘汰策略;
- 页面或账号切换时是否清理;
- 内存紧张时能否主动释放;
- 是否真的需要缓存完整大对象。
6. 闭包无意中捕获整个页面
如果回调只需要一个文章 ID,就尽量只保存 ID,而不是顺手捕获 this:
// 捕获整个页面
taskQueue.add(() => this.uploadReadingProgress())
// 如果业务允许,可以只传任务真正需要的数据
const articleId = this.articleId
taskQueue.add(() => uploadProgressById(articleId))
第二种写法不一定适用于所有业务,但它提醒我们:闭包捕获范围越小,意外保留大对象的风险越低。
十一、把资源释放写成“可重复执行”
页面生命周期在复杂导航、前后台切换或异常流程中,未必永远按开发者脑海中的理想顺序出现。清理代码最好具备幂等性,也就是调用多次仍然安全。
class PageResources {
private timerId: number = -1
private released: boolean = false
start(): void {
if (this.released || this.timerId >= 0) {
return
}
this.timerId = setInterval(() => {
// do work
}, 1000)
}
release(): void {
if (this.released) {
return
}
this.released = true
if (this.timerId >= 0) {
clearInterval(this.timerId)
this.timerId = -1
}
}
}
对于同时持有监听、定时器和系统资源的复杂页面,可以集中管理:
class DetailPageResources {
private cleanups: Array<() => void> = []
private released: boolean = false
add(cleanup: () => void): void {
if (this.released) {
cleanup()
return
}
this.cleanups.push(cleanup)
}
releaseAll(): void {
if (this.released) {
return
}
this.released = true
this.cleanups.reverse().forEach((cleanup: () => void) => {
try {
cleanup()
} catch (error) {
console.error(`resource cleanup failed: ${String(error)}`)
}
})
this.cleanups = []
}
}
使用时把清理逻辑和创建逻辑放在一起:
const resources = new DetailPageResources()
FontScaleBus.on(this.fontScaleListener)
resources.add(() => FontScaleBus.off(this.fontScaleListener))
const timerId = setInterval(() => this.syncReadingProgress(), 10000)
resources.add(() => clearInterval(timerId))
// 页面退出时统一调用
resources.releaseAll()
这种方式的好处是:创建资源时就登记如何释放,不用等到写 aboutToDisappear() 时再凭记忆补清理代码。
实际 ArkUI 组件中还需要结合组件对象的构造方式、生命周期和工程规范调整写法,但“获取资源的同时登记释放动作”这个思路非常实用。
十二、怎样在代码评审阶段提前发现?
内存泄漏最好不要等稳定性测试发现。代码评审看到下面这些关键词时,可以条件反射地检查清理逻辑:
| 看到的代码 | 立刻追问 |
|---|---|
setInterval / 延迟任务 | 页面退出后在哪里取消? |
on / subscribe / addListener | 是否用同一个引用解除? |
单例中的 Map / Set / 数组 | 谁删除?有没有容量上限? |
箭头函数中使用 this | 回调会活多久?是否捕获整个页面? |
| 图片、视频、相机、文件对象 | 是否需要主动释放或关闭? |
| 长轮询和重试任务 | 页面退出后能否停止? |
| Web 组件与 JSBridge | 回调和页面对象何时解除? |
aboutToAppear 中创建资源 | aboutToDisappear 中是否对称清理? |
团队还可以制定一个很简单的规则:
任何生命周期长于页面的对象,都不能无条件持有页面引用。
全局单例、任务队列、事件总线、缓存管理器、系统服务,生命周期通常都比一个页面长。把页面回调交给它们时,要格外小心。
十三、一份可以直接照着走的排查流程
以后再遇到“越用越卡、内存不降”,可以按下面的顺序处理。
第 1 步:固定现场
- 固定设备、构建类型、账号和数据;
- 写出可以重复执行的操作路径;
- 记录每轮操作次数、停留时间和内存基线。
第 2 步:判断是峰值还是持续增长
- 重复同一个操作 10~30 次;
- 每轮结束回到相同页面;
- 留出 GC 和异步任务结束的时间;
- 观察基线是否持续抬升。
第 3 步:获取多份快照
- 操作前一份;
- 中间一份;
- 多轮操作后一份;
- 尽量在相同状态下比较。
第 4 步:找增长对象
- 实例数是否随操作次数增长;
- Retained Size 是否异常;
- 退出页面后对象是否仍存在。
第 5 步:查看到 GC Root 的路径
重点关注:
- 全局对象;
- 静态集合;
- 事件监听;
- 定时器;
- 长任务回调;
- Native 包装对象。
第 6 步:做最小修复
先切断确定的异常引用链,不要一上来重构半个项目。修复越集中,越容易验证因果关系。
第 7 步:按原路径回归
使用相同操作、相同次数和相同观察方法。只有内存趋势和对象残留同时改善,才能确认修复有效。
第 8 步:扩大检查范围
根因确认后,在全项目搜索相同模式:
.on(
.off(
subscribe(
unsubscribe(
addListener(
removeListener(
setInterval(
setTimeout(
同一种错误通常不会只出现一次。
十四、最终复盘:这次问题真正教会了我们什么?
这次泄漏的修复代码其实只有几行:保存监听器引用,并在页面退出时用同一个引用解除。
真正花时间的,是证明下面这条因果链:
页面反复进入
↓
事件监听器数量持续增加
↓
监听回调闭包持有旧页面
↓
旧页面及图片对象无法回收
↓
内存基线不断抬高
↓
GC 压力变大,页面开始卡顿
从这次排查中,我们总结了四点:
- 内存上涨不等于泄漏,先建立稳定复现和对照。
- 组件生命周期结束不等于对象已经回收,关键看引用链。
- 回调自身可能很小,但闭包背后保留的对象可能非常大。
- 修复必须用相同场景验证,不能只看代码逻辑。
如果只能记住一句话,那就是:
谁注册,谁注销;谁创建,谁释放;长生命周期对象不要无意间抓住整个页面。
HarmonyOS 应用中的大多数内存泄漏都不是“神秘的系统问题”,而是一条本该断开的引用链没有断开。沿着对象增长和 GC Root 路径去找,通常比盯着业务代码凭感觉猜快得多。
如果这篇复盘帮你定位了类似问题,欢迎点赞、收藏。也欢迎把你遇到的引用链或内存曲线发在评论区,一起看看究竟是谁“抓住对象不肯放”。
更多推荐
所有评论(0)