
ArkTS 内存泄漏定位实战页面销毁、定时器与订阅清理内存泄漏最麻烦的地方是它一开始不明显。页面能打开、功能能用测试几分钟也不一定出问题但用户长时间使用后页面切换越来越卡甚至出现崩溃。ArkTS 项目里常见的泄漏来源包括定时器没有清理、事件订阅没有取消、闭包持有页面对象、缓存没有淘汰。本文围绕一个可复现的页面泄漏场景讲清楚怎么建立复现路径、怎么做资源收口、怎么在aboutToDisappear释放任务并用压测方式确认内存趋势是否回落。1. 本文处理的泄漏类型泄漏来源典型表现治理思路定时器未清理页面离开后仍在执行TimerHolder 统一登记和清理事件订阅未取消页面销毁后还能收到事件SubscriptionBag 收口订阅闭包持有页面状态对象无法释放避免长生命周期持有组件上下文缓存无限增长使用越久内存越高设定容量和淘汰策略2. 资料定位与验证边界建议从华为开发者文档中心检索“性能优化”“DevEco Profiler”“内存分析”“ArkTS 生命周期”等关键词华为开发者文档中心https://developer.huawei.com/consumer/cn/doc/HarmonyOS Guideshttps://developer.huawei.com/consumer/cn/doc/harmonyos-guides/本文重点核验页面销毁后的引用释放、定时器清理和订阅解绑不讨论泛化性能调优。本文示例边界项目说明技术栈HarmonyOS NEXT、ArkTS、ArkUI关注点页面生命周期、定时器、订阅、缓存验证方式重复进出页面、观察内存趋势、日志记录工具DevEco Studio Profiler、业务调试日志具体 Profiler 面板名称可能随版本变化实际使用以当前 DevEco Studio 为准。3. 先构造可复现路径没有复现路径就很难确认泄漏是否修好。建议准备一个测试页面重复进入、等待、退出。// common/leak/LeakScenario.etsexportinterfaceLeakScenario{pageName:string;enterCount:number;staySeconds:number;}exportconstmemoryLeakScenario:LeakScenario{pageName:MonitorPage,enterCount:30,staySeconds:3};这段代码只是描述复现场景。它的价值在于把“多用一会儿会卡”转成明确动作进入页面 30 次每次停留 3 秒再观察内存趋势。4. 定时器必须统一登记页面里随手写定时器是泄漏高发点。离开页面时如果忘记清理回调仍可能持有页面状态。// common/leak/TimerHolder.etsexportclassTimerHolder{privatetimerIds:number[][];setIntervalTask(callback:()void,interval:number):void{consttimerIdsetInterval(callback,interval);this.timerIds.push(timerId);}setTimeoutTask(callback:()void,delay:number):void{consttimerIdsetTimeout(callback,delay);this.timerIds.push(timerId);}clearAll():void{this.timerIds.forEach(id{clearTimeout(id);clearInterval(id);});this.timerIds[];}}代码解释点说明职责边界只管理当前页面创建的定时任务输入约束定时器必须通过 Holder 创建避免的问题页面销毁后定时器继续持有闭包下一层连接页面生命周期调用clearAll()5. 事件订阅也要有收口对象很多页面会订阅全局事件、消息总线或数据变化。如果订阅没有取消页面销毁后仍可能收到回调。// common/leak/SubscriptionBag.etsexporttypeUnsubscribe()void;exportclassSubscriptionBag{privatecleaners:Unsubscribe[][];add(cleaner:Unsubscribe):void{this.cleaners.push(cleaner);}clear():void{this.cleaners.forEach(cleaner{cleaner();});this.cleaners[];}}这段代码非常简单但在项目里很实用。任何订阅只要返回取消函数都放进SubscriptionBag页面离开时统一清理。6. 页面生命周期里创建和释放资源下面是一个监控页面示例进入页面时启动定时器和订阅离开页面时统一清理。// entry/src/main/ets/pages/MonitorPage.etsimport{TimerHolder}from../../common/leak/TimerHolder;import{SubscriptionBag}from../../common/leak/SubscriptionBag;EntryComponentstruct MonitorPage{privatetimers:TimerHoldernewTimerHolder();privatesubscriptions:SubscriptionBagnewSubscriptionBag();StateprivatetickText:string等待刷新;aboutToAppear():void{this.timers.setIntervalTask((){this.tickText刷新时间${Date.now()};},1000);this.subscriptions.add((){console.info([MonitorPage] unsubscribe mock event);});}aboutToDisappear():void{this.timers.clearAll();this.subscriptions.clear();}build(){Column({space:16}){Text(监控页面).fontSize(26).fontWeight(FontWeight.Bold)Text(this.tickText).fontSize(15).fontColor(#667085)}.padding(20)}}这段页面代码的重点不是界面而是资源生命周期。页面创建资源页面离开释放资源。不要把定时器散落在多个函数里也不要让订阅回调直接长期持有页面对象。7. 避免长生命周期闭包持有页面如果把页面回调传给全局单例单例就可能间接持有页面。// common/leak/SafeCallbackRegistry.etsexporttypePageCallback()void;exportclassSafeCallbackRegistry{privatestaticcallbacks:Mapstring,PageCallbacknewMap();staticregister(key:string,callback:PageCallback):void{SafeCallbackRegistry.callbacks.set(key,callback);}staticunregister(key:string):void{SafeCallbackRegistry.callbacks.delete(key);}staticinvoke(key:string):void{constcallbackSafeCallbackRegistry.callbacks.get(key);if(callback!undefined){callback();}}}如果页面必须注册回调就要在离开时注销。更稳的做法是传业务 id 和事件不传整个页面上下文。8. 增加泄漏排查日志压测时需要知道页面进出次数和资源清理次数。// common/leak/LeakReport.etsexportinterfaceLeakReportItem{pageName:string;action:appear|disappear|clear_timer|clear_subscription;at:number;}exportclassLeakReport{privatestaticitems:LeakReportItem[][];staticrecord(pageName:string,action:LeakReportItem[action]):void{LeakReport.items.push({pageName,action,at:Date.now()});}staticcount(pageName:string,action:LeakReportItem[action]):number{returnLeakReport.items.filter(itemitem.pageNamepageNameitem.actionaction).length;}}这段日志用于确认清理动作有没有执行。比如进入页面 30 次离开页面也应该接近 30 次定时器清理次数不能明显少于页面离开次数。9. 验证流程步骤做法预期复现重复进入 MonitorPage 30 次内存趋势可观察快照记录页面离开后的对象保留找到定时器或订阅修复在aboutToDisappear清理离开后任务停止回归再压测同样次数内存趋势回落或稳定不要只跑一次。内存问题要看趋势至少对比修复前后两组相同动作。为了避免人工操作不一致建议把压测动作写成固定记录。哪怕暂时没有自动化脚本也要把进入次数、停留时间、返回方式写清楚。import{LeakScenario}from./LeakScenario;exportfunctiondescribeLeakRun(scenario:LeakScenario):string{return${scenario.pageName}: enter${scenario.enterCount}, stay${scenario.staySeconds}s;}这段函数看起来很小但能让复测记录标准化。修复前后如果复现条件不同内存趋势对比就没有意义。10. 泄漏问题排查表现象可能原因检查方法修复建议离开页面仍打印日志定时器没清理搜索 setInterval用 TimerHolder 收口页面销毁后仍收事件订阅未取消查看事件总线回调SubscriptionBag 清理内存持续上涨缓存无限增长观察缓存数量增加容量和淘汰回调持有页面全局单例保存闭包搜索 register callback离开时 unregister修复后仍上涨复现路径不一致对比压测步骤固定 enterCount 和停留时间11. 内存治理发布前验收检查项判定定时器有统一管理没有散落 setInterval订阅有取消函数页面离开会 clear全局回调可注销不长期持有页面压测路径固定修复前后可对比Profiler 观察过趋势不是只看日志发布前建议保留两份证据一份是 Profiler 趋势截图另一份是页面进出和清理日志。截图证明内存是否回落日志证明清理动作是否执行。两者结合才能避免“日志看起来清理了但对象仍被其他引用保留”的误判。证据作用Profiler 趋势判断内存是否持续上涨页面进出日志确认复现次数一致清理动作日志确认 timer/subscription 被释放保留对象记录定位仍被引用的对象内存专项证据包对象释放要有前后对照内存泄漏文章如果只写“记得清理定时器”读者很难迁移。更实用的写法是给出释放证据页面进入次数、退出次数、活动订阅数、定时器数量和旧页面回调次数。证据项修复前表现修复后目标活动订阅持续增加退出后归零定时器页面销毁后仍触发销毁时清理旧页面回调仍收到事件不再回调对象数量多轮进入后抬升曲线稳定interfacePageReleaseEvidence{pageName:stringactiveSubscriptions:numberactiveTimers:numberstaleCallbacks:number}functionassertPageReleased(e:PageReleaseEvidence):void{if(e.activeSubscriptions!0)thrownewError(${e.pageName}订阅未释放)if(e.activeTimers!0)thrownewError(${e.pageName}定时器未释放)if(e.staleCallbacks!0)thrownewError(${e.pageName}存在旧页面回调)}这段断言让“感觉没有泄漏”变成可核验条件适合放在调试工具或专项回归记录里。12. 内存泄漏治理总结ArkTS 内存泄漏排查要从复现开始而不是直接猜代码。先固定进出页面的压测路径再用 Profiler 和日志确认保留对象最后把定时器、订阅、全局回调和缓存逐一收口。页面生命周期的原则很简单谁创建资源谁负责在离开时释放谁注册回调谁负责在不需要时注销。