Slide 1

Slide 1 text

stand.fm(Android)における react-native-track-playerの改善 エンジニア [holly(ホリー)] 2021/02/02 PORT Firebase x React Native 1

Slide 2

Slide 2 text

自己紹介 - 三堀 裕(みつほり ゆう) - holly(ホリー) - 2021/01にstand.fmに正式ジョイン - 2020/08~: 業務委託 - Android, Flutterなどモバイル開発がメイン - ReactNativeはstand.fmで初めて触りました - 趣味:ダーツ、旅行、(麻雀) @1013Youmeee youmitsu @youmeee 2

Slide 3

Slide 3 text

アジェンダ - RNTrackPlayerとは~基本的な使い方~ - RNTrackPlayer(Android)の問題点 - RNTrackPlayerを改善する - ReactNativeのネイティブモジュールについて - RNTrackPlayer(Android)のアーキテクチャについて - stand.fmで再生コントロール通知をスワイプで削除できるようにした話 - まとめ 3

Slide 4

Slide 4 text

RNTrackPlayerとは ~基本的な使い方~
 4

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

RNTrackPlayerのstand.fmでの利用シーン - 通常の放送の再生 - LIVEの再生 6 通常の放送 LIVE

Slide 7

Slide 7 text

実装例: 音声の再生、一時停止、停止、リセット // プレイヤーの初期化 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

Slide 8

Slide 8 text

実装例: プレイヤーのオプション設定 内容 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)

Slide 9

Slide 9 text

実装例: プレイヤーの外部からのイベントに応じて処理をする // プレイヤーからのイベント通知時のコールバックのセット TrackPlayer.addEventListener('remote-play', e => { // 外部入力(通知コントロールなど)から再生イベントが発生した場合 }) TrackPlayer.addEventListener('remote-pause', e => { // 外部入力(通知コントロールなど)から一時停止イベントが発生した場合 }) TrackPlayer.addEventListener('remote-jump-forward', e => { // 外部入力(通知コントロールなど)からスキップイベント(次へ)が発生した場合 }) TrackPlayer.addEventListener('remote-jump-backward', e => { // 外部入力(通知コントロールなど)からスキップイベント(前へ)が発生した場合 }) 9

Slide 10

Slide 10 text

- リリースが2020/04から止まっている - PRのマージは去年の10月が最後... - (特にAndroid)で足りない機能やバグがある - 再生コントロール通知がスワイプで削除できない - Pixel系で再生中にアプリをキルしても 再生のプロセスがキルされない ただ、いくつか問題点が。。 10 問題に対処するためにリポジトリをforkして独自実装を入れている

Slide 11

Slide 11 text

- Androidの通知センターでメディアのコントロールが可能 - 一般的な音声アプリ(SpotifyやYouTubeMusicなど)では、 一時停止すると通知はスワイプで削除することができる - RNTrackPlayerではstop(停止)機能が用意されていてそちらで 代用する方法もあるが、ボタンが増えるのでかっこ悪い - 補足:Android11ではメディアコントロールの仕様が変わったため 対象外 Androidで再生コントロール通知がスワイプで削除できない 自分で直すしかないが、ネイティブ側を直す必要がある... 11

Slide 12

Slide 12 text

ReactNativeで ネイティブを触るには
 12

Slide 13

Slide 13 text

13 ReactNativeのネイティブモジュールとは
 - ReactNativeでネイティブのコードを実行する仕組み - https://reactnative.dev/docs/native-modules-intro 
 引用: https://medium.com/hackernoon/first-experiences-with-react-native-bridging-an-andr oid-native-module-for-app-authentication-501fec247b2b 


Slide 14

Slide 14 text

ReactNativeのネイティブモジュールの実装例( Android) public class MusicModule extends ReactContextBaseJavaModule { @Override public 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 Module Native Module

Slide 15

Slide 15 text

RNTrackPlayerの ネイティブモジュールは どうなっているのか 15

Slide 16

Slide 16 text

RNTrackPlayerのアーキテクチャ(JS+Android Native) 16 JS
 Native
 ReactComponent RNTrackPlayer js module NativeModuleの関数を 呼び出すモジュール MusicEvent js側への イベントエミッター MusicModule js側から呼び出される インターフェース MusicService バックグラウンドプロセス MusicBinder Serviceへのブリッジ MusicManager 再生状態の管理・イベント ハンドラ ExoPlayback ExoPlayerのインターフェース MetadataManager 通知コントロールの 管理 ExoPlayer イベントのEmit
 プレイヤー操作
 プレイヤー操作
 再生状態の変更通知


Slide 17

Slide 17 text

AndroidのServiceとは - Androidでバックグラウンドで処理を実行するための仕組み - Activityのようにライフサイクルがある - Serviceを起動するにはcontext.startService()を呼ぶ - Serviceを起動後、Serviceをバインドすると、起動元から起動中のServiceとの通信が 可能になり、様々な操作が可能になる 17 @ReactMethod public 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

Slide 18

Slide 18 text

MusicService起動の流れ 18 JS
 Native
 ReactComponent RNTrackPlayer js module NativeModuleの関数を 呼び出すモジュール MusicEvent js側への イベントエミッター MusicModule js側から呼び出される インターフェース MusicService バックグラウンドプロセス MusicBinder Serviceへのブリッジ MusicManager 再生状態の管理・イベント ハンドラ ExoPlayback ExoPlayerのインターフェース MetadataManager 通知コントロールの 管理 ExoPlayer イベントのEmit
 プレイヤー操作
 プレイヤー操作
 再生状態の変更通知
 ②Serviceが生成される ①Service起動要求 (startService) ③ServiceのBind要求 ④ServiceとModuleの コネクションが確立

Slide 19

Slide 19 text

AndroidのForegroundService - Androidのバックグラウンドでの実行制限が厳しくなり、8.0 から導入されたServiceの一種 - ユーザーが視認可能なService - Serviceを開始したあとに通知を作り、Serviceと通知を紐 付けることによって起動する。 - 起動後10秒以内に紐付けないと、アプリが落ちる 19 ForegroundService起動のために 
 作成された通知


Slide 20

Slide 20 text

ForegroundServiceの起動方法 - context.startForegroundService()を呼び出す 20 MusicModule - ForegroundServiceが開始されたあと、10秒以内にService側で通知を作成し、 startForeground()を呼ばなければならない - 呼ばないとANRになりアプリが落ちる仕様になっている MusicService public class MusicModule extends ReactContextBaseJavaModule { @ReactMethod public void setupPlayer(ReadableMap data, final Promise promise) { ... Intent intent = new Intent(getReactApplicationContext(), MusicService.class); context.startForegroundService(intent); ... } ... } public class MusicService extends HeadlessJsTaskService { @Override public 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と紐付ける

Slide 21

Slide 21 text

- 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未満

Slide 22

Slide 22 text

ForegroundService起動→停止の流れ 22 JS
 Native
 ReactComponent RNTrackPlayer js module NativeModuleの関数を 呼び出すモジュール MusicEvent js側への イベントエミッター MusicModule js側から呼び出される インターフェース MusicService バックグラウンドプロセス MusicBinder Serviceへのブリッジ MusicManager 再生状態の管理・イベント ハンドラ ExoPlayback ExoPlayerのインターフェース MetadataManager 通知コントロールの 管理 ExoPlayer イベントのEmit
 プレイヤー操作
 プレイヤー操作
 再生状態の変更通知
 ②Service生成→通知生成→ startForeground()でフォアグラウン ドサービスを開始 ①ForegroundService 起動要求 (startForegroundService) ④ForegroundService 停止要求 (stopForeground) ③プレイヤー状態 変更通知 stop

Slide 23

Slide 23 text

23 これらを踏まえて、通知をスワイプで消すためにやること 
 MetadataManagerの
 通知状態の更新時に
 プレイヤーが一時停止状態であれば
 stopForeground(false or STOP_FOREGROUND_DETACH)
 を呼んでForegroundServiceを停止させる


Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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)が呼ばれるように修正する 25 MetadataManager.java

Slide 26

Slide 26 text

26 補足1:アプリが破棄されても通知が残り続けてしまうことがある 
 - 通知とServiceが切り離されると、Serviceがキルされても通知が消えない場合がある 
 - アプリが破棄された際に通知を削除するように修正 
 public MusicModule(ReactApplicationContext reactContext) { ... reactContext.addLifecycleEventListener(new LifecycleEventListener() { // Activityのライフサイクルの変化を Listen @Override public void onHostResume() { // no-op } @Override public void onHostPause() { // no-op } @Override public void onHostDestroy() { // Activityが破棄されたときに呼ばれる reactContext.removeLifecycleEventListener(this); NotificationManager nManager = ((NotificationManager) getReactApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE)); if (nManager != null) { nManager.cancel(1); // 通知を削除する } } }); } MusicModule.java

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

修正後の動作(通知がスワイプで消せるようになった) 
 28

Slide 29

Slide 29 text

29 forkしたコードをどうやってアプリに含めるか 
 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

Slide 30

Slide 30 text

ライブラリをforkして改修することへの考察 
 pros:
 - 自分たちがやりたいように機能追加できる 
 - メンテナに依存しないので修正を待ったり、マージされるか心配する必要がない 
 - ユーザーに早く価値を提供できる 
 cons:
 - 公式の実装が活発だと修正がコンフリクトする可能性がある(追従しづらい) 
 - 修正にはネイティブの知識が必要になることも多い 
 - 既存実装をしっかり考慮して既存仕様に影響のないように適切に修正するのが難しい 
 30

Slide 31

Slide 31 text

まとめ - stand.fmではRNTrackPlayerを使っているが、UXの改善のために足りない機能や バグについてはフォークをして独自実装をしている - ReactNativeはクロスプラットフォームSDKであるが、ネイティブの知識が必要な場 面も多くある - ネイティブエンジニアが活躍できる場面もしばしばある 31

Slide 32

Slide 32 text

32

Slide 33

Slide 33 text

ForegroundServiceの起動方法~MusicModule側~ - context.startForegroundService()を呼び出す - RNTrackPlayerではMusicModuleで起動している 33 public class MusicModule extends ReactContextBaseJavaModule { @ReactMethod public 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

Slide 34

Slide 34 text

ForegroundServiceの起動方法~MusicService側~ - ForegroundServiceを開始する場合、10秒以内にService側で通知を作成し、 startForeground()を呼ばなければならない - 呼ばないとANRになりアプリが落ちる仕様になっている 34 public class MusicService extends HeadlessJsTaskService { @Override public 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

Slide 35

Slide 35 text

35 RNTrackPlayerを例にしたService起動のイメージ 
 js
 Module
 Binder
 Service
 Manager
 Player
 setup
 destroy
 ︙
 Stop
 Unbind
 add
 play
 pause
 ModuleからPlayerの操作をする際には 
 Binderを経由して行われる 
 Start
 Bind


Slide 36

Slide 36 text

36 RNTrackPlayerを例にした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を呼ぶ