設(shè)計一個更加 Swift 的 Notification 系統(tǒng)

前言

Notification 作為蘋果開發(fā)平臺的通信方式, 雖然開銷比直接回調(diào)來的多, 但確實是在不引入第三方SDK的前提下非常方便的方式, 使用方式也很簡單

注冊只需要:

NotificationCenter.default.addObserver(observer, selector: selector, name: Notification.Name("notification"), object: nil)

或者使用閉包的形式:

let obs = NotificationCenter.default.addObserver(forName: Notification.Name("notification"), object: nil, queue: nil) { (notification) in }

發(fā)送通知只需要:

NotificationCenter.default.post(name: Notification.Name("notification"), object: nil, userInfo: [:])

系統(tǒng)就會自動執(zhí)行注冊的回調(diào)

這個系統(tǒng)在 Objc 的時代其實沒什么問題, 畢竟 Objc 類型沒有嚴格限制, 但是放在 Swift 里就顯得格格不入了, 使用者第一次用或者忘記的時候都得去查文檔看 userInfo 里面有什么, 每次用都得浪費時間去試, 整個項目只用一次的東西可能沒什么關(guān)系, 但頻繁用的真的很煩

當然這套系統(tǒng)也有好處, 那就是泛用性特別好, 畢竟都使用了字典, 既不存在版本限制, 也不存在類型寫死, 甚至手動亂調(diào)用系統(tǒng)通知, 亂傳不是字典的類型都沒問題

那么, 怎么使用 Swift 強大的范型系統(tǒng)和方法重載來改造呢? 順便再改造一下系統(tǒng)自帶的通知.

設(shè)計

新的通知系統(tǒng)需要滿足以下幾點

  1. userInfo 類型必須是已知的, 如果是模型, 可能不存在的值定為可選就行, 方便調(diào)用者使用
  2. 為了簡化篇幅這里只實現(xiàn)帶閉包的addObserver, 當 addObserver 傳入 object 的時候, 回調(diào)里的 notification 就不需要帶 object 了, 有必要時手動把 object 帶進回調(diào)閉包就行
  3. 提供沒有 userInfo 版本的通知, 當初始化的通知不帶參數(shù)時, 去掉回調(diào)閉包的參數(shù) notification 比如: addObserver(forName: Notification.Name("notification"), object: nil, queue: nil) { }

實現(xiàn)

初始化

基于上面三點易得一個區(qū)別于原版的 Notificatable:

struct Notificatable<Info> {
    private init() { }
}
extension Notificatable {
    static func name(_ name: String) -> ... {
        ...
    }
}

初始化通知從:

let notification = Notification.Name("notification")

變?yōu)榱?

let notification = Notificatable<String>.name("notification")

為了實現(xiàn)沒有 userInfo 版本的通知, 引入一個 _Handler 作為實現(xiàn)載體, :

struct Notificatable<Info> {
    private init() { }
    
    struct _Handler<Verify> {
        fileprivate var name: Foundation.Notification.Name
        fileprivate init(_ name: String) {
            self.name = .init(name)
        }
        fileprivate init(_ name: Foundation.Notification.Name) {
            self.name = name
        }
    }
}
extension Notificatable {
    static func name(_ name: String) -> _Handler<Any> {
        .init(name)
    }
}

創(chuàng)建的 notification 的類型也就變成

// Notificatable<String>._Handler<Any>
let notification = Notificatable<String>.name("notification")

引入 _Handler 后, 實現(xiàn)沒有 userInfo 版本的通知也就很簡單了:

extension Notificatable where Info == Never {
    static func name(_ name: String) -> _Handler<Never> {
        .init(name)
    }
}

初始化:

// Notificatable<Never>._Handler<Never>
let notification = Notificatable.name("notification")

回調(diào)

addObserver 參考了一下 rx, 因為確實有些場景需要通知的回調(diào)一直存活的, 這種場景下直接使用原版就比較難用了, 這里簡單實現(xiàn)一個 Disposable:

private var disposeQueue = Set<ObjectIdentifier>()
extension Notificatable {
    class Disposable {
        var holder: Any?
        init(_ holder: Any) {
            self.holder = holder
            disposeQueue.insert(.init(self))
        }
        deinit {
            holder = nil
        }
        func dispose() {
            disposeQueue.remove(.init(self))
        }
    }
}

為了簡化使用, 簡單模仿一下 rx 的 dispose(by: ), 順便給 NSObject 做分類方便接下來在 UIView/UIViewController 里直接用:

protocol NotificatableDisposeBy {
    func add<Info>(disposable: Notificatable<Info>.Disposable)
}

extension Notificatable.Disposable {
    func dispose(by owner: NotificatableDisposeBy) {
        owner.add(disposable: self)
        disposeQueue.remove(.init(self))
    }
}

extension NSObject: NotificatableDisposeBy {
    private struct AssociatedKey {
        static var queue = ""
    }
    private var notificatableDisposeQueue: [Any] {
        get {
            objc_getAssociatedObject(self, &AssociatedKey.queue) as? [Any] ?? []
        }
        set {
            objc_setAssociatedObject(self, &AssociatedKey.queue, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    }
    func add<Info>(disposable: Notificatable<Info>.Disposable) {
        notificatableDisposeQueue.append(disposable)
    }
}

Notificatable._Handler

Verify == Any

根據(jù)設(shè)計, 這里根據(jù)綁不綁定 object 分為兩種 subscribe 方法, 綁定 object 的 subscribe 直接回調(diào) Info 就行了

extension Notificatable._Handler where Verify == Any {
    struct Notification {
        let object: Any?
        let userInfo: Info
    }
    
    @discardableResult
    func subscribe(center: NotificationCenter = .default, queue: OperationQueue? = nil, _ action: @escaping (_ notification: Notification) -> Void) -> Notificatable.Disposable {
        let dispose =  center.addObserver(forName: name, object: nil, queue: queue) { noti in
            guard
                let info = noti.userInfo?[NotificatableUserInfoKey] as? Info
                else { return }
            
            action(.init(object: noti.object, userInfo: info))
        }
        return .init(dispose)
    }
    
    @discardableResult
    func subscribe(object: Any, center: NotificationCenter = .default, queue: OperationQueue? = nil, _ action: @escaping (_ info: Info) -> Void) -> Notificatable.Disposable {
        let dispose =  center.addObserver(forName: name, object: object, queue: queue) { noti in
            guard
                let info = noti.userInfo?[NotificatableUserInfoKey] as? Info
            else { return }
            
            action(info)
        }
        return .init(dispose)
    }
}

使用的時候:

notification.subscribe { (notification) in
  print("is (Notification) -> Void")
  print(notification)
}
    
notification.subscribe(object: NSObject()) { info in
  print("is (String) -> Void")
  print(info)
}

Verify == Never

同理不難得到 Verify == Never 的回調(diào)方法, 但由于不需要回調(diào) userInfo 了, 所以只需要直接把 Object 回調(diào)出去就行:

extension Notificatable._Handler where Verify == Never {
    @discardableResult
    func subscribe(center: NotificationCenter = .default, queue: OperationQueue? = nil, _ action: @escaping (_ object: Any?) -> Void) -> Notificatable.Disposable {
        let dispose =  center.addObserver(forName: name, object: nil, queue: queue) { noti in
            action(noti.object)
        }
        return .init(dispose)
    }
    
    @discardableResult
    func subscribe(object: Any, center: NotificationCenter = .default, queue: OperationQueue? = nil, _ action: @escaping () -> Void) -> Notificatable.Disposable {
        let dispose =  center.addObserver(forName: name, object: object, queue: queue) { _ in
            action()
        }
        return .init(dispose)
    }
}

發(fā)送

發(fā)送沒什么難的, 就兩套 post 方法而已

Verify == Any

extension Notificatable._Handler where Verify == Any {
    func post(_ userInfo: Info, object: Any? = nil, center: NotificationCenter = .default) {
        center.post(name: name, object: object, userInfo: [
            NotificatableUserInfoKey: userInfo
        ])
    }
}

Verify == Never

extension Notificatable._Handler where Verify == Never {
    func post(object: Any? = nil, center: NotificationCenter = .default) {
        center.post(name: name, object: object, userInfo: nil)
    }
}

適配系統(tǒng)通知

改造回調(diào)方法

Notificatable._Handler

為 Notificatable._Handler 添加一個轉(zhuǎn)換 NSDictionary 為 Info 的方法數(shù)組和處理方法

fileprivate var userInfoConverters: [([AnyHashable: Any]) -> Info?] = [{
    $0[NotificatableUserInfoKey] as? Info
  }]
func convert(userInfo: [AnyHashable: Any]?) -> Info? {
  guard let userInfo = userInfo else { return nil }
  for converter in userInfoConverters {
    if let info = converter(userInfo) {
      return info
    }
  }
  return nil
}

subscribe

把 noti.userInfo?[NotificatableUserInfoKey] as? Info 改成了 convert(userInfo:), 例如:

@discardableResult
func subscribe(center: NotificationCenter = .default, queue: OperationQueue? = nil, _ action: @escaping (_ notification: Notification) -> Void) -> Notificatable.Disposable {
  let dispose =  center.addObserver(forName: name, object: nil, queue: queue) { noti in
    guard
      let info: Info = self.convert(userInfo: noti.userInfo)
    else { return }

    action(.init(object: noti.object, userInfo: info))
  }
  return .init(dispose)
}

把 Notification.Name 轉(zhuǎn)換成 Notificatable

Swift 里不依賴第三方把 Dictionary 轉(zhuǎn)模型最直接的方法就是 Codable了, 但 userInfo 不是標準的 JSON 對象, 沒法直接使用系統(tǒng)的 JSONDecoder, 那么隨便自定義一個 Decoder 用于轉(zhuǎn)換 userInfo 不就好了嗎

不得不說每次寫 Decoder 的實現(xiàn)真的又臭又長, 80%的代碼都是重復的... 為了篇幅著想, 以下代碼不需要的部分用 fatalError() 略過, 錯誤處理也省略掉了, 除了枚舉外, 其他類型都不存在嵌套, 相關(guān)邏輯也省略掉了, 有興趣可以自己補充

extension Notificatable {
    fileprivate class Decoder {
        var codingPath: [CodingKey] = []
        
        var userInfo: [CodingUserInfoKey: Any] = [:]
        
        var decodingUserInfo: [AnyHashable: Any]
        
        init(_ decodingUserInfo: [AnyHashable: Any]) {
            self.decodingUserInfo = decodingUserInfo
        }
        
        struct Container<Key: CodingKey> {
            let decoder: Decoder
        }
    }
}

extension Notificatable.Decoder: Swift.Decoder {
    func container<Key>(keyedBy type: Key.Type) throws -> KeyedDecodingContainer<Key> where Key: CodingKey {
        .init(Container(decoder: self))
    }
    
    func unkeyedContainer() throws -> UnkeyedDecodingContainer {
        fatalError()
    }
    
    func singleValueContainer() throws -> SingleValueDecodingContainer {
        self
    }
}

extension Notificatable.Decoder.Container: KeyedDecodingContainerProtocol {
    
    var codingPath: [CodingKey] {
        decoder.codingPath
    }
    
    var allKeys: [Key] {
        decoder.decodingUserInfo.keys.compactMap {
            $0.base as? String }.compactMap { Key(stringValue: $0) }
    }
    
    func contains(_ key: Key) -> Bool {
        allKeys.contains {
            $0.stringValue == key.stringValue
        }
    }
    
    func decodeNil(forKey key: Key) throws -> Bool {
        let value = decoder.decodingUserInfo[key.stringValue]
        return value == nil || value is NSNull
    }
    
    func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool {
        decoder.decodingUserInfo[key.stringValue] as? Bool ?? false
    }
    
    func decode(_ type: String.Type, forKey key: Key) throws -> String {
        decoder.decodingUserInfo[key.stringValue] as? String ?? ""
    }
    
    func decode(_ type: Double.Type, forKey key: Key) throws -> Double {
        decoder.decodingUserInfo[key.stringValue] as? Double ?? 0
    }
    
    func decode(_ type: Float.Type, forKey key: Key) throws -> Float {
        decoder.decodingUserInfo[key.stringValue] as? Float ?? 0
    }
    
    func decode(_ type: Int.Type, forKey key: Key) throws -> Int {
        decoder.decodingUserInfo[key.stringValue] as? Int ?? 0
    }
    
    func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 {
        decoder.decodingUserInfo[key.stringValue] as? Int8 ?? 0
    }
    
    func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 {
        decoder.decodingUserInfo[key.stringValue] as? Int16 ?? 0
    }
    
    func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 {
        decoder.decodingUserInfo[key.stringValue] as? Int32 ?? 0
    }
    
    func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 {
        decoder.decodingUserInfo[key.stringValue] as? Int64 ?? 0
    }
    
    func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt {
        decoder.decodingUserInfo[key.stringValue] as? UInt ?? 0
    }
    
    func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 {
        decoder.decodingUserInfo[key.stringValue] as? UInt8 ?? 0
    }
    
    func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 {
        decoder.decodingUserInfo[key.stringValue] as? UInt16 ?? 0
    }
    
    func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 {
        decoder.decodingUserInfo[key.stringValue] as? UInt32 ?? 0
    }
    
    func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 {
        decoder.decodingUserInfo[key.stringValue] as? UInt64 ?? 0
    }
    
    func decode<T>(_ type: T.Type, forKey key: Key) throws -> T where T : Decodable {
        guard let value = decoder.decodingUserInfo[key.stringValue] else {
            throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(key)."))
        }
        if let value = value as? T {
            return value
        } else {
            decoder.codingPath.append(key)
            defer {
                decoder.codingPath.removeLast()
            }
            return try T.init(from: decoder)
        }
    }
    
    func nestedContainer<NestedKey>(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer<NestedKey> where NestedKey : CodingKey {
        fatalError()
    }
    
    func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer {
        fatalError()
    }
    
    func superDecoder() throws -> Decoder {
        fatalError()
    }
    
    func superDecoder(forKey key: Key) throws -> Decoder {
        fatalError()
    }
}

extension Notificatable.Decoder: SingleValueDecodingContainer {
    func decodeNil() -> Bool {
        let value = currentValue
        return value == nil || value is NSNull
    }
    var currentValue: Any? {
        decodingUserInfo[codingPath.last!.stringValue]
    }
    func decode(_ type: Bool.Type) throws -> Bool {
        currentValue as? Bool ?? false
    }
    
    func decode(_ type: String.Type) throws -> String {
        currentValue as? String ?? ""
    }
    
    func decode(_ type: Double.Type) throws -> Double {
        currentValue as? Double ?? 0
    }
    
    func decode(_ type: Float.Type) throws -> Float {
        currentValue as? Float ?? 0
    }
    
    func decode(_ type: Int.Type) throws -> Int {
        currentValue as? Int ?? 0
    }
    
    func decode(_ type: Int8.Type) throws -> Int8 {
        currentValue as? Int8 ?? 0
    }
    
    func decode(_ type: Int16.Type) throws -> Int16 {
        currentValue as? Int16 ?? 0
    }
    
    func decode(_ type: Int32.Type) throws -> Int32 {
        currentValue as? Int32 ?? 0
    }
    
    func decode(_ type: Int64.Type) throws -> Int64 {
        currentValue as? Int64 ?? 0
    }
    
    func decode(_ type: UInt.Type) throws -> UInt {
        currentValue as? UInt ?? 0
    }
    
    func decode(_ type: UInt8.Type) throws -> UInt8 {
        currentValue as? UInt8 ?? 0
    }
    
    func decode(_ type: UInt16.Type) throws -> UInt16 {
        currentValue as? UInt16 ?? 0
    }
    
    func decode(_ type: UInt32.Type) throws -> UInt32 {
        currentValue as? UInt32 ?? 0
    }
    
    func decode(_ type: UInt64.Type) throws -> UInt64 {
        currentValue as? UInt64 ?? 0
    }
    
    func decode<T>(_ type: T.Type) throws -> T where T : Decodable {
        guard let value = currentValue else {
            throw DecodingError.keyNotFound(codingPath.last!, DecodingError.Context(codingPath: self.codingPath, debugDescription: "No value associated with key \(codingPath.last!)."))
        }
        if let value = value as? T {
            return value
        } else {
            return try T.init(from: self)
        }
    }
}

給 Notification.Name 實現(xiàn)一下轉(zhuǎn)換方法

extension Notification.Name {
    func notificatable() -> Notificatable<Never>._Handler<Never> {
        return .init(self)
    }
    
    func notificatable<Info>(userInfoType: Info.Type) -> Notificatable<Info>._Handler<Any> where Info: Decodable {
        var notification = Notificatable<Info>._Handler<Any>(self)
        notification.userInfoConverters.append {
            try? Info.init(from: Notificatable<Info>.Decoder($0))
        }
        return notification
    }
}

完成了!

測試

讓我們拿 UIResponder.keyboardWillChangeFrameNotification 試一下, keyboardWillChangeFrameNotification 的回調(diào)包含了: 鍵盤開始尺寸, 結(jié)束尺寸, 動畫時間等等, 非常適合作為例子

struct KeyboardWillChangeFrameInfo: Decodable {
    let UIKeyboardCenterBeginUserInfoKey: CGPoint
    let UIKeyboardCenterEndUserInfoKey: CGPoint
    
    let UIKeyboardFrameBeginUserInfoKey: CGRect
    let UIKeyboardFrameEndUserInfoKey: CGRect
    
    let UIKeyboardIsLocalUserInfoKey: Bool
    
    let UIKeyboardAnimationDurationUserInfoKey: TimeInterval
    let UIKeyboardAnimationCurveUserInfoKey: UIView.AnimationOptions
}

不要忘記也給 UIView.AnimationOptions 實現(xiàn)以下 Decoable

extension UIView.AnimationOptions: Decodable {
    public init(from decoder: Decoder) throws {
        try self.init(rawValue: decoder.singleValueContainer().decode(UInt.self))
    }
}

找個有輸入框的 viewController 試一下

let notification = UIResponder.keyboardWillChangeFrameNotification.notificatable(userInfoType: KeyboardWillChangeFrameInfo.self)

notification.subscribe { (notification) in
    print(notification.userInfo.UIKeyboardFrameEndUserInfoKey)
}.dispose(by: self)

看一下效果, 雖然屬性名有點長, 但還是非常完美好用的

image-20201027113428492.png

下一步

看到 notification.object 這個了沒有, 實際上大部分系統(tǒng)通知這個 object 都是 nil, 包括我們自己寫的通知大部分情況下都是沒有的, 有沒有辦法在聲明 Notificatable 的時候就過濾掉呢? 但是過濾掉這個又可能降低整體的拓展性, 對此各位是覺得有沒有必要呢? 歡迎在評論區(qū)留下看法

另外本文自己實現(xiàn)了一個簡單的 Disposable, 如果已經(jīng)集成了想 rx 之類的第三方, 可能會遇到 Object 類型不一樣的問題, 歡迎發(fā)表自己遇到的坑

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末爹脾,一起剝皮案震驚了整個濱河市饵婆,隨后出現(xiàn)的幾起案子蜀撑,更是在濱河造成了極大的恐慌送讲,老刑警劉巖盯串,帶你破解...
    沈念sama閱讀 207,113評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件责循,死亡現(xiàn)場離奇詭異宿饱,居然都是意外死亡陨溅,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,644評論 2 381
  • 文/潘曉璐 我一進店門歼跟,熙熙樓的掌柜王于貴愁眉苦臉地迎上來和媳,“玉大人格遭,你說我怎么就攤上這事哈街。” “怎么了拒迅?”我有些...
    開封第一講書人閱讀 153,340評論 0 344
  • 文/不壞的土叔 我叫張陵骚秦,是天一觀的道長。 經(jīng)常有香客問我璧微,道長作箍,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,449評論 1 279
  • 正文 為了忘掉前任前硫,我火速辦了婚禮胞得,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘屹电。我一直安慰自己阶剑,他們只是感情好,可當我...
    茶點故事閱讀 64,445評論 5 374
  • 文/花漫 我一把揭開白布危号。 她就那樣靜靜地躺著牧愁,像睡著了一般。 火紅的嫁衣襯著肌膚如雪外莲。 梳的紋絲不亂的頭發(fā)上猪半,一...
    開封第一講書人閱讀 49,166評論 1 284
  • 那天,我揣著相機與錄音,去河邊找鬼磨确。 笑死沽甥,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的乏奥。 我是一名探鬼主播安接,決...
    沈念sama閱讀 38,442評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼英融!你這毒婦竟也來了盏檐?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,105評論 0 261
  • 序言:老撾萬榮一對情侶失蹤驶悟,失蹤者是張志新(化名)和其女友劉穎胡野,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體痕鳍,經(jīng)...
    沈念sama閱讀 43,601評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡硫豆,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,066評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了笼呆。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片熊响。...
    茶點故事閱讀 38,161評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖诗赌,靈堂內(nèi)的尸體忽然破棺而出汗茄,到底是詐尸還是另有隱情,我是刑警寧澤铭若,帶...
    沈念sama閱讀 33,792評論 4 323
  • 正文 年R本政府宣布洪碳,位于F島的核電站,受9級特大地震影響叼屠,放射性物質(zhì)發(fā)生泄漏瞳腌。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,351評論 3 307
  • 文/蒙蒙 一镜雨、第九天 我趴在偏房一處隱蔽的房頂上張望嫂侍。 院中可真熱鬧,春花似錦荚坞、人聲如沸挑宠。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,352評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽痹栖。三九已至,卻和暖如春瞭空,著一層夾襖步出監(jiān)牢的瞬間揪阿,已是汗流浹背疗我。 一陣腳步聲響...
    開封第一講書人閱讀 31,584評論 1 261
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留南捂,地道東北人吴裤。 一個月前我還...
    沈念sama閱讀 45,618評論 2 355
  • 正文 我出身青樓,卻偏偏與公主長得像溺健,于是被迫代替她去往敵國和親麦牺。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 42,916評論 2 344