ソーシャル経済メディア「NewsPicks」でiOSエンジニアをしている金子です。
NewsPicks iOSアプリでは数年前からリアーキテクチャを進めており、TCAへの移行を進めています。
本記事では、最近リリースしたショート動画機能について、TCAでどのように実装したかをご紹介します。
ショート動画機能について
Youtubeのショート動画やInstagramのリールのように、短い動画を上下スワイプで切り替えて次々に視聴することができる機能です。
NewsPicksでは多数のオリジナル動画を配信していますが、動画の長さは1時間程度と長く、サムネイル画像と動画だけで見たい動画を探すのはなかなか大変です。
ショート動画機能を提供することで、ユーザが見たい動画に出会えるようにして、視聴数を増やすことを狙っています。
初期リリースでは動画の再生・停止・ループなど最低限の機能だけ実装しており、今後機能拡張していく予定です。
画面のビュー構成
ルートのビューはSwiftUI、上下スワイプで動画を切り替えられるリストビューはUIKit(UICollectionView)、セルのビューはSwiftUIという構成になっています。
そして、各ビューに対してReducerが存在します。
. ├── List │ ├── Item │ │ ├── ShortMovieItemReducer.swift │ │ └── ShortMovieItemView.swift <- セルのビュー │ ├── ShortMovieListReducer.swift │ └── ShortMovieListView.swift <- リストビュー ├── ShortMovieReducer.swift └── ShortMovieView.swift <- ショート動画のルートビュー
リストビューの実装
UICollectionViewを採用
ショート動画のUIを作る方法として、フルSwiftUIという選択肢もあるかもしれません。
ですが、これまでニュースフィード画面を始め、リスト/スクロール形式のUIを実装するときにSwiftUI.ListやSwiftUI.ScrollViewを試してきたのですが、やはりパフォーマンス面やUI/UX・ログ等の細かい要件を満たすにあたり、SwiftUIだけでは実現できない問題に多くぶつかってきました。
ショート動画の上下スワイプでページを切り替えるUIも、UICollectionViewだと提供されてるAPIを使うだけで簡単に実現できることがわかっていましたし、今後機能拡張していく予定もあるのでAPIが充実しているUICollectionViewを使うという判断をしました。
ショート動画のルートビューはSwiftUIなので、UICollectionViewで実装したビューをSwiftUIで使えるようにしなければなりません。
今回はUIViewControllerRepresentableを使い、以下のように実装しました。
struct ShortMovieListView: UIViewControllerRepresentable { let store: StoreOf<ShortMovieListReducer> func makeUIViewController(context: Context) -> ShortMovieListViewController { return ShortMovieListViewController(store: store) } func updateUIViewController(_ uiViewController: ShortMovieListViewController, context: Context) { } } final class ShortMovieListViewController: UIViewController { let store: StoreOf<ShortMovieListReducer> var collectionView: UICollectionView! init(store: StoreOf<ShortMovieListReducer>) { self.store = store super.init(nibName: nil, bundle: nil) } ... }
ShortMovieListViewControllerがUICollectionViewを持っていて、リストビューのUIやロジック全体がここに実装されています。
表示するデータはStoreを通じて参照します。
ShortMovieListViewはただのラッパーで、ShortMovieListViewControllerを返します。
レイアウトの実装
UICollectionViewCompositionalLayoutでレイアウトを構成していますが、各セルが画面いっぱいに表示されるだけのシンプルなレイアウトなので、実装もシンプルです。
スワイプでページが切り替わる挙動は、UICollectionViewのisPagingEnabled
プロパティをtrue
にすることで実現できます。
func initCollectionView() { collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeCompositionalLayout()) collectionView.isPagingEnabled = true ... } func makeCompositionalLayout() -> UICollectionViewCompositionalLayout { let sectionProvider: UICollectionViewCompositionalLayoutSectionProvider = { _, _ in let size = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1) ) let item = NSCollectionLayoutItem(layoutSize: size) let group = NSCollectionLayoutGroup.horizontal(layoutSize: size, subitems: [item]) return NSCollectionLayoutSection(group: group) } return UICollectionViewCompositionalLayout(sectionProvider: sectionProvider) }
データソースの実装
データソースはUICollectionViewDiffableDataSourceを使っています。
ショート動画画面ではセクションは一つだけで、アイテムが複数並ぶという構成です。
アイテムのIDにはUUIDを使用しています。
var dataSource: UICollectionViewDiffableDataSource<Section, UUID>! enum Section { case main }
リストビューのStateでは、items
という各セルを表現するStateの配列を保持しています(IdentifiedArrayOf<ShortMovieItemReducer.State>
という型です)。
items
を監視し、状態に変化があればスナップショットの更新処理を行います。
items
の中のいずれかのプロパティが変化するだけでここのスナップショット更新処理は呼ばれますが、データソースはアイテムのIDの差分だけを見ているので、例えば動画の再生状態が変わったとしてもスナップショットに差分は発生せず、セルの再読み込みなどは行われません。
セルの状態変化に伴うビューの再描画は、セルのStoreを通じて行います(後述します)。
var cancellables: [AnyCancellable] = [] store.publisher.items .sink { [weak self] items in guard let self = self else { return } var snapshot = NSDiffableDataSourceSnapshot<Section, UUID>() snapshot.appendSections([.main]) snapshot.appendItems(items.map(\.id), toSection: .main) dataSource.apply(snapshot, animatingDifferences: false) } .store(in: &cancellables)
最後にセルの登録処理です。
HostingCellというのは、UICollectionViewCellのcontentViewにSwiftUIビューを表示することができるセルのクラスです。
HostingCell(クリックすると展開されます)
final class HostingCell<Content: View>: UICollectionViewCell { private let hostingController = UIHostingController<Content?>(rootView: nil) override init(style: UICollectionViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) hostingController.view.backgroundColor = .clear } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } func set(rootView: Content, parentController: UIViewController) { self.hostingController.rootView = rootView self.hostingController.view.invalidateIntrinsicContentSize() let requiresControllerMove = hostingController.parent != parentController if requiresControllerMove { parentController.addChild(hostingController) } if !self.contentView.subviews.contains(hostingController.view) { self.contentView.addSubview(hostingController.view) NSLayoutConstraint.activate([ hostingController.view.topAnchor.constraint(equalTo: contentView.topAnchor), hostingController.view.rightAnchor.constraint(equalTo: contentView.rightAnchor), hostingController.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), hostingController.view.leftAnchor.constraint(equalTo: contentView.leftAnchor) ]) } if requiresControllerMove { hostingController.didMove(toParent: parentController) } } }
セルに表示するSwiftUIビューには、セル用のStoreを渡します。
store.scope
の部分に注目ですが、items
からアイテムID指定でStateを取り出そうとするとOptional型になってしまうので、ifLet
を使ってアンラップしています。
そして、生成したセル用のStoreをセルに渡します。
セルに表示するSwiftUIビューはStoreを参照するので、状態変化をリアルタイムにUIに反映することができます。
TCAの公式UIKitサンプルではセルはリードオンリーであり、例えばセルの中のオブジェクトをタップして見た目が変化するなどのインタラクティブな実装の例がありません。
公式サンプルやTCAのDiscussionでこのような実装方法を見たことは無いので、この実装に問題が無いとも言い切れないのですが、今のところ問題なく動作しています。
let cellRegistration = UICollectionView.CellRegistration<HostingCell<ShortMovieItemView>, UUID> { [weak self] cell, _, itemId in guard let self else { return } store.scope( state: { $0.items[id: itemId] }, action: { .item(id: itemId, action: $0) } ) .ifLet { [weak self] in guard let self else { return } cell.set(rootView: ShortMovieItemView(store: $0), parentController: self) } .store(in: &cancellables) } dataSource = UICollectionViewDiffableDataSource<Section, UUID>( collectionView: collectionView, cellProvider: { (collectionView, indexPath, item) in collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) } )
セルのビューの実装
セルのビューはほぼ動画プレイヤーです。
再生ボタンやシークバーなどを除くと、以下のような実装になります。
struct ShortMovieItemView: View { let store: StoreOf<ShortMovieItemReducer> @ObservedObject var viewStore: ViewStore<ViewState, ShortMovieItemReducer.Action> init(store: StoreOf<ShortMovieItemReducer>) { self.store = store self.viewStore = ViewStore(store, observe: ViewState.init(state:)) } struct ViewState: Equatable { let moviePlayerId: AVPlayerRepository.PlayerId init(state: ShortMovieItemReducer.State) { self.moviePlayerId = state.moviePlayerId } } var body: some View { MoviePlayerView(playerId: viewStore.moviePlayerId) } } private struct MoviePlayerView: UIViewControllerRepresentable { let playerId: AVPlayerRepository.PlayerId @Dependency(\.avPlayerRepository) var avPlayerRepository func makeUIViewController(context: Context) -> AVPlayerViewController { let controller = AVPlayerViewController() controller.player = avPlayerRepository.player(playerId) return controller } func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) { playerController.player = avPlayerRepository.player(playerId) } }
動画プレイヤー(AVPlayer)はAVPlayerViewControllerを使って表示しています。
AVPlayerのインスタンスはAVPlayerRepositoryにキャッシュしていて、PlayerIdを指定して取り出します。
AVPlayerRepositoryについては後述します。
updateUIViewController(_:context:)
でAVPlayerインスタンスを入れ替えていますが、これはセルの再利用を考慮しているためです。
ShortMovieItemViewのセルが初めて表示されるときはmakeUIViewController(context:)
が呼ばれAVPlayerが設定されますが、スワイプで動画を切り替えたときにはセルが再利用されるため、そのままだと以前に設定された動画が表示されてしまいます。
セルが再利用されるときにupdateUIViewController(_:context:)
が呼ばれるので、このタイミングでAVPlayerインスタンスの入れ替えを行います。
Reducerの実装
リストビュー用のReducerであるShortMovieListReducerの役割は、各セルのデータやページ位置の保持、およびページ切替時に動画を再生・停止することです。
ページ切替時の動画の再生・停止処理について工夫したことがあるので、ここではそれについてご紹介します。
上下スワイプによるページ切り替えの検知は以下のようなロジックで行っています。
ページ切り替えを検知したら、pageChanged(Int)
アクションを送信します。
extension ShortMovieListViewController: UICollectionViewDelegate { func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { let page = Int(collectionView.contentOffset.y / collectionView.frame.height) if page != store.withState(\.currentPage) { store.send(.pageChanged(page)) } } }
Reducerはアクションを受信すると、ページ切り替え前の動画の停止、および切り替え後のページの動画の再生を行います。
ShortMovieListReducerを親、ShortMovieItemReducerを子とすると、ページ切り替えのアクションを親で検知し、それを子に通知することで子は自身の状態を変化させる、という関係になります。
このとき、親から子にアクションを送信し、子がアクションを受信して自身の状態を変化させるという方法は、TCAでは非推奨とされています。
case let .pageChanged(newPage): let currentPage = state.currentPage state.currentPage = newPage // 子にアクションを送信するのはNG return .merge( .send(.item(id: state.items[currentPage], action: .pause)), .send(.item(id: state.items[newPage], action: .play)) )
TCAにおいて、アクションを送信するという行為はパフォーマンスの観点で高コストなのです。
ビューからは1つのアクションだけが送信されたにもかかわらず、追加で2つのアクションが送信されることになるので、非常に負荷が高くなります。
このあたりについては公式ドキュメントに記載があるので是非読んでください。
ではどうするかというと、子のStateにヘルパーメソッドを実装します。
これはTCAのDiscussionsで提案されている方法です。
ページを切り替えて動画が表示されたとき、子は動画を再生し、再生状態(playbackState
)を再生中(.playing
)に更新します。
ヘルパーメソッドは以下のようになります。
ロジックに副作用が伴う場合、ヘルパーメソッドの戻り値の型はEffectにします。
ヘルパーメソッド内で依存を使用したい場合は@Dependency
でDIできます。
struct ShortMovieItemReducer: Reducer { struct State: Equatable, Identifiable { mutating func viewDisplayed(firstMovieId: LiveMovieId) -> Effect<Action> { @Dependency(\.avPlayerRepository) var avPlayerRepository playbackState = .playing return .run { [moviePlayerId] _ in await avPlayerRepository.play(moviePlayerId) }, }
親のReducerでは、ヘルパーメソッドを以下のように呼び出します。
ヘルパーメソッドは子のActionに紐づくEffectを返すので、親のActionに変換してあげる必要があります。
case let .pageChanged(newPage): let currentPage = state.currentPage state.currentPage = newPage return .merge( state.items[currentPage].viewHidden(shouldReset: true, leaveFor: .shortMovie) .map { [state] in Action.item(id: state.items[currentPage].id, action: $0) }, state.items[page].viewDisplayed(firstMovieId: state.items[0].movieId) .map { [state] in Action.item(id: state.items[newPage].id, action: $0) } )
AVPlayerの扱いについて
TCAで動画を扱うにあたり、AVPlayerのインスタンスをどこに保持するかという問題があります。
まずStateで保持するという案がありますが、一般的にStateに参照型を保持するというのは非推奨です。
値型と参照型を混在させると同値比較が複雑になったり、未知の問題に遭遇する可能性もあります。
例えば動画を停止状態から再生状態にするとAVPlayerのtimeControlStatus
プロパティの値は変わりますが、ViewStoreは差分を検知しないのでビューの再描画がされません。
また、AVPlayerをモック化することは困難なので、AVPlayerが絡む部分のユニットテストが書けません。
以上の理由から、AVPlayerインスタンスを管理するAVPlayerRepositoryを用意し、Reducerからは@Dependency
を通じて利用することにしました。
AVPlayerRepositoryの実装
AVPlayerRepositoryの実装はこんな感じです(一部だけ載せてます)。
import Dependencies import AVKit public struct AVPlayerRepository: Sendable { public var play: @Sendable (PlayerId) async -> Void public var pause: @Sendable (PlayerId) async -> Void public var player: @Sendable (PlayerId) -> AVPlayer? public var createPlayer: @Sendable (CreatePlayerParams) -> Void public var deletePlayers: @Sendable ([PlayerId]) -> Void public struct PlayerId: Hashable, Sendable { public let rawValue: UUID public init(rawValue: UUID) { self.rawValue = rawValue } } public struct CreatePlayerParams: Sendable { public let id: PlayerId public let url: URL public init(id: PlayerId, url: URL) { self.id = id self.url = url } } } extension AVPlayerRepository: DependencyKey { public static let liveValue: AVPlayerRepository = .live() static func live() -> Self { let players = LockIsolated<[PlayerId: AVPlayer]>([:]) return .init( play: {@MainActor in players[playerId]?.play() }, pause: { @MainActor in players[$0]?.pause() }, player: { players[$0] }, createPlayer: { params in players.withValue { if $0[params.id] == nil { $0[params.id] = .init(url: params.url) } } }, deletePlayers: { ids in players.withValue { dict in for id in ids { dict.removeValue(forKey: id) } } } ) } }
ReducerからID指定でAVPlayerインスタンスを取り出せるようにするために、[PlayerId: AVPlayer]
という辞書型で保持しています。
この辞書にAVPlayerインスタンスを追加したり削除したりするので、mutableにする必要があるのですが、そうするとSendable違反になりコンパイルエラーとなってしまいます。
var players = [PlayerId: AVPlayer]() return .init( play: { @MainActor playerId in players[playerId]?.play() // Reference to captured var 'players' in concurrently-executing code }
そこで、swift-concurrency-extrasというライブラリが提供するLockIsolatedというクラスを使います。
LockIsolatedは内部にロック機構を持っており、スレッドセーフに値の変更をすることを可能にしてくれます。
そしてLockIsolated自体がSendableに準拠しているため、[PlayerId: AVPlayer]
の値をLockIsolatedで保持することで、コンパイルエラーが出なくなります。
なお、辞書はactorに保持させるほうが筋が良さそうではあるんですが、AVPlayerを取り出してビューにセットすることなどを考慮すると、同期的に扱えるほうが都合が良かったのでそうしました。
AVPlayerのplay()
とpause()
メソッドはMainActorで実行する必要があるため、AVPlayerRepositoryのplay
とpause
はasyncになっています。
そしてlive実装側ではクロージャに@MainActor
をつけています。
AVPlayerRepositoryの利用
セルのビューに対応するReducerであるShortMovieItemReducerのStateに、AVPlayerRepository.PlayerIdを持たせます。
AVPlayerRepositoryは@Dependency
でDIします。
動画の再生や停止といった操作は、Stateに持たせたPlayerIdをAVPlayerRepositoryに渡すことで実現できます。
struct ShortMovieItemReducer: Reducer { struct State: Equatable, Identifiable { ... let moviePlayerId: AVPlayerRepository.PlayerId ... } @Dependency(\.avPlayerRepository) var avPlayerRepository ... case .playButtonTapped: return .run { [state] _ in await avPlayerRepository.play(state.moviePlayerId) }
あえてReducerでやらなかったこと
TCAにおいては、機能に関するロジックは全てReducerに持たせるのが基本です。
Reducerはライブラリのサポートもありテスタビリティが非常に高いですが、ビューのテストを書くのは難しいからです。
ですが、1つだけ、Reducerではなくビュー側にオフロードしたロジックがあります。
それは動画の進捗管理です。
ショート動画には動画の再生位置を示すシークバーがあるのですが、これは動画の進捗をリアルタイムに監視し、非常に短い間隔でバーの進捗を更新する必要があります。
いくつか試した結果、0.2秒間隔であればバーの動きがカクつかず見栄えが良いことがわかりました。
当初はこの動画の進捗監視をReducerで行っていたのですが、0.2秒間隔でアクションが送信されることになるので、CPU負荷が非常に高くなっていました。
CPU負荷が高いと動画が再生されなかったり動画の切り替え動作がもたついたりするので、改善する必要がありました。
.run { [moviePlayerId] send in // 0.2秒間隔で動画の再生位置を取得する for await time in avPlayerRepository.periodicTime(.init(id: moviePlayerId, interval: .init(value: 1, timescale: 5))) { guard let duration = avPlayerRepository.duration(moviePlayerId) else { return } let progress = Float(time.seconds / duration.seconds) // 進捗率をStateに反映するためにアクションを送信する await send(.timeTicked(progress)) } }
Reducerでこのロジックを持っている限りは改善できなそうだったので、シークバーのビューの実装に動画の進捗監視およびUIの更新処理を入れることにしました。
TCAのドキュメントにもこのような高頻度のアクション送信について言及がありますが、対策としてはアクション送信数を減らすことしかなさそうで、今回のケースではそれはできないため、仕方なくビュー側に実装した形です。
終わりに
ショート動画機能の実装について解説しましたが、結構TCAの実装テクニックが詰まってて、個人的にはとてもやりがいのある機能だったなと思います。
TCAのドキュメントは随分充実してきた感がありますが、本記事で取り上げたようにGithub Discussionsでは細かいテクニックがたくさん提案されているので、何か困ったことがあったらドキュメントだけでなくDiscussionsを見に行くことをオススメします。
また、気軽に参加できるコミュニティ空間としてSlackも用意されているので、そこで質問したりすることもできます。
本記事が少しでも参考になれば幸いです。
最後までお読みいただきありがとうございました。