ArkTS 九种泄漏模式的排查指南(下)

0 评论 752 浏览 0 收藏 17 分钟

上篇我们认识了五种内存泄漏模式,今天继续揭晓剩下的四种"内存刺客"。如果你还没看上篇,建议先回顾一下——因为下篇的排查方法论总结,会把九种模式串成一条完整的排查链路。

本原创文章帖发布在华为开发者联盟社区,欢迎开发者前往访问评论交流,更多与该内容相关讨论,请点击原帖查看:

ArkTS 九种泄漏模式的排查指南(下)-华为开发者话题 | 华为开发者联盟

 

前言

上篇我们认识了五种内存泄漏模式,今天继续揭晓剩下的四种”内存刺客”。如果你还没看上篇,建议先回顾一下——因为下篇的排查方法论总结,会把九种模式串成一条完整的排查链路。

本文(下) 带你认识后四种泄漏模式:

       6. 全局静态集合残留

       7. 箭头函数跨组件持有 this

       8. 原生/框架资源未 dispose

       9. NAPI 跨层闭包泄漏

一、模式六:全局静态集合残留——”进程维度的记忆宫殿”

全局静态集合(static Set / static Map)的生命周期和进程一样长。业务代码通常依赖”正常销毁路径”来移除其中的元素——可问题是,正常路径并不总是能走到。

Ability 被系统强杀、onHidden 没触发就直接销毁、异常退出……这些非常规场景下,正常路径走不到,元素就会持续滞留在那个”记忆宫殿”里。时间一长,宫殿变成了仓库,仓库变成了垃圾场。

🔍 问题根因: 静态集合生命周期与进程相同,非常规销毁场景下正常移除路径走不到,元素持续滞留。

 反例代码

✅ 修复方案 🔗 引用持有链
class WaterMarkUtil {
static displaySet: Set<WaterMarkWrapper> = new Set()

static setStatus(show: boolean, wrapper: WaterMarkWrapper) {
if (show) {
WaterMarkUtil.displaySet.add(wrapper)
} else {
WaterMarkUtil.displaySet.delete(wrapper)
}
}
}

@Component
struct MyPage {
private wrapper: WaterMarkWrapper = new WaterMarkWrapper()

onPageShow() {
WaterMarkUtil.setStatus(true, this.wrapper)
}

onPageHide() {
WaterMarkUtil.setStatus(false, this.wrapper)
// 【问题】若 onPageHide 未触发,wrapper 持续永久滞留
}

aboutToDisappear() {
// 【问题】没有兜底清理
}
}

class WaterMarkUtil {
static displaySet: Set<WaterMarkWrapper> = new Set()

static forceRemove(wrapper: WaterMarkWrapper) {
WaterMarkUtil.displaySet.delete(wrapper)
}
}

@Component
struct MyPage {
private wrapper: WaterMarkWrapper = new WaterMarkWrapper()

onPageShow() {
WaterMarkUtil.setStatus(true, this.wrapper)
}

aboutToDisappear() {
// 【修复】兜底清理,不依赖 onPageHide 是否触发
WaterMarkUtil.forceRemove(this.wrapper)
}
}

💡 核心原则: 对于静态集合、全局缓存等长生命周期容器,在销毁路径中必须加入无条件清理逻辑(兜底),不能依赖前序流程正常执行。

二、模式七:箭头函数跨组件持有 this——”甩不掉的影子”

ArkTS struct 的箭头函数字段在赋值时,会词法绑定 this(父组件实例)。当这个函数被传递给子组件并由子组件保存时,子组件的存活就会拖住父组件——即使父组件已经从视图树上被移除了。

这就像你搬了家,但你的影子还留在老房子里,而且这个影子还紧紧抓着你的旧家具不放。

🔍 问题根因: 箭头函数字段词法绑定父组件 this,传递给子组件后,子组件存活拖住父组件。

📌 场景一:传入子组件

❌ 反例代码 ✅ 修复方案 🔗 引用持有链
// ParentPage.ets
@Component
struct ParentPage {
createContext = (param: ContextParam): PageContext => {
return new PageContext(this.pageData, param)
// 【问题】箭头函数字段:词法绑定 this = ParentPage 实例
}

build() {
ChildComponent({
createContext: this.createContext // 传给子组件
})
}
}

// ChildComponent.ets
@Component
struct ChildComponent {
createContext?: (param: ContextParam) => PageContext
// 【问题】ChildComponent 保存了父组件的箭头函数,
// 即使 ParentPage 已从视图树移除,ChildComponent 仍持有 this

@Component
struct ChildComponent {
createContext?: (param: ContextParam) => PageContext

aboutToDisappear() {
this.createContext = undefined
// 【修复】子组件销毁时主动切断对父组件 this 的引用
}
}

📌 场景二:CellData onClick 持有页面 this

❌ 反例代码 ✅ 修复方案 🔗 引用持有链
@Component
struct CalendarPage {
@State topBarCellData: TopBarCellData = new TopBarCellData()

aboutToAppear() {
// onClick 是箭头函数,词法捕获 CalendarPage 的 this
this.topBarCellData.onClick = () => {
this.handleTabClick()
}
// 【问题】@State 装饰的 topBarCellData 生命周期长,
// onClick 闭包持有 CalendarPage 的 this
}
}

@Component
struct CalendarPage {
@State topBarCellData: TopBarCellData = new TopBarCellData()

aboutToAppear() {
this.topBarCellData.onClick = () => { this.handleTabClick() }
}

aboutToDisappear() {
// 【修复】页面销毁时清空 CellData 中的闭包引用
this.topBarCellData.onClick = undefined
}
}

三、模式八:原生/框架资源未 dispose——”跨平台的幽灵”

跨平台 Channel、Native Handle、事件引擎等框架资源,在内部维护着一张”注册表”,保存了业务侧传入的 handler/context/adapter 对象。这些注册表就像框架的”通讯录”——你不主动注销,它就永远记得你一直存在。

若不主动调用 dispose()/destroy(),这些内部注册表就会从框架侧向业务侧形成强引用。整条链无法 GC,页面销毁了,框架的通讯录里还留着你的”号码”。

🔍 问题根因: 框架资源内部注册表持有业务对象,不主动 dispose 就会从框架侧向业务侧形成强引用。

❌ 反例代码 ✅ 修复方案 🔗 引用持有链
@Component
struct MyCrossPlatformPage {
private methodChannelMgr: MethodChannelManager = new MethodChannelManager()
private crossPlatformContext: CrossPlatformContext | null = null
private pageParam: PageParam | null = null

aboutToAppear() {
this.crossPlatformContext = new CrossPlatformContext()
this.crossPlatformContext.init(this.methodChannelMgr)
// 【问题】没有 aboutToDisappear 中 dispose
}
}

@Component
struct MyCrossPlatformPage {
private methodChannelMgr: MethodChannelManager = new MethodChannelManager()
private crossPlatformContext: CrossPlatformContext | null = null

aboutToDisappear() {
// 【修复】主动 dispose 所有框架资源
this.crossPlatformContext?.destroy()
this.crossPlatformContext = null
this.methodChannelMgr.destroy()
}
}

引用持有链:MethodChannelManager 和 CrossPlatformContext 都通过其内部的注册表/引用,反向持有 Page 的 context 或业务对象,形成从框架侧到业务侧的引用。

四、模式九:NAPI 跨层闭包泄漏——”C++ 端的定时炸弹”

这是九种模式中最”硬核”的一种。NAPI 闭包各自通过 LexicalEnv 强引用同一个 callback,而 callback 内部又通过闭包链引用整个 UI 树。C++ 端的 napi_create_reference 使这些闭包成为 GC Root 的直达子节点——整条引用链,GC 根本打不断。

想象一下,C++ 端像是一个”铁腕管家”,它用 napi_create_reference 把闭包牢牢锁在手里。即使页面销毁了,管家也不松手——因为管家根本不知道页面已经销毁了。

🔍 问题根因: NAPI 闭包通过 LexicalEnv 强引用 callback,C++ 端 napi_create_reference 使闭包成为 GC Root 子节点,整条链无法被 GC 打断。

❌ 反例代码 ✅ 修复方案 🔗 引用持有链
class NativeBridge {
static asyncOperation(input: Buffer, userCallback: UserCallback) {
NativeUtil.nativeCall(input,
// C++ 回调闭包 #1 —— 闭包捕获 userCallback
(result: Uint8Array) => {
userCallback.onSuccess(result)
},
// C++ 回调闭包 #2 —— 闭包捕获 userCallback
(error: string) => {
userCallback.onError(error)
}
)
}
}
// 【问题】三个闭包各自通过 LexicalEnv 强引用同一个 userCallback,
// userCallback 内部又通过闭包链引用了整个 UI 树
class NativeBridge {
static asyncOperation(input: Buffer, userCallback: UserCallback) {
// 关键:在传入 NAPI 前先用 WeakRef 包装
const weakCb = new WeakRef(userCallback)

NativeUtil.nativeCall(input,
(result: Uint8Array) => {
const cb = weakCb.deref()
if (cb) cb.onSuccess(result)
// userCallback 被 GC 后,deref() 返回 undefined,安全退出
},
(error: string) => {
const cb = weakCb.deref()
if (cb) cb.onError(error)
}
)
}
}

问题说明:三个闭包各自通过 LexicalEnv 强引用同一个 userCallback,而 userCallback 内部又通过闭包链引用了整个 UI 树。C++ 端的 napi_create_reference 使这些闭包成为 GC Root 的直达子节点,整条引用链无法被 GC 打断。

修复后的引用持有链:WeakRef 不阻止 GC,页面销毁后 deref() 返回 undefined。

图:NAPI 跨层闭包泄漏修复前后引用持有链对比

       💡 修复原理: WeakRef 不阻止 GC 回收 userCallback。当页面销毁后,userCallback 被 GC 回收,C++ 回调执行时 deref() 返回 undefined,安全退出,不再持有已销毁页面的引用。

五、五步排查方法论总结

聊完了九种泄漏模式,最后来一套”万能排查公式”。下次遇到内存泄漏,按这五步走,基本能搞定:

第一步:确认泄漏现象

通过现网闪退日志、内存快照对比等手段,确认确实存在泄漏。别急着改代码,先确认问题。

第二步:沿引用链追溯

从被泄漏对象出发,追问”谁还持有它?”逐层向上,直到找到长生命周期持有方。就像侦探破案,顺着线索一条一条追。

第三步:对照九种模式

将排查结果与上述九种模式逐一比对,快速定位泄漏类型。大多数泄漏都能归到这九类里。

第四步:在销毁时机切断引用

在 aboutToDisappear 或等效的销毁入口中,确保所有引用被对称清理。注册了就注销,添加了就移除——对称之美。

第五步:增加防御性兜底

对于静态集合、全局缓存等长生命周期容器,在销毁路径中加入无条件清理逻辑,不依赖前序流程正常执行。多一层兜底,少一次线上告警。

六、开放讨论

以上九种模式覆盖了我们在实际排查中遇到的主要场景。但鸿蒙生态仍在快速演进中,新的框架特性和 API 可能会引入新的泄漏风险。

在你的项目中,是否遇到过上述模式之外的泄漏场景?

对于 NAPI 跨层闭包这类复杂场景,除了 WeakRef 方案,是否有其他实践值得分享?

欢迎在评论区交流你的排查经验与解决方案。让我们一起,别让内存悄悄溜走!

——全文完 ——

—————————————————————————————————–

🔗 官网开发者学堂视频:https://developer.huawei.com/consumer/cn/training/result?type2List=201783644516849879&orderBy=1&courseType=5

🔗 社区DFX专题文章:  https://developer.huawei.com/consumer/cn/forum/subject/2101218731402391001

【扫码加入 HarmonyOS DFX 技术交流群】

本文由 @华为开发者联盟 授权发布于人人都是产品经理。未经作者许可,禁止转载

题图来自Unsplash,基于CC0协议

该文观点仅代表作者本人,人人都是产品经理平台仅提供信息存储空间服务

更多精彩内容,请关注人人都是产品经理微信公众号或下载App
海报
评论
评论请登录
  1. 目前还没评论,等你发挥!