stand.fm(Android)におけるreact-native-track-playerの改善エンジニア [holly(ホリー)]2021/02/02 PORT Firebase x React Native1
View Slide
自己紹介- 三堀 裕(みつほり ゆう)- holly(ホリー)- 2021/01にstand.fmに正式ジョイン- 2020/08~: 業務委託- Android, Flutterなどモバイル開発がメイン- ReactNativeはstand.fmで初めて触りました- 趣味:ダーツ、旅行、(麻雀)@1013Youmeeeyoumitsu@youmeee2
アジェンダ- RNTrackPlayerとは~基本的な使い方~- RNTrackPlayer(Android)の問題点- RNTrackPlayerを改善する- ReactNativeのネイティブモジュールについて- RNTrackPlayer(Android)のアーキテクチャについて- stand.fmで再生コントロール通知をスワイプで削除できるようにした話- まとめ3
RNTrackPlayerとは~基本的な使い方~ 4
react-native-track-player(RNTrackPlayer)とは- ReactNativeの音声再生に特化した軽量な多機能ライブラリ- Android, iOS, Windowsに対応- アプリ以外のコントロールに対応- OSのコントロール通知, Bluetoothデバイス, SmartWatchなど- 様々な音声フォーマットに対応- mp3, m4a, Streaming(HLS, DASH)など- Network, Local両音源対応- https://react-native-track-player.js.org/5
RNTrackPlayerのstand.fmでの利用シーン- 通常の放送の再生- LIVEの再生6通常の放送 LIVE
実装例: 音声の再生、一時停止、停止、リセット// プレイヤーの初期化TrackPlayer.setupPlayer()// 再生する音声のメタデータをキューに追加TrackPlayer.add({id: 'unique track id',url: 'http://example.com/sample_sound.mp3',title: 'sample_sound',artist: 'Bob',artwork: 'http://example.com/sample_artwork.png',})// 音声の再生、一時停止、停止TrackPlayer.play()TrackPlayer.pause()TrackPlayer.stop()// プレイヤーのキューに入っているtrackの削除TrackPlayer.remove(['unique track id'])// プレイヤーのリセット//(再生中のトラックを停止、キューをクリア)TrackPlayer.reset()// プレイヤーの破棄//(一度呼ぶとsetupPlayerを呼ばない限り、再生ができなくなる)TrackPlayer.destroy()7
実装例: プレイヤーのオプション設定内容8// プレイヤーの詳細設定TrackPlayer.updateOptions({stopWithApp: true, // アプリがキルされたときにプレイヤーも止めるかjumpInterval: 15, // 何秒単位でスキップできるようにするかcapabilities: [ // 通知プレイヤーなどがユーザーに提供する機能の種類TrackPlayer.CAPABILITY_PLAY,TrackPlayer.CAPABILITY_PAUSE,TrackPlayer.CAPABILITY_JUMP_FORWARD,TrackPlayer.CAPABILITY_JUMP_BACKWARD,],})// 再生速度の設定TrackPlayer.setRate(this.props.rate)
実装例: プレイヤーの外部からのイベントに応じて処理をする// プレイヤーからのイベント通知時のコールバックのセットTrackPlayer.addEventListener('remote-play', e => {// 外部入力(通知コントロールなど)から再生イベントが発生した場合})TrackPlayer.addEventListener('remote-pause', e => {// 外部入力(通知コントロールなど)から一時停止イベントが発生した場合})TrackPlayer.addEventListener('remote-jump-forward', e => {// 外部入力(通知コントロールなど)からスキップイベント(次へ)が発生した場合})TrackPlayer.addEventListener('remote-jump-backward', e => {// 外部入力(通知コントロールなど)からスキップイベント(前へ)が発生した場合})9
- リリースが2020/04から止まっている- PRのマージは去年の10月が最後...- (特にAndroid)で足りない機能やバグがある- 再生コントロール通知がスワイプで削除できない- Pixel系で再生中にアプリをキルしても再生のプロセスがキルされないただ、いくつか問題点が。。10問題に対処するためにリポジトリをforkして独自実装を入れている
- Androidの通知センターでメディアのコントロールが可能- 一般的な音声アプリ(SpotifyやYouTubeMusicなど)では、一時停止すると通知はスワイプで削除することができる- RNTrackPlayerではstop(停止)機能が用意されていてそちらで代用する方法もあるが、ボタンが増えるのでかっこ悪い- 補足:Android11ではメディアコントロールの仕様が変わったため対象外Androidで再生コントロール通知がスワイプで削除できない自分で直すしかないが、ネイティブ側を直す必要がある...11
ReactNativeでネイティブを触るには 12
13ReactNativeのネイティブモジュールとは - ReactNativeでネイティブのコードを実行する仕組み- https://reactnative.dev/docs/native-modules-intro 引用:https://medium.com/hackernoon/first-experiences-with-react-native-bridging-an-android-native-module-for-app-authentication-501fec247b2b
ReactNativeのネイティブモジュールの実装例(Android)public class MusicModule extends ReactContextBaseJavaModule {@Overridepublic String getName() {return "TrackPlayerModule";}@ReactMethod // js側から呼び出すことができるインターフェースpublic void setupPlayer(ReadableMap data, final Promise promise) {// ネイティブ側のプレイヤーの初期化処理}}14// プレイヤーの初期化TrackPlayer.setupPlayer()import { NativeModules } from 'react-native';const { TrackPlayerModule: TrackPlayer } = NativeModules;function setupPlayer(options) {return TrackPlayerModule.setupPlayer(options || {});}RNTrackPlayerはNativeModuleで実装されているjs Component js ModuleNative Module
RNTrackPlayerのネイティブモジュールはどうなっているのか15
RNTrackPlayerのアーキテクチャ(JS+Android Native)16JS Native ReactComponentRNTrackPlayerjs moduleNativeModuleの関数を呼び出すモジュールMusicEventjs側へのイベントエミッターMusicModulejs側から呼び出されるインターフェースMusicServiceバックグラウンドプロセスMusicBinderServiceへのブリッジMusicManager再生状態の管理・イベントハンドラExoPlaybackExoPlayerのインターフェースMetadataManager通知コントロールの管理ExoPlayerイベントのEmit プレイヤー操作 プレイヤー操作 再生状態の変更通知
AndroidのServiceとは- Androidでバックグラウンドで処理を実行するための仕組み- Activityのようにライフサイクルがある- Serviceを起動するにはcontext.startService()を呼ぶ- Serviceを起動後、Serviceをバインドすると、起動元から起動中のServiceとの通信が可能になり、様々な操作が可能になる17@ReactMethodpublic void setupPlayer(ReadableMap data, final Promise promise) {// 省略Intent intent = new Intent(getReactApplicationContext(), MusicService.class);context.startService(intent); // Serviceの起動intent.setAction(Utils.CONNECT_INTENT);context.bindService(intent, this, Context.BIND_AUTO_CREATE); // Serviceのバインド}MusicService.java
MusicService起動の流れ18JS Native ReactComponentRNTrackPlayerjs moduleNativeModuleの関数を呼び出すモジュールMusicEventjs側へのイベントエミッターMusicModulejs側から呼び出されるインターフェースMusicServiceバックグラウンドプロセスMusicBinderServiceへのブリッジMusicManager再生状態の管理・イベントハンドラExoPlaybackExoPlayerのインターフェースMetadataManager通知コントロールの管理ExoPlayerイベントのEmit プレイヤー操作 プレイヤー操作 再生状態の変更通知 ②Serviceが生成される①Service起動要求(startService)③ServiceのBind要求④ServiceとModuleのコネクションが確立
AndroidのForegroundService- Androidのバックグラウンドでの実行制限が厳しくなり、8.0から導入されたServiceの一種- ユーザーが視認可能なService- Serviceを開始したあとに通知を作り、Serviceと通知を紐付けることによって起動する。- 起動後10秒以内に紐付けないと、アプリが落ちる19ForegroundService起動のために 作成された通知
ForegroundServiceの起動方法- context.startForegroundService()を呼び出す20MusicModule- ForegroundServiceが開始されたあと、10秒以内にService側で通知を作成し、startForeground()を呼ばなければならない- 呼ばないとANRになりアプリが落ちる仕様になっているMusicServicepublic class MusicModule extends ReactContextBaseJavaModule {@ReactMethodpublic void setupPlayer(ReadableMap data, final Promise promise) {...Intent intent = new Intent(getReactApplicationContext(), MusicService.class);context.startForegroundService(intent);...}...}public class MusicService extends HeadlessJsTaskService {@Overridepublic int onStartCommand(Intent intent, int flags, int startId) {...startForeground(1, new NotificationCompat.Builder(this, channel).build());return START_NOT_STICKY;}}1. ForegroundServiceを起動2. 通知を作成後startForeground()でServiceと紐付ける
- ForegroundServiceを停止すると、Serviceと通知の紐付けを解除できる(Serviceは停止されない)- 停止するには、stopForeground(boolean)を呼ぶ。引数にフラグを渡すことで、停止方法を選べる- ForegroundServiceの停止方法は2つa. 通知を残したまま停止する => false(Android 8.0以降はSTOP_FOREGROUND_DETACH)b. 通知を削除し停止する => true(Android 8.0以降はSTOP_FOREGROUND_REMOVE)ForegroundServiceの停止21// a. 通知を残したまま停止するservice.stopForeground(STOP_FOREGROUND_DETACH); // Android8.0以上service.stopForeground(false); // Android8.0未満// b. 通知を削除し停止するservice.stopForeground(STOP_FOREGROUND_REMOVE); // Android8.0以上service.stopForeground(true); // Android8.0未満
ForegroundService起動→停止の流れ22JS Native ReactComponentRNTrackPlayerjs moduleNativeModuleの関数を呼び出すモジュールMusicEventjs側へのイベントエミッターMusicModulejs側から呼び出されるインターフェースMusicServiceバックグラウンドプロセスMusicBinderServiceへのブリッジMusicManager再生状態の管理・イベントハンドラExoPlaybackExoPlayerのインターフェースMetadataManager通知コントロールの管理ExoPlayerイベントのEmit プレイヤー操作 プレイヤー操作 再生状態の変更通知 ②Service生成→通知生成→startForeground()でフォアグラウンドサービスを開始①ForegroundService起動要求(startForegroundService)④ForegroundService停止要求(stopForeground)③プレイヤー状態変更通知stop
23これらを踏まえて、通知をスワイプで消すためにやること MetadataManagerの 通知状態の更新時に プレイヤーが一時停止状態であれば stopForeground(false or STOP_FOREGROUND_DETACH) を呼んでForegroundServiceを停止させる
RNTrackPlayerのMetadataManagerの従来の実装private void updateNotification(boolean isPaused) {if(session.isActive()) { // プレイヤーが有効である場合service.startForeground(1, builder.build()); // こちらが呼ばれていた} else {service.stopForeground(true);}}一時停止時にはisActiveのままなので、stopForeground()が呼ばれない(呼ばれたとしても引数がtrueなので通知が消えてしまう)24- RNTrackPlayerではプレイヤーの状態に応じてMetadataManagerで通知の更新を行っている- MediaSessionと呼ばれるもので、プレイヤーが有効かを判断し、通知の更新を行うMetadataManager.java
MetadataManagerのコードの修正後private void updateNotification(boolean isPaused) {if (isPaused) { // 一時停止時にはactiveがどうかをチェックしないservice.startForeground(1, builder.build()); // activeではあるのでstartForeground()は呼ぶif (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {service.stopForeground(STOP_FOREGROUND_DETACH); // ≧8.0の場合通知を残すが、サービスは停止しない} else {service.stopForeground(false); // <8.0の場合通知を残して、サービス停止}return;}if(session.isActive()) {service.startForeground(1, builder.build());} else {service.stopForeground(true);}}一時停止時にはstopForeground(false)が呼ばれるように修正する25MetadataManager.java
26補足1:アプリが破棄されても通知が残り続けてしまうことがある - 通知とServiceが切り離されると、Serviceがキルされても通知が消えない場合がある - アプリが破棄された際に通知を削除するように修正 public MusicModule(ReactApplicationContext reactContext) {...reactContext.addLifecycleEventListener(new LifecycleEventListener() { // Activityのライフサイクルの変化を Listen@Overridepublic void onHostResume() { // no-op }@Overridepublic void onHostPause() { // no-op }@Overridepublic void onHostDestroy() { // Activityが破棄されたときに呼ばれるreactContext.removeLifecycleEventListener(this);NotificationManager nManager= ((NotificationManager) getReactApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE));if (nManager != null) {nManager.cancel(1); // 通知を削除する}}});}MusicModule.java
27補足2:通知をスワイプすると再度Serviceが起動してしまう - 既存実装でDeleteIntentが設定されているため、通知が削除された際にServiceが起動してしまう - DeleteIntent: 通知が削除されたのをイベントとしてブロードキャストするためのもの - Serviceが起動されてもstartForeground()を呼べないのでANRで落ちてしまう public MetadataManager(MusicService service, MusicManager manager) {this.builder = new NotificationCompat.Builder(service, channel);…builder.setDeleteIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(service,PlaybackStateCompat.ACTION_STOP)); // この行を削除}DeleteIntentをセットしないようにするMetadataManager.java
修正後の動作(通知がスワイプで消せるようになった) 28
29forkしたコードをどうやってアプリに含めるか 1. react-native-track-playerのリポジトリをフォーク 2. package.jsonにてGithubリポジトリを指定する "dependencies": {…,"react-native-track-player": "git://github.com/username/package.git#commit" // 例}https://docs.github.com/ja/packages/guides/configuring-npm-for-use-with-git※privateリポジトリの場合少し方法が変わるので、こちらをご確認ください package.json
ライブラリをforkして改修することへの考察 pros: - 自分たちがやりたいように機能追加できる - メンテナに依存しないので修正を待ったり、マージされるか心配する必要がない - ユーザーに早く価値を提供できる cons: - 公式の実装が活発だと修正がコンフリクトする可能性がある(追従しづらい) - 修正にはネイティブの知識が必要になることも多い - 既存実装をしっかり考慮して既存仕様に影響のないように適切に修正するのが難しい 30
まとめ- stand.fmではRNTrackPlayerを使っているが、UXの改善のために足りない機能やバグについてはフォークをして独自実装をしている- ReactNativeはクロスプラットフォームSDKであるが、ネイティブの知識が必要な場面も多くある- ネイティブエンジニアが活躍できる場面もしばしばある31
32
ForegroundServiceの起動方法~MusicModule側~- context.startForegroundService()を呼び出す- RNTrackPlayerではMusicModuleで起動している33public class MusicModule extends ReactContextBaseJavaModule {@ReactMethodpublic void setupPlayer(ReadableMap data, final Promise promise) {...Intent intent = new Intent(getReactApplicationContext(), MusicService.class);if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {context.startForegroundService(intent); // 8.0以上の場合フォアグラウンドサービスを起動} else {context.startService(intent);}...}...}MusicModule.java
ForegroundServiceの起動方法~MusicService側~- ForegroundServiceを開始する場合、10秒以内にService側で通知を作成し、startForeground()を呼ばなければならない- 呼ばないとANRになりアプリが落ちる仕様になっている34public class MusicService extends HeadlessJsTaskService {@Overridepublic int onStartCommand(Intent intent, int flags, int startId) {...onStartForeground();return START_NOT_STICKY;}private void onStartForeground() {...// 通知を作成し、startForeground()を呼ぶstartForeground(1, new NotificationCompat.Builder(this, channel).build());}}MusicService.java
35RNTrackPlayerを例にしたService起動のイメージ js Module Binder Service Manager Player setup destroy ︙ Stop Unbind add play pause ModuleからPlayerの操作をする際には Binderを経由して行われる Start Bind
36RNTrackPlayerを例にしたForegroundService起動のイメージ js Module Binder Service Music Manager Metadata Manager setup destroy ︙ Stop Unbind play pause Start Bind startForeground onPlay 通知更新 (startForeground) 再生リクエスト 一時停止リクエスト onPause 通知更新 (stopForeground) Playerの状態変化時に通知状態を更新するため、 start/stopForeground()を呼び出す Service起動後にstartForegroundを呼ぶ