[譯]理解閉包中的記憶體洩漏

IsaacPan發表於2019-03-01

在初學者階段,開發過程中你甚至不知道會有記憶體洩漏的問題,完全忽略了他們,以至於最後發現程式碼中到處都有這種問題,一籌莫展。

所以現在我們來深入理解一下記憶體洩漏什麼時候會出現,以及用什麼工具來避免它們。

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,在他對本文主題持有深刻理解的幫助下,我完成了這篇文章。

相關文章