[译]理解闭包中的内存泄漏

4,169 阅读7分钟

在初学者阶段,开发过程中你甚至不知道会有内存泄漏的问题,完全忽略了他们,以至于最后发现代码中到处都有这种问题,一筹莫展。

所以现在我们来深入理解一下内存泄漏什么时候会出现,以及用什么工具来避免它们。

Apple写了一篇关于类之间的强引用和循环引用的不错的文章,清晰易懂地解释了什么是内存泄漏,以及在一些情况下如何避免它们。但是文章讲述的只是不常出现的情况,并且也很容易辨认出来,关于闭包的部分写的还是很困惑,所以让我们来彻底搞清楚这个问题。

闭包中的循环引用

首先,你得明白什么是闭包,它是干什么的。我喜欢把它称为这样的一小段代码,当声明过后,它会创建出一个临时的类,包含所有它执行时所需要的对象的引用。

我们来从一个简单的例子开始看起:一个包含CustomView的ViewController。CustomView中声明了一个闭包,点击按钮时,执行这个闭包。

class CustomView:UIView{ 
    var onTap:(()->Void)?
    ...
}

class ViewController:UIViewController{ 
    let customView = CustomView() 
    var buttonClicked = false
    
    func setupCustomView(){
        var timesTapped = 0
        customView.onTap = {
            timesTapped += 1 
            print("button tapped \(timesTapped) times")
            self.buttonClicked = true
        }
    }
}

当传值给这个闭包时,它需要引用一些变量才能执行。在这里,selftimesTapped这两个闭包外的变量被引用了。为了确保这些变量在执行时能够使用,闭包会强引用它们,这样它们就不会在使用前被释放,以至于崩溃。

但是仔细看看,ViewController强引用了CustomView,CustomView强引用了onTap这个闭包,而这个闭包却强引用了self 所以这时候的引用关系成了这样:

从图中我们可以清楚地看到循环引用。这意味着当你退出这个view controller时,它不会从内存中释放掉,因为它仍然被这个闭包所引用。

这个例子十分清楚,viewController中包含一个subview属性,subview又包含一个捕获了selfonTap闭包。但不幸的是,还有更复杂的情况。

潜在的循环引用

有一个问题需要你不断地提醒自己:谁持有了这个闭包?

UITableView

如果写过iOS app,你应该已经知道了在一些情况下怎么去使用UITableView了,并且大多数时候用的还是自定义的cell和button

下面是如何使用swift来实现。首先创建一个CustomCell,这个cell定义了一个用于执行点击事件的闭包:

class CustomCell: UITableViewCell {
  
  @IBOutlet weak var customButton: UIButton!
  var onButtonTap:(()->Void)?
    
  @IBAction func buttonTap(){
      onButtonTap?()
  }
}

然后在ViewController中去实现这个闭包的功能:

class TableViewController: UITableViewController {

  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
      let cell = tableView.dequeueReusableCell(withIdentifier: "CustomCell", for: indexPath) as! CustomCell
      cell.onButtonTap = {
          self.navigationController?.pushViewController(NewViewController(), animated: true)
      }
  }
}

谁持有了这个闭包?由于我们在CustomCell中明确声明了,所以在这里,我们清楚地知道是这个cell,并且tableView持有了cell,tableViewController也持有了tableView。

如下图所示,循环引用又出现了。并且如果你之前没有碰到这样的情况,这次的循环引用更难被发现。

GCD

相信你之前已经用到过GCD了,你清楚下面代码中是否有循环引用吗?

override func viewDidLoad() {
  super.viewDidLoad()
  DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    self.navigationController?.pushViewController(NewViewController())
  }
}

首先我们来搞清楚,谁持有了这个闭包?ViewController并没有任何相关的属性,它只是在一个DispatchQueue的单例中被调用了。所以这时,最坏的情况发生了,DispatchQueue的单例在调用asyncAfter方法时持有了它。遗憾的是我们不能看到这个方法具体的实现,但是,这个闭包只会执行一次,并且是在一个事先明确的时间执行,这个单例没有理由保留这个引用关系。在这种情况下,当闭包执行完毕后,对self的引用就结束了,而self又没有引用这个闭包,因此,并没有循环引用。请注意,使用UIView.animate(){}闭包实现动画同样适用这个逻辑。

Alamofire

我们来看看这种情况,我们需要实现一个App,有一个LoginViewController,我们需要用Alamofire来与服务器交互数据:

Alamofire.request("https://yourapi.com/login", method: .post, parameters: ["email":"test@gmail.com","password":"1234"]).responseJSON { (response:DataResponse<Any>) in
    if response.response?.statusCode == 200 {
        self.navigationController?.pushViewController(NewViewController(), animated: true)
    }else{
        //Show alert
    }
}

谁持有了这个闭包?在这里,闭包是作为request函数的参数声明的,但是你并不知道Alamofire在闭包中做了什么,也不知道闭包什么时候被释放的。

如果你去深究一下实现的原理,你就能了解到,request方法有一个操作队列queue。当response()方法被调用时,我们会把这个闭包放入queue中,当闭包执行完毕时,闭包就会从queue中移除。所以,在这里并没有循环引用发生,因为只有queue保留了闭包,但一旦执行完毕,闭包就立刻被释放了。

注意,即使你保留了request的引用,或者引用SessionManager,闭包也会被释放掉,不会有任何循环引用。

RxSwift

在这个例子里,你需要实现一个UISearchBar,当你改变searchBar中的文字时,label同时改变:

class ViewController: UIViewController {
  
  @IBOutlet weak var searchBar: UISearchBar!
  @IBOutlet weak var label: UILabel!
  
  override func viewDidLoad() {
    searchBar.rx.text.throttle(0.2, scheduler: MainScheduler.instance).subscribe(onNext: {(searchText) in
      self.label.text = "new value: \(searchText)"
    }).addDisposableTo(bag)
  }
}

谁持有了这个闭包?这个闭包能被多次调用,并且我们也不知道什么时候被调用,所以RxSwift需要保持对闭包的引用。在这种情况下闭包实际上是被searchBar直接持有的,因为当searchBar被释放时,闭包也一定要被释放。但是仔细看看,self 持有了searchBar,闭包又引用了self。所以在这里是存在循环引用的,我们需要打破这个引用以防内存泄漏。

打破循环引用

要打破循环引用,你只需要破坏其中的一个引用关系即可,我们当然要选择最简单的。当处理闭包问题时,我们期望能打破其中最后一个连接,那就是闭包的引用。

要实现这一点,你需要明确,当你的闭包捕获外部变量时,最好不要是一个强引用关系。你可以有两个选择,在闭包头部使用 weak 或者 unowned关键字。

例如,在上面的UITableView例子中:

cell.onButtonTap = { [unowned self] in
    self.navigationController?.pushViewController(NewViewController(), animated: true)
}

是用weak 还是 unowned呢?这有一点复杂。通常来说,当闭包不会比它所捕获的变量存在得久时,你需要使用unowned。在上面的例子中,cell和闭包不会比tableViewController更“持久”,所以我们可以使用unowned。如果你想知道更多关于weakunowned的用法,我推荐阅读一下这些非常棒的文章

Unowned or Weak? Lifetime and Performance

"WEAK, STRONG, UNOWNED, OH MY!" - A GUIDE TO REFERENCES IN SWIFT

内存泄漏的调试

有时候,你会发现想要搞清楚闭包是否被引用是很困难的,特别是你使用了第三方的一些库或者一些私有声明的时候。所以,你需要通过调试来找出循环引用。Xcode提供了非常好用的工具来帮助你找到内存泄漏。打开你App的工程,点击Xcode底部如下图所示的小图标,你就能查看内存情况。

还是上面的TableView例子中,如果在闭包onButtonTap中你不使用weak或者unowned关键字,你就会发现下图所示的情况:

右侧的感叹号代表发生了内存泄漏。但有时候,Xcode并不能正常地发现泄漏,泄漏真实存在但并没有被发现是完全有可能的。在这种情况下,你只需要关注内存中有哪些东西,如果你发现了一些本不应该存在的东西,很有可能就发生了泄漏。

这篇文章能帮助到你更加深刻的理解在闭包中内存泄漏时如何发生的,希望你能喜欢。如果有任何疑问或者反馈,尽情写下来吧!

特别感谢Rémy Virin,在他对本文主题持有深刻理解的帮助下,我完成了这篇文章。