分析 Combine 的内存泄漏(Memory Leak)或循环引用(Retain Cycle)其实和分析普通的 Swift 闭包类似,但因为 Combine 是声明式且具有链式调用的特性,引用关系往往隐藏在操作符(Operators)之中。
以下是分析工具、典型原因以及解决方案的完整指南。
1. 如何分析与定位?
在 Swift 中分析 Combine 的内存问题,通常遵循“由易到难”的三个步骤:
A. 使用 Xcode Memory Graph Debugger
这是最直观的方法。运行 App 后,点击 Xcode 底部调试栏的 "Memory Graph" 图标(三个小圆圈连接的图标)。
- 观察: 搜索你的
Cancellable存储对象(如AnyCancellable)或相关的ViewModel。 - 线索: 如果你看到一个本该被销毁的对象依然存在,且连线图中出现一个循环箭头,那就是发生了循环引用。
B. 利用 deinit 日志
最简单有效的“土办法”。在你怀疑泄漏的对象(如 ViewController 或 ViewModel)中添加:
Swift
deinit {
print("(Self.self) 销毁了")
}
如果页面关闭了但日志没出,说明必然存在持有的引用。
C. 使用 handleEvents 操作符
Combine 提供了 handleEvents 来监控流的生命周期:
Swift
publisher
.handleEvents(receiveCancel: { print("流被取消了") })
.sink { ... }
.store(in: &cancellables)
2. 典型的泄漏原因
原因一:在 sink 中强引用 self(最常见)
当你使用 .sink 并在闭包内调用 self 的方法或属性时,sink 返回的 Cancellable 被存储在 self.cancellables 中,形成了闭环。
-
错误示例:
Swift
self.viewModel.$data .sink { value in self.updateUI(value) // 这里的 self 被强引用了 } .store(in: &cancellables) // self 持有 cancellables,cancellables 持有 sink,sink 持有 self
原因二:在 assign(to:on:) 中强引用
.assign(to: .property, on: self) 会强引用 on 传入的对象,直到流结束。
- 风险点: 如果流是一个无限流(比如
Timer或PassthroughSubject),self永远不会被释放。 - 注意: 在 iOS 14+ 中,使用
assign(to: &$publishedProperty)语法可以安全地避免这个问题,因为它不创建强引用。
原因三:自定义操作符或闭包内部
像 map、flatMap 或 filter 这样的操作符内部如果捕获了 self,也会导致同样的问题。
3. 解决方案
方案 A:使用 [weak self]
这是解决闭包循环引用的标准做法。
Swift
.sink { [weak self] value in
guard let self = self else { return }
self.updateUI(value)
}
方案 B:使用 assign(to: &$...)
如果你是更新 @Published 属性,请优先使用这种方式:
Swift
// 这种方式会自动处理生命周期,不会产生循环引用
viewModel.$status
.assign(to: &$status)
方案 C:手动管理生命周期
如果某些流需要提前结束,可以在适当的时机手动调用 cancellable.cancel() 或将存储容器置空:
Swift
self.cancellables.removeAll()
总结对照表
| 场景 | 潜在风险 | 推荐对策 |
|---|---|---|
| .sink | 强捕获 self 导致闭环 | 使用 [weak self] |
| .assign(to:on:) | 对 on 对象强引用 | 使用 assign(to: &$) (iOS 14+) |
| 无限流 (Timer/Subject) | Cancellable 永远不释放 | 结合生命周期手动调用 .cancel() |
| 复杂的 flatMap | 内部闭包捕获外部变量 | 仔细检查嵌套闭包的捕获列表 |