Swift その2 Advent Calendar 2017の11日目の記事です。
はじめに
動画を
- 画像+音声の集合体
- 画像が時間に応じて変化していく
- 音声が時間に応じて変化していく
時間に応じて変化していくデータと考えた際に[リアクティブプログラミング]
(http://ninjinkun.hatenablog.com/entry/introrxja)と相性が良いと思いました。
本エントリーでは、AVFoundationとRxSwift/RxCocoaを使い、動画再生に関してRxと組み合わせた実装を少し紹介します。
サンプルコードは公開しているので良かったら参考にしてみて下さい。
to4iki/VideoPlayer
動画再生の前提知識
動画を再生するまでのコンポーネントの紹介
AVAsset
動画自体のメディアデータやメタデータをロードし、保持するクラス
AVPlayerItem
動画の再生ステータスや時間軸に応じたメタデータを取得するクラス
AVPlayer
動画の再生、停止を管理するクラス
音声、再生レート、シークはこのクラスを介して行う
AVPlayerLayer
AVPlayerからの情報を使って動画を描画するUIを提供するクラス
動画を表示するまでの流れとしては、
動画URLを基に、AVAsset => AVPlayerItem => AVPlayer => AVPlayerLayerを作成。
UIViewに適応させるイメージです。
動画の再生準備
UIViewのlayerClassをoverrideしたカスタムViewを用意しておきます。
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
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を実装します。
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コンポーネントとのバインディングに使用するケースが多いです。
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中かどうかを判断します。
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が完了していたら、動画を再生開始。
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を実装。
extension Reactive where Base: AVPlayerItem {
var didPlayToEnd: Observable<Notification> {
return NotificationCenter.default.rx.notification(.AVPlayerItemDidPlayToEndTime, object: base)
}
}
ViewModelがoutputとして動画プレイヤーの状態を返す。
class PlayerViewModel {
...
let didPlayToEnd: Driver<Void>
init(player: AVPlayer) {
....
self.didPlayToEnd = player.currentItem!.rx.didPlayToEnd
.map { _ in () }
.asDriver(onErrorDriveWith: .empty())
}
}
ViewControllerで観測。
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)
}
}
動画の再生時間をシーク表示
動画再生位置の観察/動画アイテム間隔をストリームとして扱うためにラップする。
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) }
}
}
}
extension Reactive where Base: AVPlayerItem {
....
var duration: Observable<CMTime> {
return observe(CMTime.self, #keyPath(AVPlayerItem.duration))
.map { $0 ?? kCMTimeZero }
}
}
ViewModelがoutputとして動画の進捗を返す。
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で観測。
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
- AVFoundationプログラミングガイド (TP40010188 0.0.0)
- HTTP Live Streaming in iOS // Speaker Deck
- メルカリアッテのRxSwift実装ガイド // Speaker Deck
-
AVPlayerItemのstatusが.readyToPlayになってからセットするべき ↩