用Xcode内存图调试器精准定位iOS保留周期与内存泄漏
2. 为什么内存问题难排查而“内存图调试器”能一锤定音先说一个我自己的真实体会。做 iOS 开发这几年团队里最磨人的 bug 不是闪退也不是界面错乱而是那种“过一段时间就卡一下、内存涨上去就下不来、再往后直接被系统杀死”的诡异问题。这类问题十有八九就是内存泄漏尤其是**保留周期Retain Cycle**造成的。你单看某一个页面、某一段代码逻辑完全没问题但就是因为一个强引用闭环对象该释放的时候死活释放不掉。更麻烦的是它不像崩溃那样有明确的堆栈报错它就是让你的 App 变得越来越迟钝最后连系统都看不下去了一刀把进程干掉。早期排查内存泄漏大家主要靠 Instruments 里的 Leaks 模板。这个方案有效但它有一个比较尴尬的短板它能把“发生了泄漏”这件事告诉你却很难把“哪里发生了泄漏、谁和谁互相引用”这件事讲清楚。尤其在闭包、代理、Block 这些机制参与进来之后你看到的往往是一堆晦涩的调用栈得对着代码一点点猜。后来 Xcode 里出了一个后来居上的工具就是内存图调试器Memory Graph Debugger。我第一次用它的时候是在某个电商项目的结算页面当时有个购物车对象怎么也释放不掉Instruments 能看到内存泄漏但就是定位不了细节。用内存图调试器打开内存图一看明白得吓人一个页面对象被一个闭包抓住这个闭包又被这个页面对象抓住两者绕成了一个环。整个引用链路画得清清楚楚把 UI 上所有跟这个对象有关的引用关系都铺在你面前。我写这篇分享就是想把这个工具的使用方法、判断思路、再看代码时的拆解技巧尽量完整地梳理一遍。看完这篇文章你至少能做到三件事第一用内存图调试器在几分钟内定位一个泄漏点第二看懂一条“保留环”里谁是根、谁是受害者第三在写新代码的时候能提前避开那些容易形成保留周期的写法。这篇文章适合刚接触内存排查不久的开发同学也适合已经用过 Instruments、但总觉得定位效率不够高的老手全文我会尽量用实际场景来讲不堆名词。2. 内存基础先弄清楚“保留周期”到底是怎么产生的在使用任何工具之前我觉得有必要把内存管理的底层逻辑理一理。iOS 用的是引用计数这套机制Swift 里有自动引用计数ARC帮我们管理但 ARC 只管“该不该释放”它管不了“能不能释放”。这句话很关键因为很多的误解就出在这里。2.1 引用计数与释放条件在 ARC 体系里一个对象被创建出来的时候引用计数是 1。每次被别的对象强引用一次计数加 1每次引用方把强引用断开计数减 1。当引用计数减到 0对象就会立刻被释放。听起来很简单对吧但保留周期的可怕之处在于它能让一个对象的引用计数永远不等于 0。举个最简单的例子。有两个对象 A 和 BA 强引用了 B同时 B 又强引用了 A。这时候 A 说“我要被释放了”但系统一看B 还抓着 A 不放B 说“我也要被释放了”但系统一看A 还抓着 B 不放。两个对象就像互相拉扯的两个人谁都走不了。这就是保留周期这是对象层面最容易理解的一种泄漏姿势也是最难靠“肉眼扫代码”发现的。2.2 为什么用 Deinit 很难抓住泄漏很多初学者会想到一个简单的排查方式在对象里写一个 deinit 方法打印一行日志如果这个对象被释放了日志就会打出来。这个方法本身没问题它在开发阶段确实能帮你发现一部分没被释放的对象。但现实情况往往很尴尬你这个页面挺复杂的里面有列表、有网络请求、有很多子视图就算你在页面层写了 deinit日志没打出来你还是不知道具体是谁抓了它不放。而且 deinit 的方式只能告诉你“这个对象可能没被释放”它告诉你不了“这个对象是被谁抓着”。你接下来要做的事情还是回到代码里翻。如果这个对象参与了三四个闭包、两三个协议代理、一两个全局单例这个排查过程会非常痛苦。2.3 谁最喜欢制造残留的引用根据我踩过的坑iOS 里最容易产生保留周期的几类场景其实很有规律闭包Closure闭包捕获了某个对象而该对象又以某种方式保留了这个闭包。最常见的就是把闭包存下来或者让闭包成为某个属性。注意 Swift 里关键字是 implicit很多人因为记住了一个词条却忽略了本质实际上你根本不需要默认关闭什么你需要关心的是闭包捕获列表到底捕获了谁。代理DelegateA 强引用了代理对象 B而 B 又强引用了 A。这里的关键就是 delegate 该用 weak 的时候用了 strong。这在很多不规范的第三方组件里特别多拿到源码一看代理是 strong 的你的页面把代理设置成了自己然后页面又被组件强引用住一个环就形成了。定时器Timer一个对象强引用了 TimerTimer 又把这个对象作为 target 强引用住了。这可能是最防不胜防的一种因为 NSTimer 在循环引用的时候不会立刻报错它只会在你退出页面之后继续默默工作等你回来的时候这个对象竟然还活着状态已经不是刚初始化的样子了。拥有这些背景之后我们再来看内存图调试器就会觉得“这工具简直是给上面这些疑难杂症准备的”。3. 内存图调试器到底是什么为什么它能“看到”泄漏内存图调试器是 Xcode 内置的可视化内存工具它最核心的能力是做一件事抓拍Snapshot当前时刻所有对象之间的引用关系并且把“谁指向谁”的图完整画出来。3.1 它和 Instruments 的 Leaks 有什么区别很多团队一讲内存泄漏排查默认就上 Instruments 的 Leaks。这个工具有没有用有用。但它的强项是帮你发现“有”泄漏弱点是定位“在哪里”。原因在于 Leaks 是通过“对象分配了但最终没有任何外部引用”来判定的它没有把对象之间的持有关系当作第一手信息来呈现。内存图调试器的思路完全是另一个方向它不直接告诉你“这里泄漏了”它把内存里对象的引用关系原原本本铺开来给你看。看到一条强引用链一端是全局控制器、另一端是某个 ViewController中间有没有断开的可能、有没有绕成一个闭环一眼就知道。因为泄漏的判断本来就是“该断的连接没断”图一出来这条“没断的连接”就藏不住了。有些场景用表格对比更清晰对比维度Instruments Leaks内存图调试器核心能力发现泄漏对象呈现对象引用关系定位方式靠调用栈分析靠在图中追踪引用链优点自动化程度高直观保留周期一目了然缺点细节不直观需要手动分析图适用场景批量扫描精准定位某个泄漏我的实际经验是先让 Instruments 扫一遍发现有泄漏再切换到内存图调试器做深度定位。两条路子配合起来效率特别高。3.2 内存图里的几种关键标识打开内存图之后新手最容易懵的是不知道看什么。我先说几个最重要的紫色图标代表某类对象或实例。你在图里看到紫色说明这里有一个需要重点关注的对象实例。灰色虚线圈代表某种类型的类对象或者系统 Framework。黄色三角形警告这个最需要留意它表示该对象被检测为“存在泄漏可能性”也就是系统认为这个实例应该已经不可见但不该在内存里了结果它还在。这个标记基本可以当作一个强信号。蓝色实例连线箭头代表强引用如果你看到一个对象指向另一个对象就意味着一个抓住了另一个不放。这三个图标认清楚后你就可以开始做实际排查了。3.3 Debug Memory Graph 按钮的位置在 Xcode 的调试工具栏里有一排按钮其中一个是三个圆环连在一起的图标名字就叫“Debug Memory Graph”。点一下它Xcode 会暂停当前运行的应用然后抓取一份内存引用关系快照。注意这个操作是即时抓拍不是持续的监控所以你要抓哪个时间点就得先让你的 App 停留在那个状态上。比如你先进入某个页面然后返回到首页再点这个按钮抓出来的就是“返回首页之后”的内存状态这时候如果页面对象还活着那就有问题了。4. 实战三步走用内存图调试器揪出保留周期的方法我不喜欢讲空洞的理论接下来直接说操作方法和步骤你可以打开 Xcode 跟着做一遍。4.1 第一步让 App 停留在“泄漏现场”这里有一个很重要的操作习惯。很多人调试内存问题直接打开 App 就开始点按钮然后随手点一下 Debug Memory Graph结果图里一片混乱什么都看不出来。正确的是先想清楚排查目标。比如你要确认“商详页”是不是泄漏的那你就先正常进入商详页再从商详页返回列表页在返回完成、页面动画结束之后立刻点 Debug Memory Graph。这个时间点抓到的内存图就是“商详页应该已经被释放”的状态。在这个状态里你还能看到商详页对象它就是泄漏了你看不到那就是正常释放了。提示如果在进入页面和返回页面的过程中太快了UI 动画还没结束就抓图有些中间状态会干扰判断。建议返回之后停顿一两秒再抓。抓拍之后Xcode 会在左侧生成一个层次列表右侧是这个时刻所有对象的引用关系图。你可以在左边列表的搜索框里输入你关心的类名比如你那个商详页的类名然后直接跳到这个对象的位置看它周围连了哪些线。4.2 第二步顺着“线”找闭环图中每个对象都有一个或多个箭头。箭头指向的方向就是这个对象持有了谁。如果你要判断某个对象是否存在保留周期就沿着它引出的线一直往下走观察有没有一条路径能绕回它自己。我举个实际的例子。某个列表页的 cell 里有一个闭包闭包里调用了页面上的某个刷新方法同时这个闭包又被 cell 的一个属性强引用着。虽然 cell 本身不一定直接强引用页面但页面强引用了列表列表强引用了 cellcell 又通过闭包强引用了页面这其实已经绕成了一个闭环。内存图里你会看到一条从“页面实例”出发经过“列表”“cell”“闭包”之后最终又回到“页面实例”的路径。很直白。看到这样的闭环之后你要做的只是决定“断哪条线”。大多数情况下我们不会为了“不形成环”就去改对象结构而是在闭包捕获处弱化引用。比如闭包捕获列表里把页面改成 weak 修饰或者代理关系里把 delegate 改成 weak这个环就自然断开了。4.3 第三步利用“仅显示泄漏”过滤器快速缩小范围如果项目非常复杂图里对象成千上万你不可能一个一个看。这时候要善用左下角的过滤器面板。面板里有几个很有用的选项Only Show Leaked Objects仅显示泄漏对象只显示被系统怀疑泄漏的对象。这个选项配合顶部搜索效率最高。Only Show Cycles仅显示循环引用只显示参与了循环引用的对象。这个是专门用来找保留周期的。Objects / Strings / C Objects等分类过滤按数据类别筛选一般排查 UI 对象时重点盯着 Objects 就行。我的建议是先开“Only Show Leaked Objects”把可疑对象圈出来再看它的引用关系如果发现对象之间形成了环再开“Only Show Cycles”确认是不是保留周期问题。这样操作下来大多数泄漏都能在几分钟内定位清楚。5. 真实场景拆解闭包、代理、Timer 三种典型泄漏的图形特征接下来我拆解三种最常见的场景并说说它们在各内存图里的长相。熟悉这些图形长相之后你在实际排查中会快很多。5.1 闭包捕获导致的保留周期某团队的业务模块里开发者为了监控页面状态把一段业务回调存成了属性final class LoggerManager { var onFinishing: (() - Void)? } final class OrderViewController: UIViewController { let logger LoggerManager() override func viewDidLoad() { super.viewDidLoad() logger.onFinishing { self.finishOrder() } } }这段代码的问题在哪里OrderViewController 强引用了 loggerlogger 强引用了闭包闭包强引用了 self而 self 就是 OrderViewController。三者加在一起绕了个结。内存图里你会看到OrderViewController 实例连接一条线到 LoggerManager 实例LoggerManager 实例又连接到某块闭包存储区上闭包存储区再拉一条线回到 OrderViewController 实例。三条线连成一个三角形非常直观。解决办法也直接logger.onFinishing { [weak self] in self?.finishOrder() } // 或者如果确定生命周期安全也可以用 unowned logger.onFinishing { [unowned self] in self.finishOrder() }必须提醒一句unowned 也要小心如果页面已经释放了再调用还是会崩。我一般情况下都推荐 weak只有在非常确定对象生命周期才用 unowned。5.2 Delegate 代理属性的强引用陷阱再讲一个非常容易发生在自定义组件里的问题。假设你写了一个图片选择器组件它需要把自己的加载进度通知外部页面。有的开发者写着写着就把 delegate 写成了 strongfinal class ImagePickerView: UIView { var delegate: ImagePickerDelegate? // 这里没有 weak } protocol ImagePickerDelegate: AnyObject { func pickerDidFinishPicking() }如果某个 ViewController 强引用了 ImagePickerViewImagePickerView 又强引用了这个 ViewController一个环就出现了。内存图里同样会出现一条环形链路ViewController 连到 ImagePickerViewImagePickerView 再连回 ViewController。这类问题一旦在内存图上看到修复方式基本是固定的在协议对象上写 weak 或者 class-constrained protocolfinal class ImagePickerView: UIView { weak var delegate: ImagePickerDelegate? }看到没有一个小关键词的差异就能决定对象能不能正常释放。这也是为什么我总是建议新人先学会看内存图再回去改代码因为你能亲眼看懂一个 weak 的威力。5.3 Timer 引发的隐藏残留Timer 的坑是最隐蔽的。很多人在某个页面里启动了一个定时器页面退出时忘了 invalidate或者 invalidate 放在了一个没有执行到的分支里。比如final class TimerDemoViewController: UIViewController { var timer: Timer? override func viewDidLoad() { super.viewDidLoad() timer Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in self.refreshUI() } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) // 注意这里没有写 timer?.invalidate() } }只要 timer 还在运行它就会强引用闭包闭包再强引用 self而 self 作为 ViewController 又强引用 timer。结果就是页面即使从导航栈里 pop 掉了这个 ViewController 仍然好好活在内存里定时器每一秒都在访问它的方法。你以为页面关了实际上它在后台还在跑CPU 和内存都被白白消耗。这类问题在内存图里的特征是你会看到一个 Timer 实例连接着某个闭包区域闭包又连接到 ViewController同时在图形上可能会有一个比较显眼的系统计时器相关的引用。修复方式有两步第一在合适的时机调用 invalidate第二闭包捕获列表用 weak self防止 invalidate 之前的一小段窗口时间形成残留。这两步最好都做不能只做其中一步。6. 排查工具组合拳内存图调试器之外还需要什么虽然我前面花了很多篇幅夸内存图调试器但它也不是万能的。实际开发里我经常把几种工具组合起来用效率和准确率都更高。6.1 用 malloc_check 辅助做“泄漏确认”有时候内存图调试器会显示某个对象还在但你自己也不太确定它就是泄漏可能它只是被某个全局单例有意保留下来的。这时候可以对比运行时行为观察 deinit 是否打印、该方法是否被执行、页面返回之后属性是否还在被调用。一个很简单的辅助手段是在返回页面后等待几秒钟再通过断点或者 log 检查某个关键通知回调是否还在触发。如果还在触发基本可以坐实这个对象没有释放。6.2 用 Time Profiler 观察持续性 CPU 占用保留周期有时候不仅带来内存问题还带来 CPU 问题。如果你用内存图调试器找到了一个疑似保留周期但你想知道它到底影响多大可以在 Instruments 里用 Time Profiler 跑一段用户路径看这个页面的对象在“退出后”是否依然占据很高的 CPU 时间。如果退出之后某一堆函数的 CPU 占用还在持续增长那不只是内存泄漏还是一个活跃的定时器或者后台任务这种问题需要立刻处理。6.3 建立团队的内存基线排查工具再多也不如提前预防。我见过不少团队在开发阶段根本不做内存专项检测快到发版本了才急急忙忙排查。正确做法是每次提交一个稍大点的功能模块时就做一轮快速内存检查进入页面再返回用内存图调试器抓一张图看一眼再配合 Malloc Stack 记录一下确认没有泄漏。这能省掉后面大量返工的时间。7. 常见问题与排查技巧实录7.1 为什么我搜不到那个对象的名字内存图调试器左侧列表是按类型名称显示的并不是所有属性名都能直接搜到。如果你的对象名比较常见搜索的时候会出来一堆结果可以配合“区分大小写”或者只搜一部分前缀。还有一种情况是 Swift 的类名被编译器加了一些标记比如模块名前缀你搜的时候可以只输入类名最后那一段比较容易匹配。7.2 图里箭头太多看不清楚怎么办这是最影响体验的问题。图一旦大了满屏都是线和节点。我的经验是先选中左侧的泄漏对象右侧图会自动聚焦到你选中的节点周围的引用关系会相应高亮。把不关心的系统 Framework 隐藏掉内存图右上角有一个 filter 选项可以只显示你感兴趣的那几条引用链。尽量不要在图形缩放程度很小的情况下分析。选中节点之后再放大往往就能看清主线了。7.3 内存图调试器和 Zombie 什么关系如果是野指针崩溃那就不是保留周期的问题了那一类需要用 Zombie Objects 工具来查。内存图调试器定位的是对象“该释放没释放”也就是“应该消失却还活着”的情况。而野指针是对象“已经释放了还被引用”本质上是反向的问题。我刚从事开发的时候经常把它们搞混调试起来自然抓瞎。建议你把这两类工具的分工记牢观察“该释放的没释放”选择内存图调试器、Leaks。观察“不该用的被用了”选择 Zombie Objects、Address Sanitizer。7.4 为什么在模拟器里看到的图和真机有差异这个我特别提醒一下。模拟器的内存管理系统和真机不完全相同尤其涉及到图片解码、系统 Framework 的缓存方式时表现会有差异。比如某些模拟器上的系统缓存会看起来像泄漏但真机上一切正常。所以凡是涉及内存判断尽量在真机上进行。尤其是你要发布上线前一定要用真机跑一遍泄漏检查不要图方便只跑模拟器。7.5 内存图调试器能看到 Swift 泛型或协议对象吗能但表达上确实不够友好。有时候你会看到一堆加了复杂字母后缀的泛型名称看着头晕。这时候不要直接盯着搜索框抠名字而是根据对象在完整引用链里的位置去判断。你点击它左侧的 Xcode 会显示内存地址和类型描述然后你再用转到源代码的功能跳转到相关类定义就能和代码一一对应了。另外协议类型的对象往往显示为协议名比如ImagePickerDelegate但实际底层对象可能是某个具体的 ViewController你在图上看到的名字可能是变形后的描述。多看几次习惯之后问题不大。8. 从“会查”到“不犯”养成写代码时的闭环自查习惯工具终究是补救手段真正厉害的是在一开始就不制造保留周期。我结合长期排查经验把写代码时最容易埋雷的几个习惯总结一下。8.1 凡是用到闭包属性先问一句“捕获列表写了什么”每当你写一个属性类型的闭包或者把闭包当作函数参数传递的时候都养成条件反射式地检查捕获列表。如果你看到闭包里面用了 self 的方法或属性而闭包并没有[weak self]那就得想一想这个闭包的生命周期是多久它是立即被调用还是被存起来持续使用只有后者才需要担心保留周期。8.2 代理属性统一使用 weak这个已经是行业共识了。只要你的类型没有强持有代理对象的特殊需求delegate 一律用 weak。在 Swift 里如果你想用 weak那么对应的协议得声明为AnyObject或者class。这类代码规范建议直接用代码评审规则来约束因为靠个人意志太容易漏了。8.3 Timer 和 DispatchSourceTimer 注意释放时机现在 Swift 开发里 DispatchSourceTimer 也用得越来越多它如果被某个属性强引用同时它的 handler 里捕获了 self同样可能形成保留周期。只不过 DispatchSourceTimer 的释放策略比 NSTimer 更灵活一点最稳妥的做法是在 deinit 里调用cancel()并且 handler 里用[weak self]。不过这里有一个细节如果你的 Timer 被一个全局单例持有那么就算你写了[weak self]它还是会不断干活也会产生别的副作用。所以核心思路是想在时间点上触发的事情一定要明确“谁拥有它、它什么时候停”。8.4 全局单例中的闭包容易成为“长期根”保留周期的另一个隐蔽入口是全局单例。你的页面可能本身没有直接拥抱全局单例但你在页面里把自己注册到了全局单例的某个回调列表里。只要这个回调列表一直保留着闭包闭包捕获了你的页面你的页面就永远无法释放。内存图里它也是以一个箭头链存在的全局单例 →某个数组 →闭包 →页面。排查这类问题的时候你需要注意两点闭包里有没有 weak self以及有没有在 deinit 里把闭包从单例上取消。第一个好发现第二个很容易忘。8.5 列表复用不等于页面存活还有一个很有意思的误区。有人以为 UITableView 的 cell 复用了页面就应该释放其实这完全是两码事。cell 是列表持有的列表是页面持有的只要页面被某个泄漏关系抓住页面里所有 cell、所有 subview全部都会留在内存里。所以在排查的时候不要只盯着“对象还在还在图里”这个事实要追踪根因把最上层那个没释放的容器对象找出来。内存图调试器可以帮你一层层往上找直到引用的源头是某个全局对象、某个闭包区域或者某个系统 Framework那就说明泄漏的根找到了。9. 一次完整的排查过程回放为了让读者更直观地看到完整流程我模拟一次排查过程尽量还原实际操作的节奏。假设在某公司的直播间项目里反馈说“进直播间再退出操作二十多次之后手机发烫App 偶尔被系统杀掉”。顺手用内存图调试器抓一张直播间退出后的内存图发现直播间 ViewController 的实例还活着。我当时的操作是这样的打开内存图调试器在左侧搜索框输入直播间类名。点击直播间的实例右侧会显示所有与它关联的引用线。这时候看到一条很长的链路直播间 → 网络请求处理器 → 某个用户信息更新闭包 → 直播间。再去看网络请求处理器这个类发现它在发起用户信息更新的请求时用了这样一个写法networkHandler.onUserUpdate { [weak self] user in self?.handleUserUpdate(user) }这段代码看着没问题问题出在 networkHandler 本身被一个全局的管理器持有而它又是页面上的一个属性。所以即使 onUserUpdate 里面是 weak self页面仍然被全局管理器那张大网抓着。换句话说这个保留周期的“根”不在闭包捕获而在于网络请求处理器被长时间保留。最终修法是把网络请求处理器的生命周期和页面绑定页面退出时把它释放掉并且从全局管理器里移除。修复后再跑同样的路径内存图里直播间实例就不存在了。这个案例给我的启发是排查内存问题不能只看闭包里面有没有 weak还要看谁持有了这个持有闭包的对象。内存图调试器的强大之处就在于此它能让你看清完整链路而不是停留在某一个局部代码的猜测上。10. 最后分享一个小技巧不妨把内存图调试器当成日常开发的“体检仪”从学会用内存图调试器到现在我的开发习惯改变很大。以前我只有被反馈“内存异常”的时候才去查现在我基本上每天下班前都会随手把当天写过的页面测一遍进入页面、返回、抓一张图看一眼有没有异常对象存活。这个过程全程不超过两三分钟却能避免大量的返工。我自己特别喜欢在提交代码评审之前做一次快速自检重点检查所有新增的闭包属性是否都写了捕获列表代理是否都是 weak凡是跟 Time 相关的有没有合理取消。如果自查时拿不准就直接用内存图调试器验证。毕竟人力记忆不可靠工具的图更诚实它会告诉你在当前状态下谁跟谁确实还挂着线。排查熟练之后你甚至不需要完全看懂图中的每一个节点只要能看到“该断的线断了”心里就踏实了。内存图调试器不是一个用来“解决启动崩溃后大事故”的应急工具它更像是一个日常开发可以反复使用的体检仪。把这套方法用熟练你写代码时对“引用关系”的敏感度会高很多很多内存问题在代码评审阶段就会被发现而不是等用户用了一个月之后手机发烫、卡顿才追悔莫及。