8-20.【内存管理机制】在 SwiftUI 的 ViewModel 和 View 中如何避免循环引用?

38 阅读2分钟

在 SwiftUI 开发中,虽然声明式视图(View)是结构体(Struct),本身不会产生循环引用,但 ViewModel(通常是 Class)异步闭包 是内存泄漏的高发区。

以下是在 SwiftUI 架构中避免循环引用的核心场景与对策:


1. ViewModel 内部的闭包捕获

这是最常见的场景。ViewModel(如使用 @ObservableObservableObject)通常持有网络请求或计时器的回调闭包。

  • 危险点: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 运行越来越卡,或者内存占用只升不降,请按以下步骤操作:

  1. 析构检查:在 ViewModel 中加入 deinit { print("ViewModel released") }
  2. Memory Graph Debugger:在 Xcode 中运行项目,点击底部的“三圆连线”图标,寻找带有紫色警告的类,观察引用环。
  3. 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:

  1. Deinit Check: Add deinit { print("ViewModel released") } to your ViewModel.

  2. 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.

  3. 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.