目录
[1. 所谓的"僵尸页面"是如何拖垮你的App的](#1. 所谓的“僵尸页面”是如何拖垮你的App的)
[2. Block闭包:绝不仅仅是 [weak self] 那么简单](#2. Block闭包:绝不仅仅是 [weak self] 那么简单)
[场景一:你以为安全的"非 self"引用](#场景一:你以为安全的“非 self”引用)
[场景二:嵌套 Block 的"漏网之鱼"](#场景二:嵌套 Block 的“漏网之鱼”)
[场景三:不需要 weak 的时候别瞎用](#场景三:不需要 weak 的时候别瞎用)
[3. 定时器:RunLoop 里的"钉子户"](#3. 定时器:RunLoop 里的“钉子户”)
[NSTimer / Timer 的原罪](#NSTimer / Timer 的原罪)
[方案 A:由于 iOS 10 带来的 Block API](#方案 A:由于 iOS 10 带来的 Block API)
[方案 B:针对旧代码或复杂场景的 Proxy(白手套)](#方案 B:针对旧代码或复杂场景的 Proxy(白手套))
[4. 那些藏在 NotificationCenter 里的"鬼魂"](#4. 那些藏在 NotificationCenter 里的“鬼魂”)
[坑一:Block-based Notification](#坑一:Block-based Notification)
[坑二:KVO 的 Controller 封装](#坑二:KVO 的 Controller 封装)
[5. 延迟执行与网络请求的"大撒把"](#5. 延迟执行与网络请求的“大撒把”)
[6. 别只盯着 ViewController,View 和 Layer 才是隐藏的内存刺客](#6. 别只盯着 ViewController,View 和 Layer 才是隐藏的内存刺客)
[陷阱一:drawRect 的 Backing Store 噩梦](#陷阱一:drawRect 的 Backing Store 噩梦)
[7. 静态缓存:只吃不拉的"貔貅"](#7. 静态缓存:只吃不拉的“貔貅”)
[8. 监控实战:Instruments 里的"海拉鲁分析法"](#8. 监控实战:Instruments 里的“海拉鲁分析法”)
[9. 自动化监控:MLeaksFinder 的"看门狗"哲学](#9. 自动化监控:MLeaksFinder 的“看门狗”哲学)
[10. 这种时候,Autoreleasepool 也是救命稻草](#10. 这种时候,Autoreleasepool 也是救命稻草)
1. 所谓的"僵尸页面"是如何拖垮你的App的
在解决问题前,你得先知道问题长啥样。
在 iOS 的内存管理机制里,引用计数(Reference Counting)是核心。简单说,页面 ViewController 被 navigationController push 进去,引用计数+1;pop 出来,引用计数-1。理论上,pop 之后,引用计数归零,dealloc 方法被调用,内存回收。
但在高频场景下,最可怕的事情发生了:页面虽然从屏幕上消失了,但在内存里它还活着。
我管这个叫"僵尸页面"。
试想一下,你做了一个直播间页面。 用户点进去,加载了:
-
一个 20MB 的礼物动画资源。
-
一个 5MB 的直播流 buffer。
-
十几个全屏的 UIView。
用户退出来,因为某个小闭包没写好,dealloc 没走。 用户又点进去... 这时候内存里有两个直播间。一个在台前,一个在幕后"吸血"。 用户再退,再进...
只要这个操作重复 10 次,你的 App 内存占用就多了几百兆。系统看你不顺眼,直接就在后台把你杀了。这种 Crash,你甚至在 Crash 报告里都很难定位,因为它是被系统强杀的,堆栈里可能啥都没有。
判断页面是否泄漏的唯一金标准: 当页面退出(Pop/Dismiss)时,dealloc 必须被调用。
如果在控制台没看到那个亲切的 Creating dealloc...(假设你打了日志),那恭喜你,你有活儿干了。
2. Block闭包:绝不仅仅是 [weak self] 那么简单
说到内存泄漏,90% 的 iOS 开发者第一反应就是 Block 循环引用。于是大家养成了肌肉记忆,看见 Block 就加 [weak self]。
但你真的懂为什么加吗?盲目加 weak 有时候反而会出Bug,甚至导致 crash。
场景一:你以为安全的"非 self"引用
这是我代码Review时抓到最多的典型错误。看下面这段代码:
// 假设这是一个自定义的 CustomView,被 ViewController 持有
class CustomView: UIView {
var clickHandler: (() -> Void)?
func setup() {
// ... layout code
let tap = UITapGestureRecognizer(target: self, action: #selector(didTap))
self.addGestureRecognizer(tap)
}
@objc func didTap() {
clickHandler?()
}
}
// 在 ViewController 里
class ProductDetailVC: UIViewController {
let headerView = CustomView()
var productModel: ProductModel?
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(headerView)
// 坑来了!
headerView.clickHandler = {
self.jumpToComment() // 这里大家知道要加 [weak self]
}
}
func jumpToComment() { ... }
}
上面这个大家都懂,headerView 持有 clickHandler,clickHandler 持有 self (VC),VC 持有 headerView。三角恋,必须断开。
但如果换一种写法呢?
headerView.clickHandler = {
// 假设 productModel 是个类实例,不是结构体
print(self.productModel?.name ?? "")
}
很多人觉得,我引用的是 productModel,又不是 self,应该没事吧?
大错特错。 在 Swift 的闭包捕获机制里,如果你在闭包里访问了 self 的属性(比如 self.productModel),闭包默认会捕获 self,而不是捕获那个属性对象本身。
所以,哪怕你写的是 print(productModel.name)(Swift 允许省略 self),编译器底层还是把 self 给强引用进去了。这照样是一个标准的 Retain Cycle。
干货: 只要在闭包里用了属于 self 的任何东西(属性、方法、计算属性),都必须考虑 self 的生命周期。
场景二:嵌套 Block 的"漏网之鱼"
有时候逻辑复杂,Block 里套 Block,很容易就把人绕晕了。
NetworkManager.shared.fetchData { [weak self] data in
guard let strongSelf = self else { return }
strongSelf.processData(data) { result in
// 这里没有加 weak self !
strongSelf.showToast("完成")
}
}
看仔细了。外层加了 [weak self],然后变成了 strongSelf。 内层的那个闭包(processData 的回调),捕获的是谁? 它捕获的是 strongSelf。 而 strongSelf 本质上就是 self 的一个强引用指针。
如果 processData 这个方法内部,把它的 completion handler 保存了下来(比如存到一个属性里延后执行),那么: self -> savedCompletionBlock -> strongSelf (which is self)。 Boom。环形成了。
解法: 在多层嵌套里,每一层都需要审视捕获关系。通常在内层 Block 里,依然需要再次声明 [weak self] 或者使用 [weak strongSelf](虽然语法怪怪的,但逻辑是对的)。
场景三:不需要 weak 的时候别瞎用
有些新人被吓怕了,连 UIView.animate 都加 weak self。
UIView.animate(withDuration: 0.3) { [weak self] in
self?.view.alpha = 0
}
这不仅多余,还恶心。 UIView.animate 的闭包是这个类方法立刻执行或者由系统动画队列管理的,它不会被 self 长期持有 。动画结束,闭包销毁,引用就断了。 同理,GCD 的 DispatchQueue.main.async 也不需要 weak self,除非你这个任务要跑很久(比如几分钟),而你想让页面关闭时立刻释放 self,那才需要加 weak。
记住一点: 只有当 闭包被 self 直接或间接持有(比如存成了属性,或者被 self 持有的对象存成了属性)时,才需要打破循环。
3. 定时器:RunLoop 里的"钉子户"
如果说 Block 循环引用是显性的坑,那定时器(Timer)就是隐形的杀手。特别是在做"倒计时"、"轮播图"这种功能时。
NSTimer / Timer 的原罪
// 经典错误写法
self.timer = Timer.scheduledTimer(timeInterval: 1.0,
target: self,
selector: #selector(tick),
userInfo: nil,
repeats: true)
这行代码一下去,三个强引用关系就建立了:
-
self持有timer(作为属性)。 -
timer持有self(作为 target)。 -
最关键的:RunLoop 持有 timer。
只要 repeats: true,RunLoop 就会一直抓着 timer 不放,timer 就一直抓着 self 不放。 你在 dealloc 里写 timer.invalidate()? 做梦呢。 因为 self 被 timer 抓着,引用计数降不下来,dealloc 永远不会被调用。dealloc 不跑,invalidate 就不跑。这就是个死锁。
方案 A:由于 iOS 10 带来的 Block API
如果你的 App 最低支持 iOS 10(现在基本都是了),请直接原地抛弃 target-action 模式,改用 Block:
self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
self?.tick()
}
看到没?这里 timer 持有的是闭包,闭包里弱引用了 self。 虽然 RunLoop -> Timer -> Block 还在,但 Block -> Self 断开了。 当 ViewController pop 出去时,它没有被 timer 强引用,可以正常销毁。 但是! 别高兴太早。 dealloc 走了,self 没了,但是 Timer 还在跑! 因为 RunLoop 依然持有 Timer。你的 tick 代码虽然因为 self 变成了 nil 不会 crash,但 Timer 作为一个僵尸任务一直在后台空耗 CPU。
必须在 dealloc 里调用 invalidate():
dealloc {
timer?.invalidate()
}
这就完美了吗?是的,对于 Block 版本的 Timer,这样就够了。
方案 B:针对旧代码或复杂场景的 Proxy(白手套)
有时候你需要封装一个通用的计时器组件,或者处理 CADisplayLink(它也有同样的 target 强引用问题),这时候 NSProxy 就该登场了。
原理很简单:找个"中间人"。 Self 强引用 Timer。 Timer 强引用 Proxy。 Proxy 弱引用 Self。
消息转发流程:Timer 触发 -> Proxy 接收 -> Proxy 转发给 Self。
// 一个极简的弱引用代理
class WeakProxy: NSObject {
weak var target: NSObjectProtocol?
init(target: NSObjectProtocol) {
self.target = target
super.init()
}
override func forwardingTarget(for aSelector: Selector!) -> Any? {
return target
}
// 必须重写这个,不然消息转发机制有时候会找不到签名
override func methodSignature(for selector: Selector) -> NSMethodSignature? {
return (target as? NSObject)?.methodSignature(for: selector)
}
override func responds(to aSelector: Selector!) -> Bool {
return target?.responds(to: aSelector) ?? false
}
}
// 使用
let proxy = WeakProxy(target: self)
self.timer = CADisplayLink(target: proxy, selector: #selector(tick))
self.timer?.add(to: .main, forMode: .common)
这样,timer 抓着的是 proxy,proxy 对 self 是弱引用。self 就可以安心去死(dealloc)了。 然后在 dealloc 里把 timer 停掉。
这种设计模式非常优雅,建议存进你的 snippet 代码库里。
4. 那些藏在 NotificationCenter 里的"鬼魂"
很多人以为 iOS 9 之后,NotificationCenter 不需要手动 removeObserver 了。 官方文档确实说过:对于 block-based 的观察者之外的普通观察者,系统会在对象销毁时自动移除。
但是,有两个巨大的坑 很多人不知道。
坑一:Block-based Notification
如果你用的是 addObserver(forName:object:queue:using:):
// 这里返回了一个 observer token
self.observerToken = NotificationCenter.default.addObserver(forName: .someName, object: nil, queue: .main) { [weak self] note in
self?.handleNotification()
}
这个 API 返回的 observerToken,你必须手动持有它,并且在 dealloc 里明确移除: NotificationCenter.default.removeObserver(self.observerToken)
如果你不移除,虽然 block 里用了 weak self 不会造成循环引用,但这个 Observer 对象本身会残留在 NotificationCenter 的内部表中 。 这虽然不算严格意义上的"ViewController 泄漏",但它是一种 内存泄漏。积少成多,NotificationCenter 的分发效率会下降。
坑二:KVO 的 Controller 封装
有些三方库或者老代码,喜欢把 KVO 封装在 Controller 里面。 如果你的 Controller 监听了单例(Singleton)或者全局对象(比如 UserSession.shared)的属性变化。 千万别忘了移除监听。
虽然现代 Swift 的 KVO (KeyPath 那个) 比较安全,但老式的 addObserver:forKeyPath: 如果忘记移除,当单例再次修改属性时,系统会尝试通知一个已经被销毁的对象地址 -> Crash: BAD_ACCESS。
这比内存泄漏更惨,这是直接炸。
5. 延迟执行与网络请求的"大撒把"
开发中经常有这种需求:"进入页面后,延迟3秒弹个窗" 或者 "请求一个接口,回来刷新UI"。
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.showAlert()
}
这代码没毛病。weak self 保证了不会死锁。 但是,这是一种资源浪费。
如果用户进页面 1 秒就退出了。 2 秒后,这个闭包依然会在主线程执行。虽然 self 是 nil 了,逻辑不会跑,但 GCD 调度的开销发生了。
更严重的是网络请求。 如果是上传大文件,或者下载大图片。用户都退出了,那个 URLSessionTask 还在跑,还在占网速,还在占内存 buffer。
高阶做法: 在 dealloc 或者 viewDidDisappear 时,主动取消(Cancel)所有正在进行的异步任务。
-
对于 GCD: 使用
DispatchWorkItem,它支持cancel()。 -
对于网络请求: 保存
dataTask的引用,退出时调用task.cancel()。
这一点在"频繁进出"的场景下尤为重要。想象一下,用户快速点击进入了 5 次详情页,发起了 5 次 GET /api/details。如果前 4 次不取消,你的网络队列就堵塞了,第 5 次(用户真正想看的这一次)反而加载变慢了。
这不仅是内存优化,更是 用户体验优化。
6. 别只盯着 ViewController,View 和 Layer 才是隐藏的内存刺客
很多人有误区:只要 VC 销毁了,View 肯定也就没了。 大部分情况是这样,但如果你涉及到了高性能绘图 或复杂动画,事情就没那么简单了。
陷阱一:drawRect 的 Backing Store 噩梦
现在的 iOS 开发,大家习惯了 AutoLayout 和简单的 UIView 堆砌。但如果你在做 K线图、复杂的自定义仪表盘,可能会用到 drawRect:。
你知道重写 drawRect: 的代价是什么吗? 它会为这个 View 创建一个等大的位图(Backing Store)。
假设你有一个全屏的 View (iPhone 13 Pro Max, 428 x 926 points, @3x scale)。 内存占用 = 428 * 926 * 3 * 3 * 4 bytes (RGBA) ≈ 14 MB。
这仅仅是一张静态图的内存!如果你的页面里有 5 个这样的 View,进去一次就是 70MB。 如果你频繁 Push/Pop 这个页面,而系统回收内存的速度赶不上你创建的速度(内存碎片化),App 就会因为瞬时内存峰值过高被杀掉。
优化方案:
-
能用
CAShapeLayer就绝不用drawRect。CAShapeLayer是矢量绘图,由 GPU 渲染,不占用 CPU 内存生成位图,内存消耗几乎可以忽略不计。 -
缩小 Backing Store。 如果非要用
drawRect,尽量把 View 的尺寸设小,或者通过layer.contentsScale降低分辨率(视情况而定)。
陷阱二:大图的内存爆炸
不管是 UIImage(named:) 还是 SDWebImage,图片在磁盘上可能只有 500KB,但解压到内存里显示时,它的大小取决于像素点数量,而不是文件大小。
经常遇到的 Crash 场景: 用户在一个Feed流页面疯狂快速滑动。每个 Cell 都有一张高清大图。 虽然 Cell 复用了,但图片解压的 Buffer 是实打实的。 如果此时你 Push 进一个详情页,详情页又加载了几张大图。 内存水位瞬间突破阈值(通常是几百 MB 到 1GB 不等,视机型而定)。
干货技巧: 在 didReceiveMemoryWarning 里,或者在页面不可见(viewDidDisappear)时,主动释放大图引用。
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
// 这种操作在低端机优化里非常有效
self.heavyImageView.image = nil
}
别指望 SDWebImage 的 Cache 帮你管理一切,它的 Cache 是基于 LRU 的,但在高频交互下,LRU 可能还没来得及淘汰,内存就已经爆了。
7. 静态缓存:只吃不拉的"貔貅"
这是我接手烂摊子项目时最痛恨的代码。 开发者为了"性能优化",搞了一堆 static 的字典或者单例数组来做缓存。
class DateFormatterCache {
static let shared = DateFormatterCache()
// 这是一个只增不减的无底洞
var cache: [String: DateFormatter] = [:]
func getFormatter(format: String) -> DateFormatter {
if let fm = cache[format] { return fm }
let fm = DateFormatter()
fm.dateFormat = format
cache[format] = fm
return fm
}
}
看着没毛病?DateFormatter 初始化确实慢,缓存一下是对的。 但如果你的 App 运行了 3 天,或者这个 key 是动态生成的(比如包含时区、包含语言环境),这个字典就会越来越大。 更可怕的是存图片的缓存、存各种临时计算结果的缓存。
这不叫缓存,这叫内存泄漏。 任何没有淘汰策略的缓存,本质上都是泄漏。
正确的姿势:使用 NSCache。 很多人把 NSCache 忘了。它专门为内存管理设计:
-
线程安全(不用你自己加锁)。
-
自动清理 。当系统内存警告时,
NSCache会自动丢弃部分内容释放内存。这是Dictionary做不到的。let cache = NSCache<NSString, DateFormatter>()
// 甚至可以设置数量限制
cache.countLimit = 50
把你代码里所有的 static var dictionary 换成 NSCache,很多莫名其妙的 OOM 就能缓解一半。
8. 监控实战:Instruments 里的"海拉鲁分析法"
好了,理论说完了。现在你手里有一个怀疑泄漏的 App,怎么抓? 别告诉我你只会在 Xcode 下面看那个 Debug Gauge 的内存条,那个太粗糙了。
我们要用 Instruments 里的 Allocations 工具(注意,不是 Leaks)。 为什么不用 Leaks? 因为 Leaks 只能检测 Cycle(循环引用) 。 但是像我上面说的"大图没释放"、"缓存无限增长",它们之间是有强引用的,不是循环引用,所以 Leaks 工具觉得它们是"合法"的。但它们占着茅坑不拉屎,这就是泄漏。
Allocations 的 Heapshot Analysis(堆快照分析)才是核武器。
操作步骤(教科书级别的排查流程):
-
打开 Instruments -> Allocations。
-
启动 App,进入首页,静置 5 秒,让内存稳定。
-
点击 "Mark Generation" 按钮。 这时候你会在时间轴上插个旗子(Generation A)。
-
执行操作: Push 进入目标页面,玩一玩,然后 Pop 回来。等待 2 秒,让该释放的都释放完。
-
再次点击 "Mark Generation"。 插旗子(Generation B)。
-
重复步骤 4 和 5,做个三四次。
如何看数据? 盯着面板上的 Growth(增长量)。
-
第一次进出(Gen A -> Gen B):增长可能是正常的,因为可能会初始化一些全局单例、图片缓存等。
-
关键看第二次、第三次(Gen B -> Gen C, Gen C -> Gen D)。
-
如果后续每一次进出,Growth 都不为 0 且保持稳定增长(比如每次都多 2MB),那实锤了,绝对有泄漏。
怎么定位? 点开那个 Generation,里面的对象列表就是"在这个时间段内分配且到现在还没释放 "的对象。 如果你在里面看到了 VideoDetailViewController,或者一堆 UITableViewCell,或者大量的 VM 对象,不用怀疑,点进去看堆栈,就能找到是谁创建了它们。
9. 自动化监控:MLeaksFinder 的"看门狗"哲学
用 Instruments 排查是事后诸葛亮,而且很累。作为架构师,你需要把监控前置到开发阶段。 你要让你的组员在写出 Bug 的那一瞬间,App 就弹窗骂他。
这就是微信读书团队开源的 MLeaksFinder 的核心逻辑,非常简单粗暴,但极其好用。
核心原理: 利用 AOP(面向切面编程),Hook 掉 UINavigationController 的 popViewController 和 UIViewController 的 dismiss。
当一个 VC 被 Pop 时,理论上 2 秒后它应该销毁。 MLeaksFinder 会在 Pop 时,对这个 VC 建立一个弱引用,并延迟 2 秒执行一个检测 Block。
// 伪代码逻辑
func swizzled_popViewController() {
let poppedVC = self.topViewController
original_popViewController() // 执行真正的 pop
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak poppedVC] in
if poppedVC != nil {
// 2秒了,这货还活着?报警!
Alert("内存泄漏:\(poppedVC) 没有释放!")
}
}
}
不仅是 VC,它还会遍历 VC 的 view 树,检查所有的 Subviews 是否也都释放了。 强烈建议 在你的 Debug 包里集成这个工具。它能拦截 99% 粗心大意导致的 Block 循环引用。当你开发完一个功能,跑一遍流程,没弹窗,心里才踏实。
10. 这种时候,Autoreleasepool 也是救命稻草
最后,再说一个容易被忽视的场景:短时间内产生大量临时对象的循环。
比如你在处理数据库迁移,或者在解析一个巨大的 JSON 列表,需要 for 循环 10000 次,每次循环都创建几个临时的 String 或 Model。
for item in largeDataList {
let processed = process(item) // 产生临时对象
saveToDB(processed)
}
虽然这些临时对象在循环结束后会释放,但在循环进行中,它们都在内存里堆积!如果数据量够大,这个峰值就能把 App 撑爆。
解法: 在循环体内加 @autoreleasepool。
for item in largeDataList {
autoreleasepool {
let processed = process(item)
saveToDB(processed)
} // 这一行结束,本次循环的临时内存立刻回收
}
这能把内存曲线从"一路狂飙"变成"锯齿状平稳运行",这就是峰值管理的艺术。