Block 形式的通知中心觀察者是否需要手動登出

SwiftGG翻譯組發表於2018-07-26

原文連結:swift.gg/2018/07/26/…
作者:Ole Begemann
譯者:BigNerdCoding
校對:pmst
定稿:CMB

簡單回答:需要 (在 iOS 11.2 上驗證過)

幾周之前,我在 twitter 上提出了一個問題

在 iOS 11 中是否還需要手動移除基於 block 形式的通知觀察者?蘋果開發文件中比較模糊。addObserver(forName:object:queue:using:) 中說需要,而 removeObserver(_:) 中又表明 iOS 9 之後都不在需要。

雖然我沒有統計準確的數字,但是大致看來持不同意見的人差不多五五開。

所以下面我們就來具體測試驗證一下。

問題

首先,我所說的基於 block 的介面宣告是 NotificationCenter.addObserver(forName: object: queue: using:) 。使用該 API 我們在通知中心註冊了一個函式用於處理對應的通知,並且得到一個表示觀察者的返回值。

class MyObserver {
    var observation: Any? = nil

    init() {
        observation = NotificationCenter.default.addObserver(
            forName: myNotification, object: nil, queue: nil) { notification in
                print("Received \(notification.name.rawValue)")
            }
    }
}
複製程式碼

問題是:當程式碼中的返回值 observation 銷燬時(例如,MyObserver 例項物件析構了),通知中心會不會自動忽略並停止呼叫處理函式呢?畢竟基於 KeyPathKVO 新介面當觀察者銷燬後,響應處理不再被呼叫,所以通知可能也被理解成是這樣進行的。

或者,我們依舊需要手動呼叫 NotificationCenter.removeObserver(_:)(例如,在 MyObserver 的解構函式 deinit 手動登出)?

文件中的說明

基於 selector 形式的觀察介面 addObserver(_:selector:name:object:) 的手動登出操作在 iOS 9 和 OSX 10.11 之後已經變成可選了。然而在 Foundation 釋出注意事項中明確表明 Block 形式的介面依然需要進行手動登出操作。

通過 -[NSNotificationCenter addObserverForName:object:queue:usingBlock:_] 形式新增的block型別觀察者在無用時依然需要進行登出操作,因為系統會保留對該觀察者的強引用。

該文件釋出之後是否存在新變化呢?

addObserver(forName:object:queue:using:) 文件說明部分也明確指出了登出操作是必要的:

所有通過 addObserver(forName:object:queue:using:) 建立的觀察者在析構之前都需要呼叫 removeObserver(_:) 或者 removeObserver(_:name:object:) 進行登出操作。

然而 removeObserver(_:) 文件說明處似乎與之相反:

如果你的 APP 執行在 iOS 9 或者 macOS 10.11 及最新的版本上的話則不需要登出這個觀察者在它的析構方法。

該文件中並沒有對 selector 或者 block 進行區分說明,也就是說該操作同時適用於兩者。

進行測試驗證

通過我寫的測試應用,你可以得到驗證上訴問題(通過 Xcode 的終端輸出)。

下面是我發現的:

  • 基於block 形式的觀察者依然需要進行手動登出操作(即使在 iOS 11.2 上),所以 removeObserver (_:) 文件存在明顯的誤導。
  • 如果沒有進行登出操作的話,那麼 block 就會被一直持有而且依然能夠被相關通知觸發執行。此時該行為對 APP 的潛在威脅取決於 block 內部持有的物件。
  • 即使你在 deinit 中呼叫了登出操作,你依舊需要注意 block 中不能捕獲 self 引用,否則會造成迴圈引用此時 deinit 也永遠不會得到執行。

自動登出

處理這個問題最好的方式是什麼呢?我的建議是:對觀察物件進行一次封裝。該封裝型別的指責就是保持觀察者物件並且在解構函式中自動將其登出。

/// Wraps the observer token received from 
/// NotificationCenter.addObserver(forName:object:queue:using:)
/// and unregisters it in deinit.
final class NotificationToken: NSObject {
    let notificationCenter: NotificationCenter
    let token: Any

    init(notificationCenter: NotificationCenter = .default, token: Any) {
        self.notificationCenter = notificationCenter
        self.token = token
    }

    deinit {
        notificationCenter.removeObserver(token)
    }
}
複製程式碼

通過封裝處理,我們將觀察者的生命週期和該型別例項進行了繫結。接下來我們只需要將該封裝型別例項通過私有屬性進行儲存,那麼其持有者就會 deinit 觸發時銷燬該封裝例項緊接著銷燬觀察者例項物件。這樣就不需要在程式碼中對其進行手動登出操作了。另外我們還可以將該例項宣告為 Optional <Notification​Token> ,這樣通過將其設定為 nil 也能進行手動登出操作。該模式被稱為 資源獲取即初始化 (RAII)

接下來讓我們為 NotificationCenter 編寫一個便利點的方法,它為我們承擔了包裝觀察介面的任務。

extension NotificationCenter {
    /// Convenience wrapper for addObserver(forName:object:queue:using:)
    /// that returns our custom NotificationToken.
    func observe(name: NSNotification.Name?, object obj: Any?, 
    queue: OperationQueue?, using block: @escaping (Notification) -> ())
    -> NotificationToken
    {
        let token = addObserver(forName: name, object: obj, queue: queue, using: block)
        return NotificationToken(notificationCenter: self, token: token)
    }
}
複製程式碼

如果此時將原有的 addObserver(forName:​object:​queue:​using:) 替換為新 API ,並將得到 NotificationToken 例項通過屬性儲存的話,你將不再需要手動登出操作了。

Chris 和 Florian 也在 Swift Talk episode 27: Typed Notifications 中提到過該技術,我向你強烈的推薦它。

相關文章