实战救火型 从 500MB 降到 50MB:高频业务场景下的 iOS 内存急救与避坑指南

目录

[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)是核心。简单说,页面 ViewControllernavigationController push 进去,引用计数+1;pop 出来,引用计数-1。理论上,pop 之后,引用计数归零,dealloc 方法被调用,内存回收。

但在高频场景下,最可怕的事情发生了:页面虽然从屏幕上消失了,但在内存里它还活着。

我管这个叫"僵尸页面"。

试想一下,你做了一个直播间页面。 用户点进去,加载了:

  1. 一个 20MB 的礼物动画资源。

  2. 一个 5MB 的直播流 buffer。

  3. 十几个全屏的 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 持有 clickHandlerclickHandler 持有 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)

这行代码一下去,三个强引用关系就建立了:

  1. self 持有 timer (作为属性)。

  2. timer 持有 self (作为 target)。

  3. 最关键的: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 强引用 TimerTimer 强引用 ProxyProxy 弱引用 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 抓着的是 proxyproxyself 是弱引用。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)所有正在进行的异步任务。

  1. 对于 GCD: 使用 DispatchWorkItem,它支持 cancel()

  2. 对于网络请求: 保存 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 就会因为瞬时内存峰值过高被杀掉。

优化方案:

  1. 能用 CAShapeLayer 就绝不用 drawRect CAShapeLayer 是矢量绘图,由 GPU 渲染,不占用 CPU 内存生成位图,内存消耗几乎可以忽略不计。

  2. 缩小 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 忘了。它专门为内存管理设计:

  1. 线程安全(不用你自己加锁)。

  2. 自动清理 。当系统内存警告时,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(堆快照分析)才是核武器。

操作步骤(教科书级别的排查流程):

  1. 打开 Instruments -> Allocations。

  2. 启动 App,进入首页,静置 5 秒,让内存稳定。

  3. 点击 "Mark Generation" 按钮。 这时候你会在时间轴上插个旗子(Generation A)。

  4. 执行操作: Push 进入目标页面,玩一玩,然后 Pop 回来。等待 2 秒,让该释放的都释放完。

  5. 再次点击 "Mark Generation"。 插旗子(Generation B)。

  6. 重复步骤 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 掉 UINavigationControllerpopViewControllerUIViewControllerdismiss

当一个 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 次,每次循环都创建几个临时的 StringModel

复制代码
for item in largeDataList {
    let processed = process(item) // 产生临时对象
    saveToDB(processed)
}

虽然这些临时对象在循环结束后会释放,但在循环进行中,它们都在内存里堆积!如果数据量够大,这个峰值就能把 App 撑爆。

解法: 在循环体内加 @autoreleasepool

复制代码
for item in largeDataList {
    autoreleasepool {
        let processed = process(item)
        saveToDB(processed)
    } // 这一行结束,本次循环的临时内存立刻回收
}

这能把内存曲线从"一路狂飙"变成"锯齿状平稳运行",这就是峰值管理的艺术。

相关推荐
山里看瓜4 小时前
解决 iOS 上 Swiper 滑动图片闪烁问题:原因分析与最有效的修复方式
前端·css·ios
网络研究院4 小时前
苹果修复了iOS系统中两个被定向攻击利用的零日漏洞
macos·ios·cocoa
如此风景6 小时前
IOS SwiftUI 全组件详解
ios
雾散声声慢6 小时前
解决 iOS 上 Swiper 滑动图片闪烁问题:原因分析与最有效的修复方式
前端·css·ios
QuantumLeap丶8 小时前
《Flutter全栈开发实战指南:从零到高级》- 24 -集成推送通知
android·flutter·ios
YungFan8 小时前
iOS开发之MetricKit监控App性能
ios·swiftui·swift
二流小码农9 小时前
鸿蒙开发:上架困难?谈谈我的上架之路
android·ios·harmonyos
图图大恼9 小时前
在iOS上体验Open-AutoGLM:从安装到流畅操作的完整指南
人工智能·ios·agent
笑尘pyrotechnic11 小时前
[iOS原理] Block的本质
ios·objective-c·cocoa