客製化 NotificationCenter 讓你使用起來更簡單


觀察者模式是一個常見、而且歷史悠久的程式設計模式,而在 Swift 裡,它主要是以通知與通知中心 (NotificationCenter) 的形式存在的。簡單來說,物件可以去向通知中心註冊,成為某一種通知事件的觀察者,然後當有人向通知中心送出通知的時候,通知中心就會去找它的註冊表裡面,所有有註冊這個通知類型的觀察者,並將通知傳送給它們。

通知中心模式跟 Target-action 模式與 Delegate 模式一樣,都是 iOS 開發裡不可或缺的一部分。像是要應對軟體鍵盤彈出的話,就得要去註冊相關的通知才行。然而,它有一個很麻煩的地方是:它的 API 有點囉唆。

原本的通知中心

NotificationCenter 是一個非常有 Objective-C 風格的 API。在早期版本的 Swift 中,它多數方法的參數都是 String 或者 Any,直到後來才有了 Notification.Name 等的新型別去包裝這些字串。但即使如此,它用起來還是感覺有些礙手礙腳。

// 註冊為觀察者
NotificationCenter.default.addObserver(觀察者, selector: #selector(觀察者用來處理通知的方法), name: 通知的名稱, object: 要觀察的對象物件)

通知中心用來辨別通知類型的方法,是透過它的名稱,所以你必須要找到相對應的名稱才行。官方框架的部分名稱是直接歸在 Notification.Name 這個命名空間底下,很好找,但有其它更多的名稱四散在各處,不看文件很難找得到。

// 送出通知
NotificationCenter.default.post(name: 通知的名稱, object: 送出通知的物件, userInfo: 要包含在通知裡的資訊)

送出通知有時也只需要一行,似乎很簡潔對不對?但魔鬼就藏在細節裡!userInfo 這個參數所接收的型別是 [AnyHashable: Any],所以如果要讓通知夾帶一些資訊的話,就必須要把資訊包裝到一個 Dictionary 裡面去,而這可以說是最沒有效率的包裝法了。送出通知的時候或許還不覺得,但接收的時候要解開包裝就麻煩了。

// 接收通知
class SomeClass {

    @objc func didReceive(notification: Notification) {
        // 解開 userInfo 的包裝
        guard let foo = notification.userInfo?["Foo"] as? Bar else { return }

        // 處理 foo...
    }

}

首先,要知道用來存放 someInfo 的字典鍵 (key) 是甚麼(這裡是字串 "SomeInfo")。接著,還要將它從 Any 轉型成 SomeType 才能用。也就是說,光是要取出一個值 (value),就要知道分開的兩個資訊(鍵與型別)。對於官方框架的通知來說,我們只能查文件去了解甚麼鍵值對應到甚麼型別的資訊;而如果通知是我們自己發送、userInfo 是我們自己打包的話,每一個自訂的通知就相當於要管理 :

  1. 通知名稱
  2. userInfo 鍵值對的鍵
  3. 鍵值對的值的型別

等資訊。而如果 userInfo 的鍵值對不只一個,要管理的資訊又更多了。

登場:Swift 的型別系統

這樣子存放附帶的資訊,真的是浪費了 Swift 強大的型別系統。在 Swift 裡,我們可以用 structenum 去定義資料結構,用 class 去定義物件,或用 protocol 去定義協定等等。重點是,所有這些型別都會經過編譯器的型別檢查

比如說,原本當 userInfo[AnyHashable: Any] 字典型別的時候,我們需要用字串去取值,取了值之後還要再轉換型別:

if let foo = userInfo["foo"] as? Bar {
    // 處理 foo...
}

但如果今天 userInfo 是定義成一個 struct,而不是一個 Dictionary 的話,像這樣:

struct UserInfo {
    var foo: Bar
}

Swift 編譯器就會知道它有哪些成員,分別是甚麼型別:

let foo: Bar = userInfo.foo
// 處理 foo...

我們不用再去找字典鍵、不用猜它的型別,userInfo.foo 直接就是 Bar 型別的值,甚至連解開包裝的動作都不用做。這樣不是很好嗎?

我們今天就是要來把通知中心模式中,這些弱型別的元素都轉化成 Swift 的強型別系統。

打包!全都用 Structure 打包起來!

首先,先想辦法把 userInfo 從字典轉成其它的型別,免去字典鍵管理與型別轉換的麻煩。這其實很容易達到,只需要把相關的資訊全部打包到一個型別裡面就可以了。

// 與其把資料直接放到字典裡
let userInfo: [AnyHashable : Any] = ["name" : "王大明", "age" : 10]

// 每次取值都要轉型
if let name = userInfo["name"] as? String {
    print(name) // 王大明
}
if let age = userInfo["age"] as? Int {
    print(age) // 10
}

// 不如定義一個包裝用的型別
struct PeopleInfo {
    var name: String
    var age: Int
}

// 與一個通用的鍵
let userInfoKey = "UserInfo"

// 就可以把所有資訊都放到字典裡的同一個位置了
let userInfo: [AnyHashable : Any] = [userInfoKey : PeopleInfo(name: "王大明", age: 10)]

// 取值時只需轉型一次,就可以獲得所有資訊!
if let peopleInfo = userInfo[userInfoKey] as? PeopleInfo {
    print(peopleInfo.name) // 王大明
    print(peopleInfo.age) // 10
}

雖然還是需要做轉型,但只需要做一次就可以獲得所有資訊,已經算是進步了。不過,如果拿到處理通知的方法裡面來看的話,還是有點囉唆:

class SomeClass {

    // 處理 PeopleInfo 通知的方法
    @objc func didReceive(notification: Notification) {

        // 用 Optional chaining 取值
        guard let peopleInfo = notification.userInfo?[userInfoKey] as? PeopleInfo else { return }

        // 處理 peopleInfo...
    }

}

這時我們只要幫 PeopleInfo 加個便利的 init,把重複性高的程式碼丟進去:

extension PeopleInfo {

    // 把鍵移到 PeopleInfo 裡面作為一個 static var 方便管理
    static var userInfoKey: AnyHashable {
        return "UserInfo"
    }

    // 使 PeopleInfo 可以從一個 Notification 建構出來
    init?(notification: Notification) {
        if let peopleInfo = notification.userInfo?[PeopleInfo.userInfoKey] as? PeopleInfo {
            self = peopleInfo
        } else {
            return nil
        }
    }

}

就可以把剛剛的程式碼縮減成這樣:

class SomeClass {

    @objc func didReceive(notification: Notification) {

        // 用可失敗的建構式取值
        guard let peopleInfo = PeopleInfo(notification: notification) else { return }

        // 處理 peopleInfo...
    }

}

問號都不見了,infoKey 不見了,也不用再轉型了,是不是簡潔很多呢?

不過,現在還只簡化到接收通知的部分。傳送通知的時候,除了要打包 userInfo 之外,還需要傳入通知的名稱:

// 通知的名稱
let didMeetPeopleNotificationName = Notification.Name(rawValue: "DidMeetPeople")

// 通知的資訊
let userInfo = [PeopleInfo.userInfoKey : PeopleInfo(name: "王大明", age: 10)]

// 傳送通知
NotificationCenter.default.post(name: didMeetPeopleNotificationName, object: nil, userInfo: userInfo)

這時就可以用 Extension 去給 NotificationCenter 加個方便的方法了:

extension PeopleInfo {

    // 再給 PeopleInfo 新增一個 static var,回傳屬於它的通知名稱
    static var notificationName: Notification.Name {
        return .init("PeopleEvent")
    }

}

extension NotificationCenter {

    // 發送通知用的便利方法
    func post(peopleInfo: PeopleInfo, object: Any? = nil) {
        post(name: PeopleInfo.notificationName, object: object, userInfo: [PeopleInfo.userInfoKey : peopleInfo])
    }

}

以後要傳送通知的時候,就可以這樣寫:

NotificationCenter.default.post(PeopleInfo(name: "王大明", age: 10))

這樣夠不夠簡單呢?

到現在為止,新增的程式碼長這樣:

// 通知的隨附資料之結構
struct PeopleInfo {
    var name: String
    var age: Int
}

extension PeopleInfo {

    // 跟通知相關的鍵與名稱
    static var notificationName: Notification.Name {
        return .init("PeopleEvent")
    }
    static var userInfoKey: AnyHashable {
        return "UserInfo"
    }

    // 將通知的隨附資訊解開成特殊型別的便利建構式
    init?(notification: Notification) {
        if let peopleInfo = notification.userInfo?[PeopleInfo.userInfoKey] as? PeopleInfo {
            self = peopleInfo
        } else {
            return nil
        }
    }
}

extension NotificationCenter {

    // 傳送通知用的便利方法
    func post(_ peopleInfo: PeopleInfo, object: Any? = nil) {
        post(name: PeopleInfo.notificationName, object: object, userInfo: [PeopleInfo.userInfoKey : peopleInfo])
    }

}

這樣,就可以大幅簡化發送 PeopleInfo 的通知過程了。然而,它有一個極大的缺點 —— 就是所有這些程式碼,都只影響到 PeopleInfo 所代表的通知而已。如果想要使另一種類的通知也被簡化的話,就只能把這些程式碼複製一份過去新的包裝型別裡面了⋯⋯ 真的是這樣嗎?

Protocol Extension 來拯救大家了

在 Swift 裡,給一個型別直接加上方法與屬性的方式有幾種:

  1. 直接寫在主要宣告內(不適用於官方或第三方等,無法更動原始碼的型別)
  2. 寫在 Extension 內
  3. 寫在父類型內
  4. 寫在 Protocol Extension 內

就這裡的狀況來說,1 跟 2 都是需要針對每一個通知類型都寫一次大致相同的程式碼。寫在父類的話,又只支援 Class 型別,沒辦法用在 Structure 或 Enumeratoin 等無繼承的型別上面。最後,只剩 Protocol Extension 來拯救這一天了!

PeopleInfo 的擴充功能用 Protocol Extension 來改寫其實並不難,只要稍微改幾個字就可以了:

protocol NotificationRepresentable { }

// 寫在 Protocol extension 裡的方法會預設套用給所有遵守此 protocol 的型別。
extension NotificationRepresentable {

    // 跟通知相關的鍵與名稱
    static var notificationName: Notification.Name {

        // 以實際型別的框架+名稱來當通知名稱,避免撞名
        return Notification.Name(String(reflecting: Self.self))
    }
    static var userInfoKey: String {
        return "UserInfo"
    }

    // 將通知的隨附資訊解開成特殊型別的便利建構式
    init?(notification: Notification) {

        // 使用 Self 以取得實際上的型別(套用此協定的型別)
        guard let value = notification.userInfo?[Self.userInfoKey] as? Self else { return nil }
        self = value
    }

}

至於傳送通知的方法,則可能更複雜一點,要動用到通用型別 (Generics):

extension NotificationCenter {

    // 傳送通知用的便利方法
    // T 即是代表實際被送出去的型別
    func post<T>(_ notificationRepresentable: T, object: Any? = nil) where T: NotificationRepresentable {
        post(name: T.notificationName, object: object, userInfo: [T.userInfoKey : notificationRepresentable])
    }

}

那使用起來如何呢?

由於新的 NotificationRepresentable 協定沒有任何的需求,所以要使原本的 PeopleInfo 遵守它,只需要加一行就可以了:

extension PeopleInfo: NotificationRepresentable { }

只要有了這一行,就可以直接使用所有剛剛寫的便利方法了!夠方便嗎?

// Swift 編譯器會自動把通用型別 T 識別成 PeopleInfo,所以不需要特別寫明 T 的型別
NotificationCenter.default.post(PeopleInfo(name: "王大明", age: 10))

class SomeClass {

    @objc func didReceive(notification: Notification) {

        // 由於 PeopleInfo 遵守了 NotificationRepresentable,所以也繼承了它的 Extension 裡的便利建構式
        guard let peopleInfo = PeopleInfo(notification: notification) else { return }

        // 處理 peopleInfo...
    }

}

不過,如果有很多種通知的話,那是不是就也要宣告很多新的包裝型別了呢?

Enumeration 也來參一腳

不用,因為可以用 Enumeration!我們可以定義一個 enum 去把不同的通知種類列成不同的 case,然後把所有類似的通知,都註冊到同一個接收通知的方法上面。這樣的話,就可以在接收到通知之後,去用 switch 切換不同種類的通知處理了。

// 用一個 enum 來代表同一性質的通知
enum InputEvent: NotificationRepresentable {
    case touchesBegan(Set<UITouch>), touchesMoved(Set<UITouch>), touchesEnded(Set<UITouch>), touchesCancelled(Set<UITouch>)
}

class ViewController: UIViewController {

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event)

        // 可以把值(touches)當成 enum 的關聯值(associated value)一起發送出去
        NotificationCenter.default.post(InputEvent.touchesBegan(touches), object: self)
    }

}

class SomeObserver {

    @objc func didReceiveInputEvent(notification: Notification) {
        guard let inputEvent = InputEvent(notification: notification) else { return }

        // 用 switch 來決定通知的種類
        switch inputEvent {

        // 將關聯值取出來用
        case .touchesBegan(let touches):
            // 處理 touches...

        case .touchesMoved(let touches):
            // 處理 touches...

        default:
            break
        }
    }

}

這樣子的通知中心,是不是更吸引人呢?

至於向通知中心註冊的便利方法,就留給你自己去實作了。

(提示:用通用型別抽換掉某個參數看看!)


iOS 開發者、寫作者、filmmaker。現正負責開發 Storyboards by narrativesaw 此一故事板文件 app 中。深深認同 Swift 對於程式碼易讀性的重視。個人網站:lihenghsu.com。電郵:[email protected]

blog comments powered by Disqus
Shares
Share This