客製化 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
是我們自己打包的話,每一個自訂的通知就相當於要管理 :
- 通知名稱
userInfo
鍵值對的鍵- 鍵值對的值的型別
等資訊。而如果 userInfo
的鍵值對不只一個,要管理的資訊又更多了。
登場:Swift 的型別系統
這樣子存放附帶的資訊,真的是浪費了 Swift 強大的型別系統。在 Swift 裡,我們可以用 struct
與 enum
去定義資料結構,用 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 裡,給一個型別直接加上方法與屬性的方式有幾種:
- 直接寫在主要宣告內(不適用於官方或第三方等,無法更動原始碼的型別)
- 寫在 Extension 內
- 寫在父類型內
- 寫在 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 } } }
這樣子的通知中心,是不是更吸引人呢?
至於向通知中心註冊的便利方法,就留給你自己去實作了。
(提示:用通用型別抽換掉某個參數看看!)