weak self 的黄金法则

893 阅读7分钟

原文:The Golden Rules of weak self

在闭包中捕获 self 是 Swift 中常见的事情,并且隐藏了很多细微差别。你是否需要使其变 weak 以避免引用循环?让它始终保持 weak 存在问题吗?

上周的 iOS Dev Weekly 刊登了一篇由 Benoit Pasquier 撰写的关于在闭包中捕获 self 的自以为是的文章。这篇文章将与之相矛盾。没关系!对所有这些建议持保留态度,了解权衡取舍,然后选择最适合你的技术。

好的,让我们开始吧。

三个黄金法则

关于引用循环的推断很难。当我教人们使用 weak self(或捕获列表😀)来避免内存泄漏时,我介绍了三个黄金法则:

  1. 强引用的 self 并不总是会导致引用循环;
  2. 弱引用的 self 永远不会导致引用循环;
  3. 在闭包顶部将 self 升级为 strong 以避免奇怪的行为;

让我们看看这些规则的实际应用。

引用循环示例

引用循环是指一个对象保留引用了它自己。在这里,子类保留引用其父类的闭包,从而导致引用循环:

class Parent {
    let child = Child()
    var didChildPlay = false
    // parent.playChildLater() -> child.playLater() -> self
    func playChildLater() {
        child.playLater {
            self.didChildPlay = true
        }
    }
}

class Child {
    var finishedPlaying: () -> Void = {}
    func playLater(completion: @escaping () -> Void) {
        finishedPlaying = completion
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
            // Play! ⚽️🏀🥏
            completion() // 这是一个逃逸闭包!
        }
    }
}

let parent = Parent()
parent.playChildLater()
// `parent` is no longer used, but not recycled.

这个循环可以被打破。如果 finishedPlaying 在调用后被重新分配给一个空闭包,循环将被打破,内存将被回收。这需要我们有很多意识,要非常小心何时会发生以及何时消除它。

规则 1:Strong self 并不总是存在引用循环

虽然将强引用的 self 传递给闭包是一种非常好的意外创建引用循环的方法,但这并不能保证一定存在。实际上,编译器是在试图帮助我们正确地使用内存。它区分了逃逸(escaping)和非逃逸(non-escaping)闭包。

逃逸与非逃逸闭包

你可能已经编写了一个采用闭包的方法,只是被编译器大喊大叫:

Error: Assigning non-escaping parameter ‘closure’ to an @escaping closure. 错误:将非逃逸参数 “closure” 分配给 @escaping 闭包。

如果你的方法的闭包参数的生命周期超出了方法的生命周期,则编译器需要此注释。换句话说,它是否逃离了函数的大括号?

如果没有,那么当方法返回时,我们知道没有什么可以保留该方法。如果没有任何东西可以拥有那个闭包,那么它就不会成为引用循环的一部分,无论它强捕获什么。换句话说,在非 @escaping 闭包中使用 strong self 总是安全的

一个非逃逸子方法

让我们看看上面代码的实际效果。如果我们可以保证在方法返回之前我们将完成方法,我们可以删除 @escaping 注释。让我们写一个非逃逸(non-escaping)的 play(completion:) 方法:

extesion Child {
    func play(completion: () -> Void) {
        // 🏓
        completion()
    }
}

使用 Parent 的这个方法,我们可以看到它的实际效果:

extension Parent {
    func playChild() {
        child.play {
            self.didChildPlay = true
        }
    }
}

更好的是,我们不需要指定 self 关键字。闭包仍然捕获 self,但编译器知道它不会创建引用循环,因此不需要显式指定它:

extension Parent {
     func playChild() {
         child.play {
-            self.didChildPlay = true
+            didChildPlay = true
         }
     }
 }

这是编译器帮助我们的方式之一。如果我们不用写 self. 就能过关,那么我们可以确定这个闭包不会作为引用循环的一部分保留。

规则 2:Weak self 永远不会导致引用循环

也许你更喜欢看到 self.,即使不需要它,也可以使捕获语义明确。现在你需要决定是否应该使用 weak 捕获 self。第一条黄金法则的真正问题是:你确定那个方法中的闭包不是 @escaping 的吗?

  • 你是否检查了你创建的每个闭包的文档?
  • 你确定文档与实施相符吗?
  • 你确定更新依赖项时实现没有改变吗?

如果这些问题中的任何一个埋下了怀疑的种子,你就会明白为什么在任何使用闭包的地方使用 [weak self] 的技术如此流行。让我们在 playLater(completion:) 方法中使用 weak self

class Parent {
    // ...
    func playChildLater() {
        child.playLater { [weak self] in
            self?.didChildPlay = true
        }
    }
}

这个闭包是如何传递、保留的,或者它是否是 @escaping 都无关紧要。该闭包没有捕获对 Parent 类的强引用,因此我们确信它不会创建引用循环。

规则 3:升级 self 以避免奇怪的行为

如果我们遵循第二条规则,那么我们将不得不在任何地方都与大量 weak self 打交道。这会变得很麻烦。标准建议是使用 guard let 语句在闭包顶部将 self 升级强引用,如下所示:

class Parent {
    // ...
    func playChildLater() {
        child.playLater { [weak self] in
            guard let self = self else { return }
            self.didChildPlay = true
        }
    }
}

但为什么?为什么不…

  • 使用 strongSelf 以便我可以保持弱引用?
  • 只是在我的代码中反复使用 weak self

使用 strongSelf 而不是 self

考虑以下代码:

class Parent {
    // ...
    let firstChild = Child()
    let secondChild = Child()
    func playWithChildren(completion: @escaping (Int) -> Void) {
        firstChild.playLater { [weak self] in
            guard let strongSelf = self else {
                return
            }
            strongSelf.gamesPlayed += 1
            strongSelf.secondChild.playLater {
                if let strongSelf = self {
                    print("Played \(self?.gamesPlayed ?? -1) with first child.")
                }
                strongSelf.gamesPlayed += 1
                completion(strongSelf.gamesPlayed)
            }
        }
    }
}

在这里,我们将升级后的 self 命名为 “strongSelf”,这样我们仍然可以将 weak self 传递给后面的方法。此代码有效,但增加了你必须编写的代码的复杂性。每当复杂性增加时,出现偷偷摸摸的错误的可能性就更大。

例如,你是否注意到:

  • strongSelf 不像 self 那样语法高亮,因此更难看到;
  • self?.gamesPlayed ?? -1 用于可以使用 strongSelf.gamesPlayed 的地方;
  • strongSelf 在内部闭包中被意外捕获,导致使用 weak self 的闭包中的引用循环;

😨

你可能会看到这个并想:“是的,但我不会写这样的代码。” 也许不会!你确定你的整个团队都了解这种细微差别吗?我不得不在强大的编码团队中使用 strongSelf 修复这样的错误。像这样的错误发生。为什么不让这些工具尽最大努力让找到它们变得容易呢?

我就用 self? 无处不在

假设我已经把你吓得远离了 strongSelf。考虑以下代码:

class Parent {
    // ...
    let points = 1
    let firstChild = Child()
    func awardPoints(completion: @escaping (Int) -> Void) {
        firstChild.playLater { [weak self] in
            var totalPoints = 0
            totalPoints += self?.points ?? 0 // 1️⃣
            totalPoints += self?.points ?? 0 // 2️⃣
            completion(totalPoints)
        }
    }
}

这行得通,而且完全安全,但可能会导致一些你可能意想不到的奇怪行为。

虽然 self 是弱引用,它没有增加 self 的引用计数。这意味着,在任何时候,保留 self 的对象都可以释放它。由于这是一个多线程环境,这可能发生在你的闭包中间。换句话说,任何引用 self? 的都可能是第一个为你的方法的其余部分返回 nil 的人。

结果完成可能是:

  • Called with 0 points
  • Called with 2 points
  • Called with 1 point

等等…… 什么?结果总分 1 看起来像一个错误。**当 self 在第 1️⃣ 行运行之后,但在第 2️⃣ 行运行之前变为 nil 时,就会发生这种情况。**事实上,每次访问 self? 都在你的代码中创建了一个可能的分支,在它之前存在 self,在它之后是 nil

这比我们通常想要创建的要复杂得多。通常,我们只是想避免引用循环,让闭包一直执行下去。好消息:你可以通过在闭包顶部将 self 升级为强引用来强制闭包的全有或全无。

class Parent {
    // ...
    let points = 1
    let firstChild = Child()
    func awardPoints(completion: @escaping (Int) -> Void) {
        firstChild.playLater { [weak self] in
            guard let self = self else {
                completion(0)
                return
            }
            var totalPoints = 0
            totalPoints += self.points
            totalPoints += self.points
            completion(totalPoints)
        }
    }
}

现在只有一个分支 self 可能为 nil,而且它早早地就被排除在外了。要么 self 在此闭包运行之前已经变为 nil,要么它保证在其持续时间内存在。completion 将用 2 或 0 调用,但永远不能用 1 调用。

总结

正如我所说,这东西不容易推理。如果你想尽可能少地推理,请遵循以下三个规则:

  1. 仅对非 @escaping 闭包使用强引用(理想情况下,忽略它并信任编译器);
  2. 如果你不确定,请使用 weak self
  3. 在闭包的顶部将 self 升级为强引用 self

这些规则可能是重复的,但它们会导致最安全、最容易推理的代码。而且它们很容易记住。