タイトルの通りSwiftUIで複数のPublisherな値を監視する実装を必要とするケースが発生したので、今回はその時に学んだ内容を書いていきたいと思います。
前提
コードを書いていく前に、なぜそのような実装が必要だったのかについて前提を簡単に書いておきたいと思います。
今回開発していたのがインスタライクな複数動画プレイヤーを縦に並べて表示することをやろうとしていて、その中で無限スクロール発火時にプレイヤー情報を5件ずつ取得して追加で表示するといったことを行なっていました。
ただ追加で表示する際に、ユーザー体験としてプレイヤーが表示されたらすぐに再生が始まるようにしたくて、この場合あらかじめ動画の読み込み状態が再生可能な状態になってから画面に表示する必要がありました。
ビデオの読み込み状態を監視するのはVideoPlayerViewModelというものが管理していて、これが動画プレイヤーごとに複数存在しています。なので無限スクロールが発火してから画面に追加分が表示される条件として、新たに生成される追加5件分のVideoPlayerViewModel全てが再生可能な状態になったということを確認する必要があったというのが今回の前提になります。
Combineを使って監視を行う
VideoPlayerViewModelにはPublisherな値であるisVideoLoadedという動画の読み込み完了を表すフラグが存在します。なのでそれぞれのVideoPlayerViewModelが持つisVideoLoaded全てがtrueになることを監視するというのが今回実装したい内容になります。Combineについての詳細な説明は長くなるので省きますが、簡単にいうと非同期処理におけるデータフローを管理する仕組みのことです。実際のコードを見ていただけるとイメージしやすいかと思いますので、下記に記載します。
import Combine
import SwiftUI
class FeedVideosLoaderWatcher: ObservableObject {
@Published var allVideosLoaded = false
private var cancellables: Set<AnyCancellable> = []
init(viewModels: [VideoPlayerViewModel]) {
// 全ビデオのロード状態を監視するため、各Publisherの値を1つのストリームとして監視する
let videoLoadPublishers = viewModels.map { $0.$isVideoLoaded }
Publishers.MergeMany(videoLoadPublishers)
.receive(on: RunLoop.main)
// 全てのisVideoLoadedがtrueかどうかをチェックする
.map { _ in viewModels.allSatisfy { $0.isVideoLoaded } }
.sink { [weak self] allLoaded in
if allLoaded {
self?.allVideosLoaded = allLoaded
// 全てのビデオがロードされたタイミングで、全ての購読をキャンセルする
self?.cancellables.forEach { $0.cancel() }
self?.cancellables.removeAll()
}
}
.store(in: &cancellables)
}
}
コードの要点解説
まず監視対象である各viewModelのisVideoLoadedの値をまとめて取得し、Publisherな値としてまとめて配列に詰めます。
let isLoadedPublishers = viewModels.map { $0.$isVideoLoaded }
上記で取得した複数のPublisherな値を1つのPublisherとして管理するようにします。こうすることで全ビデオのロード状態を一つのストリームで管理することができるようになり、sinkで購読する箇所が1つにまとまるので、購読管理が簡素化され、リソースの使用が最適化されます。
Publishers.MergeMany(isLoadedPublishers)
// 下記のようにも書けるが、個別に購読することになり最適化されているとは言えず、コードも冗長になっている
for viewModel in viewModels {
viewModel.$isVideoLoaded
.sink { [weak self] isLoaded in
}
ここでは購読対象いずれかのisVideoLoadedが更新される => map内のallSatisfyがトリガーされ、全viewModelのisVideoLoadedがtrueかをチェック => その結果をsinkで受け取るような流れになっています。これにより最終的には全てのロードが完了したタイミングでallLoadedにはtrueが入ることになります。
.map { _ in viewModels.allSatisfy { $0.isVideoLoaded } }
.sink { [weak self] allLoaded in
最後に全viewModelのisVideoLoadedがtrueであることを確認できたら、全ての購読イベントをキャンセルしてリソースを解放します。
if allLoaded {
// 全てのビデオがロードされた場合、全ての購読をキャンセル
self?.cancellables.forEach { $0.cancel() }
self?.cancellables.removeAll()
}
}
終わりに
以上がCombineを使用した、複数のPublisherな値の変更を監視するやり方になります。実装方法は色々ありそうで、最適解を探していった結果今回のような実装になりました。もし上記以外で望ましいやり方がありましたら、ぜひコメント頂けますと有り難いです!