(譯文)swift中的監聽者

weixin_33670713發表於2018-05-13

這是一篇已經在我的部落格釋出的文章-Swift by Sundell。相比在這裡閱讀,我更推薦在我的部落格,因為你可以看到高亮的語法、主題或者更多。
當我們構建App時,經常會發現我們需要處理物件之間一對多的情況。它可能是在同一種情況下多個物件都需要發生變化,或者當某個事件需要被廣播到系統的不同部分。
在這些情況下,為特定的物件新增監聽是非常普遍的。像大多數程式設計技術一樣,在Swift中也有很多種為物件新增監聽的能力—他們都有各自的利弊。這一週,我們先研究兩種技術,下一週我們再研究其他的。
下面我們開始潛水:
例項:
為了更直觀的對不同技術進行比較,我們將盡力使用相同的例項。我們將會用AudioPlayer 類來做例子,讓其他的物件監聽它的狀態(PlaybackState)。每當開始播放、暫停或者完成回放,我們希望通知它的監聽者完成狀態的改變。這樣多個物件將邏輯與同一個播放器繫結,比如顯示可播放專案的列表、播放器的UI以及在螢幕底部顯示“迷你播放器”之類的功能按鈕。
下面是AudioPlayer類的程式碼:

 class AudioPlayer {
private var state = State.idle {
    // We add a property observer on 'state', which lets us
    // run a function on each value change.
    didSet { stateDidChange() }
}

func play(_ item: Item) {
    state = .playing(item)
    startPlayback(with: item)
}

func pause() {
    switch state {
    case .idle, .paused:
        // Calling pause when we're not in a playing state
        // could be considered a programming error, but since
        // it doesn't do any harm, we simply break here.
        break
    case .playing(let item):
        state = .paused(item)
        pausePlayback()
    }
}

func stop() {
    state = .idle
    stopPlayback()
}

}
從上面可以看出我們使用在之前在Modelling state in Swift中介紹的技巧,使用列舉來表示AudioPlayer類內部的不同狀態。這樣可以給我們更加清晰、定義明確的播放器狀態,而不是可選或者多種真相。
下面是我定義AudioPlayer狀態的列舉:

private extension AudioPlayer {
enum State {
    case idle
    case playing(Item)
    case paused(Item)
}

}
在上面你可以看到每當播放狀態變化的時候,每次我會呼叫方法,stateDidChange(),我們的主要工作就是使用不同的技術,不一樣的實現方法來填充這個方法。
通知中心:
首先要看的就是系統NotificationCenter,每當播放狀態改變的時候,我們就用它的API來傳送廣播。想大多數系統級別的API,NotificationCenter也是一個單例,為了明確表示我們依賴NotificationCenter來對AudioPlayer類來做測試,我們將在AudioPlayer類中注入一個notification center的例項,如下:

class AudioPlayer {
private let notificationCenter: NotificationCenter

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

}
如果想學習更多的依賴注入,可以檢視:“Different flavors of dependency injection in Swift",
NotificationCenter將使用不同的通知名字來區分事件的監聽或者發生。為了避免使用內聯字串作為API的一部分,我們將為NotificationCenter.Name新增擴充套件,來獲得單一的訊息來源的通知名字,我將新增一個回放開始,一個暫停、一個停止:

extension Notification.Name {
static var playbackStarted: Notification.Name {
    return .init(rawValue: "AudioPlayer.playbackStarted")
}

static var playbackPaused: Notification.Name {
    return .init(rawValue: "AudioPlayer.playbackPaused")
}

static var playbackStopped: Notification.Name {
    return .init(rawValue: "AudioPlayer.playbackStopped")
}

}
上面的擴充套件將幫助更方便的使用點語法來呼叫API來獲取通知的名字,像.playbackStarted,使用起來非常不錯。
通過上面新增擴充套件,現在可以傳送通知了。我們檢查當前的狀態看看我們需要傳送什麼通知,將之前的stateDidChange()方法補充完整。不僅是播放和暫停的狀態,我們也需要把當前播放的item作為通知物件:

private extension AudioPlayer {
func stateDidChange() {
    switch state {
    case .idle:
        notificationCenter.post(name: .playbackStopped, object: nil)
    case .playing(let item):
        notificationCenter.post(name: .playbackStarted, object: item)
    case .paused(let item):
        notificationCenter.post(name: .playbackPaused, object: item)
    }
}

}
就是這樣!在此程式碼基礎上,我們可以在任何地方輕鬆監聽到當前錄影播放的狀態。舉個例子,下面是可能會有一個控制器NowPlayingViewController監聽何時錄影播放開始,進而顯示當前的名字和總時間。

class NowPlayingViewController: UIViewController {
deinit {
    // If your app supports iOS 8 or earlier, you need to manually
    // remove the observer from the center. In later versions
    // this is done automatically.
    notificationCenter.removeObserver(self)
}

override func viewDidLoad() {
    super.viewDidLoad()

    notificationCenter.addObserver(self,
        selector: #selector(playbackDidStart),
        name: .playbackStarted,
        object: nil
    )
}

@objc private func playbackDidStart(_ notification: Notification) {
    guard let item = notification.object as? AudioPlayer.Item else {
        let object = notification.object as Any
        assertionFailure("Invalid object: \(object)")
        return
    }

    titleLabel.text = item.title
    durationLabel.text = "\(item.duration)"
}

}
使用通知中心的優勢就是非常容易實現,不僅是物件內部被監聽,而且是任何一個想要開始監聽它的內部。通知也是大多數Swift開發者經常使用的API,而且蘋果自身也在使用它來傳送系統的通知,比如鍵盤的事件。
然而,這種方法也有明顯的缺點。第一:儘管NotificationCenter是Objective-C API,不能使用Swift的特性,像泛型一樣保持型別安全。儘管可以像上面一樣實現(通過建立某種形式的解包),預設的方式是需要我們像上面前幾行playbackDidStart那樣進行型別轉換。這樣是我們的程式碼非常脆弱,因為我們無法利用編譯器來確保我們的觀察者和被觀察的物件使用相同型別的廣播值。
說到廣播,使用廣播的另一個缺點就是通知沒有任何限制,在應用內都可以監聽到。雖然這樣很方便我們在任何地方對觀察的物件進行監聽,但是它也使參與觀察的物件之間的關係變得更好鬆散,使得應用程式的各個部分之間更加無法保持清晰的分離,尤其是在程式碼增長的時候。
Observation protocols
下面,讓我們看看如何通過協議來構建一個更加嚴格標準、定義明確的監聽API。當我們用這個技術的時候需要想要監AudioPlayer的物件遵守AudioPlayerObserver協議。就像我定義了三種通知為錄影播放的每一種狀態,我將定義了三種方法來監聽每一種事件,就像這樣:
protocol AudioPlayerObserver: class {
func audioPlayer(_ player: AudioPlayer,
didStartPlaying item: AudioPlayer.Item)

func audioPlayer(_ player: AudioPlayer, 
                 didPausePlaybackOf item: AudioPlayer.Item)

func audioPlayerDidStop(_ player: AudioPlayer)

}
為了只監聽一個事件,我將使用協議擴充套件為每個事件來新增預設實現。

extension AudioPlayerObserver {
func audioPlayer(_ player: AudioPlayer,
                 didStartPlaying item: AudioPlayer.Item) {}

func audioPlayer(_ player: AudioPlayer, 
                 didPausePlaybackOf item: AudioPlayer.Item) {}

func audioPlayerDidStop(_ player: AudioPlayer) {}

}
弱儲存
當設計監聽API的時候,通常好的做法就是對所有的監聽者保持弱飲用。否則,當觀察物件同時在觀察自己時很容易造成迴圈飲用。然而,在Swift集合中用不錯的方式弱引用儲存物件不是最直接的方式,因為,集合預設對所有的成員保持強引用。
為了解決監聽需求帶來的問題,我將介紹一個小解包型別,對監聽者只保持簡單的弱引用狀態。

private extension AudioPlayer {
struct Observation {
    weak var observer: AudioPlayerObserver?
}

}
使用上面的型別,現在我們給AudioPlayer新增一個監聽集合。在這種情況下,我們將用一個鍵是ObjectIdentifier的字典插入和刪除觀察者們:
class AudioPlayer {
private var observations = ObjectIdentifier : Observation
}
ObjectIdentifier是系統內建的值型別,可以作為類物件例項的唯一表示。想要了解更多-請學習“Identifying objects in Swift”。
我們可以在stateDidChange()中遍歷所有的監聽,並且呼叫相對應狀態的協議來實現。而且值得注意的是,當我們在迭代的同時,還可以清除那些沒有使用的觀察值(如果相應的物件已經被釋放銷燬)。

private extension AudioPlayer {
func stateDidChange() {
    for (id, observation) in observations {
        // If the observer is no longer in memory, we
        // can clean up the observation for its ID
        guard let observer = observation.observer else {
            observations.removeValue(forKey: id)
            continue
        }

        switch state {
        case .idle:
            observer.audioPlayerDidStop(self)
        case .playing(let item):
            observer.audioPlayer(self, didStartPlaying: item)
        case .paused(let item):
            observer.audioPlayer(self, didPausePlaybackOf: item)
        }
    }
}

}
觀察
最後,我們需要物件實現AudioPlayerObserver協議來註冊自己成為觀察者.我們同樣需要一個簡單取消是監聽者的方式,以防監聽者不再需要監聽狀態更新。為了實現這來兩種情況,我們將會
AudioPlayer新增一個包含兩個方法的擴充套件:

extension AudioPlayer {
func addObserver(_ observer: AudioPlayerObserver) {
    let id = ObjectIdentifier(observer)
    observations[id] = Observation(observer: observer)
}

func removeObserver(_ observer: AudioPlayerObserver) {
    let id = ObjectIdentifier(observer)
    observations.removeValue(forKey: id)
}

}
非常棒!我們可以用新的監聽協議代替通知來更新NowPlayingViewController:

class NowPlayingViewController: UIViewController {
override func viewDidLoad() {
    super.viewDidLoad()
    player.addObserver(self)
}
  }

extension NowPlayingViewController: AudioPlayerObserver {
func audioPlayer(_ player: AudioPlayer,
                 didStartPlaying item: AudioPlayer.Item) {
    titleLabel.text = item.title
    durationLabel.text = "\(item.duration)"
    }
}

正如上面你看到的那樣,相比通知,使用監聽協議的主要優勢是我們確保編譯時的型別安全。由於使用協議時直接呼叫AudiPlayer.Item型別,不需要在觀察的方法中進行型別轉換,是我們的程式碼更加清晰。
新增一個明顯的監聽API也可以幫助理解監聽類是如何工作的,而不需要弄清楚NotificationCenter應該使用什麼。通過我們的API例子,你應該支援使用AudioPlayerObserver協議去監聽。
然而,這種方法也有它自身的缺點,相比通知那就是在內部使用了更多的程式碼。它需要引入額外的協議和型別,如果程式碼非常依賴於觀察結果,這可能就是一個缺點。
未完待續:第二部分!

相關文章