音声/動画プレイヤーとClean Architecture雑メモ

公開:2020/10/10
更新:2020/10/10
4 min読了の目安(約3600字TECH技術記事

最近、

というか数年前から、AVPlayer(iOS)とかMediaPlayer(Android)とか、View層にあると情報の流れがごちゃっとして管理しづらいから、データレイヤーの方に無理矢理押し込んじゃった方がわかりやすいんじゃないか?とかぼんやり考えてました。それでちょっと仕事で音声プレイヤー周りを触る機会があったので、試しにClean ArchitectureのRepositoryに組み込んでみました。結構ちゃんと考えて作りました。Androidにも適用できると思っていて、軽く検証は済ませてます。まあ一旦鼻で笑っていただければ幸いです。

struct PlayerState

PlayerからViewへの状態リレーを簡略化するため、Repositoryでstruct PlayerStateを生成して下流に流す形。

UserEventBinder

ユーザーのタッチイベントをまとめたもの。あくまで1画面にPresenterはひとつだけにするポリシーがあるので、プレイヤー専用のPresenterは設けない感じ。

動画の場合

動画の場合はPlayerStateの中にプレイヤー含めてViewに渡す必要があるため、以下のような構造が必要になります。

struct PlayerState {
    // メモリリーク回避のためWeakBox化
    let player: Weak<AVPlayer>
		...
}

struct Weak<T: AnyObject> {
    weak var value: T?

    init(_ value: T) {
        self.value = value
    }
}

Viewレイヤーに届く前に途中のレイヤーで触れるから副作用があってダメじゃんというのはあります。(後ほど弁解)

プレイヤーはViewレイヤーではないのか?

音声ならともかく、動画はViewレイヤーだろって普通は考えますよね。
ただバックグラウンド再生時、AVPlayerはViewからdetachしてどこか別の場所に保持する必要があります。この「どこか別の場所」ってのが大体AppDelegateとかそれに類する設計的に宙ぶらりんなSingletonクラスになりがちで、ここ数年ずっと悩ましかったんですよね。

さらに経験上、View以外にもAVPlayerを渡す相手が増えることがあります。例えば以前の案件では、サードパーティのプレイヤーアナリティクスSDKがAVPlayerのインスタンスを直接欲しがるケースが普通にありました。こういう時、Clean Architectureの場合はSDKをRepositoryにラップして直接依存しないような形にするよねと思うのですが、そこまで考えると今回の PlayerRepository にAVPlayerを保持する設計は相性が良いんじゃないかなと思いましたし、 Weak<AVPlayer> を誰でも触れる点も諦めがつくというかそういう無我の境地でございます。

実際の状態を持っているのはプレイヤー本体

ちょっとここでそもそもの課題感を共有したいと思います。

例えば isPlaying ってViewやPresenter層では結構よく問い合わせると思うのですが、プレイヤーがViewにある場合、厳密にやるならどうしてもPresenter側で完結できないので以下のような感じになっちゃうと思います。

class PlayerViewPresenter {
    func tapPlayPauseButton() {
	output?.togglePlayPause()
    }
}

class PlayerViewController: PlayerViewPresenterOutput {
    func togglePlayPause() {
        if player.rate > 0 {
	    player.pause()
	} else {
	    player.play()
	}
    }
}

もはやPresenter意味ないですね。
もしくは、プレイヤーの状態を監視するObservableやPublisher達をPresenterに持っておくというのは一応できると思います。

class PlayerViewPresenter {
    let isPlaying = BehaviorRelay<Bool>()

    func tapPlayPauseButton() {
        if isPlaying.value {
	    output?.pause()
	} else {
	    output?.play()
	}
    }
}

class PlayerViewController: PlayerViewPresenterOutput {
    func observePlayer() {
        player.addPeriodicObserver...
	    // => ゴニョゴニョして得た状態をpresenter.isPlayingへバインドしてしまう
    }

    func play() {
        player.play()
    }
}

よく見る感じのコードになってきましたが、これもPresenterとViewのコードがどんどん肥大化していくので、あまり上手いやり方ではないんじゃないかなとずっと思っていました。
View層からPresenterが受け取るのは基本ユーザーの操作イベントだけにしたい気がするのですが、プレイヤー周りってユーザーの意図と関係なく勝手にめちゃくちゃイベント発火されることになるし状態も複雑なので、肝心のViewコードに集中できなくなっちゃうんですよね。

プレイヤーってViewのようでいて、URL渡されて外部インターフェースとI/Oするものでもあるので、やっぱりViewよりはデータ層にいて欲しいなって思いがあるんだと思います。どうでしょうか。

余談: isPlaying

ちなみにこの場合のisPlayingはユーザーの意図とは別です。
プレイヤーは非同期処理なので当然、 player.play() の後も「rate > 0かつ再生は止まっている」という状態が普通に想定されます。実際再生しているかどうかはisPlaying、ユーザーやプログラムが再生させたいと思っているかどうかはrequestPlayingのような区別が内部的には必要になると思います。
実際再生しているかどうかは、(AVPlayerにはここ数年で色々なフラグや状態enumが追加されたのですが結局どれも役に立たないので、)PeriodicTimeObserverで通知される再生位置が進んでいたらtrue、更新が止まったらfalseというのが一番安定すると思います。これ豆知識です。

まとめ

お仕事の方で音声プレイヤー周りを触ることになったので、設計周りで考えてたことを雑に書いてみました。大体課題感は伝わったのではないでしょうか。上記の設計で一度コードに組み込んで、結構いい感じで動いてるところまでは確認できました。難しい部分はほぼ全部Repositoryに押し込めるので、PresenterやUseCaseがかなりスッキリしました。
ちなみに上の設計を「音声プレイヤー」で組み込むのか、「動画プレイヤー」で組み込むのかで結構見え方が変わると思うので、試してみてください。
本格的に組み込むのはこれからなので、ガッツリ導入してみた所感をそのうち書くかもしれません。
個人的にはSwiftUIやFlutterでこの辺りどのくらいシンプル化できてどのくらい実用に耐えるのかが気になっているというか、楽しみなところです。
それでは。