42
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

AVFoundationとRxを組み合わせる

Posted at

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

はじめに

動画を

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

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

本エントリーでは、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になってからセットするべき

42
22
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
42
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?