要点
isLoading を UiState に含める設計はよく見かけるが、ローディング状態は画面の「データ」ではなく「UI フィードバック」である。
本記事では、ローディング管理を UiState から分離するクラス IndicatorState を提案する。さらに runCatchingOrCancel と withErrorLog を組み合わせることで、安全で見通しの良いエラーハンドリングを実現する。
よくある設計:UiState に isLoading を含める
1
2
3
4
5
|
data class ProfileScreenUiState(
val userName: String = "",
val address: String = "",
val isLoading: Boolean = false,
)
|
この設計は動作するが、isLoading の性質は userName や address とは異なる。
| フィールド |
性質 |
変更の契機 |
| userName |
画面が表示するデータ |
API レスポンスの取得 |
| address |
画面が表示するデータ |
API レスポンスの取得 |
| isLoading |
UI フィードバック |
非同期処理の開始と終了 |
userName と address は「何を表示するか」であり、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 のアノテーションで、再コンポジション時の不要な再描画を抑制する 。
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. キャンセル時:確実に停止する保険
}
}
|
also で stopLoading() を呼んでから 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 を返す際、標準の runCatching は CancellationException も捕捉してしまう。コルーチンのキャンセル伝播を妨げないラッパーが必要である。
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 が必要か考える
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 のコードは「何をするか」に集中でき、ローディング管理やエラーログといった横断的関心事を宣言的に扱える。