这是 iOS 内存深度剖析系列的第二篇——场景与优化篇。
上篇 · 原理篇:从虚拟内存到 ARC,jetsam 杀进程的真实逻辑(已发,建议先看)。 中篇 · 场景与优化篇(本文):图片解码 / Autorelease 池 / 循环引用 / 大对象生命周期 / Swift 内存陷阱。 下篇 · 实战篇:5 类 OOM 形态 / Memory Graph / MetricKit / 线上止损 Playbook。
上篇我们把 iOS 内存的"原理面"扒透了——5 类内存页、phys_footprint才是 jetsam 真正看的指标。但读到这里你应该有个疑问:那我代码里到底是哪些操作在贡献 dirty page?
这一篇就是答案。我们围绕 5 类真实占满你 App 内存的元凶展开:图片、Autorelease Pool、循环引用、大对象缓存、Swift 特有陷阱。每一类都给出可以直接用到代码里的优化手段+ 量化收益。
全文阅读时间约 15 分钟。
iOS 内存优化做到极致,80% 的精力都花在图片上——这不是夸张。一张 4K 图(4032 × 3024)在内存里展开后是:
4032 × 3024 × 4 bytes(RGBA)= 46.6 MB就这一张图——已经远超 Notification Service Extension 24MB 的内存上限(上篇钩子里 28MB OOM 的同款限额)。10 张这样的图在列表里同时滚动,前台 App 也能瞬间被 jetsam 杀掉。
很多人以为 UIImage(named:)就是"读个图"——错。它背后是 Apple 维护的一份 named image cache:
let img1 = UIImage(named: "big_photo") // 读元数据 + 加入 named cachelet img2 = UIImage(named: "big_photo") // 直接复用同一个 UIImage 实例三个特征:
UIImage实例——同一个 name 多次调用,复用同一个对象UIImage,位图就在这就是为什么"我的 ImageView 释放了,内存却没下降"——你释放的是 ImageView 对 UIImage 的一个引用,但 UIImage实例还在 named cache 里,背后的位图也还在。系统会在 memory warning 时清理 named cache,但平时不会。
**对比 UIImage(contentsOfFile:)**:
UIImage(named:) | UIImage(contentsOfFile:) | |
|---|---|---|
| 大图、一次性使用的内容图 |
实战结论:
UIImage(named:),受益于 cacheUIImage(contentsOfFile:),避免大图被 named cache 长时间持有PNG / JPG 文件本身不是位图——它是压缩格式。一张 10MB 的 JPG 文件,在磁盘上 10MB,解码后变成位图可能是 40-60 MB。
解码发生的时机大多数人不知道:
let img = UIImage(named: "photo") // ① 读元数据 + 加入 named cache,不解码imageView.image = img // ② 仍未解码// ↓// 当 imageView 第一次 layout / display 时// CoreAnimation 在主线程触发解码 → 位图进 dirty memory
UIImage(contentsOfFile:)同样是延迟解码——首次渲染才解码是 UIKit 的统一行为,不是named:独有的。
问题:解码发生在主线程,且发生在你看不到的时机——这是为什么列表滚动时偶尔卡顿的根因。
解决方案:提前在子线程强制解码:
extensionUIImage{funcdecoded() -> UIImage {guardlet cgImage = self.cgImage else { returnself }let colorSpace = CGColorSpaceCreateDeviceRGB()let context = CGContext( data: nil, width: cgImage.width, height: cgImage.height, bitsPerComponent: 8, bytesPerRow: cgImage.width * 4, space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue |CGBitmapInfo.byteOrder32Little.rawValue ) context?.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))guardlet decoded = context?.makeImage() else { returnself }returnUIImage(cgImage: decoded) }}// 用法(子线程)DispatchQueue.global().async {let decoded = UIImage(contentsOfFile: path)?.decoded()DispatchQueue.main.async { imageView.image = decoded // 此时位图已解码完,主线程不再阻塞 }}SDWebImage / Kingfisher 默认就在子线程做了强制解码——这是它们比手写
UIImage(contentsOfFile:)快的根本原因之一。
最关键的优化技巧——大多数列表场景根本不需要原图分辨率。
举例:你列表里一个 cell 的缩略图区域是 200 × 200 pt(即 600 × 600 px @3x),但用户上传的是 4032 × 3024的原图。如果直接 UIImage(contentsOfFile:):
原图位图:4032 × 3024 × 4 = 46.6 MB显示区域只有 600 × 600 px,差距 28 倍正确做法——用 ImageIO的 thumbnail API 在解码时直接缩小:
import ImageIOfuncdownsample(imageAt url: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage? {let imageSourceOptions = [kCGImageSourceShouldCache: false] asCFDictionaryguardlet imageSource = CGImageSourceCreateWithURL(url asCFURL, imageSourceOptions) else {returnnil }let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scalelet downsampleOptions: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceShouldCacheImmediately: true, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels ]guardlet downsampledImage = CGImageSourceCreateThumbnailAtIndex( imageSource, 0, downsampleOptions asCFDictionary) else {returnnil }returnUIImage(cgImage: downsampledImage)}// 用法let thumbnail = downsample(imageAt: photoURL, to: CGSize(width: 200, height: 200), scale: UIScreen.main.scale)收益(按列表 prefetch 20 张同时存活计算):
UIImage(contentsOfFile:) | ||
downsample |
降低 30+ 倍——这是抖音、微信等社交 App 在 feed 流里的标配做法。
总结上面三点,正确的列表图片加载流程:
① 主线程:cell 出现,触发图片加载 ↓② 子线程:CGImageSourceCreateThumbnailAtIndex 一步搞定 (读文件 + 解码 + 缩放,全在子线程) ↓③ 主线程:拿到缩略图 UIImage,赋值给 imageView ↓④ cell 滑出屏幕:取消该 cell 的图片加载任务(避免重复加载)反模式(性能极差):
// ❌ 错误做法cell.imageView.image = UIImage(contentsOfFile: largeImagePath)// 问题:1) 主线程读磁盘 2) 主线程解码 3) 用原图分辨率正确做法:用 SDWebImage / Kingfisher 这类成熟库——它们默认做了子线程解码 + 磁盘 cache + 内存 cache + cell 取消加载。但 downsample 需要显式配置(Kingfisher 用 DownsamplingImageProcessor、SDWebImage 通过 context: [.imageThumbnailPixelSize: ...])——别假设它默认会缩放。如果非要手写,至少做到 downsample + 子线程解码这两点。
记住一句话:图片优化的核心是 **"渲染需要多少像素,就只解码到那么多像素"**——而不是把原图整个塞进内存。
@autoreleasepool是 ObjC/Swift 里最常被误解的东西。很多人觉得"自从 ARC 之后就不用管它了"——完全错。它是控制临时对象生命周期的关键开关,特别是在大循环里。
主线程之所以"不用管 autorelease pool",是因为 RunLoop 在固定时机自动 push/pop:
主线程 RunLoop 的 pool 行为:进入 RunLoop(kCFRunLoopEntry) → push pool ├─ 处理事件 → 临时对象进入当前 pool ├─ 即将休眠(kCFRunLoopBeforeWaiting)→ pop 旧 pool + push 新 pool ├─ 醒来继续处理事件 → 进入新 pool └─ 退出 RunLoop(kCFRunLoopExit) → pop pool也就是说,每次主线程从"忙"转"闲"(即将休眠等下一个事件)时,本轮产生的所有 autorelease 对象就会被释放一次。这就是为什么平时主线程内存看着稳定——临时对象不会跨事件循环堆积。
但子线程不一样——dispatch_queue / NSThread / pthread 创建的子线程没有 RunLoop。
GCD 有个补救:每次 block 执行时自动包一层 autorelease pool,block 结束 drain。但这只在 block 边界生效——block 内部的大循环里产生的 autorelease 对象,仍然要等整个 block 结束才统一释放。
这意味着:
// ❌ 在子线程的大循环里产生 autorelease 对象DispatchQueue.global().async {for i in0..<10000 {let str = "iter \(i)"// 纯 Swift String,不走 autoreleaselet nsstr = NSString(format: "%d", i) // ⚠️ NSObject 子类,会被 autorelease// 处理 nsstr ... }// 循环结束才统一释放,期间内存峰值 = 10000 个 NSString}正确写法:
DispatchQueue.global().async {for i in0..<10000 { autoreleasepool {let nsstr = NSString(format: "%d", i)// 处理 nsstr ... } // 每次循环结束,立刻释放 }}最经典的场景:批量处理图片 / 数据。
// ❌ 反模式:一次循环跑完才释放,内存峰值爆炸DispatchQueue.global().async {var thumbnails: [UIImage] = []for url in100张图的URL列表 {let img = UIImage(contentsOfFile: url.path)!let thumb = img.resized(to: CGSize(width: 200, height: 200)) thumbnails.append(thumb)// 期间 img 被 autorelease,要等循环结束才能释放// 假设每张图解码后 ~30 MB(中等尺寸),100 张峰值 ≈ 3 GB → OOM }}// ✅ 正确做法DispatchQueue.global().async {var thumbnails: [UIImage] = []for url in100张图的URL列表 { autoreleasepool {let img = UIImage(contentsOfFile: url.path)!let thumb = img.resized(to: CGSize(width: 200, height: 200)) thumbnails.append(thumb) } // 每次结束,原图 img 立即释放 }// 内存峰值 ≈ 单张图 30MB + thumbnails 数组(100 × 0.5MB)≈ 80 MB}真实案例:很多团队在做"导出相册到文件"功能时遇到 OOM,根因就是没在循环里加 autoreleasepool。加了之后内存峰值从几 GB 直接降到几十 MB。
Swift 里写法和 ObjC 略不同——是个全局函数:
autoreleasepool {// 临时对象的代码}注意点:
String/ Array/ struct)不需要 pool——它们是 value type(内部 buffer 走 COW,不走 ObjC autorelease 机制)NSObject子类的临时对象(包括 UIImage / NSData / Foundation 类型),仍然受 pool 影响URL/ Date/ Data)也可能产生 autorelease 对象实操建议:在子线程跑大循环 + 涉及任何 Foundation / UIKit 对象时,**无脑加 autoreleasepool**。代价是几乎可以忽略的,收益可能是从 OOM 到不 OOM 的差距。
记住一句话:autorelease pool 不是"ARC 之前的遗物"——它是子线程大循环里唯一能控制临时对象生命周期的工具。RunLoop 帮你管的只是主线程,子线程要自己管。
ARC 自动管理引用计数,但有一个它解决不了的问题:两个对象互相 strong 持有,引用计数永远不会归零,永远不释放。这就是循环引用,是 iOS 内存泄漏的头号原因。
类型 1:delegate 错用 strong
// ❌@property (nonatomic, strong) id<MyDelegate> delegate;// 当 delegate 反过来又持有 self(典型场景:VC 自己当 cell/view 的 delegate),就形成循环// ✅@property (nonatomic, weak) id<MyDelegate> delegate;类型 2:block 捕获 self
// ❌self.completion = ^{ [self doSomething]; // block 捕获 self → self 持有 block → block 持有 self → 循环};// ✅__weaktypeof(self) wself = self;self.completion = ^{ __strongtypeof(wself) sself = wself; // 在 block 内"提升"为 strong,避免中途释放 [sself doSomething];};类型 3:NSTimer 持有 target
// ❌self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(tick) userInfo:nil repeats:YES];// timer 持有 self(target),self 持有 timer → 循环// ✅(iOS 10+)__weaktypeof(self) wself = self;self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer *t) { [wself tick];}];NSTimer 的坑特别深:即使你
[timer invalidate],只要 RunLoop 还活着,target 仍然不释放——因为 RunLoop 持有 timer,timer 持有 target。所以 ViewController 在 dealloc 里 invalidate 是走不到的——正因为循环引用让 self 一直活着,dealloc 永远不会被调到。唯一正确做法:在viewWillDisappear或外部主动 invalidate。
Swift 里循环引用最常见的就是闭包捕获 self:
// ❌ 闭包默认 strong 捕获 selfviewModel.onUpdate = {self.updateUI() // 循环:self 持有 viewModel,viewModel 持有 onUpdate,onUpdate 持有 self}// ✅ 用 weak selfviewModel.onUpdate = { [weakself] inself?.updateUI()}// ⚠️ 用 unowned self(更快但有风险)viewModel.onUpdate = { [unownedself] inself.updateUI() // 如果 self 已被释放,崩溃}[weak self]vs [unowned self]怎么选:
[weak self] | |
[unowned self] |
实操原则:**默认 [weak self]**——Apple Swift 社区指南也是这个建议。unowned和 weak的性能差距在现代 Swift 已经很小,不值得为几纳秒承担 crash 风险。只在你能 100% 证明生命周期顺序时才用 [unowned self]。
iOS 13+ 引入 Combine 和 Swift Concurrency 后,循环引用又冒出几种新形态。
Combine 订阅没保存就漏:
// ❌ subscription 没保存publisher.sink { [weakself] value inself?.handle(value)}// 这个 sink 返回的 `AnyCancellable` 离开当前作用域立即 deinit// → cancel() → 后续异步发出的值都收不到(看起来代码能跑,实际信号丢失)// ✅ 保存到属性privatevar cancellables = Set<AnyCancellable>()publisher.sink { [weakself] value inself?.handle(value)}.store(in: &cancellables)Combine 闭包的隐性循环:
// ❌ self 隐式持有publisher.sink { value inself.handle(value) // 闭包持有 self}.store(in: &self.cancellables) // self 持有 cancellable,cancellable 持有闭包,闭包持有 self修法和闭包一样:用 [weak self]。
Swift Concurrency Task 的循环:
classViewController: UIViewController{var task: Task<Void, Never>?funcstartWork() { task = Task { await self.fetchData() // ⚠️ Task 闭包 strong 捕获 self } }// 只要 task 没结束,self 永远不释放——ViewController 即使被 pop 也会一直活着}修法:
task = Task { [weakself] in await self?.fetchData()}Xcode 自带的 Memory Graph 是找循环引用的最简单工具:
Xcode 这套自动检测能发现 80% 的常见循环(直接strong 闭环),少数间接路径仍需配合 Instruments Allocations / Leaks——但比 Instruments Leaks 直观 10 倍,作为日常自查首选。
记住一句话:循环引用是 iOS 内存泄漏最常见的形态。用 Memory Graph 5 分钟扫一遍,能查出绝大多数泄漏。
App 里到处都是"缓存"——图片、网络结果、计算中间值、数据库查询结果。但缓存用不好就是内存炸弹。这一节讲怎么科学地管理大对象。
iOS 里最常用的两种缓存方案:
NSCache | 默认首选 | ||
| 自实现 LRU |
你可能听过
NSPurgeableData——它能"被丢弃后再重建",但 Apple 多年未更新、文档稀薄、社区基本不用,新项目不建议。
NSCache最常见的误用:
// ❌ 没设置上限——cache 会一直长let cache = NSCache<NSString, UIImage>()cache.setObject(image, forKey: "key"asNSString)// ✅ 设上限let cache = NSCache<NSString, UIImage>()cache.countLimit = 100// 最多 100 个对象cache.totalCostLimit = 50_000_000// 最多 50MB(按 setObject:forKey:cost: 累计)NSCache三个特性:
NSCopying**——cache 内部会复制 key(这点和 NSDictionary一致)单例(sharedInstance)是 iOS 最常用的模式,但有个隐藏代价:全生命周期都活着,没人显式释放。
classTrackingService{staticlet shared = TrackingService()var pendingEvents: [TrackEvent] = [] // 等待上报的事件functrack(_ event: TrackEvent) { pendingEvents.append(event) }// 网络好时一次上报;但用户长时间断网或上报失败重试逻辑没写好,// pendingEvents 只增不减 → 单例全程活着 → 内存只涨不降}真实项目里 cache 不会粗暴写成
[String: UIImage],但单例 + 任意可增长属性都是同一类问题:埋点队列、撤销栈、未发送消息、日志缓冲……
优化方案:
NSCache**——让缓存能被系统自动清classMySingleton{staticlet shared = MySingleton()privatelet cache = NSCache<NSString, UIImage>()init() { cache.totalCostLimit = 50_000_000// 监听内存警告NotificationCenter.default.addObserver(self, selector: #selector(handleMemoryWarning), name: UIApplication.didReceiveMemoryWarningNotification, object: nil ) }@objcfunchandleMemoryWarning() { cache.removeAllObjects() }}UINavigationController的栈是强引用持有所有压栈的 VC:
NavigationController.viewControllers = [VC1, VC2, VC3, VC4, VC5, ...] ↑ 全是 strong 引用问题:用户从 VC1 推到 VC5,VC1-4 都在内存里没释放——它们的 ImageView、表格数据、网络请求结果都还活着。
优化方向:
viewDidDisappear里释放重资源(比如清空大图缓存)popToViewController,让中间 VC 们能被释放第三方 SDK 通常都有自己的单例 + 全局缓存——你不知道它们缓存了多少。常见的"内存大户":
| 网络库 | |
| 图片库 | |
| 数据库 | |
| Crash 监控 | |
| 统计 SDK |
实操建议:
clearCache/ reset方法)记住一句话:缓存是用空间换时间——没设置上限的缓存就是定时炸弹。所有缓存层(你自己的 + SDK 的)都要有明确的容量上限和清理策略。
Swift 比 ObjC 多了几个值得专门讲的内存陷阱。
上篇 §3.2 讲过 Swift Array 的 COW 机制——赋值不复制,写时才复制。但有个常被忽略的陷阱:
// ❌ 多线程共享 + 频繁修改classDataStore{var items: [Item] = [] // 多个线程都在改funcadd(_ item: Item) { items.append(item) }}// 问题:多线程同时 append 是**未定义行为**// 可能 buffer 重复释放 crash、可能数据丢失、可能内存破坏// 即使没崩,COW 频繁触发,性能也很差修复方案:
DispatchQueue串行化所有写操作(最常见)actor**(Swift 5.5 / iOS 15+):原生并发安全的数据容器,最 Swiftyos_unfair_lock/ NSLock**:性能最好的细粒度锁注意:
NSMutableArray本身也不是线程安全的——Apple 文档明确说明。换它解决不了 race condition。
classDataStore{privatelet queue = DispatchQueue(label: "store", attributes: .concurrent)privatevar items: [Item] = []funcadd(_ item: Item) { queue.async(flags: .barrier) {self.items.append(item) } }}Swift 闭包对值类型的捕获会"快照式"复制一份——大对象的复制成本被低估:
let bigArray = Array(0..<1_000_000) // 8MBlet closure = {print(bigArray.count) // 闭包捕获 bigArray}// 每次 closure 被持有/复制 → bigArray 也被持有// 如果 closure 被多个对象持有 → 多份 8MB 数组优化:
// ✅ 把大对象抽到 class 里——class 是引用类型,闭包捕获的是引用,不复制内容classDataHolder{let bigArray = Array(0..<1_000_000)}let holder = DataHolder()let closure = { [weak holder] in// weak 是为了防止同一对象被多个闭包持有时形成循环print(holder?.bigArray.count ?? 0)}Swift Concurrency 时代的新陷阱——Task 持有自己的栈帧 + 捕获的所有变量:
classViewController: UIViewController{var ongoingTasks: [Task<Void, Never>] = [] // 累积所有 taskfuncloadData() {let task = Task {let data = try? await fetchData() // 假设 fetchData 要 30 秒self.handle(data) } ongoingTasks.append(task) // ⚠️ 永远不清理 }}问题:
ongoingTasks永远在增长——每次调用 loadData多一个 task修复:
[weak self]deinit { ongoingTasks.forEach { $0.cancel() }}@Published闭包陷阱SwiftUI / Combine 项目里这个特别常见:
// ❌classViewModel: ObservableObject{ @Publishedvar text = ""init() { $text .debounce(for: 0.3, scheduler: RunLoop.main) .sink { value inself.process(value) // ⚠️ 闭包持有 self } .store(in: &cancellables)// ViewModel.cancellables 持有 sink 订阅// sink 订阅持有闭包// 闭包持有 self// → 循环 }}// ✅init() { $text .debounce(for: 0.3, scheduler: RunLoop.main) .sink { [weakself] value inself?.process(value) } .store(in: &cancellables)}记住一句话:Swift 编译期对引用关系的约束比 ObjC 严格——但它管不了你不让它管的(闭包捕获 self、Task 持有 self、Combine 订阅持有闭包)。这些场景仍然要你手动 weak。
到这里我们已经走完了 iOS 内存优化的"5 大场景":
NSCache自动响应内存压力,所有缓存必须设上限记住一句话:iOS 内存优化的核心三件事——少创建(图片 downsample)、早释放(autoreleasepool / NSCache)、查泄漏(Memory Graph)。
下篇我们进入实战篇——5 类 OOM 形态怎么辨识、Memory Graph / MetricKit / Instruments 三件套实操、线上止损 Playbook、两个真实案例(WKWebView 泄漏 + Extension 24MB 限额求生),以及 8 道内存面试题与全景图。
下篇见。
参考资料:Apple WWDC 2018《iOS Memory Deep Dive》、WWDC 2021《Detect and diagnose memory issues》、SDWebImage / Kingfisher 源码、Mike Ash《Friday Q&A》系列、抖音 / 美团公开技术分享。