post Image
AVFoundationとRxを組み合わせる

Swift その2 Advent Calendar 2017の11日目の記事です。

はじめに

動画を

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

時間に応じて変化していくデータと考えた際にリアクティブプログラミングと相性が良いと思いました。

本エントリーでは、AVFoundationRxSwift/RxCocoaを使い、動画再生に関してRxと組み合わせた実装を少し紹介します。

サンプルコードは公開しているので良かったら参考にしてみて下さい。
to4iki/VideoPlayer

動画再生の前提知識

動画を再生するまでのコンポーネントの紹介

AVAsset
動画自体のメディアデータやメタデータをロードし、保持するクラス

AVPlayerItem
動画の再生ステータスや時間軸に応じたメタデータを取得するクラス

AVPlayer
動画の再生、停止を管理するクラス
音声、再生レート、シークはこのクラスを介して行う

AVPlayerLayer
AVPlayerからの情報を使って動画を描画するUIを提供するクラス

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

動画の再生準備

UIViewのlayerClassをoverrideしたカスタムViewを用意しておきます。

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

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を実装します。

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って何?より

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

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

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中かどうかを判断します。

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が完了していたら、動画を再生開始。

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を実装。

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

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

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

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

ViewControllerで観測。

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)
    }
}

動画の再生時間をシーク表示

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

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

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

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で観測。

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


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


『 Swift 』Article List
Category List

Eye Catch Image
Read More

Androidに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

AWSに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Bitcoinに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

CentOSに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

dockerに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

GitHubに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Goに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Javaに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

JavaScriptに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Laravelに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Pythonに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Rubyに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Scalaに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Swiftに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Unityに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Vue.jsに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Wordpressに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

機械学習に関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。