本周,我们将继续探索在Swift中实现观察者模式的各种方法。上周我们看了一下如何使用NotificationCenter API和观察协议来让AudioPlayer被观察,本周,我们将做同样的事情,但是将重点放在多种基于闭包的技术上。

Closures

闭包是现代API设计的重要组成部分。从带有回调的异步api,到函数操作(比如在集合上使用forEach或map)——闭包无处不在,无论是在Swift标准库中,还是我们作为第三方开发者编写的应用程序中。

闭包的美妙之处在于,它允许API用户捕获他们所在的当前上下文,并在对事件做出反应时使用它 - 在当前的情况下,当我们的音频播放器的状态改变时。这支持一个更“轻量级”的调用站点,可以导致更简单的代码——但是闭包也有自己的一套权衡。

让我们看看如何将基于闭包的观察API添加到我们的AudioPlayer中。 我们将从定义一个元组开始,我们将使用它来跟踪观察——启动、暂停和停止——我们的三个事件组成的观察闭包数组,如下所示:

class AudioPlayer {
    private var observations = (
        started: [(AudioPlayer, Item) -> Void](),
        paused: [(AudioPlayer, Item) -> Void](),
        stopped: [(AudioPlayer) -> Void]()
    )
}

当然,我们可以在上面使用单独的属性——但就像我们在“在Swift中使用元组作为轻量级类型”中看到的那样。-使用元组将相关的属性组合在一起是一种非常简洁的方式来组织事物。

接下来,让我们定义我们的观察方法。与前面的方法一样,我们将为每个观察事件定义一个方法,使事物清晰地分开。每个方法都有一个闭包,我们将继续传递当前播放项目给开始和暂停的观察者,而只是传递播放器本身给停止的:

extension AudioPlayer {
    func observePlaybackStarted(using closure: @escaping (AudioPlayer, Item) -> Void) {
        observations.started.append(closure)
    }

    func observePlaybackPaused(using closure: @escaping (AudioPlayer, Item) -> Void) {
        observations.paused.append(closure)
    }

    func observePlaybackStopped(using closure: @escaping (AudioPlayer) -> Void) {
        observations.stopped.append(closure)
    }
}

通常情况下,让player自己进入所有关闭机制是一种很好的做法,可以避免意外的保留循环- 如果player是在自己的观察闭包中使用,我们就会忘记微弱地捕捉它。更多信息,请查看“捕捉Swift闭包中的对象”。

有了上述设置,剩下的就是当玩家的状态发生变化时,调用匹配事件的所有观察闭包,如下:

private extension AudioPlayer {
    func stateDidChange() {
        switch state {
        case .idle:
            observations.stopped.forEach { closure in
                closure(self)
            }
        case .playing(let item):
            observations.started.forEach { closure in
                closure(self, item)
            }
        case .paused(let item):
            observations.paused.forEach { closure in
                closure(self, item)
            }
        }
    }
}

让我们来看看我们的新API吧!当我们使用新的基于闭包的观察API时,我们的NowPlayingViewController(第一部分)看起来是这样的:

class NowPlayingViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        let titleLabel = self.titleLabel
        let durationLabel = self.durationLabel

        player.observePlaybackStarted { player, item in
            titleLabel.text = item.title
            durationLabel.text = "\(item.duration)"
        }
    }
}

又漂亮又干净!👍

然而,上述方法有一个主要的缺点——无法移除观察结果。虽然这对于拥有他们正在观察的对象的对象很好(因为被观察的对象将与它的所有者一起被分配),但对于共享对象(我们的AudioPlayer很可能是)并不理想,因为新增的观测数据将会永远存在。

Tokens

一种允许删除观察数据的方法是使用标记。就像我们在“使用令牌处理异步Swift代码”中所做的那样,我们可以在每次添加一个观察闭包时返回一个ObservationToken-稍后可以用来取消观察和移除闭包。

让我们从创建token类本身开始,它只是作为一个封装器,可以调用它来取消一个观察:

class ObservationToken {
    private let cancellationClosure: () -> Void

    init(cancellationClosure: @escaping () -> Void) {
        self.cancellationClosure = cancellationClosure
    }

    func cancel() {
        cancellationClosure()
    }
}

因为闭包并没有真正的标识概念,所以我们需要添加一些方法来惟一地标识一个观察,以便能够删除它。一种方法是将之前的闭包数组转换成字典,用UUID值作为键,像这样:

class AudioPlayer {
    private var observations = (
        started: [UUID : (AudioPlayer, Item) -> Void](),
        paused: [UUID : (AudioPlayer, Item) -> Void](),
        stopped: [UUID : (AudioPlayer) -> Void]()
    )
}

然后,当每个闭包被插入时,我们将为它分配一个新的UUID值。为了简化这个过程,当键类型为UUID时,我们可以在Dictionary上添加一个简单的扩展,使我们可以一次执行插入和创建标识符的操作:

private extension Dictionary where Key == UUID {
    mutating func insert(_ value: Value) -> UUID {
        let id = UUID()
        self[id] = value
        return id
    }
}

有了上面的内容,我们就可以更新观察方法以返回令牌了。我们将使用@discardableResult使实际使用返回的令牌成为可选的,以避免在观察观察者自身拥有的对象时产生任何警告。

在每个方法中,我们都将使用上面提到的insert方法,然后创建一个ObservationToken实例,并用闭包来删除这个观察,如下所示:

extension AudioPlayer {
    @discardableResult
    func observePlaybackStarted(using closure: @escaping (AudioPlayer, Item) -> Void)
        -> ObservationToken {
        let id = observations.started.insert(closure)

        return ObservationToken { [weak self] in
            self?.observations.started.removeValue(forKey: id)
        }
    }

    @discardableResult
    func observePlaybackPaused(using closure: @escaping (AudioPlayer, Item) -> Void)
        -> ObservationToken {
        let id = observations.paused.insert(closure)

        return ObservationToken { [weak self] in
            self?.observations.paused.removeValue(forKey: id)
        }
    }

    @discardableResult
    func observePlaybackStopped(using closure: @escaping (AudioPlayer) -> Void)
        -> ObservationToken {
        let id = observations.stopped.insert(closure)

        return ObservationToken { [weak self] in
            self?.observations.stopped.removeValue(forKey: id)
        }
    }
}

在我们准备好给我们新的基于令牌的API一个尝试之前,我们需要对我们的播放器的stateDidChange方法做一个轻微的修改,当迭代每个字典时使用values属性(因为我们不感兴趣在这里的键):

private extension AudioPlayer {
    func stateDidChange() {
        switch state {
        case .idle:
            observations.stopped.values.forEach { closure in
                closure(self)
            }
        case .playing(let item):
            observations.started.values.forEach { closure in
                closure(self, item)
            }
        case .paused(let item):
            observations.paused.values.forEach { closure in
                closure(self, item)
            }
        }
    }
}

我们现在可以让我们的NowPlayingViewController在它被释放的时候把它自己注销为一个观察者,像这样:

class NowPlayingViewController: UIViewController {
    private var observationToken: ObservationToken?

    deinit {
        observationToken?.cancel()
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        let titleLabel = self.titleLabel
        let durationLabel = self.durationLabel

        observationToken = player.observePlaybackStarted { player, item in  
            titleLabel.text = item.title
            durationLabel.text = "\(item.duration)"
        }
    }
}

当你想动态地创建和删除多个对象的观察时,基于令牌的API是非常好的,正如你在上面看到的,当我们想要做的只是添加一个简单的观察时,这变得有点麻烦。忘记对令牌调用cancel也是一个常见的错误,它要求我们在观察者中保持更多的状态(通过存储令牌)。

让我们看看是否能找到改进方案的方法。🤔

The best of both worlds

让我们探索一种方法来保持我们的基于令牌的API(因为它通过让我们对观察结果进行更细粒度的控制而增加了价值),但仍然减少样板。

一种方法是将一个观察闭包与观察者自身的生命周期绑定在一起,而不需要观察者遵守特定的协议(就像我们上周对AudioPlayerObserver所做的那样)。我们要做的是添加一个API,让我们把任何对象作为观察者传递,同时也像之前一样传递一个闭包。
然后我们将弱地捕获该对象,并防止它为nil,以确定观察是否仍然有效,如下所示:

extension AudioPlayer {
    @discardableResult
    func addPlaybackStartedObserver<T: AnyObject>(
        _ observer: T,
        closure: @escaping (T, AudioPlayer, Item) -> Void
    ) -> ObservationToken {
        let id = UUID()

        observations.started[id] = { [weak self, weak observer] player, item in
            // If the observer has been deallocated, we can
            // automatically remove the observation closure.
            guard let observer = observer else {
                self?.observations.started.removeValue(forKey: id)
                return
            }

            closure(observer, player, item)
        }

        return ObservationToken { [weak self] in
            self?.observations.started.removeValue(forKey: id)
        }
    }
}

通过以上的方法,我们可以说是两全其美。如果需要或喜欢使用令牌,我们可以使用令牌,而且当对象被回收时,我们还可以自动删除它注册的任何观察值。现在我们可以安全地在NowPlayingViewController中观察我们的玩家,就像这样:

lass NowPlayingViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        player.addPlaybackStartedObserver(self) {
            vc, player, item in

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

Conclusion

在这篇分两部分的文章中,我们尝试了多种方法来给对象添加观察功能——通过使用通知、观察协议、闭包和标记。当然,Swift中还有很多使用观察者的方式——包括使用函数式响应式编程和事件调度,所以这肯定是我们在以后的文章中会讨论的话题。

原文链接