觀察者模式是一個常見、而且歷史悠久的程式設計模式,而在 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
}
}
}
這樣子的通知中心,是不是更吸引人呢?
至於向通知中心註冊的便利方法,就留給你自己去實作了。
(提示:用通用型別抽換掉某個參數看看!)