weak self 的黄金法则

原文: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 以避免奇怪的行为;

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

引用循环示例

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

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 调用。

总结

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

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

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

相关推荐
冰暮流星8 小时前
css之动画
前端·css
jump6808 小时前
axios
前端
spionbo8 小时前
前端解构赋值避坑指南基础到高阶深度解析技巧
前端
用户4099322502128 小时前
Vue响应式声明的API差异、底层原理与常见陷阱你都搞懂了吗
前端·ai编程·trae
开发者小天8 小时前
React中的componentWillUnmount 使用
前端·javascript·vue.js·react.js
永远的个初学者9 小时前
图片优化 上传图片压缩 npm包支持vue(react)框架开源插件 支持在线与本地
前端·vue.js·react.js
爱吃土豆的马铃薯ㅤㅤㅤㅤㅤㅤㅤㅤㅤ9 小时前
npm i / npm install 卡死不动解决方法
前端·npm·node.js
Kratzdisteln9 小时前
【Cursor _RubicsCube Diary 1】Node.js;npm;Vite
前端·npm·node.js
杰克尼9 小时前
vue_day04
前端·javascript·vue.js
明远湖之鱼10 小时前
浅入理解跨端渲染:从零实现 React DSL 跨端渲染机制
前端·react native·react.js