8-18.【内存管理机制】典型循环引用场景有哪些?(例如 delegate、闭包、双向引用)

31 阅读5分钟

在 Swift 开发中,循环引用(Strong Reference Cycles)通常出现在那些需要长期持有引用或者定义了双向关系的场景中。

以下是四大典型的循环引用场景及其底层成因分析:

1. 代理模式 (Delegate) —— 最经典的场景

这是 iOS 开发中最常见的内存泄漏点。

  • 场景描述ViewController 持有一个 UITableView,而 UITableViewdelegate 属性又指回了 ViewController

  • 成因:如果 delegate 属性在内部声明为 strong(默认情况),则 VC -> TableView -> VC 形成闭环。

  • 解决方案:将代理属性声明为 weak

    Swift

    protocol MyDelegate: AnyObject { }
    class MyView {
        weak var delegate: MyDelegate? // 必须使用 weak
    }
    

2. 逃逸闭包 (Escaping Closures) —— 最隐蔽的场景

闭包是引用类型,它会捕获并持有其内部使用的所有外部对象。

  • 场景描述:类实例持有一个闭包作为属性(例如用于回调),而闭包内部使用了 self

  • 成因self 强持有 closureclosure 强捕获 self

  • 典型案例:网络请求封装、自定义 View 的点击事件回调、或者是存储在数组里的动画 Block。

  • 解决方案:使用捕获列表 [weak self]

    Swift

    class NetworkManager {
        var onComplete: (() -> Void)?
        func fetchData() {
            onComplete = { [weak self] in // 避免强捕获 self
                self?.updateUI()
            }
        }
    }
    

3. 双向强引用 (Parent-Child Relationships) —— 逻辑层面的场景

当两个数据模型对象之间存在逻辑上的“所属”和“反向关联”时。

  • 场景描述

    • Teacher 类有一个学生列表 [Student]
    • Student 类有一个老师属性 var teacher: Teacher
  • 成因:老师强引用学生,学生也强引用老师。

  • 解决方案:根据逻辑上的生命周期,将一方设为 weakunowned。通常“子”对象对“父”对象的引用应为 weak

4. 计时器与观察者 (Timer & Observer) —— 系统层面的场景

这些场景的循环引用往往不是两个自定义对象之间,而是 系统组件 <-> 你的对象 之间。

  • Timer (NSTimer)

    • 问题RunLoop 强持有 Timer,而 Timer 默认强持有它的 target(通常是 self)。只要 Timer 不停止,self 就永远无法释放。
    • 解决:使用 Swift 5 之后提供的闭包版 Timer,并在闭包内使用 [weak self];或者在页面消失时手动调用 invalidate()
  • NotificationCenter (Block-based)

    • 问题:使用 addObserver(forName:...) 返回的观察者对象会被强持有,且闭包内部如果强捕获了 self,也会导致泄漏。
    • 解决:存储返回的观察者对象,并在 deinit 时移除,同时闭包内使用 [weak self]

5. 多层级容器 (Collection Types)

  • 场景描述:将 self 放入一个由 self 持有的数组或字典中。
  • 成因:数组会强持有其内部的所有元素。如果数组是 self 的属性,则形成了 self -> Array -> self

如何快速识别这些场景?

当你怀疑有循环引用时,可以问自己三个问题:

  1. 我是否在类的属性里存储了一个闭包? (如果是,检查闭包里有没有 self
  2. 我是否有两个类互相指向对方? (如果是,检查是否有一方是 weak
  3. 这个对象在页面退出后,deinit 方法执行了吗? (如果没有,说明上述某个连线断不掉)

英文版

8-18. [Memory Management] What are the Typical Strong Reference Cycle Scenarios? (e.g., Delegates, Closures, Bi-directional References)

In Swift development, strong reference cycles (Retain Cycles) typically appear in scenarios requiring long-term reference retention or where bi-directional relationships are defined.

Here are the four most typical scenarios and an analysis of their underlying causes:


1. Delegate Pattern — The Most Classic Scenario

This is the most common memory leak point in iOS development.

  • Scenario: A ViewController holds a UITableView, and the UITableView's delegate property points back to the ViewController.
  • Cause: If the delegate property is declared as strong (the default), a closed loop of VC -> TableView -> VC is formed.
  • Solution: Declare the delegate property as weak.

Swift

protocol MyDelegate: AnyObject { }

class MyView {
    weak var delegate: MyDelegate? // Must use weak to break the cycle
}

2. Escaping Closures — The Most Subtle Scenario

Closures are reference types; they capture and hold all external objects used within them.

  • Scenario: A class instance holds a closure as a property (e.g., for a callback), and the closure uses self internally.
  • Cause: self strongly holds the closure, and the closure strongly captures self.
  • Typical Cases: Network request wrappers, custom View click event callbacks, or animation blocks stored in an array.
  • Solution: Use a capture list [weak self].

Swift

class NetworkManager {
    var onComplete: (() -> Void)?
    
    func fetchData() {
        onComplete = { [weak self] in // Avoid strong capture of self
            self?.updateUI()
        }
    }
}

3. Bi-directional Strong References (Parent-Child Relationships)

This occurs when two data model objects have a logical "ownership" and "reverse association."

  • Scenario:

    • A Teacher class has a list of students: [Student].
    • A Student class has a teacher property: var teacher: Teacher.
  • Cause: The teacher strongly references the students, and the students strongly reference the teacher.

  • Solution: Based on the logical lifecycle, set one side to weak or unowned. Usually, the "child" object's reference to the "parent" should be weak.


4. Timers and Observers — System-Level Scenarios

In these cases, the cycle isn't between two custom objects, but between a System Component <-> Your Object.

  • Timer (NSTimer) :

    • The Issue: The RunLoop strongly holds the Timer, and the Timer defaults to strongly holding its target (usually self). As long as the Timer doesn't stop, self can never be released.
    • The Fix: Use the closure-based Timer provided since Swift 5 with [weak self], or manually call invalidate() when the page disappears.
  • NotificationCenter (Block-based) :

    • The Issue: The observer object returned by addObserver(forName:...) is strongly held. If the closure strongly captures self, a leak occurs.
    • The Fix: Store the returned observer object, remove it in deinit, and use [weak self] inside the closure.

5. Multi-level Containers (Collection Types)

  • Scenario: Placing self into an array or dictionary that is itself held by self.
  • Cause: Arrays strongly hold all their internal elements. If the array is a property of self, it forms self -> Array -> self.

How to Quickly Identify These Scenarios?

When you suspect a strong reference cycle, ask yourself these three questions:

  1. Am I storing a closure in a class property? (If yes, check if the closure contains self).
  2. Do I have two classes pointing at each other? (If yes, check if one side is weak).
  3. Does the deinit method execute after the page is dismissed? (If not, one of the connections above is failing to break).