1. はじめに
皆様お疲れ様です。「Swift/Kotlin愛好会 Advent Calendar 2021」の15日目を担当させて頂きます、fumiyasac(Fumiya Sakai)と申します。何卒よろしくお願い致します。
まずは僕自身の今年のトピックスとしては、昨年5月からお世話になっている現場では、iOSアプリ開発と並行してAndroidアプリ開発にも携わる機会があったり、これまでに執筆をしていた「iOSアプリ会開発 UI実装であると嬉しいレシピブック Vol.3」の商業誌化、そしてiOSDC Japan 2021でのパンフレット原稿執筆&登壇など、慌ただしくしながらも楽しく過ごしてきました。
業務の中で動画プレイヤー機能を盛り込んだiOS/Androidアプリ開発に携わっていたこともあり、今年のiOSDC Japan 2021の登壇では、現在開発しているアプリ機能を実現していく中で押さえておくと良さそうな部分についての解説をさせて頂きました。その中でもYouTube Premium/Spotify/Apple Music等をはじめとする動画や音声を視聴するアプリの機能の一部として組み込まれている、アプリがBackground状態となった時にも音声のみを途切れる事なく視聴可能なバックグラウンド再生機能 の実装については、日頃からもよく活用している機能の1つです。
iOSアプリ内での動画プレイヤーと連動したバックグラウンド再生機能について調査をしていた際に、Androidアプリで同様の機能を実装したい場合の方針や押さえておきたい基本事項についても個人的に気になっていた部分ではありましたので、登壇資料を作成していた当時から少しずつ調査をしていました。本記事はAndroid側でもBackground再生機能を実現する際に、押さえておきたい基本事項や実装に関する理解を深めていく上でポイントとなりそうな部分について、iOS側で似た様なバックグラウンド再生機能を実現する場合とも見比べながら解説をしたものになります。これまでは、iOSアプリ開発の経験はそれなりにはあったものの、動画関連の事やAndroidアプリ開発に触れたのは最近のことでしたがこれらの部分に触れていく機会は僕自身もとても楽しく感じております。
iOSDC 2021でのiOS動画プレイヤー動画実装に関連する登壇資料:
動画プレイヤーアプリの開発を通じて学んだ機能を実現するための要点解説
- Slide: https://www.slideshare.net/fumiyasakai37/ss-250211336
- Youtube: https://www.youtube.com/watch?v=JirR4lotBM0
※ 上記スライドのNo.23〜No.36&No.51にてポイントとなる処理部分の概要を説明しております。
2. iOSアプリでBackground音声再生機能を実現する際のポイントをおさらいする
まずは、iOSアプリ内に搭載されている動画プレイヤーに対してBackground再生機能に対してのおさらいをここで簡単に述べておこうと思います。実装を組み立てていくに当たってのコードを交えたポイントについては前述した登壇内容や後述する参考資料にも掲載しておりますので、ここでは詳細は割愛しますが、ここではiOSアプリにおけるフォアグラウンド時からバックグラウンド時(またその逆も然り)へ切り替えた際に必要な処理の概要について図解を交えて簡単にご紹介致します。
⭐️ 2-1: iOSアプリにおけるBackground再生機能に関する概要
基本的には動画プレイヤー画面を表示した状態から、動画を視聴中に「メッセージが届いたのでメールアプリで確認したい・駅に電車を降りてこれから移動する等」の状況の変化 が発生して、視聴コンテンツ音声だけ聞いた状態にする形のイメージになります。
ここでのポイントとなる点は、
-
ControlCenterの処理を利用した操作と必要な情報表示:
- MPRemoteControlCenter(Background再生時にControlCenterの操作を画面へ状態を反映する際に必要な部分)
- MPNowPlayingInfoCenter (現在の動画に関する情報をControlCenterへ反映する際に必要な部分)
-
AppDelegate(SceneDelegate)のライフサイクル変化をNotificationCenterで監視:
- applicationWillEnterForeground (フォアグラウンド復帰時)
- applicationDidEnterBackground (バックグラウンド移行時)
- AVPlayerで表示している内容の更新:
の3点を踏まえながら、動画プレイヤー画面とControlCenter間での連携を図りながら、利用しているAVPlayerの状態を更新し画面の状態変更を反映する処理を考える部分にあるかと思います。
final class VideoPlayerViewController: UIViewController {
// (省略) その他VideoPlayer用画面に配置している再生状態や再生位置コントロール用のボタン要素やSeekbar等を配置する
// 動画を画面に表示する際に必要なAVPlayerLayerを中に含んだView要素
// 参考: https://developer.apple.com/documentation/avfoundation/avplayerlayer
// ポイント: 動画を表示するための部分
@IBOutlet weak var playerView: AVPlayerView!
// 動画の再生や管理を司る部分
// ポイント: Background再生時に実行する操作を適用するのはこの部分
private var player = AVPlayer()
// (省略) 再生状態や再生位置コントロール用のボタン要素やSeekbar等の操作が実行されたタイミングでAVPlayerのインスタンスに状態変化を反映する点が動画プレイヤー機能を考えていく上でのポイントとなります。
// MARK: - Override
override func viewDidLoad() {
super.viewDidLoad()
// (省略) 動画プレイヤー画面に関する必要な設定を記述する
// Background再生処理に必要な処理群の設定を画面をセットアップするタイミングで実施する
// (1) アプリのライフサイクルと連動するNotificationの設定
setupNotificationsForApplicationLifecycle()
// (2) MPRemoteControlCenterの設定
setupControlCenterForBackgroundPlay()
// (3) MPNowPlayingInfoCenterの設定(※VideoDataModel内に動画に関する情報を格納している想定)
setupNowPlayingInfoForBackgroundPlay(videoDataModel: VideoDataModel)
}
// MARK: - Private Function
private func setupNotificationsForApplicationLifecycle() {
// フォアグラウンド復帰時に実行する処理
NotificationCenter.default.addObserver(
forName: .applicationWillEnterForeground,
object: nil,
queue: .main
) { [weak self] _ in
guard let weakSelf = self else {
return
}
// 再生状態でない場合は再生処理を実行する
if !weakSelf.player.isPlaying {
weakSelf.play()
}
// バックグラウンド再生から復帰
if weakSelf.player.isPlaying {
// (省略)再生状態の画面反映処理
} else {
// (省略)停止状態の画面反映処理
}
// 動画表示を画面に反映する
// ポイント: AVPlayer自体は処理され続けているため、動画の表示がこれまでの再生位置から開始される
weakSelf.playerView.player = weakSelf.player
}
// バックグラウンド移行時に実行する処理
NotificationCenter.default.addObserver(
forName: .applicationDidEnterBackground,
object: nil,
queue: .main
) { [weak self] _ in
guard let weakSelf = self else {
return
}
// 動画表示をnilにしておく
// ポイント: AVPlayer自体は処理され続けているため、直前まで再生していた位置から音声のみの出力が開始される
weakSelf.playerView.player = nil
}
}
private func setupControlCenterForBackgroundPlay() {
let commandCenter = MPRemoteCommandCenter.shared()
// (1) 10秒送り & 10秒戻し処理をControlCenterで実行した際の処理
commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: 10.0)]
commandCenter.skipForwardCommand.addTarget { [weak self] _ in
guard let weakSelf = self else {
return .commandFailed
}
// (省略) ControlCenterで実行された10秒送り処理を動画プレイヤー側にも反映する
return .success
}
commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: 10.0)]
commandCenter.skipBackwardCommand.addTarget { [weak self] _ in
guard let weakSelf = self else {
return .commandFailed
}
// (省略) ControlCenterで実行された10秒戻し処理を動画プレイヤー側にも反映する
return .success
}
// (2) 再生 & 停止処理をControlCenterで実行した際の処理
commandCenter.playCommand.addTarget { [weak self] _ in
guard let weakSelf = self else {
return .commandFailed
}
// (省略) ControlCenterで実行された再生処理を動画プレイヤー側にも反映する
return .success
}
commandCenter.pauseCommand.addTarget { [weak self] _ in
guard let weakSelf = self else {
return .commandFailed
}
// (省略) ControlCenterで実行された停止処理を動画プレイヤー側にも反映する
return .success
}
// (3) SeekBarでの再生位置コントロール処理をControlCenterで実行した際の処理
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let weakSelf = self else {
return .commandFailed
}
if let event = event as? MPChangePlaybackPositionCommandEvent {
let value = Float(event.positionTime)
// (省略) ControlCenterで実行されたSeekBarでの位置変更を動画プレイヤー側にも反映する
return .success
} else {
return .commandFailed
}
}
}
private func setupNowPlayingInfoForBackgroundPlay(videoDataModel: VideoDataModel) {
// 現在画面内で利用しているAVPlayerから現在再生中の動画に関する情報を取得
guard let currentItem = player.currentItem else {
return
}
var nowPlayingInfo: [String: Any] = [:]
// API等から取得した動画と関連する情報をnowPlayingInfoへ反映する
// (1) MPMediaItemPropertyAlbumTitle: 動画全体のタイトル
// (2) MPMediaItemPropertyTitle: セクション単位のタイトル
// (3) MPMediaItemPropertyArtwork: サムネイル画像 (※サムネイル画像をURLから取得する場合はキャッシュを利用する)
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = videoDataModel.name
nowPlayingInfo[MPMediaItemPropertyTitle] = videoDataModel.sectionName
if let image = videoDataModel.thumbnailImage {
nowPlayingInfo[MPMediaItemPropertyArtwork] =
MPMediaItemArtwork(boundsSize: image.size) { size in
return image
}
}
// AVPlayerから取得した現在の動画情報をnowPlayingInfoへ反映する
// (1) MPMediaItemPropertyPlaybackDuration: 合計再生時間
// (2) MPNowPlayingInfoPropertyElapsedPlaybackTime: 現在再生時間
// (3) MPNowPlayingInfoPropertyPlaybackRate: 選択中再生レート
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = currentItem.asset.duration.seconds
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentItem.currentTime().seconds
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = Double(player.rate)
// MPNowPlayingInfoCenterへ作成したnowPlayingInfoを反映する
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
// ※補足1: 動画プレイヤー内で再生レートの変更や現在の再生位置が更新された際に実行する処理
// → MPNowPlayingInfoCenter.default().nowPlayingInfo内の調整したい値を書き換える
private func updateNowPlaying(
currentTime: Double,
playbackRate: Double
) {
guard var info = MPNowPlayingInfoCenter.default().nowPlayingInfo else {
return
}
info[MPNowPlayingInfoPropertyPlaybackRate] = playbackRate
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
// ※補足2: 動画プレイヤーを閉じる際に実行する処理
// → MPNowPlayingInfoCenter.default().nowPlayingInfoをnilに変更する
private func resetNowPlaying() {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
}
}
この様に、iOSアプリでBackground再生機能を実現していく際においては、動画プレイヤー画面側にControlCenterとの連携処理を記述することで、お互いの整合性をとっていく様な形となる点に注目すると考えやすくなるかと思います。
⭐️ 2-2: iOSアプリ側でBackground再生機能を実装する際の参考資料
Background再生機能を理解する際は、Appleの公式ドキュメントにも詳細に紹介されているので、この方法を応用して動画プレイヤー画面に適用していく方針となる点を押さえておくとより理解がしやすくなるかと思います。
特に動画Player画面をAVPlayerViewController
を利用せずに、フルスクラッチでの実装をしている場合については、動画Player画面内での再生位置や再生状態のコントロールをするための処理との連動して状態の整合を取る様な形で、動画プレイヤー側の処理を組み立てていく点を意識しながら進めると良さそうです。
【Appleの公式ドキュメント】
- Enabling Background Audio
- Playing Audio from a Video Asset in the Background
- Controlling Background Audio
【AVPlayerLayerを利用した動画再生機能の実装例紹介】
3. AndroidアプリでBackground再生機能を考える際に必要な技術やライブラリ等のおさらい
ここからはAndroidアプリでBackground再生を実現する際に、まずは押さえておきたい基本事項や利用する処理に関する部分を紹介できればと思います。AndroidでBackground再生機能を考えていく場合のアイデアとポイントとしましては、動画プレイヤーを表示した状態のアプリをバックグラウンド状態へ移行しても常駐して動作するServiceを利用する点になるかと考えています。Serviceはいわば「表示するUIが存在しないActivity」と捉えることもできるので、動画プレイヤー画面が生成されたタイミングでBackground再生機能用のServiceを起動させ、Service内の処理を介して動画再生状態の変化を画面に反映させるイメージをして頂けると良さそうです。
また、動画プレイヤー画面の構成については「ExoPlayer」というとても便利なライブラリを活用する方針を本記事では取った形を想定しています。ExoPlayerの特徴としては、動画プレイヤーに必要な機能が一通り揃っており、デザイン面でのカスタマイズについても柔軟に行える点に加え、MediaSessionとの連携するための拡張も加えられているため、これを利用して再生中の動画情報を取得可能な点も嬉しいところではないかと思います。
※ 本記事内で紹介している事例以外に、もし良い方法をご存知や設計や実装過程の中で改善できそうな部分のアイデアをお持ちの方がいらっしゃいましたら、是非ともご教示頂けますと幸いです。
⭐️ 3-1: Service & BroadcastReceiverの利用
Background再生機能を実現するにあたり、基本方針としてはServiceを利用することで実現できますが、これだけではBackground再生状態の時に再生位置の更新処理処理や一時停止等の処理を動画プレイヤー画面側に伝える事ができませんので、Service側での操作による変更内容を受け取って動画プレイヤー画面へ変更内容を反映するためにBroadcastReceiverも一緒に利用する方針となるかと思います。
class VideoPlayerFragment : Fragment(R.layout.fragment_video_player) {
// ・・・(省略)・・・
// 動画表示等に関する情報を画面側へ反映する等、FragmentにServiceでの状態を伝えて反映する際にはBroadcastReceiverを利用する
private val videoEventReceiver = object : BroadcastReceiver() {
// Broadcastを受信した時に実行する処理
override fun onReceive(
context: Context?,
intent: Intent?
) {
intent ?: return
when (intent.action) {
// (省略) Action名に応じた処理を実施する
// → Service側ではsendBroadcast()でIntentを発行する
}
}
}
// Serviceへの接続を監視する
private val connection = object : ServiceConnection {
// Serviceとの接続が確認できた際に実行する処理
override fun onServiceConnected(
name: ComponentName?,
service: IBinder?
) {
// → Service側でインスタンス化したExoPlayerを反映する
}
// Serviceとの接続が切断された際に実行する処理
override fun onServiceDisconnected(name: ComponentName?) {}
}
// ・・・(省略)・・・
}
動画プレイヤー画面内での操作による状態の更新処理については後述するExoPlayerを利用した処理がベースとなりますが、ExoPlayerのインスタンス生成処理についてはService側で受け持って、動画プレイヤー側でも利用する形をイメージして頂くと良いかもしれません。
【Androidの公式ドキュメントや解説資料】
Service:
Broadcast Receiver:
【Serviceを利用したサンプル実装事例】
⭐️ 3-2: ExoPlayerでの動画プレイヤー作成と動画に関する情報の取得
ExoPlayerではUIが提供されているので基本的にはこちらを利用しながら動画プレイヤー画面を構築していく方針になります。表示動画の内容をコントロールするためのUI要素部分とレイアウトを分割できる点等メリットも多いと感じることもありました。とはいえ、最初はExoPlayerを利用した動画プレイヤーの基本的な処理の作り方を理解する部分が重要になるかと思いますので、下図のBackground再生機能解説と合わせてその他ドキュメントや解説資料も合わせて理解を深めていくと良さそうに思います。
【ExoPlayer2を利用した実装に関するドキュメントや解説資料】
- 公式ドキュメント
- 【入門】ExoPlayerと仲良くなっていかない?
- ExoPlayerを使ってAndroidアプリに動画を入れた話
- ExoPlayerのUI変更方法
- Androidでオーディオアプリを作るということ
- ExoPlayerとMediaSessionを何となく使う
- ExoPlayerでMediaSessionをいい感じに扱う
- The MediaSession extension for ExoPlayer
- Playback Notifications with ExoPlayer
⭐️ 3-3: 動画を再生する画面との整合性を取るために重要と感じた部分の考察
前述したServiceとExoPlayerを利用して実装した動画プレイヤー画面の連携処理に加えて、両方のライフサイクル処理も活用することで画面またはBackground再生の状態をコントロールする必要がある点に注意しながら機能の設計や実装を考えていく様にすると個人的にイメージがしやすいのではないかと感じました。iOS側での処理は基本的に動画プレイヤー側での処理とApplicationのライフサイクル処理との連携がポイントでしたが、Android側ではService/Broadcast処理と動画プレイヤー画面との連携方法とライフサイクル処理との整合性をいかに合わせるかという点がポイントになりそうだと改めて思いました。
-
動画プレイヤー側で利用しそうなライフサイクル処理例:
- onStart() / onResume() / onPause() / onStop() / onDestroyView() ...
-
Service側で利用しそうなライフサイクル処理例:
- onCreate() / onDestroy() / onBind() / onUnbind() / onRebind() / onStartCommand() / onTaskRemoved() ...
元のきっかけとしては、iOS側でのBackground再生機能と類似した形をAndroidでも実現しようと考えた場合に、ServiceやBroadCastReceiverを理解したり動画プレイヤーを構築する際に押さえるべき基本事項を整理することで、個人的にイメージが徐々に掴める様になったので、「どの様な技術があり、どう組み合わせるか」という実装の前段にあたる部分の事項を自分なりの形でまとめた次第です。今回は具体的な画面実装例やサンプルコード等はありませんが、少しでも理解の一助となる事ができれば嬉しく思います。
4. 全体像やポイントをまとめたノート
余談として、本記事におけるAndroidアプリでのバックグラウンド再生に関する知識やポイントをダイジェストとしてまとめたノートはこちらになります。僕自身はiOSエンジニアとしての経験が長かったこともあったので、iOS側との実装と明確に考え方が異なる部分をヒントにしながら進めていく様にしていました。特にServiceやBroadCastといったAndroidのシステムが用意している機能の理解やExoPlayerの利用を前提とした基本的な動画プレイヤー画面の構築の2点について、まずは理解を深めていく様に進めると個人的にはわかりやすかった様に感じています。
⭐️ 4-1: Background再生機能概要 & ExoPlayerでの動画プレイヤー作成
⭐️ 4-2: ExoPlayerとServiceの連携 & MediaSessionの利用
⭐️ 4-3: Background再生機能を実現する際の全体像を掴む
⭐️ 4-4: 両方のOS間での大まかな違いと振る舞いを実現する上でのポイント
5. Flutterで動画または音楽プレイヤー機能を実現するためのパッケージ紹介
こちらも余談になりますが、最近少しずつ取り組み始めているFlutterで動画プレイヤー・オーディオプレイヤー機能とそれに付随する機能を実現していく際のアプローチについても、簡単ではありますがここでご紹介できればと思います。
iOS/Androidのネイティブアプリではそれぞれ動画まわりの実装方針が大きく異なっている故にFlutterで完結したい場合には、
等のPackageを活用していく形に基本的にはなっていくと思います。(この2つについては割と古くから利用されているPackageです。また、その他のPackageについても内部で利用しているiOS/Androidの動画プレイヤーがそれぞれ異なる点にも注目すると、より良い選択ができるかと思います。)
その中でも、Background再生機能までをサポートしてくれているPackageや実装の解説については後述する様なPackageの利用や解説記事を参考にすると良さそうです。
【Background再生機能をサポートする動画プレイヤーパッケージ例】
Package:
【Background再生機能をサポートするオーディオプレイヤーパッケージ例 & 実装解説記事例】
Package:
解説記事:
- Create an awesome background running Music Player like Amazon Music in Flutter
- Background audio in Flutter with Audio Service and Just Audio
※ ここで紹介したオーディオプレイヤーでのBackground再生に関する記事には動画による解説もありますので、こちらにも目を通しておくとよりイメージが掴めるのではないかと思います。
6. あとがき
iOS/Android間では端末がそもそも違う故に、動画プレイヤーを実装する部分については考え方はそれぞれ全く異なる方針や考え方になるので共通点は少ないですが、動画ないしは音声プレイヤー機能を持つアプリにBackground再生機能を実装していく際にはまず、
- プレイヤー機能を実現するために必要な処理
- アプリ自体のライフサイクル処理と連携するための処理
- アプリ内のプレイヤー画面とバックグラウンド状態で動作するServiceクラスを連携するための処理
の3点についての理解を深めておくと、機能の動作や実装に関するイメージが掴みやすくなるのではないかと思います。
実際のアプリに導入していく際には、プレイヤー機能を持つ画面の機能に加えてアプリ全体との整合性や兼ね合いを踏まえる必要もある部分にその難しさを感じる部分ではあるものの、フォアグラウンド時からバックグラウンド時(またその逆も然り)へ切り替えた際にも途切れることなく音声のみの再生へシームレスへ移行し、なおかつ音声を聴きながらスマートフォンで他の操作を実行できる体験は、そのアプリを利用しているユーザーにとっても心地良いものです。
昨年から少しずつではあるものの、動画関連の事やAndroidアプリ開発にも業務を含めて取り組み始めてからおよそ1年半が経過しました。iOS/Android両方のアプリ開発の両方を経験する事で、改めて理解できた事や発見もありました。特にiOS/Android間で同じ機能やUIを実装する際に2つのデバイス間における類似点や相違点に着目しながら実務へのキャッチアップや個人的にインプットをしていた様に思います。
【過去のSwift/Kotlin愛好会での登壇資料】
【過去の取り組み事例】
また、最近では不定期ではあるものの、Twitterで自分が学習したことをノートにまとめたものを公開したりもしています。
【Android関連で気になるTIPSをまとめたものの例】
- 過去にAndroidアプリ機能開発にて参考にしたリンク集
- Androidの新しいVersionから利用できるUI実装コンポーネントに関するメモ
- Android Motion Layoutの考察
- 続・Android Motion Layoutの考察
今後とも自分が取り扱う技術については、これまでも長く取り組んできたiOSアプリ開発のUI実装部分についても怠ることなく精進していくと同時に、Androidアプリ開発やFlutter等に関する知見を貯める活動についても両立を図っていき、i登壇や記事等でのアウトプットの機会も増やしていく事ができればと考えております。特に動画や配信に関する処理については、元々僕自身も知見がなかった部分ではありますが、iOSDCでの登壇をきっかけに自分でもUI実装やアニメーション・インタラクションとも組み合わせた表現やPicture-In-Picture等の動画ならではの機能との連携部分についても関心が上がっている部分です。
そして本記事へのフィードバックや感想等もございましたら、何卒よろしくお願い致します。