Jetpack ComposeでAndroidアプリを作る——状態管理とアーキテクチャの基本

Jetpack Composeを使ったAndroidアプリ開発において、StateとViewModelをどう組み合わせるかを実際のコードで解説します。

Composeにおける状態管理の基本

Jetpack Composeは宣言的UIフレームワークなので、UIは「状態(State)の関数」として定義されます。状態が変わると対応するComposableが再コンポーズ(再描画)されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }

    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text(text = "Count: $count", style = MaterialTheme.typography.headlineMedium)
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

remember はコンポーズをまたいで値を保持します。ただし、これだけだと画面回転でリセットされてしまうため、ViewModelと組み合わせるのが実用上の基本です。


ViewModelとUiStateを使う

画面単位でViewModelを定義し、UIの状態を UiState としてまとめるパターンが現在のAndroid推奨構成です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// UiState
data class CounterUiState(
    val count: Int = 0,
    val isLoading: Boolean = false
)

// ViewModel
class CounterViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(CounterUiState())
    val uiState: StateFlow<CounterUiState> = _uiState.asStateFlow()

    fun increment() {
        _uiState.update { it.copy(count = it.count + 1) }
    }

    fun reset() {
        _uiState.update { CounterUiState() }
    }
}

Composable側では collectAsStateWithLifecycle() でStateFlowを受け取ります(collectAsState() よりライフサイクルを考慮した収集ができます)。

 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
26
27
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    CounterContent(
        count = uiState.count,
        onIncrement = viewModel::increment,
        onReset = viewModel::reset
    )
}

// UIロジックを持たない純粋なComposable(テストしやすい)
@Composable
fun CounterContent(
    count: Int,
    onIncrement: () -> Unit,
    onReset: () -> Unit
) {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text(text = "Count: $count", style = MaterialTheme.typography.headlineMedium)
        Row {
            Button(onClick = onIncrement) { Text("+") }
            Spacer(Modifier.width(8.dp))
            OutlinedButton(onClick = onReset) { Text("Reset") }
        }
    }
}

CounterScreen(ViewModel依存)と CounterContent(依存なし)を分離するのがポイントです。CounterContent はPreviewやUnit Testで簡単に検証できます。


Repositoryパターンでデータ層を分離する

APIやDBへのアクセスはRepositoryに閉じ込めます。ViewModelはRepositoryのインターフェイスだけに依存するようにしておくとテスト時にモックに差し替えやすくなります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
interface UserRepository {
    suspend fun getUser(id: String): Result<User>
}

class UserRepositoryImpl(
    private val api: UserApi,
    private val db: UserDao
) : UserRepository {
    override suspend fun getUser(id: String): Result<User> {
        return runCatching {
            // キャッシュがあればDBから、なければAPIから取得
            db.find(id) ?: api.fetchUser(id).also { db.insert(it) }
        }
    }
}

ViewModelからは viewModelScope で非同期処理を呼び出します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class UserViewModel(private val repo: UserRepository) : ViewModel() {
    private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()

    fun loadUser(id: String) {
        viewModelScope.launch {
            _uiState.value = UserUiState.Loading
            repo.getUser(id)
                .onSuccess { _uiState.value = UserUiState.Success(it) }
                .onFailure { _uiState.value = UserUiState.Error(it.message ?: "Unknown error") }
        }
    }
}

sealed class UserUiState {
    object Loading : UserUiState()
    data class Success(val user: User) : UserUiState()
    data class Error(val message: String) : UserUiState()
}

UI側では sealed class の状態に応じて表示を切り替えます。

1
2
3
4
5
when (val state = uiState) {
    is UserUiState.Loading -> CircularProgressIndicator()
    is UserUiState.Success -> UserProfile(state.user)
    is UserUiState.Error   -> ErrorMessage(state.message)
}

依存性注入にはHiltを使う

RepositoryやViewModelの依存関係を手動で組み立てると、クラスが増えるにつれて管理が辛くなります。Hilt(Dagger2ベースのDIライブラリ)を使うとアノテーションだけで解決できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@HiltViewModel
class UserViewModel @Inject constructor(
    private val repo: UserRepository
) : ViewModel() { ... }

@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {
    @Provides
    @Singleton
    fun provideUserRepository(api: UserApi, db: UserDao): UserRepository =
        UserRepositoryImpl(api, db)
}

@HiltViewModel をつけるだけで、Composable内の viewModel() から自動的にインジェクション済みのインスタンスが得られます。


まとめ

レイヤー役割主なクラス
UI状態を受け取って描画するだけComposable
ViewModelUIの状態を管理・更新ViewModel + StateFlow
Repositoryデータ取得・キャッシュ戦略Repository
DataSourceAPI / DB の実装Retrofit / Room

各レイヤーを疎結合に保つことで、ユニットテストが書きやすくなり、仕様変更にも強い構成になります。Jetpack Composeに移行しながら段階的にこの構成を適用していくのが現実的なアプローチです。