Swift闭包的上下文捕获

1,645 阅读6分钟

起因

来水一篇文章。。。事情的起因是笔者在开发自测是发现了一处内存泄漏。排查到最后发现有一处用RxSwift实现的代码对ViewController强引用了,导致了循环引用。代码的写法与下列代码类似:

class ViewController: UIViewController {

  private let a: PublishSubject<Int> = PublishSubject<Int>.init()
  private let bag: DisposeBag = DisposeBag.init()
  
  override func viewDidLoad() {
      super.viewDidLoad()
      // Do any additional setup after loading the view.
      a.asObserver().subscribe { event in
          print(event.element ?? 0)
          
          DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) { [weak self] in
              print((self?.description ?? "") + "asyncAfter")
          }
      }.disposed(by: bag)
  }
  
  deinit {
      print(self.description + "deinit")
  }
}

image.png

最终形成了一个环。解决的方法就是可以在subscribe方法的闭包上声明[weak self]的捕获。由于代码的时间比较长,有可能是当时认为该闭包没有使用到self,所以就没有特意声明。这个也可以说是笔者犯的一个低级错误了,也借此机会填补一下闭包上下文捕获的知识盲区。

所以本文将记录:

  • Swift闭包的上下文捕获的逻辑
  • 以此衍生出是否什么闭包都需要[weak self]

问题解析

分析

a.asObserver().subscribe { event in
      print(event.element ?? 0)

      DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) { [weak self] in
          print((self?.description ?? "") + "asyncAfter")
      }
}.disposed(by: bag)

先解决笔者遇到的这个问题,代码中涉及到两个闭包的嵌套。造成泄漏的点在于DispatchQueue.main.asyncAfter中的闭包有对于self(ViewController实例)的捕获,导致了外层的subscribe中的闭包也需要捕获selfself在外层闭包并没有声明weak,导致了该闭包对于self的引用是强引用的。

  • ObservableType#subscribe的闭包为逃逸闭包 image.png
  • GCD的DispatchQueue#asyncAfter的闭包也是逃逸闭包 image.png

所以就导致了闭包与self间接形成循环引用的问题。

验证

通过对a发送一个信号a.onNext(1)触发上述的闭包。在subscribe的闭包处打上断点 image.png 此时,闭包内可访问的变量有:

  • 闭包的参数event
  • self image.png

那么如果将代码更改为:

a.asObserver().subscribe { event in
  print(event.element ?? 0)
  
  DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) { //[weak self] in
    //    print((self?.description ?? "") + "asyncAfter")
      print("asyncAfter")
  }
}.disposed(by: bag)

然后还是发送一个信号a.onNext(1)触发上述的闭包。在subscribe的闭包处打上断点。

此时闭包内可访问的变量就只剩下闭包的参数event

image.png

这样就说明,是由于GCDDispatchQueue#asyncAfter的闭包需要用到self,从而导致外层的闭包也需要隐式的捕获self。最终导致上述那个非常对称的环产生。

闭包的上下文捕获

Swift 闭包显式捕获

Swift 闭包隐式捕获

隐式捕获

笔者遇到的问题,其实就是一个隐式捕获的问题。这里借用文档中的例子,在incrementer闭包调用时打上断点。

func makeIncrementer(forIncrement amount: Int) -> () -> Int {
  var runningTotal = 0
  func incrementer() -> Int {
      runningTotal += amount
      return runningTotal
  }
  return incrementer
}

image.png

闭包中可访问的变量makeIncrementer方法中的runningTotalamount。说明它隐式捕获的这两个变量

造成笔者遇到的问题,其实还有一个前提:闭包需要是逃逸闭包。有以下代码:

private let b: Int = 1;

make {
  print(b)
}

func make(closure: () -> Void) {
  closure()
}
  • 变量b为该类的全局变量
  • make方法中传入闭包closureclosure非逃逸闭包

closure闭包内的print(b)其实与print(self.b)等价。原因是因为闭包已经隐式捕获的self。如果对上述代码稍作修改

private let b: Int = 1;

make {
  print(b)
}

func make(closure: @escaping () -> Void) {
  closure()
}

就会报错:Reference to property 'b' in closure requires explicit use of 'self' to make capture semantics explicit。大概意思就是,需要使用self.b的写法来明确捕获语义

make { 
  print(self.b)
}

需要明确的是,不管是逃逸还是非逃逸,在这种写法下都是隐式捕获了self,只是编译器在逃逸闭包上作了约束,必须显示的使用self,目的应该是为了规范开发者避免循环引用的发生Swift的官方文档中也有类似的描述。

image.png

显式捕获

显式捕获很好理解,就比如经常接触的[weak self]。当然,显式捕获可以选择您在闭包中使用到的变量(不一定是self,变量一般为全局变量。还是上述的例子,可以修改为

private let b: Int = 1;

make { [b] in
  print(b)
}

func make(closure: @escaping () -> Void) {
  closure()
}

这样closure闭包就只会捕获到变量b,而不会捕获到self,除非您在闭包中使用了self,那就又是一个隐式捕获了。

说回最常见的[weak self]。无论是显式捕获还是隐式捕获以下两种写法是等价的:

// 隐式捕获
make { 
  print(self.b)
}

// 显式捕获
make { [self] in
  print(self.b)
}

[weak self]的意义是显式捕获一个弱引用的self,以此来保证不会因为闭包对于self的持有形成的循环引用而无法正常回收。(ps:注意,这里说的是循环引用,后面会在什么情况下使用[weak self]聊到这个事情)。

除了[weak self]还有一个[unowned self],但是这个是一个比较危险的用法。可以参考:

[译]Swift中的weak self和unowned self

什么闭包都需要[weak self]吗?

其实这种做法是错误的,上述聊到逃逸闭包的时候,出现了一个编译器要求我们显式调用self的报错。所以事情应该是这样的:

  • 由于闭包被声明为逃逸,所以它有被某个对象保存为全局变量的可能。(ps:注意,这里只是可能)。
  • 无论是隐式捕获还是显式,实际上也是捕获了,比如捕获了self。也就是闭包持有了该对象的引用
  • 由于捕获的对象不一定是self,所以不一定是声明[weak self]可以是[weak abc]
  • 造成内存泄漏的罪魁祸首还是循环引用,所以只要闭包和self(或者self中的其他全局变量)的引用没有形成环,就不会存在泄漏。

基于以上的总结,[weak self]这种写法,其实只是为了避免逃逸闭包作为了一个对象(如:self)的全局变量,而闭包本身又捕获了(持用了)该对象这种循环引用问题

做一个合格的非逃逸闭包

上述已经分析了逃逸闭包在使用到self(或其他全局变量)时,需要声明[weak xxx]的原因。那是不是就意味着非逃逸闭包就没有这种问题呢?答案是否定的。原因是因为需要看闭包的使用情况如果非逃逸闭包后续会传递给逃逸闭包的话,那么还是存在一样的情况。

let d = {
  print(self.b)
}
make(closure: d)

func make(closure: @escaping () -> Void) {

}

上面的代码,闭包d不是一个合格的非逃逸闭包

总结

本文主要通过笔者遇到的一次内存泄漏,引申出Swift闭包的上下文捕获问题。简单介绍了闭包的隐式捕获显式捕获[weak self]不适合所有情况,只有是闭包被作为了全局变量与对象相互持有了引用的情况下,才需要显式使用[weak self]来解决循环引用的问题。当然,[weak self]不一定是self,可以按需缩小需要捕获的范围,优化对象的释放问题。

参考文章:

关于 Swift 闭包上下文捕获的官方文档导读

Swift 闭包无脑加 [weak self] 行不行?