当前位置:首页>iOSAPP>iOS 内存管理深度剖析(中):图片解码 / Autorelease 池 / 循环引用 / 大对象生命周期

iOS 内存管理深度剖析(中):图片解码 / Autorelease 池 / 循环引用 / 大对象生命周期

  • 2026-11-03 06:19:21
iOS 内存管理深度剖析(中):图片解码 / Autorelease 池 / 循环引用 / 大对象生命周期

这是 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 内存第一杀手

iOS 内存优化做到极致,80% 的精力都花在图片上——这不是夸张。一张 4K 图(4032 × 3024)在内存里展开后是:

4032 × 3024 × 4 bytes(RGBA)= 46.6 MB

就这一张图——已经远超 Notification Service Extension 24MB 的内存上限(上篇钩子里 28MB OOM 的同款限额)。10 张这样的图在列表里同时滚动,前台 App 也能瞬间被 jetsam 杀掉。

1.1 UIImage(named:) 的隐性 cache

很多人以为 UIImage(named:)就是"读个图"——错。它背后是 Apple 维护的一份 named image cache:

let img1 = UIImage(named: "big_photo")   // 读元数据 + 加入 named cachelet img2 = UIImage(named: "big_photo")   // 直接复用同一个 UIImage 实例

三个特征:

  1. 缓存的是 UIImage实例——同一个 name 多次调用,复用同一个对象
  2. 位图常驻——首次渲染时解码出位图(CGImage backing store),之后默认不主动释放,再次显示直接复用
  3. 进程级共享——你整个 App 共用一份 cache,只要 cache 还持有这个 UIImage,位图就在

这就是为什么"我的 ImageView 释放了,内存却没下降"——你释放的是 ImageView 对 UIImage 的一个引用,但 UIImage实例还在 named cache 里,背后的位图也还在。系统会在 memory warning 时清理 named cache,但平时不会。

**对比 UIImage(contentsOfFile:)**:

行为
UIImage(named:)UIImage(contentsOfFile:)
named cache
✅ 有
❌ 无
重复创建
复用 cache
每次都重新解码
释放时机
系统决定
ARC 决定
适合场景
小图标、反复使用的 UI 资源
大图、一次性使用的内容图

实战结论:

  • App 自带的小图标 / 按钮图:用 UIImage(named:),受益于 cache
  • 从网络 / 相册 / 沙盒加载的大图:用 UIImage(contentsOfFile:),避免大图被 named cache 长时间持有

1.2 解码时机:什么时候 PNG/JPG 变成位图

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:)快的根本原因之一。

1.3 Downsampling:内存占用降低一个数量级

最关键的优化技巧——大多数列表场景根本不需要原图分辨率。

举例:你列表里一个 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 张同时存活计算):

方案
单张内存占用
20 张 prefetch
UIImage(contentsOfFile:)
原图
~47 MB
~940 MB(前台必 OOM)
downsample
到 600x600
~1.4 MB
~28 MB ✅

降低 30+ 倍——这是抖音、微信等社交 App 在 feed 流里的标配做法。

1.4 列表加载图片的正确姿势

总结上面三点,正确的列表图片加载流程:

① 主线程: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 + 子线程解码这两点。

记住一句话:图片优化的核心是 **"渲染需要多少像素,就只解码到那么多像素"**——而不是把原图整个塞进内存。


二、Autorelease Pool:被低估的内存控制阀

@autoreleasepool是 ObjC/Swift 里最常被误解的东西。很多人觉得"自从 ARC 之后就不用管它了"——完全错。它是控制临时对象生命周期的关键开关,特别是在大循环里。

2.1 主线程的 autorelease pool 是 RunLoop 自动管的

主线程之所以"不用管 autorelease pool",是因为 RunLoop 在固定时机自动 push/pop:

主线程 RunLoop 的 pool 行为:进入 RunLoop(kCFRunLoopEntry)           → push pool   ├─ 处理事件 → 临时对象进入当前 pool   ├─ 即将休眠(kCFRunLoopBeforeWaiting)→ pop 旧 pool + push 新 pool   ├─ 醒来继续处理事件 → 进入新 pool   └─ 退出 RunLoop(kCFRunLoopExit)      → pop pool

也就是说,每次主线程从"忙"转"闲"(即将休眠等下一个事件)时,本轮产生的所有 autorelease 对象就会被释放一次。这就是为什么平时主线程内存看着稳定——临时对象不会跨事件循环堆积。

2.2 子线程必须自己包,否则内存会爆

但子线程不一样——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 ...        }   // 每次循环结束,立刻释放    }}

2.3 一个 for 循环把 200MB 压到 20MB

最经典的场景:批量处理图片 / 数据。

// ❌ 反模式:一次循环跑完才释放,内存峰值爆炸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。

2.4 Swift 里 autoreleasepool 的注意事项

Swift 里写法和 ObjC 略不同——是个全局函数:

autoreleasepool {// 临时对象的代码}

注意点:

  • 纯 Swift 类型(String/ Array/ struct)不需要 pool——它们是 value type(内部 buffer 走 COW,不走 ObjC autorelease 机制)
  • 但只要有 NSObject子类的临时对象(包括 UIImage / NSData / Foundation 类型),仍然受 pool 影响
  • 跨语言桥接对象(Foundation 桥接:URL/ Date/ Data)也可能产生 autorelease 对象

实操建议:在子线程跑大循环 + 涉及任何 Foundation / UIKit 对象时,**无脑加 autoreleasepool**。代价是几乎可以忽略的,收益可能是从 OOM 到不 OOM 的差距。

记住一句话:autorelease pool 不是"ARC 之前的遗物"——它是子线程大循环里唯一能控制临时对象生命周期的工具。RunLoop 帮你管的只是主线程,子线程要自己管。


三、循环引用:经典且永不过时

ARC 自动管理引用计数,但有一个它解决不了的问题:两个对象互相 strong 持有,引用计数永远不会归零,永远不释放。这就是循环引用,是 iOS 内存泄漏的头号原因。

3.1 ObjC 三种典型循环引用

类型 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。

3.2 Swift 闭包:[weak self] vs [unowned self]

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]怎么选:

情况
推荐
不确定 self 和闭包谁先死
[weak self]
闭包是 self 的实例属性,且 self 一定先于闭包销毁
[unowned self]

实操原则:**默认 [weak self]**——Apple Swift 社区指南也是这个建议。unowned和 weak的性能差距在现代 Swift 已经很小,不值得为几纳秒承担 crash 风险。只在你能 100% 证明生命周期顺序时才用 [unowned self]。

3.3 Combine / Swift Concurrency 时代的新坑

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()}

3.4 工具:Memory Graph 5 分钟找循环引用

Xcode 自带的 Memory Graph 是找循环引用的最简单工具:

  1. Xcode 跑起来 App(Debug build)
  2. 点击底部 Debug 工具栏里的 Debug Memory Graph 按钮(三个圆圈相连的图标)
  3. App 暂停,左侧 Navigator 列出所有存活对象
  4. 点击右下角的紫色 ⚠️ 图标——只显示 Xcode 检测到的潜在泄漏对象
  5. 点开任一对象,中间画布显示完整的引用链——直接看到谁强引用了谁

Xcode 这套自动检测能发现 80% 的常见循环(直接strong 闭环),少数间接路径仍需配合 Instruments Allocations / Leaks——但比 Instruments Leaks 直观 10 倍,作为日常自查首选。

记住一句话:循环引用是 iOS 内存泄漏最常见的形态。用 Memory Graph 5 分钟扫一遍,能查出绝大多数泄漏。


四、大对象生命周期:什么该缓存、什么不该

App 里到处都是"缓存"——图片、网络结果、计算中间值、数据库查询结果。但缓存用不好就是内存炸弹。这一节讲怎么科学地管理大对象。

4.1 NSCache vs 自实现 LRU

iOS 里最常用的两种缓存方案:

方案
自动响应内存压力
容量上限
适用场景
NSCache
✅ 系统自动清理
可设置(按数量 / 总成本)
默认首选
——大多数场景
自实现 LRU
❌ 需自己监听 memory pressure
手动控制
需要精确控制淘汰策略

你可能听过 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三个特性:

  1. 线程安全——内置锁,不需要自己加
  2. 自动响应 memory warning——内存紧张时系统自动清理
  3. **key 必须实现 NSCopying**——cache 内部会复制 key(这点和 NSDictionary一致)

4.2 单例的代价:sharedInstance 永远不死

单例(sharedInstance)是 iOS 最常用的模式,但有个隐藏代价:全生命周期都活着,没人显式释放。

classTrackingService{staticlet shared = TrackingService()var pendingEvents: [TrackEvent] = []   // 等待上报的事件functrack(_ event: TrackEvent) {        pendingEvents.append(event)    }// 网络好时一次上报;但用户长时间断网或上报失败重试逻辑没写好,// pendingEvents 只增不减 → 单例全程活着 → 内存只涨不降}

真实项目里 cache 不会粗暴写成 [String: UIImage],但单例 + 任意可增长属性都是同一类问题:埋点队列、撤销栈、未发送消息、日志缓冲……

优化方案:

  • **单例内部用 NSCache**——让缓存能被系统自动清
  • 单例响应 memory pressure——主动释放可重建的数据
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()    }}

4.3 ViewController 的隐性持有

UINavigationController的栈是强引用持有所有压栈的 VC:

NavigationController.viewControllers = [VC1, VC2, VC3, VC4, VC5, ...]                                        ↑ 全是 strong 引用

问题:用户从 VC1 推到 VC5,VC1-4 都在内存里没释放——它们的 ImageView、表格数据、网络请求结果都还活着。

优化方向:

  • VC 的 viewDidDisappear里释放重资源(比如清空大图缓存)
  • 真正退出某层逻辑时,主动 popToViewController,让中间 VC 们能被释放
  • 避免 VC 持有不该持有的东西——比如把数据库查询结果塞到 VC 属性里

4.4 第三方 SDK 的"全局状态"

第三方 SDK 通常都有自己的单例 + 全局缓存——你不知道它们缓存了多少。常见的"内存大户":

SDK 类型
典型问题
网络库
(Alamofire / 老项目 AFNetworking)
大文件响应未流式处理时整块入内存;URLCache 默认 4MB 内存 + 20MB 磁盘
图片库
(SDWebImage / Kingfisher)
内存 cache + 磁盘 cache,默认几百 MB
数据库
(Realm / FMDB)
缓存查询结果,长连接保持
Crash 监控
(Bugly / Sentry)
缓存日志、堆栈快照
统计 SDK
(GA / Firebase)
事件队列等待发送

实操建议:

  • 集成时主动设置 SDK 的缓存上限(基本所有 SDK 都提供 API)
  • 监听 memory warning,主动清理 SDK 缓存(多数 SDK 提供 clearCache/ reset方法)
  • 上线后用 Memory Graph 抓快照看 SDK 实际占用

记住一句话:缓存是用空间换时间——没设置上限的缓存就是定时炸弹。所有缓存层(你自己的 + SDK 的)都要有明确的容量上限和清理策略。


五、Swift 特有的内存陷阱(4 个高频场景)

Swift 比 ObjC 多了几个值得专门讲的内存陷阱。

5.1 Array / Dictionary 的 COW 误用

上篇 §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+):原生并发安全的数据容器,最 Swifty
  • **用 os_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)        }    }}

5.2 Swift 闭包捕获的隐藏成本

Swift 闭包对值类型的捕获会"快照式"复制一份——大对象的复制成本被低估:

let bigArray = Array(0..<1_000_000)   // 8MBlet closure = {print(bigArray.count)   // 闭包捕获 bigArray}// 每次 closure 被持有/复制 → bigArray 也被持有// 如果 closure 被多个对象持有 → 多份 8MB 数组

优化:

  • 把大对象抽到 reference type(class)里,闭包持有 class 引用而非值副本
  • 避免在闭包里直接捕获大值类型
// ✅ 把大对象抽到 class 里——class 是引用类型,闭包捕获的是引用,不复制内容classDataHolder{let bigArray = Array(0..<1_000_000)}let holder = DataHolder()let closure = { [weak holder] in// weak 是为了防止同一对象被多个闭包持有时形成循环print(holder?.bigArray.count ?? 0)}

5.3 actor / Task 的内存模型

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
  • 每个 task 持有 self(强引用)+ 任务执行期间的所有局部状态
  • 用户来回滑页面时,ongoingTasks 可能有几十个,所有相关内存都不释放

修复:

  • 任务完成后从数组里移除
  • 或者用 [weak self]
  • 或者在 deinit / viewWillDisappear 时主动 cancel
deinit {    ongoingTasks.forEach { $0.cancel() }}

5.4 一个 Swift 特有反模式:@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 大场景":

  • 图片——80% 的内存来自这里。核心是 downsample(解码到显示尺寸)+ 子线程解码
  • Autorelease Pool——子线程大循环必须包,典型场景能把内存峰值压一个数量级(§2.3 的例子从几 GB → 80MB)
  • 循环引用——闭包 / delegate / Combine / Task 都是高发区,Memory Graph 5 分钟扫一遍
  • 大对象生命周期——用 NSCache自动响应内存压力,所有缓存必须设上限
  • Swift 特有陷阱——COW 多线程、闭包捕获大对象、Task 累积、Combine 闭包

记住一句话: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》系列、抖音 / 美团公开技术分享。

最新文章

随机文章