在 SwiftUI 开发中,虽然声明式视图(View)是结构体(Struct),本身不会产生循环引用,但 ViewModel(通常是 Class) 和 异步闭包 是内存泄漏的高发区。
以下是在 SwiftUI 架构中避免循环引用的核心场景与对策:
1. ViewModel 内部的闭包捕获
这是最常见的场景。ViewModel(如使用 @Observable 或 ObservableObject)通常持有网络请求或计时器的回调闭包。
- 危险点:ViewModel 持有闭包,闭包内部又强引用了
self(ViewModel)。 - 解决方案:在 ViewModel 的所有逃逸闭包中使用
[weak self]。
Swift
@Observable
class UserViewModel {
var name = "Guest"
func updateProfile() {
// 模拟异步请求
NetworkManager.shared.fetch { [weak self] newName in
// 使用 guard 确保 self 还在,同时提升为强引用处理逻辑
guard let self = self else { return }
self.name = newName
}
}
}
2. View 中的 .task 或 .onAppear 闭包
虽然 SwiftUI 的 View 是值类型(Struct),但在闭包中捕获 ViewModel 时,仍需注意持有关系。
- 机制说明:SwiftUI 的
.task修饰符非常智能,它会在视图消失时自动取消任务。但如果任务内部触发了长时运行且未检查取消状态的闭包,引用仍可能残留。 - 对策:由于
View重新构建频繁,尽量不要在 View 的闭包中捕获ViewModel的局部强引用,而是直接通过属性访问。
Swift
struct ProfileView: View {
@State private var viewModel = UserViewModel()
var body: some View {
Text(viewModel.name)
.task {
// task 会在销毁时自动取消,通常这里比较安全
await viewModel.loadData()
}
}
}
3. Combine 订阅中的循环引用
如果你在 ViewModel 中使用 Combine 监听数据流,sink 闭包是一个巨大的泄漏隐患。
- 危险点:
AnyCancellable集合存储在 ViewModel 中,sink闭包又捕获了self。 - 解决方案:始终在
sink中使用[weak self]。
Swift
class SearchViewModel: ObservableObject {
@Published var searchText = ""
private var cancellables = Set<AnyCancellable>()
init() {
$searchText
.debounce(for: .seconds(0.5), scheduler: RunLoop.main)
.sink { [weak self] text in
self?.performSearch(text)
}
.store(in: &cancellables)
}
}
4. 跨组件的 Delegate 或 Callback
如果你的 ViewModel 需要回调给另一个父级 ViewModel。
- 场景:
ChildViewModel有一个onDelete闭包,ParentViewModel初始化它时在闭包里引用了自己。 - 解决方案:在赋值闭包时使用捕获列表。
Swift
// ParentViewModel 中
childViewModel.onDelete = { [weak self] in
self?.handleDeletion()
}
5. 识别与调试工具
如果你发现 App 运行越来越卡,或者内存占用只升不降,请按以下步骤操作:
- 析构检查:在 ViewModel 中加入
deinit { print("ViewModel released") }。 - Memory Graph Debugger:在 Xcode 中运行项目,点击底部的“三圆连线”图标,寻找带有紫色警告的类,观察引用环。
- Instruments:使用 Leaks 模板,在 App 导航切换时观察是否有红色柱状图出现。
总结指南
- Struct View:本身无引用计数,放心使用。
- ViewModel (Class) :它是引用的中心,所有逃逸闭包必须检查
[weak self]。 - @escaping 闭包:只要闭包被存储(赋值给属性),就必须考虑循环引用。
英文版
8-20. [Memory Management] How to Avoid Strong Reference Cycles in SwiftUI ViewModels and Views?
In SwiftUI development, although the declarative View is a Struct (which is a value type and does not cause reference cycles itself), ViewModels (usually Classes) and asynchronous closures are high-risk areas for memory leaks.
Here are the core scenarios and countermeasures for avoiding reference cycles in the SwiftUI architecture:
1. Closure Capture inside ViewModels
This is the most common scenario. ViewModels (whether using @Observable or ObservableObject) often hold callback closures for network requests or timers.
-
The Danger: The ViewModel holds the closure, and the closure internally holds a strong reference to self (the ViewModel).
-
The Solution: Use [weak self] in all escaping closures within the ViewModel.
@Observable
class UserViewModel {
var name = "Guest"
func updateProfile() {
// Simulate an asynchronous request
NetworkManager.shared.fetch { [weak self] newName in
// Use guard to ensure self still exists, while promoting it to a strong reference for logic handling
guard let self = self else { return }
self.name = newName
}
}
}
2. Closures in .task or .onAppear****
While a SwiftUI View is a value type, you still need to be mindful of ownership when capturing a ViewModel inside a view's closure.
-
Mechanism: SwiftUI's .task modifier is quite intelligent; it automatically cancels the task when the view disappears. However, if the task triggers a long-running closure that fails to check for cancellation, references might persist.
-
Countermeasure: Since View is rebuilt frequently, avoid capturing a local strong reference to the ViewModel inside a View's closure; instead, access it directly through the property.
struct ProfileView: View {
@State private var viewModel = UserViewModel()
var body: some View {
Text(viewModel.name)
.task {
// .task automatically cancels on destruction, usually making this safe
await viewModel.loadData()
}
}
}
3. Reference Cycles in Combine Subscriptions
If you use Combine in your ViewModel to listen to data streams, the sink closure is a significant leak hazard.
-
The Danger: The AnyCancellable set is stored in the ViewModel, and the sink closure captures self.
-
The Solution: Always use [weak self] inside sink.
class SearchViewModel: ObservableObject {
@Published var searchText = ""
private var cancellables = Set()
init() {
$searchText
.debounce(for: .seconds(0.5), scheduler: RunLoop.main)
.sink { [weak self] text in
self?.performSearch(text)
}
.store(in: &cancellables)
}
}
4. Cross-Component Delegates or Callbacks
When one ViewModel needs to provide a callback to a parent ViewModel.
-
Scenario: ChildViewModel has an onDelete closure. The ParentViewModel references itself inside that closure when initializing the child.
-
The Solution: Use a capture list when assigning the closure.
// Inside ParentViewModel
childViewModel.onDelete = { [weak self] in
self?.handleDeletion()
}
5. Identification and Debugging Tools
If you find the App becoming laggy or memory usage continuously rising, follow these steps:
-
Deinit Check: Add deinit { print("ViewModel released") } to your ViewModel.
-
Memory Graph Debugger: Run the project in Xcode, click the "three connected circles" icon at the bottom, look for classes with purple warnings, and inspect the reference rings.
-
Instruments: Use the Leaks template and observe if any red bars appear during App navigation transitions.
Summary Guide
- Struct View: Has no reference count; use it freely.
- ViewModel (Class) : The center of references; all escaping closures must check for [weak self].
- @escaping Closures: Whenever a closure is stored (assigned to a property), you must consider the possibility of a strong reference cycle.