記憶體洩漏在 iOS 中是永恆的話題,如果你在開發過程中不小心對待的話,那麼總有一天他會以 Crash 的形式提醒你它的存在。記憶體洩漏不僅破壞使用者體驗,而且會影響效能甚至應用的安全。既然記憶體洩漏如此的重要,所以這篇文章在這篇文章將說一說 Swift 閉包中的記憶體洩漏問題。
Apple 在文章中詳細介紹了迴圈強引用的概念、何為記憶體洩漏、如何避免。但是文章中的例項太過於簡單,在真正的應用過程中情況遠比這個複雜,接下來的內容就是介紹其中最為複雜的閉包中的洩露分析。
閉包中的引用
首先,我們需要清楚的理解閉包的概念:閉包是自包含的函式程式碼塊,可以在程式碼中被傳遞和使用。簡單來說:閉包是一段可執行的程式碼塊並且它能自動捕獲上下文的變數和常量,然後在需要的時候被執行。詳細內容可參見地址。
我們從這個簡單的例項開始:ViewController 中有一個 CustomView 型別的成員屬性變數,同時 CustomView 有一個點選事件的閉包函式 onTap :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class CustomView:UIView{ var onTap:(()->Void)? ... } class ViewController:UIViewController{ let customView = CustomView() var buttonClicked = false func setupCustomView(){ var timesTapped = 0 customView.onTap = { _ in timesTapped += 1 print("button tapped \(timesTapped) times") self.buttonClicked = true } } } |
在給閉包函式 onTap 賦值的語句中我們對 buttonClicked 進行了賦值,這就導致了對 self 的強引用。但是我們仔細思考後就不難發現其中的問題: self 引用了 customView 變數,然後 customView 變數的飲用了 onTap 閉包,最後 onTap 閉包引用了 self 。其結果類似下圖:
上圖中你能清晰的看見迴圈結構,這導致程式退出的時候不能正常的銷燬記憶體導致記憶體洩漏的發生。
隱藏的迴圈
除了上面那種明顯的迴圈引用有些閉環隱藏的更深也更隱蔽。解決這個問題的關鍵就是:在對閉包賦值的時候問自己誰是閉包的擁有者,然後向上溯源到根節點。
下面我們來看最常見 UITableView 中隱藏的迴圈(最常見的往往越容易被忽略)。一般情況下我們都是在 UIViewController 中新建 UITableView 例項少數情況下也會使用 UITableViewController ,但是不管哪種情形我們都會新建自定義的 UITableViewCell 。
下面的程式碼中我們新建了一個名為 CustomCell 的 UITableViewCell 子類,該類中包含了一個 UIButton例項屬性以及按鍵點選事件的閉包屬性 onButtonTap。
1 2 3 4 5 6 7 8 9 |
class CustomCell: UITableViewCell { @IBOutlet weak var customButton: UIButton! var onButtonTap:(()->Void)? @IBAction func buttonTap(){ onButtonTap?() } } |
然後我們在 ViewController 對該閉包賦值:
1 2 3 4 5 6 7 8 9 10 |
class ViewController: UITableViewController { ... override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "CustomCell", for: indexPath) as! CustomCell cell.onButtonTap = { _ in self.navigationController?.pushViewController(NewViewController(), animated: true) } } } |
這裡我們對 onButtonTap閉包進行溯源:誰擁有該閉包?毫無疑問是 CustomCell類的例項 cell。而 cell 又是屬於 tableView,tableView又屬於 self 所代表的UITableViewController 例項。
正如下圖表現的那樣,這裡也有一個迴圈引用,只不過分析路線更長所以顯得更隱蔽。
GCD 中的閉包分析
如果你以前用過 GCD 的話,那麼你能一眼判斷下面程式碼是否有迴圈引用。
1 2 3 4 5 6 |
override func viewDidLoad() { super.viewDidLoad() DispatchQueue.main.asyncAfter(deadline: .now() + 2) { self.navigationController?.pushViewController(NewViewController()) } } |
同樣的使用溯源法分析閉包:擁有閉包的物件是 DispatchQueue 單例,該單例並不被 ViewController 任何屬性所引用,但DispatchQueue 單例的閉包中卻持有了 self。雖然我們不知道該單例的具體實現,但是我們清楚該非同步閉包會在2s後被執行一次,執行完成之後該閉包就會釋放對 self 的引用。所以我們由此可以斷定這段閉包程式碼是不存在迴圈引用問題的。
這部分的程式碼邏輯和分析同樣適用於 UIView 的動畫閉包函式中
Alamofire 中的閉包
Alamofire 可以說是 Swift 網路處理中最常用的第三方庫了,其中的請求處理中同樣涉及到閉包函式。下面這段程式碼是請求登陸介面:
1 2 3 4 5 6 7 |
Alamofire.request("https://yourapi.com/login", method: .post, parameters: ["email":"test@gmail.com","password":"1234"]).responseJSON { (response:DataResponse) in if response.response?.statusCode == 200 { self.navigationController?.pushViewController(NewViewController(), animated: true) } else { //Show alert } } |
上訴程式碼中的閉包又是屬於哪個物件?這裡我們需要深入 Alamofire 的實現中去探尋。首先 request方法會返回一個 DataRequest型別物件,而該物件的 responseJSON方法中將閉包作為引數 completionHandler傳入,最後該閉包存入了 OperationQueue 型別的佇列 queue 中,閉包執行完成後會自動從佇列中移除。由此我們可知:閉包被 queue所持有並且一次執行後就移除了,此處不存在迴圈引用。
迴圈引用的解決
為了打破迴圈引用帶來的記憶體洩漏問題,根本途徑就是破壞該迴圈,將某個物件對另一個物件的強引用去除。在閉包環境的迴圈問題,我們都傾向於將閉包中的強引用去除,畢竟這簡單而且看起來更直觀。
為了實現該目的,我們在閉包捕獲的上下文變數中做文章。我們使用關鍵詞 weak、unowned 來打破迴圈。例如上文中提到的 UITableView :
1 2 3 |
cell.onButtonTap = { [unowned self] in self.navigationController?.pushViewController(NewViewController(), animated: true) } |
上訴兩個關鍵詞存在著明顯的區別 weak 是可選值而 unowned 則一定不為可選值,換句話說 weak 關鍵詞所指物件可能為 nil 而 unowned 則一定不能是 nil,因此在選用的時候需要認真考慮一下。一般來說如果閉包生命週期不長於其捕獲的上下文變數的生命週期我們會使用 unowned,否則我們選擇 weak 。
記憶體洩漏的除錯
上面我們分析了大部分閉包中的迴圈引用問題,我們得知並不是所有的情況下都會導致記憶體洩漏。如果在我們使用了第三方庫尤其是一些私有實現庫的情況下,這部分的分析在程式碼層面將變的很困難並且工作量很大。好在Xcode為我們提供的除錯工具,在工程執行的情況下,我們在除錯區域可以找到如下圖所示按鍵:
在 UITableView 的示例中,如果我們移除閉包中的 unowned 或者 weak 的話,你就能在左側看見下圖
上圖中的左側感嘆號表明了這裡存在著記憶體洩漏的情況,這樣你就要去檢視程式碼了。當然你又記憶體洩漏但是沒有感嘆號標記的情況也是完全有可能的,此時你就要啟用記憶體分析工具了並且分析記憶體中的物件,這些物件是否應該存在。