ViewModelとKotlin Coroutinesの書き方あれこれ

ViewModel + Kotlin Coroutineを使う場合、どんな感じでViewModelでCoroutineを表現するかについてあれこれ書いてみました。

MVVM + Repositoryを想定しており、UIに反映する部分はLiveDataを考えています。

環境はandroidx.lifecycle:lifecycle-viewmodel-ktxは2.2.0-rc03、Coroutineは1.3.3です。

この記事は次の順序で進んでいきます。

viewModelScopeとは?

androidx.lifecycle:lifecycle-viewmodel-ktxライブラリには、viewModelScope拡張関数が含まれています。定義は次の通りです。

/**
 * [CoroutineScope] tied to this [ViewModel].
 * This scope will be canceled when ViewModel will be cleared, i.e [ViewModel.onCleared] is called
 */
val ViewModel.viewModelScope: CoroutineScope

ViewModelのライフサイクルに合わせたCoroutineScopeを取得することが出来ます。 このスコープ上でCoroutineを実行すれば、ViewModelの破棄に合わせて、自動でdisposeしてくれます。

また、viewModelScopeは、メインスレッド上で実行してくれるため、LiveData.setValueを使い、値を更新します。

val userLiveData = MutableLiveData(...)

viewModelScope.launch {
    val user = userRepository.getUser() // 適当なsuspend関数をコール

    userLiveData.setValue(user) // メインスレッド上で実行されることが保証されているのでsetValueを使う
    // userLiveData.postValues(user)
}

viewModelScopeを使っている場合は、postValueメソッドを使うケースは無いと思います。

suspend関数をコールするとき

ネットワークコールなどのAPIは、suspendで表現することになると思います。 また、Retrofitでは2.6.0から、suspendでAPIを定義出来るようになりました。またRoomでもsuspend関数を使うことが可能です。

なので、Repository層での定義は次のようになります。

interface UserApi {
    suspend fun getUser(): User
}

class UserRepository(private val retrofitService: UserApi) {
    suspend fun getUser() : User {
        val user = retrofitService.getUser()
        ...
        return user
    }
}

これをViewModelからコールします。

viewModelScope.launch {
    val user = userRepository.getUser()
    ...
}

これだけだと、ネットワークの調子が悪い時などに、例外が起きてしまうので、エラーハンドリングをする必要があります。

ViewModel側でハンドリングするなら、try-catch、もしくはrunCatchingを使うのが良いと思います。

viewModelScope.launch {
    try {
        val user = userRepository.getUser()
        ...
    } catch (e: Exception) {
        ...
    }
}
viewModelScope.launch {
    runCatching { userRepository.getUser() }
        .onSuccess { ... }
        .onFailure { ... }
}

個人的にはrunCatchingのほうが好きです。

また、ViewModelで例外処理をするのではなく、Repository側で適当な型で包むパターンもあると思います。例えばNetWorkResultのようなクラスがあるとします。

sealed class NetWorkResult<T> {
    class Success<T>(val value: T) : NetWorkResult<T>()
    class Error(val exception: Exception) : NetWorkResult<Nothing>()
    ...
}

このクラスをRepository側の返り値として使います。

class UserRepository(private val retrofitService: UserApi) {
    suspend fun getUser() : NetWorkResult<User> {
        try {
            val user = retrofitService.getUser()
            return NetWorkResult.Success(user)
        } catch (e: Exception) {
            return NetWorkResult.Error(e)
        }
    }
}

こうすることで、ViewModel側では、try-catchを使わなくて良くなり、try-catchの代わりにwhen式を使うことになります。


また、androidx.lifecycle:lifecycle-livedata-ktxに含まれている、livedata builderを使う方法もあります。livedata builderを使うと次のように書くことが出来ます。

val user = liveData<User> {
    runCatching { repository.getUser() }
        .onSuccess { emit(it) }
        .onFailure { ... }

    ...
}

非常にすっきりと書くことが出来ます!

ここまでがsuspend関数の説明になります。次にFlowを返すAPIの話です。

Flowをコール/購読するとき

Flow APIは、複数の値を流すストリームを表現することが出来ます。RxJavaで言うところのObservableとか、Flowableのようなものです。

Flowを購読するタイミングは、ViewModelのinitブロックが良いと思います。重複で購読する心配がないためです。SavedStateHandleを組み合わせることで、多くの場合、initブロックで初期化を行うことが出来ると思います。

class MyViewModel(private val state: SavedStateHandle) : ViewModel() {
    init {
        ...
    }
}

Flowの購読方法なんですが、collect、collectLatestもしくは、launchInを使います。

class MyViewModel(...) : ViewModel() {
    init {
        viewModelScope.launch {
            repository.getFlowStream()
                .collect { ... }
        }

        repository.getFlowStream()
            .onEach { ... }
            .launchIn(viewModelScope)
    }
}

エラーや、完了イベントのハンドリングが必要な場合、catchonCompletionメソッドを使います。

repository.getFlowStream()
    .onEach { ... }
    .catch { ... }
    .onCompletion { ... }
    .launchIn(viewModelScope)

個人的にはネストが少なくなるので、launchInの書き方のほうが好みです。

まとめ

Coroutineはいいぞ〜

Written by
あんどろいどでぃべろっぱぁー🍎