Swift 内存泄漏怎么查?7 种循环引用场景与 Windows 真机排查
最后更新
Swift 内存泄漏多数来自循环引用。Swift 用自动引用计数(ARC)管理内存,两个对象互相强引用、或者对象持有的闭包又强引用了它自己,页面关掉后谁也释放不了,内存越用越多。修复办法是在环上找一条引用改成 weak 或 unowned,或者在页面消失时主动断开(取消定时器、移除观察者、取消任务)。
这篇先给出 7 种最常见泄漏场景的泄漏代码和修复代码,再讲怎么在 Windows 上用 KXBuild 和 kxdevice 在 iPhone 真机上确认泄漏、找到源头、验证修好。
你需要一台 iPhone 和数据线,工程已经能用 KXBuild 编译并按 F5 装到手机上,安装见安装页,调试基础见真机调试。文中的代码在 Swift 5 和 Swift 6 语言模式下都能编译。
怎么判断对象有没有释放?
判断一个对象有没有释放,最直接的办法是看它的 deinit 有没有执行。排查前先给怀疑的页面和 ViewModel 加上一行打印。
1deinit {
2 print("OrderDetailViewController deinit")
3}
按 F5 运行,进入页面再返回,「输出 → KXBuild Console」里应该打印这一行。没有打印,说明还有别的对象强引用着它,下面的场景基本覆盖了原因。
场景 1:闭包捕获 self 怎么形成循环引用?
ViewController 持有 ViewModel,ViewModel 持有回调闭包,闭包里用了 self,三者连成一个环。
泄漏代码。
1final class SearchViewModel {
2 var onResults: (([String]) -> Void)?
3
4 func search(_ text: String) {
5 onResults?([text])
6 }
7}
8
9final class SearchViewController: UIViewController {
10 private let viewModel = SearchViewModel()
11 private let label = UILabel()
12
13 override func viewDidLoad() {
14 super.viewDidLoad()
15 viewModel.onResults = { results in
16 self.label.text = results.joined(separator: "、")
17 }
18 }
19
20 deinit {
21 print("SearchViewController deinit")
22 }
23}
修复代码。在捕获列表里写 [weak self],闭包不再持有 ViewController。闭包开头用 guard let self 解包,页面已经释放时直接返回。
1override func viewDidLoad() {
2 super.viewDidLoad()
3 viewModel.onResults = { [weak self] results in
4 guard let self else { return }
5 self.label.text = results.joined(separator: "、")
6 }
7}
不是所有闭包都要 [weak self]。UIView.animate、map、forEach 这类执行完就丢掉的闭包,以及只执行一次就结束的网络请求回调,不会形成环,最多让对象晚一点释放。要 weak 的是被别的对象长期保存的闭包:存进属性的回调、定时器、通知、Combine 订阅、一直运行的任务。
场景 2:delegate 写成强引用
DownloadTask 被 ViewController 持有,它的 delegate 又指回 ViewController。
泄漏代码。
1@MainActor
2protocol DownloadTaskDelegate: AnyObject {
3 func downloadDidFinish(_ task: DownloadTask)
4}
5
6@MainActor
7final class DownloadTask {
8 var delegate: (any DownloadTaskDelegate)?
9
10 func finish() {
11 delegate?.downloadDidFinish(self)
12 }
13}
14
15final class DownloadViewController: UIViewController, DownloadTaskDelegate {
16 private let task = DownloadTask()
17
18 override func viewDidLoad() {
19 super.viewDidLoad()
20 task.delegate = self
21 }
22
23 func downloadDidFinish(_ task: DownloadTask) {
24 print("下载完成")
25 }
26}
修复代码。delegate 属性加 weak。协议要声明 AnyObject,否则 weak 会报错,因为只有类的实例才能弱引用。
1@MainActor
2final class DownloadTask {
3 weak var delegate: (any DownloadTaskDelegate)?
4
5 func finish() {
6 delegate?.downloadDidFinish(self)
7 }
8}
系统控件的 delegate、dataSource 已经是 weak 的,问题都出在自己写的类上。Objective-C 的属性对应写成 @property (nonatomic, weak) id<XxxDelegate> delegate;。
场景 3:Timer 为什么让页面释放不了?
target/selector 形式的 Timer 会一直强引用 target,直到调用 invalidate(),这一点在 Apple 的 Timer 文档里写明了。RunLoop 持有定时器,定时器持有页面,页面关掉也不会释放,定时器还在后台一直触发。
泄漏代码。
1final class CountdownViewController: UIViewController {
2 private var timer: Timer?
3 private var remaining = 60
4
5 override func viewDidLoad() {
6 super.viewDidLoad()
7 timer = Timer.scheduledTimer(timeInterval: 1, target: self,
8 selector: #selector(tick),
9 userInfo: nil, repeats: true)
10 }
11
12 @objc private func tick() {
13 remaining -= 1
14 }
15}
修复代码。改用 block 形式并弱引用 self,页面消失时 invalidate()。block 里发现 self 已经释放,也顺手停掉定时器。
1final class CountdownViewController: UIViewController {
2 private var timer: Timer?
3 private var remaining = 60
4
5 override func viewDidAppear(_ animated: Bool) {
6 super.viewDidAppear(animated)
7 timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in
8 guard let self else {
9 timer.invalidate()
10 return
11 }
12 MainActor.assumeIsolated {
13 self.tick()
14 }
15 }
16 }
17
18 override func viewWillDisappear(_ animated: Bool) {
19 super.viewWillDisappear(animated)
20 timer?.invalidate()
21 timer = nil
22 }
23
24 private func tick() {
25 remaining -= 1
26 }
27}
block 形式的 Timer 回调是 @Sendable 闭包,在主线程创建的定时器在主线程触发,所以用 MainActor.assumeIsolated 告诉编译器这里在主线程,Swift 6 模式下才能调用页面的方法。CADisplayLink 同样强引用 target,处理方法一样,页面消失时调用 invalidate()。
场景 4:NotificationCenter 的 block 观察者
addObserver(forName:object:queue:using:) 会一直强引用 block,直到你移除这个观察者。block 里用了 self,页面就跟着一直活着。
泄漏代码。返回值没有保存,以后也没法移除。
1final class InboxViewController: UIViewController {
2 override func viewDidLoad() {
3 super.viewDidLoad()
4 NotificationCenter.default.addObserver(
5 forName: UIApplication.didBecomeActiveNotification,
6 object: nil,
7 queue: .main
8 ) { _ in
9 self.refresh()
10 }
11 }
12
13 func refresh() {}
14}
修复代码。block 里弱引用 self,返回的观察者对象交给一个小的包装类保存,页面释放时包装类跟着释放,在它的 deinit 里移除观察者。
1final class NotificationToken {
2 private let token: any NSObjectProtocol
3
4 init(_ token: any NSObjectProtocol) {
5 self.token = token
6 }
7
8 deinit {
9 NotificationCenter.default.removeObserver(token)
10 }
11}
12
13final class InboxViewController: UIViewController {
14 private var activeObservation: NotificationToken?
15
16 override func viewDidLoad() {
17 super.viewDidLoad()
18 let token = NotificationCenter.default.addObserver(
19 forName: UIApplication.didBecomeActiveNotification,
20 object: nil,
21 queue: .main
22 ) { [weak self] _ in
23 MainActor.assumeIsolated {
24 self?.refresh()
25 }
26 }
27 activeObservation = NotificationToken(token)
28 }
29
30 func refresh() {}
31}
更省事的是用 selector 形式。Apple 文档说明,App 最低支持 iOS 9 及以上时,用 addObserver(_:selector:name:object:) 注册的观察者不需要手动移除。selector 形式没有 block,也就没有捕获 self 的问题。
1override func viewDidLoad() {
2 super.viewDidLoad()
3 NotificationCenter.default.addObserver(
4 self,
5 selector: #selector(appDidBecomeActive),
6 name: UIApplication.didBecomeActiveNotification,
7 object: nil
8 )
9}
10
11@objc private func appDidBecomeActive() {
12 refresh()
13}
场景 5:Combine 的 sink
sink 返回的订阅存在 cancellables 里,cancellables 属于 self,sink 的闭包又捕获了 self。
泄漏代码。
1@MainActor
2final class SearchModel: ObservableObject {
3 @Published var query = ""
4 @Published private(set) var results: [String] = []
5 private var cancellables = Set<AnyCancellable>()
6
7 init() {
8 $query
9 .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
10 .sink { text in
11 self.results = self.search(text)
12 }
13 .store(in: &cancellables)
14 }
15
16 private func search(_ text: String) -> [String] {
17 text.isEmpty ? [] : [text]
18 }
19}
修复代码。闭包里弱引用 self。
1init() {
2 $query
3 .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
4 .sink { [weak self] text in
5 guard let self else { return }
6 self.results = self.search(text)
7 }
8 .store(in: &cancellables)
9}
结果只是赋给另一个 @Published 属性的,用 assign(to: &$results),订阅的生命周期跟着属性走,不会形成环,连 cancellables 都不用。assign(to: \.results, on: self) 会强引用 self,同样会泄漏,不要用这个写法。
1init() {
2 $query
3 .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
4 .map { text in text.isEmpty ? [] : [text] }
5 .assign(to: &$results)
6}
场景 6:Task 里捕获 self
Task 会强引用它捕获的 self,直到任务结束。普通的一次性请求很快就结束,问题不大。监听消息、轮询这种一直不结束的循环,页面就永远释放不了。
泄漏代码。
1final class ChatViewController: UIViewController {
2 private var messages: [String] = []
3
4 override func viewDidLoad() {
5 super.viewDidLoad()
6 Task {
7 for await message in ChatService.messages() {
8 self.messages.append(message)
9 }
10 }
11 }
12}
修复代码。保存任务,页面消失时取消。闭包里同时弱引用 self,每次收到消息时再解包,等待期间不持有页面。
1final class ChatViewController: UIViewController {
2 private var messages: [String] = []
3 private var listenTask: Task<Void, Never>?
4
5 override func viewDidAppear(_ animated: Bool) {
6 super.viewDidAppear(animated)
7 listenTask = Task { [weak self] in
8 for await message in ChatService.messages() {
9 guard let self else { return }
10 self.messages.append(message)
11 }
12 }
13 }
14
15 override func viewWillDisappear(_ animated: Bool) {
16 super.viewWillDisappear(animated)
17 listenTask?.cancel()
18 listenTask = nil
19 }
20
21 deinit {
22 print("ChatViewController deinit")
23 }
24}
例子里的 ChatService 是一个每秒产生一条消息的模拟数据源,任务被取消时它也会停下。
1enum ChatService {
2 static func messages() -> AsyncStream<String> {
3 AsyncStream { continuation in
4 let task = Task {
5 var n = 0
6 while !Task.isCancelled {
7 try? await Task.sleep(nanoseconds: 1_000_000_000)
8 n += 1
9 continuation.yield("消息 \(n)")
10 }
11 continuation.finish()
12 }
13 continuation.onTermination = { _ in task.cancel() }
14 }
15 }
16}
guard let self 写在循环里面,不要写在 for await 之前。写在前面,整个循环期间又变回强引用。
场景 7:SwiftUI 里的 class 状态对象
SwiftUI 的 View 是结构体,body 里的闭包捕获的是结构体的值,不会形成循环引用,不需要 [weak self]。会泄漏的是你用 @State、@StateObject 保存的 class,问题和前面一样出在它内部的定时器、订阅和任务上。
泄漏代码。模型在 init 里启动了定时器,定时器强引用模型,页面关掉后模型和定时器都还在。
1@Observable
2final class StopwatchModel {
3 var seconds = 0
4 private var timer: Timer?
5
6 init() {
7 timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
8 self.seconds += 1
9 }
10 }
11
12 deinit {
13 print("StopwatchModel deinit")
14 }
15}
16
17struct StopwatchView: View {
18 @State private var model = StopwatchModel()
19
20 var body: some View {
21 Text("\(model.seconds) 秒")
22 }
23}
修复代码。把循环改成 async 函数,交给 .task 修饰符启动。页面消失时 SwiftUI 自动取消这个任务,Task.sleep 抛出取消错误,循环退出,模型随页面释放。
1@MainActor
2@Observable
3final class StopwatchModel {
4 var seconds = 0
5
6 func run() async {
7 while !Task.isCancelled {
8 try? await Task.sleep(nanoseconds: 1_000_000_000)
9 seconds += 1
10 }
11 }
12
13 deinit {
14 print("StopwatchModel deinit")
15 }
16}
17
18struct StopwatchView: View {
19 @State private var model = StopwatchModel()
20
21 var body: some View {
22 Text("\(model.seconds) 秒")
23 .task {
24 await model.run()
25 }
26 }
27}
@Observable 需要 iOS 17。支持更低版本的,用 ObservableObject 加 @StateObject,做法相同。另外两个 SwiftUI 里常见的「看起来像泄漏」的问题也顺带检查。
- 模型被全局变量或单例保存。例如放进一个静态字典做缓存,页面关掉后仍被引用。缓存要有上限,或改用
NSCache。 - 用
@ObservedObject var model = Model()创建模型。父视图每次刷新都会新建一个,旧的被丢弃,表现是状态莫名重置。自己创建的模型用@StateObject或@State,从外面传进来的才用@ObservedObject。
七种循环引用场景分别怎么改?
| 场景 | 环是怎么形成的 | 改法 |
|---|---|---|
| 存进属性的回调闭包 | 对象 → 闭包 → self | [weak self] |
| 自己写的 delegate | 子对象 → delegate → 持有它的页面 | weak var delegate,协议加 AnyObject |
| target/selector 的 Timer、CADisplayLink | RunLoop → Timer → target | block 形式加 [weak self],页面消失时 invalidate() |
| NotificationCenter block 观察者 | 通知中心 → block → self | [weak self],保存返回值并移除,或改用 selector 形式 |
Combine sink |
self → cancellables → 闭包 → self | [weak self],或 assign(to: &$属性) |
| 不结束的 Task 循环 | Task → self,任务一直不结束 | 保存任务并 cancel(),循环内 guard let self |
| SwiftUI 状态对象内部的定时器、订阅 | 模型 → 定时器或订阅 → 模型 | 用 .task 启动循环,随页面自动取消 |
weak 和 unowned 怎么选?
两者都不增加引用计数,区别在对象释放之后。
weak |
unowned |
|
|---|---|---|
| 类型 | 可选类型,声明为 var |
非可选类型 |
| 对象释放后 | 自动变成 nil |
引用还在,再访问 App 直接崩溃 |
| 使用前 | 要解包,self?.xxx 或 guard let self |
直接用 |
| 适合 | 对方可能先释放,例如 delegate、页面里的回调、定时器、通知 | 对方的生命周期一定不短于自己,例如信用卡和它的持卡人 |
| 出错时的表现 | 什么也不做,可能看起来「点了没反应」 | 崩溃,Debug 下调试器停在访问它的那一行 |
Swift 官方的说法是,另一个实例生命周期更短时用 weak,生命周期相同或更长时用 unowned,原文见 The Swift Programming Language:Automatic Reference Counting。下面是 unowned 的典型用法,信用卡不可能比持卡人活得久。
1final class Customer {
2 let name: String
3 var card: CreditCard?
4
5 init(name: String) {
6 self.name = name
7 }
8
9 deinit {
10 print("\(name) deinit")
11 }
12}
13
14final class CreditCard {
15 let number: String
16 unowned let customer: Customer
17
18 init(number: String, customer: Customer) {
19 self.number = number
20 self.customer = customer
21 }
22}
闭包里用 [unowned self] 的典型情况是闭包只被 self 自己持有,例如 lazy 属性里的闭包。
1final class PriceFormatter {
2 let prefix = "¥"
3
4 lazy var format: (Double) -> String = { [unowned self] value in
5 "\(self.prefix)\(value)"
6 }
7}
拿不准时用 weak。页面回调、网络请求、定时器这些地方用 unowned,一旦页面先关掉、回调后到,就是一次线上崩溃。
在 Windows 上怎么排查 Swift 内存泄漏?
下面几种方法从轻到重,按需组合。
用 deinit 和日志确认是否释放
调试时看「输出 → KXBuild Console」里有没有 deinit 的打印,见运行与调试。不想每次都进出页面数打印的,在 Debug 配置下加一个检查,页面关闭两秒后对象还在就打印提示。
1#if DEBUG
2enum LeakCheck {
3 @MainActor
4 static func expectRelease(_ object: AnyObject, after seconds: UInt64 = 2) {
5 let name = String(describing: type(of: object))
6 Task { [weak object] in
7 try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
8 if object != nil {
9 print("[LeakCheck] \(name) 关闭 \(seconds) 秒后仍未释放,可能泄漏")
10 }
11 }
12 }
13}
14#endif
15
16final class DetailViewController: UIViewController {
17 override func viewDidDisappear(_ animated: Bool) {
18 super.viewDidDisappear(animated)
19 #if DEBUG
20 if isMovingFromParent || isBeingDismissed {
21 LeakCheck.expectRelease(self)
22 }
23 #endif
24 }
25}
isMovingFromParent 对应从导航栈返回,isBeingDismissed 对应关闭模态页面。页面嵌在别的容器里时按你的跳转方式调整判断条件。
不连调试器、或者要用 Release 包测试时,改用 Logger 打日志,再用 kxdevice 直接读手机日志。
1import os
2
3private let memoryLog = Logger(subsystem: "com.example.myapp", category: "memory")
4
5final class OrderDetailViewController: UIViewController {
6 private let orderID: String
7
8 init(orderID: String) {
9 self.orderID = orderID
10 super.init(nibName: nil, bundle: nil)
11 memoryLog.notice("OrderDetailViewController init \(orderID, privacy: .public)")
12 }
13
14 required init?(coder: NSCoder) {
15 fatalError("init(coder:) has not been implemented")
16 }
17
18 deinit {
19 memoryLog.notice("OrderDetailViewController deinit")
20 }
21}
1kxdevice ostrace --subsystem com.example.myapp --match OrderDetailViewController
进出页面几次,init 和 deinit 应该成对出现。只有 init 没有 deinit,就是这个页面没释放。ostrace 的其他过滤参数见 kxdevice 命令参考。
用断点和日志点找到还活着的闭包
知道哪个页面没释放以后,要找出是哪条引用把它留住了。最快的办法是看页面关掉之后还有哪段代码在跑。
- 日志点。在可疑闭包的第一行右键「添加日志点」,填
闭包仍在执行 {self}。返回上一页后调试控制台还在打印,说明这个闭包(定时器、通知、订阅、任务)还活着,它持有的对象也就还在。日志点不用改代码、不用重新编译。 - 断点。在定时器回调、通知回调里打普通断点,关掉页面后仍然停下,看「调用栈」面板就知道是谁触发的。
- 调试控制台。停在断点上时输入
po self看是哪个实例,地址和之前进入页面时打印的一样,就是旧页面没释放。
断点、日志点和调试控制台的用法见真机调试。
用 kxdevice 看内存分配和曲线
不确定是哪个页面在泄漏、或者想用数据确认修好了,用 kxdevice 记录内存分配。先启动 App 拿到 PID,App 已经在运行时用 apps ps 查。
1kxdevice apps launch com.example.myapp
2kxdevice apps ps --apps
然后记录分配和回溯,记录期间在手机上反复进出同一个页面五六次。
1kxdevice instruments allocations --pid 1234 --backtrace -n 20
只增不减的分配就是嫌疑对象,回溯指向创建它的代码和类。想看整体趋势,执行 kxdevice web,浏览器打开 http://localhost:8780,在「Instruments」页选中你的 App 进程看内存曲线。每进出一次涨一截、退出后不回落,基本就是这个页面泄漏了。这些命令的说明见性能分析,更多内存排查思路见没有 Instruments 怎么查卡顿和内存。
完整演示:订单详情页的泄漏怎么查?
用一个具体例子把上面的方法串起来。订单列表点进详情页,详情页每 5 秒刷新一次物流状态。测试反馈「看过几十个订单后 App 越来越卡」。
1. 复现并确认
给 OrderDetailViewController 加上 deinit 打印,用 Debug 配置按 F5。进入详情页再返回,重复三次。KXBuild Console 里一次 deinit 都没有打印,确认详情页没有释放。
2. 量化
在终端里拿到 PID,记录分配,再进出详情页五次。
1kxdevice apps ps --apps
2kxdevice instruments allocations --pid 1234 --backtrace -n 20
输出里 OrderDetailViewController 相关的分配每进一次多一份,没有减少。kxdevice web 的内存曲线每进出一次涨一截,和测试反馈一致。
3. 定位
打开详情页的代码,按上面的速查表逐个检查长期保存的引用。找到两处嫌疑。
1override func viewDidLoad() {
2 super.viewDidLoad()
3 refreshTimer = Timer.scheduledTimer(timeInterval: 5, target: self,
4 selector: #selector(refreshStatus),
5 userInfo: nil, repeats: true)
6 service.onUpdate = { status in
7 self.statusLabel.text = status
8 }
9}
在 refreshStatus 第一行加日志点 刷新物流 {self},返回列表后调试控制台每 5 秒还打印一次,地址是三个不同的实例。说明三个旧页面都被各自的定时器留住了,而且都还在请求网络。
4. 修复
两处都改掉。定时器换成 block 形式加 [weak self],页面消失时停掉。service 是详情页自己持有的,它的回调同样弱引用 self。
1override func viewDidAppear(_ animated: Bool) {
2 super.viewDidAppear(animated)
3 refreshTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] timer in
4 guard let self else {
5 timer.invalidate()
6 return
7 }
8 MainActor.assumeIsolated {
9 self.refreshStatus()
10 }
11 }
12}
13
14override func viewWillDisappear(_ animated: Bool) {
15 super.viewWillDisappear(animated)
16 refreshTimer?.invalidate()
17 refreshTimer = nil
18}
19
20override func viewDidLoad() {
21 super.viewDidLoad()
22 service.onUpdate = { [weak self] status in
23 self?.statusLabel.text = status
24 }
25}
只改定时器、不改回调,页面仍然释放不了。一个对象可能同时被几条环留住,每改一处都重新看一次 deinit。
5. 验证
停止调试,重新按 F5。进出详情页三次,每次返回都打印 OrderDetailViewController deinit,日志点也不再打印。再用 Release 配置装一次,启动后用同样的命令记录分配,PID 换成 apps launch 打印的新值。
1kxbuild build .\MyApp.xcodeproj -c Release --install
2kxdevice apps launch com.example.myapp
3kxdevice instruments allocations --pid 1234 --backtrace -n 20
进出页面后分配能回落,内存曲线不再阶梯式上涨,才算修好。最后把 LeakCheck.expectRelease(self) 留在这个页面里,以后再有人引入循环引用导致的 Swift 内存泄漏,Debug 运行时会第一时间提示。
常见问题
Swift 闭包里什么时候要写 [weak self]?
闭包被别的对象长期保存、而这个对象又直接或间接被 self 持有时要写,例如存进属性的回调、定时器、通知观察者、Combine 订阅、不结束的 Task。UIView.animate、map 这类立即执行完的闭包,以及只执行一次的请求回调,不会形成循环引用,可以不写。
weak 和 unowned 有什么区别?
两者都不增加引用计数。weak 是可选类型,对象释放后自动变成 nil;unowned 是非可选类型,对象释放后再访问会直接崩溃。对方可能先释放时用 weak,确定对方活得不比自己短时才用 unowned,拿不准就用 weak。
没有 Xcode 的 Memory Graph,怎么在 Windows 上查内存泄漏?
先在页面和 ViewModel 的 deinit 里打印,按 F5 进出页面看「输出 → KXBuild Console」有没有打印。确认没释放后,在可疑闭包里加日志点找出还在运行的回调。要用数据确认,执行 kxdevice instruments allocations --pid <pid> --backtrace 记录分配,或用 kxdevice web 看内存曲线。
SwiftUI 的 View 里需要写 [weak self] 吗?
不需要。View 是结构体,body 里的闭包不会和它形成循环引用。要检查的是 @State、@StateObject 保存的 class 内部有没有定时器、订阅、任务强引用了它自己,长时间运行的工作用 .task 启动,页面消失时会自动取消。
deinit 不打印一定是内存泄漏吗?
不一定,也可能只是释放得晚。例如还有一个网络请求的回调捕获了它,请求结束后才会释放。等几秒再看,或者用 LeakCheck 这类延时检查,一直不释放才是泄漏。