原文:The Golden Rules of weak self
在闭包中捕获 self
是 Swift 中常见的事情,并且隐藏了很多细微差别。你是否需要使其变 weak
以避免引用循环?让它始终保持 weak
存在问题吗?
上周的 iOS Dev Weekly 刊登了一篇由 Benoit Pasquier 撰写的关于在闭包中捕获
self
的自以为是的文章。这篇文章将与之相矛盾。没关系!对所有这些建议持保留态度,了解权衡取舍,然后选择最适合你的技术。
好的,让我们开始吧。
三个黄金法则
关于引用循环的推断很难。当我教人们使用 weak self
(或捕获列表😀)来避免内存泄漏时,我介绍了三个黄金法则:
- 强引用的
self
并不总是会导致引用循环; - 弱引用的
self
永远不会导致引用循环; - 在闭包顶部将
self
升级为strong
以避免奇怪的行为;
让我们看看这些规则的实际应用。
引用循环示例
引用循环是指一个对象保留引用了它自己。在这里,子类保留引用其父类的闭包,从而导致引用循环:
swift
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:)
方法:
swift
extesion Child {
func play(completion: () -> Void) {
// 🏓
completion()
}
}
使用 Parent
的这个方法,我们可以看到它的实际效果:
swift
extension Parent {
func playChild() {
child.play {
self.didChildPlay = true
}
}
}
更好的是,我们不需要指定 self
关键字。闭包仍然捕获 self
,但编译器知道它不会创建引用循环,因此不需要显式指定它:
swift
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
:
swift
class Parent {
// ...
func playChildLater() {
child.playLater { [weak self] in
self?.didChildPlay = true
}
}
}
这个闭包是如何传递、保留的,或者它是否是 @escaping
都无关紧要。该闭包没有捕获对 Parent
类的强引用,因此我们确信它不会创建引用循环。
规则 3:升级 self 以避免奇怪的行为
如果我们遵循第二条规则,那么我们将不得不在任何地方都与大量 weak self
打交道。这会变得很麻烦。标准建议是使用 guard let
语句在闭包顶部将 self
升级强引用,如下所示:
swift
class Parent {
// ...
func playChildLater() {
child.playLater { [weak self] in
guard let self = self else { return }
self.didChildPlay = true
}
}
}
但为什么?为什么不...
- 使用
strongSelf
以便我可以保持弱引用? - 只是在我的代码中反复使用
weak self
?
使用 strongSelf
而不是 self
考虑以下代码:
swift
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
。考虑以下代码:
swift
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
升级为强引用来强制闭包的全有或全无。
swift
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 调用。
总结
正如我所说,这东西不容易推理。如果你想尽可能少地推理,请遵循以下三个规则:
- 仅对非
@escaping
闭包使用强引用(理想情况下,忽略它并信任编译器); - 如果你不确定,请使用
weak self
; - 在闭包的顶部将
self
升级为强引用self
。
这些规则可能是重复的,但它们会导致最安全、最容易推理的代码。而且它们很容易记住。