Featured image of post [Android] IndicatorState でローディングを宣言的に扱う

[Android] IndicatorState でローディングを宣言的に扱う

ローディング状態を UiState から分離し、IndicatorState で管理する設計を提案する

要点

isLoadingUiState に含める設計はよく見かけるが、ローディング状態は画面の「データ」ではなく「UI フィードバック」である。

本記事では、ローディング管理を UiState から分離するクラス IndicatorState を提案する。さらに runCatchingOrCancelwithErrorLog を組み合わせることで、安全で見通しの良いエラーハンドリングを実現する。

よくある設計:UiState に isLoading を含める

1
2
3
4
5
data class ProfileScreenUiState(
    val userName: String = "",
    val address: String = "",
    val isLoading: Boolean = false,
)

この設計は動作するが、isLoading の性質は userNameaddress とは異なる。

フィールド 性質 変更の契機
userName 画面が表示するデータ API レスポンスの取得
address 画面が表示するデータ API レスポンスの取得
isLoading UI フィードバック 非同期処理の開始と終了

userNameaddress は「何を表示するか」であり、isLoading は「今処理中であることをユーザーに知らせる」である。関心が異なるものを同じクラスに混ぜると、以下の問題が生じる。

  • 状態の組み合わせ爆発isLoading = true かつ userName が空でない場合は有効な状態か?データの状態とローディングの状態が絡み合う
  • 更新の煩雑さ — 処理の開始時に copy(isLoading = true) 、成功時に copy(isLoading = false, userName = ...) 、失敗時に copy(isLoading = false) と、毎回 isLoading を意識する必要がある
  • 再利用の困難さ — 画面ごとに UiState の構造が異なるため、ローディング管理のコードを共通化できない

IndicatorState:ローディングを分離する

ローディング状態を独立したクラスとして切り出す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@Stable
class IndicatorState {
    // Jetpack Compose の場合
    var isLoading: Boolean by mutableStateOf(false)
        private set

    fun startLoading() { isLoading = true }
    fun stopLoading() { isLoading = false }

    suspend fun <T> withLoadingResult(block: suspend () -> Result<T>): Result<T> {
        startLoading()
        return try {
            block().also { stopLoading() }
        } finally {
            stopLoading()
        }
    }
}
Android View(StateFlow)の場合
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class IndicatorState {
    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()

    fun startLoading() { _isLoading.value = true }
    fun stopLoading() { _isLoading.value = false }

    suspend fun <T> withLoadingResult(block: suspend () -> Result<T>): Result<T> {
        startLoading()
        return try {
            block().also { stopLoading() }
        } finally {
            stopLoading()
        }
    }
}

@Stable は Jetpack Compose のアノテーションで、再コンポジション時の不要な再描画を抑制する 1

withLoadingResult の間違った使い方

block 内で onSuccess / onFailure をチェーンしてはいけない。also { stopLoading() } が呼ばれる前にコールバックが実行されるため、isLoading の状態が保証されない。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// NG: block 内で onSuccess / onFailure をチェーンしている
indicatorState.withLoadingResult {
    repository.fetchData()
        .onSuccess { /* まだ isLoading は true */ }
        .onFailure { /* まだ isLoading は true */ }
}

// OK: block の外でチェーンする
indicatorState.withLoadingResult {
    repository.fetchData()
}.onSuccess { /* isLoading は false */ }
 .onFailure { /* isLoading は false */ }

withLoadingResult の設計

withLoadingResult には stopLoading() が 2 箇所ある。これは意図的な設計である。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
suspend fun <T> withLoadingResult(block: suspend () -> Result<T>): Result<T> {
    startLoading()
    return try {
        block().also {
            stopLoading()  // 1. 正常完了時:Result を返す前に停止
        }
    } finally {
        stopLoading()      // 2. キャンセル時:確実に停止する保険
    }
}

alsostopLoading() を呼んでから Result を返す。これにより、呼び出し側の onSuccess / onFailure の時点で isLoading = false が保証される

1
2
3
4
5
6
7
8
indicatorState.withLoadingResult {
    repository.fetchUser(id)
}.onSuccess { user ->
    // この時点で isLoading = false が保証されている
    _uiState.update { it.copy(userName = user.name, address = user.address) }
}.onFailure {
    // エラー処理(runCatchingOrCancel により CancellationException は到達しない)
}

runCatchingOrCancel:CancellationException を握りつぶさない

Repository 層で Result を返す際、標準の runCatchingCancellationException も捕捉してしまう。コルーチンのキャンセル伝播を妨げないラッパーが必要である。

1
2
3
4
5
6
suspend fun <T> runCatchingOrCancel(block: suspend () -> T): Result<T> =
    runCatching { block() }.also { result ->
        result.exceptionOrNull()
            ?.takeIf { it is CancellationException }
            ?.let { throw it }
    }

前回の記事で詳しく扱っている。

エラーは例外ではなく Result で返す。独自例外を定義する前に、本当に throw が必要か考える
cover.webp

withErrorLog:ログ出力をチェーンする

エラーログの出力も Result の拡張関数として定義すると、チェーンの中に自然に組み込める。

1
2
3
4
5
6
fun <T> Result<T>.withErrorLog(): Result<T> {
    onFailure { e ->
        Log.e("AppError", e.message, e)
    }
    return this
}

runCatchingOrCancel と組み合わせることで、CancellationException はこの関数に到達しない。

全体のフロー

3 つのピースを組み合わせた全体像は以下の通り。

Repository 層

1
2
3
4
5
6
class UserRepository(private val api: UserApi) {
    suspend fun fetchUser(id: String): Result<User> =
        runCatchingOrCancel {
            api.getUser(id)
        }.withErrorLog()
}

ViewModel 層

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class ProfileViewModel(
    private val repository: UserRepository,
    val indicatorState: IndicatorState,
) : ViewModel() {
    private val _uiState = MutableStateFlow(ProfileScreenUiState())
    val uiState: StateFlow<ProfileScreenUiState> = _uiState.asStateFlow()

    fun loadProfile(id: String) {
        viewModelScope.launch {
            indicatorState.withLoadingResult {
                repository.fetchUser(id)
            }.onSuccess { user ->
                _uiState.update { it.copy(userName = user.name, address = user.address) }
            }.onFailure {
                // エラー処理(runCatchingOrCancel により CancellationException は到達しない)
            }
        }
    }
}

// UiState にはデータだけ
data class ProfileScreenUiState(
    val userName: String = "",
    val address: String = "",
)

UI 層

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    // ローディング表示(UiState とは独立)
    if (viewModel.indicatorState.isLoading) {
        CircularProgressIndicator()
    }

    // データ表示
    Text(text = uiState.userName)
    Text(text = uiState.address)
}

まとめ

isLoading は「画面のデータ」ではなく「UI フィードバック」である。UiState とは関心が異なるため、IndicatorState として分離する。

役割 クラス 責務
ローディング管理 IndicatorState 非同期処理中の UI フィードバック
例外の安全な捕捉 runCatchingOrCancel CancellationException を握りつぶさない
エラーログ出力 withErrorLog エラーをログ出力

この 3 つを組み合わせることで、ViewModel のコードは「何をするか」に集中でき、ローディング管理やエラーログといった横断的関心事を宣言的に扱える。


  1. @Stable を付けることで、Compose コンパイラはこのクラスのインスタンスが安定していると判断し、不要な再コンポジションをスキップできる。 ↩︎

Hugo で構築されています。
テーマ StackJimmy によって設計されています。