見出し画像

SkyWay X Composeシリーズ第二弾:ComposeアプリでのSkyWay Android SDK活用のベストプラクティス

前書き

みなさま、こんにちは。SkyWayチームのJinと申します。前回の記事では、初心者向けの、ComposeアプリにSkyWay Android SDKを導入する手順について話しました。今回は、ComposeアプリにSkyWay Android SDKを利用する際のベストプラクティスについてお話しできればと思います。最後までお読みいただけると幸いです。

TL;DR

SkyWay Android SDKをJetpack Composeアプリに導入する際の注意点:

  • SkyWay SDKのsuspend APIを呼び出すためのCoroutineの生成は、 lifeCycleScopeの使用を避けること。

  • Contextの利用:

    • SurfaceViewRenderer APIにはActivityContextを利用すること。

    • その他のAPIにはApplicationContextを利用すること。

  • MutableStateの取り扱い:

    • LocalVideoStreamやRemoteVideoStreamをMutableStateとして扱う場合、UIスレッドでの更新を行うこと。

  • Resourceの解放:

    • AndroidViewの解放に注意すること。

    • 適切なCoroutineでResource解放系のAPIを呼び出すこと。

SkyWay Android SDK + Jetpack Compose実装時の注意点

1. SkyWay Android SDK の初期化とクリンアップについて

SkyWay Android SDKの初期化関数 SkyWayContext.setup()  と クリンアップ関数 SkyWayContext.dispose() は基本的にペアで利用されます。
SkyWayContext.setup() APIは suspend関数のため、Coroutine内で実行する必要があります。Composeアプリでよく見かけるCoroutineの生成方法には、以下の四つのパターンがあります:

  • lifecycleScopeを利用するパターン

  • viewModelScopeを利用するパターン

  • 自前のCoroutineScopeを利用するパターン

  • @composable内Coroutineを生成するパターン

    • LaunchedEffectやrememberCoroutineScopeなどを利用して、Coroutineを生成することが可能ですが、設計上特別な理由がない限り基本的におすすめしませんのでSKIP。

1.1. lifecycleSopeを利用するバターン
ActivityやFragment内でlifecycleScopeを使用してCoroutineを生成します。lifecycleScopeはActivityやFragmentのライフサイクルに合わせてCoroutineがキャンセルされます。

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycleScope.launch {
            val option = authTokenRepository.getAuthToken()?.let {
                SkyWayContext.Options(
                    authToken = it,
                    logLevel = Logger.LogLevel.VERBOSE
                )
            }
            SkyWayContext.onErrorHandler = { error ->
                Log.d("App", "skyway setup failed: ${error.message}")
            }
            val result =  SkyWayContext.setup(applicationContext, option!!)
            if (result) {
                Log.d("App", "skyway setup succeed")
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        SkyWayContext.dispose()
    }
}
  • メリット

    • lifecycleScopeを利用することで、ActivityやFragmentのライフサイクルに合わせてコルーチンを管理できるため、手動でコルーチンのキャンセルや終了処理を行う必要がなくなります。

  • デメリット

    • lifecycleScopeを使ったコルーチンは、ActivityやFragmentに依存しているため、テストしづらくなる可能性があります

    • ActivityやFragmentのConfiguration変更(例えば、デバイスの回転による画面再生成)が発生する際に、上記のサンプルコードではSDKのクリーンアップ処理が実行されるため、意図しない通話中断が発生する可能性があります。lifecycleScopeを利用し続けながら通話中断を回避するためには、SDKの初期化処理とクリーンアップ処理の関係性を崩すことになり、あんまりお勧めできません。


1.2. viewModelScopeを利用するバターン
ViewModel内でviewModelScopeを使用してCoroutineを生成します。viewModelScopeはViewModelのライフサイクルに結びついており、ViewModelが破棄されるとCoroutineもキャンセルされます。

class HomeViewModel(
    private val authTokenRepository: AuthTokenRepository,
) : ViewModel(){
    var isSkyWayInitialized by mutableStateOf(false)
    // init blockでSDKの初期化を行う
    init {
        viewModelScope.launch {
            setupSkyWayContext(applicationContext)
        }
    }

    private suspend fun setupSkyWayContext(applicationContext: Context) {
        val option = authTokenRepository.getAuthToken()?.let {
            SkyWayContext.Options(
                authToken = it,
                logLevel = Logger.LogLevel.VERBOSE
            )
        }
        if (option == null) {
            Log.d("App", "skyway setup failed")
        }
        SkyWayContext.onErrorHandler = { error ->
            Log.d("App", "skyway setup failed: ${error.message}")
        }
        isSkyWayInitialized =  SkyWayContext.setup(applicationContext, option!!)
    }
    //viewModelが破棄される際にSDKのクリーンアップを行う
    override fun onCleared() {
        super.onCleared()
        SkyWayContext.dispose()
    }
}

class MainActivity : ComponentActivity() {
    private val homeViewModel: HomeViewModel by viewModels { HomeViewModel.Factory }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }
}
  • メリット

    • viewModelScopeで開始されたCoroutineはViewModelのライフサイクルにバインドされており、ActivityやFragmentのConfiguration変更(例えば、デバイスの回転による画面再生成)が発生してもキャンセルされることなく続行されます。これにより、SkyWayContext.dispose()の呼び出しによる通信中断が回避され、安定した通信が確保されます。

    • Composeアプリでは基本的にSingle Activity Architectureが採用されるため、ViewModelのスコープをMainActivityに設定した場合、アプリ生存期間中ViewModelScopeで開始されたCoroutineも存在し続けるため、意図しないクリンアップ操作発生する心配はございません。

  • デメリット

    • ViewModel経由でcoroutineを生成する方法は簡単になる一方、より効果的にViewModelを使いこなすには、ViewModelのScopeという概念をしっかり把握する必要があります。ViewModelのlifecycleは、そのScopeに直接関連付けられます。ViewModelは、Scopeに設定されるViewModelStoreOwnerが消えるとともにクリーンアップされます。

      • ScopeはActivityの場合Activityが終了されたらViewModelも終了します

      • ScopeはFragmentの場合FragmentがdetachされるときViewModelが終了します

      • ScopeはNavigation entryの場合そのnavigation entryはback stackから削除されるとき、ViewModelが終了します

1.3. 自前CoroutineScopeを利用するバターン
自分でCoroutineScopeを管理し、必要に応じてCoroutineを生成します。CoroutineScopeのライフサイクルを自分で管理する必要がありますが、柔軟性があります。

class SkyWayCoroutineScope(private val dispatcher: CoroutineDispatcher) : CoroutineScope {
    private val job = Job()
    override val coroutineContext: CoroutineContext
        get() = job + dispatcher

    fun cleanUp() {
        job.cancel()
    }
}

//CoroutineScopeのlifecycleをApplicationクラスと紐つける場合
class FamilyApplication: Application() {
    val skyWayDefaultScope: SkyWayCoroutineScope by lazy {
        SkyWayCoroutineScope(Dispatchers.Default)
    }
    override fun onCreate() {
        super.onCreate()
        skyWayDefaultScope.launch {
            val option = authTokenRepository.getAuthToken()?.let {
                SkyWayContext.Options(
                    authToken = it,
                    logLevel = Logger.LogLevel.VERBOSE
                )
            }
            SkyWayContext.onErrorHandler = { error ->
                Log.d("App", "skyway setup failed: ${error.message}")
            }
            val result = SkyWayContext.setup(applicationContext, option!!)
            if (result) {
                Log.d("App", "skyway setup succeed")
            }
        }
    }
    override fun onTerminate() {
        super.onTerminate()
        skyWayDefaultScope.launch {
            SkyWayContext.dispose()
        }
    }
}
  • メリット

    • カスタマイズの自由度が高い(例えば特定のthreadで処理を実行する)。

    • 自前の CoroutineScope を利用することで、lifecycleScope や viewModelScope に依存せず、より独立したコルーチンのライフサイクルを管理できます。これにより、より柔軟な処理の管理が可能になります。

  • デメリット

    • CoroutineScopeを利用する場合、自分でCoroutineのライフサイクルを管理する必要があります。これにより、リソース管理やキャンセル処理に関してより多くのコードや注意が必要になります。ここ

2. ApplicationContextまたはActivityContextの使い分け

以下のSkyWay Android SDKのAPIを利用する際にAndroid Contextが必要です:

- SkyWayContext.setup()
- CameraSource.getCameras()
- CameraSource.getFrontCameras()
- CameraSource.getBackCameras()
- CameraSource.startCapturing()
- VideoFileSource()
- ScreenSource.setup()
- SurfaceViewRenderer()
  • ApplicationContextの利用

    • 上記のAPIのほとんどは、ApplicationContextを使用することが推奨されます。ApplicationContextを利用することで、画面回転などのActivityのライフサイクルに依存せず、メモリリークを回避するメリットがあります。特に、SkyWayContextの初期化やクリーンアップなど、UIに依存しない処理においてはApplicationContextが適しています。

  • ActivityContextの利用

    • SurfaceViewRendererに関しては、android.view.Viewのサブクラスであり、UI関連のコンポーネントを操作するため、ApplicationContextは適切ではありません。SurfaceViewRendererを利用する場合は、ActivityContextを使用してください。これにより、UIの状態管理や描画が正しく行われ、Activityのライフサイクルに適切に対応することができます。

3. VideoStreamのUIState扱いについて

SkyWay Android SDKは、カメラでキャプチャされる映像と、マイクでキャプチャされる音声を以下のAPIを通じて、相手に送信する・または相手からの受信を受けることができます:

映像StreamをUIに表示する場合、UI Stateとして管理する必要があります。
Composeアプリでは、UIの状態を管理するためにMutableStateやMutableStateFlowを利用するのが一般的です。これらの特徴は以下の通りです:

  • MutableState

    • Jetpack Compose専用
      Jetpack Composeに特化しており、扱いやすいという利点があります。

    • スレッドセーフではない
      MutableStateはスレッドセーフではないため、マルチスレッド環境では注意が必要です。

    • 非同期処理には不向き
      DataStreamなどの非同期処理には適していません。

  • MutableStateFlow

    • 汎用性が高い
      より広範な用途に対応できる一方で、扱いがやや難しいことがあります。

    • スレッドセーフ
      MutableStateFlowはスレッドセーフであり、マルチスレッド環境での利用に適しています。

    • 非同期処理に適する
      非同期のデータストリーム処理に向いています。

WebRTC通信におけるVideoStreamやAudioStreamは非同期のData Streamであるため、MutableStateには向いてないと思われるかもしれませんが、SkyWay Android SDKのLocalVideoStreamRemoteVideoStreamLocalAudioStreamRemoteAudioStreamクラスは、Data Streamの管理がカプセル化されているため、これらのクラスをData Streamとして扱う必要がなく、通常のオブジェクトとして扱うことができます。
また、LocalVideoStreamRemoteVideoStream に関しては、基本的にUI Threadしか使われないので、Thread Safeのことも考慮する必要がありません。
そのため、扱いやすさを重視する場合はMutableStateを利用することも可能です。

class DirectChatViewModel(...) : ViewModel(){
    var remoteVideoStream by mutableStateOf<RemoteVideoStream?>(null)
        private set
    private fun subscribeRemoteAVStreamInternal(publication: RoomPublication) {
        viewModelScope.launch {
		        ...
            val subscription = localP2PRoomMember!!.subscribe(publication)
            subscription?.stream?.let { stream ->
                if (stream.contentType == Stream.ContentType.VIDEO) {
		            //main threadに切り替えてからremoteVideoStreamの操作を行う
                    withContext(Dispatchers.Main) {
                        remoteVideoStream = subscription.stream as RemoteVideoStream
                    }
                }
            }
        }
    }
}

@Composable
fun DirectChatScreen() {
    val remotedVideoStream = directChatViewModel.remoteVideoStream
    remotedVideoStream?.let {
        AndroidView(
            modifier = Modifier
                .fillMaxWidth()
                .height(250.dp)
                .padding(16.dp),
            factory = { context ->
                remoteRenderView = SurfaceViewRenderer(context)
                remoteRenderView!!.apply {
                    setup()
                    remotedVideoStream.addRenderer(this)
                }
            },
            update = {
                remotedVideoStream.removeRenderer(remoteRenderView!!)
                remotedVideoStream.addRenderer(remoteRenderView!!)
            }
        )
    }
}

4. AndroidViewの扱いについて

前述の通り、LocalVideoStream/RemoteVideoStreamをUI表示するためには、AndroidViewを通じてSurfaceViewRendererを利用する必要があります。SurfaceViewRendererは、ActivityContextを参照している他に、LocalVideoStream/RemoteVideoStream instanceと紐づいてるので、通信処理終了する際に、明示的にSurfaceViewRendererを解放してあげた方がよいと思います。

@Composable
fun DirectChatScreen() {
}
    val remotedVideoStream = directChatViewModel.remoteVideoStream
    var remoteRenderView by remember { mutableStateOf<SurfaceViewRenderer?>(null) }
    //disposableEffectを利用して、SurfaceViewRendererの解放処理を行う
    DisposableEffect(Unit) {
        onDispose {
            if (remoteRenderView != null) {
                remoteRenderView!!.dispose()
                remoteRenderView = null
            }
        }
    }
    remotedVideoStream?.let {
        AndroidView(
            modifier = Modifier
                .fillMaxWidth()
                .height(250.dp)
                .padding(16.dp),
            factory = { context ->
                remoteRenderView = SurfaceViewRenderer(context)
                remoteRenderView!!.apply {
                    setup()
                    remotedVideoStream.addRenderer(this)
                }
            },
            update = {
                remotedVideoStream.removeRenderer(remoteRenderView!!)
                remotedVideoStream.addRenderer(remoteRenderView!!)
            }
        )
    }
}

上記のDirectChatScreenの表示非表示操作10回行い、DisposableEffect による回収処理行われたかどうかによって、App Heap上のSurfaceViewRendererのinstance数は下記のようになります:

  • DisposableEffectによる回収処理を入れてない場合

    • App heapには10個のSurfaceViewRenderer instanceが存在します

  • DisposableEffectによる回収処理を入れた場合

    • App heapには1個のSurfaceViewRenderer instanceしか存在しません

適切な解放処理を行われないとメモリリークの根源に成りうるので注意しましょう。

5. Resourcesの解放について

自分の配信を止めたい場合

class DirectChatViewModel() {
    //LocalRoomMemberの全ての配信を止める
    private suspend fun unpublishAll() {
        withContext(Dispatchers.Default) {
            if (localP2PRoomMember == null) {
                return@withContext
            }
            localP2PRoomMember!!.publications.forEach { roomPublication ->
                localP2PRoomMember!!.unpublish(roomPublication)
            }
        }
    }
    
    //必要に応じてlocalVideoStreamもresetする
    private fun stopLocalStream() {
        CameraSource.stopCapturing()
        AudioSource.stop()
        localVideoStream?.dispose()
        localVideoStream = null
        localAudioStream?.dispose()
        localAudioStream = null
    }
}

@Composable
DirectChatScreen(
    directChatViewModel: DirectChatViewModel = viewModel(factory = DirectChatViewModel.Factory),
) {
    //localVideoStreamはViewModelのstopLocalStream() 関数よりnullにセットされ、画面表示を停止する
    if (localVideoStream != null) {
        AndroidView(
            modifier = Modifier
                .fillMaxWidth()
                .height(250.dp)
                .padding(16.dp),
            factory = { context ->
                localRenderView = SurfaceViewRenderer(context)
                localRenderView!!.apply {
                    setup()
                    localVideoStream.addRenderer(this)
                }
            },
            update = {
                localVideoStream.removeRenderer(localRenderView!!)
                localVideoStream.addRenderer(localRenderView!!)
            }
        )
    } else {
        Text("localVideoStream is null")
    }
}
  1. LocalRoomMemberのpublicationsをunpublishします

  2. (optional)映像表示や音声キャップチャも止めたい場合

    • CameraSource.stopCapturing() APIより映像キャプチャを止めます

    • AudioSource.stop()API より音声キャプチャを止めます

    • LocalStream.dispose() APIよりLocalStreamを解放します

unpublish() APIは時間かかってしまう可能性があるため、UI threadからの呼び出しはしないでください。


相手からの受信を止めたい場合

class DirectChatViewModel() {
    //特定のsubscriptionを止める
    private suspend fun unSubscribe(subscriptionId: String) {
        withContext(Dispatchers.Default) {
            if (localP2PRoomMember == null) {
                return@withContext
            }
            localP2PRoomMember!!.subscriptions.forEach { roomSubscription ->
                if (roomSubscription.id == subscriptionId) {
                    localP2PRoomMember!!.unsubscribe(roomSubscription.id)
                }
            }
        }
    }
    
    private fun stopRemoteStream() {
        remoteVideoStream?.dispose()
        remoteVideoStream = null
        remoteAudioStream?.dispose()
        remoteAudioStream = null
    }
}

@Composable
DirectChatScreen(
    directChatViewModel: DirectChatViewModel = viewModel(factory = DirectChatViewModel.Factory),
) {
    //remoteVideoStreamはViewModelのstopRemoteStream() 関数よりnullにセットされ、画面表示を停止する
    if (remoteVideoStream != null) {
        AndroidView(
            modifier = Modifier
                .fillMaxWidth()
                .height(250.dp)
                .padding(16.dp),
            factory = { context ->
                remoteRenderView = SurfaceViewRenderer(context)
                remoteRenderView!!.apply {
                    setup()
                    remoteVideoStream.addRenderer(this)
                }
            },
            update = {
                remoteVideoStream.removeRenderer(remoteRenderView!!)
                remoteVideoStream.addRenderer(remoteRenderView!!)
            }
        )
    } else {
        Text("remoteVideoStream is null")
    }
}
  1. LocalRoomMemberのsubscriptionsをunsubscribeします

  2. RemoteStream.dispose() APIよりRemoteStreamを解放します

unsubscribe() APIは時間かかってしまう可能性があるため、UI threadからの呼び出しはしないでください。

Roomから退室したい場合
LocalMemberが退室すると(LocalMember.leave()を呼び出すこと)、LocalMemberに関わるすべての受信(subscription)や配信(publication)は自動的にunsubscribe/unpublishされるため、手動で解放する必要はありません。
ただし、LocalStreamやRemoteStreamについては、UI表示に関わるため、手動で明示的に解放することをおすすめします。

class DirectChatViewModel() {
    private suspend fun leaveRoom() {
        withContext(Dispatchers.Default) {
            if (localP2PRoomMember == null) {
                return@withContext
            }
            if (localP2PRoomMember!!.leave()) {
                localP2PRoomMember = null
                stopLocalStream()
                stopRemoteStream()
            }
    }
}

@Composable
fun DirectChatScreen(...) {
    DisposableEffect(Unit) {
        onDispose {
            if (localRenderView != null) {
                localRenderView!!.dispose()
                localRenderView = null
            }
            if (remoteRenderView != null) {
                remoteRenderView!!.dispose()
                remoteRenderView = null
            }
        }
    }
}

Resourcesの解放についての注意点

ViewModelのonCleared()関数でdispose系のAPIを呼び出す場合、ViewModelScopeではなく、その他のScopeを利用する必要があります。

Class DirectChatViewModel(...) {
    private suspend fun leaveRoom() {
        withContext(Dispatchers.Default) {
            if (localP2PRoomMember == null) {
                return@withContext
            }
            if (localP2PRoomMember!!.leave()) {
                localP2PRoomMember = null
                stopLocalStream()
                stopRemoteStream()
            }
        }
    }

    /**
     * NOTICE!!!
     * localP2PRoomMember.leave() APIは suspend 関数のため、coroutineの生成が必要です。
     * ですが、viewModelScopeのonCleared()関数でviewModelScope.launchによる作れたcoroutineがキャンセルされるため
     * 別のCoroutineScopeを利用する必要があります。
     */
    override fun onCleared() {
        super.onCleared()
        skyWayDefaultScope.launch {
            leaveRoom()
        }
    }
}

ViewModel.onCleared()関数が呼ばれると、viewModelScopeに関連付けられたすべてのcoroutineはキャンセルされます。なので、下記の誤った書き方にすると、leaveRoom() メソッドはそもそも呼ばれなくなります。

# 誤るviewModelScopeの使い方、絶対そうしないでください
Class DirectChatViewModel(...) {
    override fun onCleared() {
        super.onCleared()
        viewModelScope.launch {
            leaveRoom()
        }
    }
}

最後に

本記事のフルサンプルコードは、こちらのRepositoryにあります。
サンプルコードを動かすためには、SkyWay Console を登録する必要があります。まずはSkyWay Consoleに登録し、その後、RepositoryのREADMEに従ってアプリを実際に動かしてみていただければと思います。

SkyWayのサービスサイトはこちら
https://go.skyway.ntt.com/ja

SkyWay導入に関するご質問はこちらの問い合わせからお願いします。
https://go.skyway.ntt.com/note_request

参考資料


この記事が気に入ったらサポートをしてみませんか?