Upgrade to Pro — share decks privately, control downloads, hide ads and more …

stand.fm(Android)におけるreact-native-track-playerの改善

yu mitsuhori
February 02, 2021

 stand.fm(Android)におけるreact-native-track-playerの改善

yu mitsuhori

February 02, 2021
Tweet

More Decks by yu mitsuhori

Other Decks in Technology

Transcript

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    4

    View full-size slide

  5. 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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    12

    View full-size slide

  13. 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 


    View full-size slide

  14. 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

    View full-size slide

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

    View full-size slide

  16. 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

    プレイヤー操作

    プレイヤー操作

    再生状態の変更通知


    View full-size slide

  17. 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

    View full-size slide

  18. 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の
    コネクションが確立

    View full-size slide

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

    作成された通知


    View full-size slide

  20. 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と紐付ける

    View full-size slide

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

    View full-size slide

  22. 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

    View full-size slide

  23. 23
    これらを踏まえて、通知をスワイプで消すためにやること

    MetadataManagerの

    通知状態の更新時に

    プレイヤーが一時停止状態であれば

    stopForeground(false or STOP_FOREGROUND_DETACH)

    を呼んでForegroundServiceを停止させる


    View full-size slide

  24. 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

    View full-size slide

  25. 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

    View full-size slide

  26. 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

    View full-size slide

  27. 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

    View full-size slide

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

    28

    View full-size slide

  29. 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

    View full-size slide

  30. ライブラリをforkして改修することへの考察

    pros:

    - 自分たちがやりたいように機能追加できる

    - メンテナに依存しないので修正を待ったり、マージされるか心配する必要がない

    - ユーザーに早く価値を提供できる

    cons:

    - 公式の実装が活発だと修正がコンフリクトする可能性がある(追従しづらい)

    - 修正にはネイティブの知識が必要になることも多い

    - 既存実装をしっかり考慮して既存仕様に影響のないように適切に修正するのが難しい

    30

    View full-size slide

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

    View full-size slide

  32. 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

    View full-size slide

  33. 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

    View full-size slide

  34. 35
    RNTrackPlayerを例にしたService起動のイメージ

    js
 Module
 Binder
 Service
 Manager
 Player

    setup

    destroy

    ︙

    Stop

    Unbind

    add

    play

    pause

    ModuleからPlayerの操作をする際には 

    Binderを経由して行われる 

    Start

    Bind


    View full-size slide

  35. 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を呼ぶ 


    View full-size slide