AVFoundationとRxを組み合わせる

[Swift その2 Advent Calendar 2017](https://qiita.com/advent-calendar/2017/swift2)の11日目の記事です。

## はじめに

動画を

- 画像+音声の集合体
- 画像が時間に応じて変化していく
- 音声が時間に応じて変化していく

**時間に応じて変化していくデータ**と考えた際に[リアクティブプログラミング]
(http://ninjinkun.hatenablog.com/entry/introrxja)と相性が良いと思いました。

本エントリーでは、[AVFoundation](https://developer.apple.com/documentation/avfoundation)と[RxSwift/RxCocoa](https://github.com/ReactiveX/RxSwift)を使い、動画再生に関してRxと組み合わせた実装を少し紹介します。

サンプルコードは公開しているので良かったら参考にしてみて下さい。
[to4iki/VideoPlayer](https://github.com/to4iki/VideoPlayer)

## 動画再生の前提知識
動画を再生するまでのコンポーネントの紹介

![](https://developer.apple.com/library/content/documentation/AudioVideo/Conceptual/MediaPlaybackGuide/Contents/Resources/en.lproj/Art/relationship_of_classes_2x.png)

[AVAsset](https://developer.apple.com/documentation/avfoundation/avasset)
動画自体のメディアデータやメタデータをロードし、保持するクラス

[AVPlayerItem](https://developer.apple.com/documentation/avfoundation/avplayeritem)
動画の再生ステータスや時間軸に応じたメタデータを取得するクラス

[AVPlayer](https://developer.apple.com/documentation/avfoundation/avplayer)
動画の再生、停止を管理するクラス
音声、再生レート、シークはこのクラスを介して行う

[AVPlayerLayer](https://developer.apple.com/documentation/avfoundation/avplayerlayer)
AVPlayerからの情報を使って動画を描画するUIを提供するクラス

動画を表示するまでの流れとしては、
動画URLを基に、AVAsset => AVPlayerItem => AVPlayer => AVPlayerLayerを作成。
UIViewに適応させるイメージです。

## 動画の再生準備
UIViewのlayerClassをoverrideしたカスタムViewを用意しておきます。

```swift:PlayerView.swift
class PlayerView: UIView {
    var player: AVPlayer? {
        get {
            return playerLayer.player
        }
        set {
            playerLayer.player = newValue
        }
    }
    
    var playerLayer: AVPlayerLayer {
        return self.layer as! AVPlayerLayer
    }
    
    override class var layerClass: AnyClass {
        return AVPlayerLayer.self
    }
    ...
}
```

ViewControllerにてAVPlayerをインスタンス化し、PlayerView.playerにセット[^1]

[^1]: AVPlayerItemのstatusが.readyToPlayになってからセットするべき

```swift:ViewController.swift
class ViewController: UIViewController {
    @IBOutlet private weak var playerView: PlayerView!
 
    override func viewDidLoad() {
        super.viewDidLoad()
        let url = URL(string: "https://i.imgur.com/9rGrj10.mp4")!
        let asset = AVAsset(url: url)
        let playerItem = AVPlayerItem(asset: asset)
        let player = AVPlayer(playerItem: playerItem)
        playerView.player = player
    }
}
```

## 動画の再生
動画の読み込み完了までActivityIndicatorViewを表示、PlayerViewを非表示にしてみましょう。
(ここで初めてRxが登場）

素で実装する場合は、AVPlayerの状態をKVO監視しDelegateパターンに沿ってViewControllerに状態を通知するようなイメージです。
今回は、この状態をストリームとして扱うためにextenstionを実装します。

```swift:AVPlayer+Rx.swift
extension Reactive where Base: AVPlayer {
    var status: Observable<AVPlayerStatus> {
        return observe(AVPlayerStatus.self, #keyPath(AVPlayer.status))
            .map { $0 ?? .unknown }
    }
}
```

ViewControllerの実装。
Driver\<Bool\>型のisLoadingをUIコンポーネントとバインドします。
Driverに関しては、[RxCocoaが提供するDriverって何？](https://qiita.com/k5n/items/44ef2ab400f47fb66731)より

- メインスレッドで通知
- shareReplayLatestWhileConnected を使った Cold-Hot変換
- onError通知しない

の特徴を持ったストリームで、UIコンポーネントとのバインディングに使用するケースが多いです。

```swift:ViewController.swift
class ViewController: UIViewController {
    @IBOutlet private weak var playerView: PlayerView!
    @IBOutlet private weak var indicatorView: UIActivityIndicatorView!
    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()
        ...
        playerView.player!.play()
        bind()
    }

    private func bind() {
        let isLoading = playerView.player!.rx.status
            .asDriver(onErrorJustReturn: .unknown)
            .map { $0 != .readyToPlay }
        
        isLoading
            .drive(playerView.rx.isHidden)
            .disposed(by: disposeBag)
        
        isLoading
            .map { !$0 }
            .drive(playerView.rx.isHidden)
            .disposed(by: disposeBag)
    }
```

上記のisLoading(AVPlayerのstatusを返すストリーム)と各Viewをバインディングさせることで、簡単に表示・非表示の切り替えを行うことが出来ました。

### 応用
一拍置きたいような、最低〇秒動画再生を遅延させたいケースを考えてみます。
(〇秒を超えても、ロード中の場合はロードが完了次第再生する)

ViewController内で直接定義するのではなく、Viewでバインディングに必要な情報を集約したViewModelを作成します。(InputとOutputを明確にしたい)

値の発行を遅延させる`delay`がRxSwiftに見当たらなかったので、timerを使用し3秒後に値が流れるObservableを作成。
これとplayerのstatusをcombineLatestで結合し、statusの値を見てloading中かどうかを判断します。

```swift:PlayerViewModel.swift
class PlayerViewModel {
    let isLoading: BehaviorRelay<Bool> = BehaviorRelay(value: true)
    private let disposeBag = DisposeBag()

    init(player: AVPlayer) {
        Observable.combineLatest(player.rx.status, Observable<Int>.timer(3.0, scheduler: MainScheduler.instance))
            .map { $0.0 != .readyToPlay }
            .asDriver(onErrorJustReturn: true)
            .drive(onNext: { [unowned self] done in
                self.isLoading.accept(done)
            })
            .disposed(by: disposeBag)
    }
}
```

ViewControllerで観測。
loadingが完了していたら、動画を再生開始。

```swift:ViewController.swift
class ViewController: UIViewController {
    ...
    private func bind() {
      ...
      viewModel.isLoading.asDriver()
            .filter { !$0 }
            .drive(onNext: { [unowned self] _ in
                print("item ready to play")
                self.playerView.player?.play()
            })
            .disposed(by: disposeBag)
    }
}
```

## 他のケース
### 動画の終了を検知する
終了を取り扱うために、AVPlayerItemにextensionを実装。

```swift:AVPlayerItem+Rx.swift
extension Reactive where Base: AVPlayerItem {
    var didPlayToEnd: Observable<Notification> {
        return NotificationCenter.default.rx.notification(.AVPlayerItemDidPlayToEndTime, object: base)
    }
}
```

ViewModelがoutputとして動画プレイヤーの状態を返す。

```swift:PlayerViewModel.swift
class PlayerViewModel {
    ...
    let didPlayToEnd: Driver<Void>

    init(player: AVPlayer) {
        ....
        self.didPlayToEnd = player.currentItem!.rx.didPlayToEnd
            .map { _ in () }
            .asDriver(onErrorDriveWith: .empty())
    }
}
```

ViewControllerで観測。

```swift:ViewController.swift
class ViewController: UIViewController {
    ...
    private func bind() {
        ...
        viewModel.didPlayToEnd
            .drive(onNext: { [unowned self] _ in
                print("item did play to end")
                self.backToStart() // e.g 最初から再生する
            })
            .disposed(by: disposeBag)
    }
}
```

### 動画の再生時間をシーク表示
動画再生位置の観察/動画アイテム間隔をストリームとして扱うためにラップする。

```swift:AVPlayer+Rx.swift
extension Reactive where Base: AVPlayer {
    ...
    func periodicTimeObserver(interval: CMTime) -> Observable<CMTime> {
        return Observable.create { observer in
            let time = self.base.addPeriodicTimeObserver(forInterval: interval, queue: nil) { time in
                observer.onNext(time)
            }
            return Disposables.create { self.base.removeTimeObserver(time) }
        }
    }
}
```

```swift:AVPlayerItem+Rx.swift
extension Reactive where Base: AVPlayerItem {
    ....
    var duration: Observable<CMTime> {
        return observe(CMTime.self, #keyPath(AVPlayerItem.duration))
            .map { $0 ?? kCMTimeZero }
    }
}
```

ViewModelがoutputとして動画の進捗を返す。

```swift:PlayerViewModel.swift
class PlayerViewModel {
    ...
    let progress: Driver<Float>

    init(player: AVPlayer) {
        ....
        func progress(current time: CMTime, duration: CMTime) -> Float {
            guard time.isValid && duration.isValid  else { return 0 }
            let currentSeconds = time.seconds
            let totalSeconds = duration.seconds
            guard currentSeconds.isFinite && totalSeconds.isFinite else { return 0 }
            return Float(min(currentSeconds/totalSeconds, 1))
        }
        
        let periodicTimeObserver = player.rx.periodicTimeObserver(interval: CMTime(seconds: 0.05, preferredTimescale: CMTimeScale(NSEC_PER_SEC))).asDriver(onErrorJustReturn: kCMTimeZero)
        let durarion = player.currentItem!.rx.duration.asDriver(onErrorJustReturn: kCMTimeZero)
        self.progress = Driver.combineLatest(periodicTimeObserver, durarion).map(progress)
    }
}
```

ViewControllerで観測。

```swift:ViewController.swift
class ViewController: UIViewController {
    ...
    @IBOutlet private weak var seek: UISlider!

    private func bind() {
        ...
        viewModel.progress
            .drive(seek.rx.value)
            .disposed(by: disposeBag)
    }
}
```

### まとめ
**時間軸に応じて変化する**動画の様々な状態をストリームとして扱い、UIとバインドした実装を示しました。
動画や音声関連の実装は、データとUIコンポーネントの同期処理が煩雑になりがちだと思いますが、リアクティブ(宣言的)に取り扱うことで見通しの良い直感的なコードになりますね！！！

## See also
- [AV Foundation - Apple Developer](https://developer.apple.com/av-foundation/)
- [AVFoundationプログラミングガイド (TP40010188 0.0.0)](https://developer.apple.com/jp/documentation/AVFoundationPG.pdf)
- [HTTP Live Streaming in iOS // Speaker Deck](https://speakerdeck.com/ra1028/http-live-streaming-in-ios)
- [メルカリアッテのRxSwift実装ガイド // Speaker Deck](https://speakerdeck.com/parakeety/merukariatutefalserxswiftshi-zhuang-gaido)