ViewModelからActivity/Fragmentにエラーを伝達するためのデータクラスを定義した話

下の資料の「今考え中です」について、色々と考えがまとまってきたのでデータクラスを作ってみました。

FailureStatus というライブラリにしてあります。

github.com

FailureStatusについて

どんなクラスか

FailureStatus自体は、Gistにしようかなーと悩んだ程度のクラスです。

enum class Status {
    SUCCESS,
    ERROR,
    LOADING
}

data class FailureStatus(val message: String? = null, val throwable: Throwable? = null) {
    constructor(message: String?) : this(message = message, throwable = null)
    constructor(t: Throwable?) : this(message = null, throwable = t)

    fun createMessage(throwableFunc: (throwable: Throwable?) -> String): String =
        if (!message.isNullOrEmpty()) {
            message
        } else {
            throwableFunc(throwable)
        }
}

data class NetworkState(
    val status: Status,
    val failure: FailureStatus? = null
) {
    companion object {
        val LOADED = NetworkState(status = Status.SUCCESS)
        val LOADING = NetworkState(status = Status.LOADING)
        fun error(message: String?) = NetworkState(status = Status.ERROR, failure = FailureStatus(message))
        fun error(t: Throwable) = NetworkState(status = Status.ERROR, failure = FailureStatus(t))
    }
}
  • Status
    • 通信の結果を含めた、通信状況を表すenum
  • FailureStatus ** エラー文言、もしくはエラー原因を保持するデータクラス
    • エラー原因からエラー文言を作詞するメソッドを持つ
  • NetworkState
    • StatusFailureStatusを持つデータクラス
    • LOADED, LOADING, error()を主に利用することを想定

のような想定で作っています。

やりたいこと

View層で通信状態や通信結果を表示する時、複数のLiveDataを使わずにViewModelから通信状態を伝播させることがモチベーションです。 下記のようなViewModelを書いておきたいという感じですね。

class UserViewModel : ViewModel() {

    val repository = UserRepository()

    val user = MutableLiveData<User>()
    val network = MutableLIveData<NetworkState>()

    fun fetchUser() {
        viewModelScope.launch {
            network.postValue(NetworkState.LOADING)
            runCatching {
                repository.fetchUser()
            }.fold {
                onSuccess = {
                    user.postValue(it)
                    network.postValue(NetworkState.LOADED)
                },
                onFailure = {
                    network.postValue(NetworkState.error(it))
                }
        }
    }
}

View側(Activity'やFragment)では、それぞれUserが取得したい時にはUserViewModel.userをsubscribeし、ネットワークの状況を取得したい時にはUserViewModel.networkをsubscribeします。 この時、通信レスポンスにエラーメッセージが含まれる場合などにはnetwork.postValue(NetworkState.error(it.message))` のようにStringを取り出すことができます。

                onSuccess = {
                    if (it.isSuccess()) {
                        user.postValue(it)
                        network.postValue(NetworkState.LOADED)
                    } else {
                        network.postValue(NetworkState.error(it.message))
                    }
                },

表示をどうするか

エラーケースをカバーするのであれば、ある程度表示のメソッドも共通化したいなと思います。 そこで、簡単なExtensionsを作成しました。

fun Activity.networkErrorBar(
    anchor: View,
    status: FailureStatus,
    message: (throwable: Throwable?) -> String,
    duration: Int = Snackbar.LENGTH_SHORT,
    isShowAction: Boolean = true,
    actionMessage: (throwable: Throwable?) -> String = { getString(android.R.string.ok) },
    action: (throwable: Throwable?) -> Unit = {}
) {
    val bar = Snackbar.make(anchor, status.createMessage(message), duration)
    if (isShowAction) {
        bar.setAction(actionMessage(status.throwable)) { action(status.throwable) }
    }

    bar.show()
}

message: (throwable: Throwable?) -> Stringに、想定されるThrowableに対応する文字列を(つまりエラーケースに対応する文言を)返す関数を引き渡すことで、エラー表示文言を切り替えられる仕組みになっています。 もちろん、完全に想定外のケースではwhen文のelse句などを使うことを想定しています。

これらの関数を作成すると、逆に依存関係が増えてめんどいかなーという気もしています。 その場合には、上のFailureStatusクラスだけ活用してもらえればいいのかなと思っています。

追記 (2019年4月16日)

github.com

読み直していたら、NetworkStateは下の書き方の方がいい気がしてきました。

sealed class NetworkState(val status: Status) {
    object LOADED: NetworkState(Status.SUCCESS)
    object LOADING: NetworkState(Status.LOADING)
    data class ERROR(val error: FailureStatus): NetworkState(Status.ERROR) {
        constructor(throwable: Throwable?) : this(FailureStatus(throwable))
        constructor(message: String?) : this(FailureStatus(message))
    }
}