如何在 Swift 中優雅的處理閉包導致的迴圈引用

小橘爺發表於2019-03-01

Objective-C 作為一門資歷很老的語言,新增了 Block 這個特性後深受廣大 iOS 開發者的喜愛。在 Swift 中,對應的概念叫做 Closure,即閉包。雖然更換了名字,但是概念和用法還是相似的,就算是副作用也一樣,有可能導致迴圈引用。

下面我們用一個例子看一下,首先我們需要第一個控制器(FirstViewController),它所做的就是簡單的推出第二個控制器(SecondViewController)。

class FirstViewController: UIViewController {
    
    private let button: UIButton = {
        let button = UIButton()
        button.setTitleColor(UIColor.black, for: .normal)
        button.setTitle("跳轉到 SecondViewController", for: .normal)
        button.sizeToFit()
        return button
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        button.center = view.center
        view.addSubview(button)
        button.addTarget(self, action: #selector(buttonClick), for: .touchUpInside)
    }
    
    @objc private func buttonClick() {
        let secondViewController = SecondViewController()        
        navigationController?.pushViewController(secondViewController, animated: true)
    }
}
複製程式碼

下面是 SecondViewController 的程式碼。SecondViewController 所做的事情是推出第三個控制器(ThirdViewController),不同的是,thirdViewController 是作為一個屬性存在的,同時它還有一個閉包 closure ,這是我們用來測試迴圈引用問題的。還實現了 deinit 方法,用來列印一條語句,看該控制器是否被釋放了。

class SecondViewController: UIViewController {
    
    private let thirdViewController = ThirdViewController()
    private let button: UIButton = {
        let button = UIButton()
        button.setTitleColor(UIColor.black, for: .normal)
        button.setTitle("跳轉到 ThirdViewController", for: .normal)
        button.sizeToFit()
        return button
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        button.center = view.center
        view.addSubview(button)
        button.addTarget(self, action: #selector(buttonClick), for: .touchUpInside)
    }
    
    deinit {
        print("SecondViewController-被釋放了")
    }
    
    @objc private func buttonClick() {
        thirdViewController.closure = {
            self.test()
        }
        navigationController?.pushViewController(thirdViewController, animated: true)
    }
    
    private func test() {
        print("呼叫 test 方法")
    }
}

複製程式碼

接下來我們看一下 ThirdViewController 的程式碼。在 ThirdViewController 中有一個按鈕,點選一下就會觸發閉包。同時我們還實現了 deinit 方法,用來列印一條語句,看該控制器是否被釋放了。

class ThirdViewController: UIViewController {
    
    private let button: UIButton = {
        let button = UIButton()
        button.setTitleColor(UIColor.black, for: .normal)
        button.setTitle("點選按鈕", for: .normal)
        button.sizeToFit()
        return button
    }()
    
    var closure: (() -> Void)?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        button.center = view.center
        view.addSubview(button)
        button.addTarget(self, action: #selector(buttonClick), for: .touchUpInside)
    }
    
    deinit {
        print("ThirdViewController-被釋放了")
    }
    
    @objc private func buttonClick() {
        closure?()
    }
}

複製程式碼

當我們連續推到第三個控制器,點選按鈕(觸發閉包)後,再回到第一個控制器,看一下三個控制器的生命週期。當流程走完後,發現控制檯只有一條語句:

呼叫 test 方法
複製程式碼

這說明閉包已經引起了迴圈引用問題,導致第二個控制器沒能被釋放(記憶體洩漏)。正是因為閉包會導致迴圈引用,所以在閉包中呼叫物件內部的方法時,都要顯式的使用 self,提醒我們要注意可能引起的記憶體洩漏問題。與 Objective-C 不同的是,我們不需要在每一次使用閉包之前再繁瑣的寫上 __weak typeof(self) weakSelf = self; 了,取而代之的是捕獲列表的概念:

@objc private func buttonClick() { 
    thirdViewController.closure = { [weak self] in 
        self?.test()
    }
    navigationController?.pushViewController(thirdViewController, animated: true)
}
複製程式碼

再重複一次上面的流程,可以看到控制檯多了兩條語句:

呼叫 test 方法
SecondViewController-被釋放了
ThirdViewController-被釋放了
複製程式碼

只要在捕獲列表中宣告瞭你想要用弱引用的方式捕獲的物件,就可以及時的規避由閉包導致的迴圈引用了。但是同時可以看到,閉包中對於方法的呼叫從常規的 self.test() 變為了可選鏈的 self?.test()。這是因為假設閉包在子執行緒中執行,執行過程中 self 在主執行緒隨時有可能被釋放。由於 self 在閉包中成為了一個弱引用,因此會自動變為 nil。在 Swift 中,可選型別的概念讓我們只能以可選鏈的方式來呼叫 test。下面修改一下 ThirdViewController 中的程式碼:

@objc private func buttonClick() {
    // 模擬網路請求
    DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 5) {
        self.closure?()
    }
}
複製程式碼

再次執行相同的操作步驟,這次我們發現 test 方法沒能正確的得到呼叫:

SecondViewController-被釋放了
ThirdViewController-被釋放了
複製程式碼

在實際的專案中,這可能會導致一些問題,閉包中捕獲的 selfweak 的,有可能在閉包執行的過程中就被釋放了,導致閉包中的一部分方法被執行了而一部分沒有,應用的狀態因此變得不一致。於是這個時候就要用到 Weak-Strong Dance 了。

既然知道了 self 在閉包中成為了可選型別,那麼除了可選鏈,還可以使用可選繫結來處理可選型別:

@objc private func buttonClick() { 
    thirdViewController.closure = { [weak self] in 
        if let strongSelf = self {
            strongSelf.test()
        } else {
            // 處理 self 被釋放時的情況。
        }
    }
    navigationController?.pushViewController(thirdViewController, animated: true)
}
複製程式碼

但這樣總是會讓我們在閉包中的程式碼多出兩句甚至更多,於是還有更優雅的方法,就是使用 guard 語句:

@objc private func buttonClick() { 
    thirdViewController.closure = { [weak self] in 
        guard let strongSelf = self else { return } 
        strongSelf.test()
    }
    navigationController?.pushViewController(thirdViewController, animated: true)
}
複製程式碼

一句程式碼搞定~

當然,有人看到這裡會說,每次都要使用 strongSelf 來呼叫 self 的方法,好煩啊……那麼這一點還是可以進一步被優化的,SwiftObjective-C 不同,是可以使用部分關鍵字來宣告變數的,於是我們可以:

@objc private func buttonClick() { 
    thirdViewController.closure = { [weak self] in 
        guard let `self` = self else { return } 
        self.test()
    }
    navigationController?.pushViewController(thirdViewController, animated: true)
}
複製程式碼

這樣就可以避免每次書寫 strongSelf 的煩躁感了~

原文地址:Weak-Strong Dance In Swift——如何在 Swift 中優雅的處理閉包導致的迴圈引用

如果覺得我寫的還不錯,請關注我的微博@小橘爺,最新文章即時推送~

相關文章