ViewModelの責務はどう変わった? 3つの図から紐解くアーキテクチャーの変遷

モダンなAndroid開発(Jetpack Compose + MVVM/MVI)において、ViewModelの役割は単なる「データ保持」から「状態管理のハブ」へと進化しました。今回は、3つのアーキテクチャ図を比較しながら、その設計思想の違いを整理します。

 

🤔 1. 最もシンプルな「State管理のみ」


class TaskViewModel(private val repository: TaskRepository) : ViewModel() {
    // 状態(State)のみを保持
    val uiState: StateFlow<List<Task>> = repository.getTasks()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    // 直接関数を呼び出す
    fun addTask(name: String) {
        viewModelScope.launch {
            repository.saveTask(Task(name))
        }
    }
}


@Composable
fun TaskScreen(viewModel: TaskViewModel = viewModel()) {
    // 状態をそのまま収集
    val tasks by viewModel.uiState.collectAsState()
    var text by remember { mutableStateOf("") }

    Column {
        TextField(value = text, onValueChange = { text = it })
        Button(onClick = { 
            // ViewModelの関数を直接叩く (Simple)
            viewModel.addTask(text) 
        }) {
            Text("追加")
        }
        
        LazyColumn {
            items(tasks) { task -> Text(task.name) }
        }
    }
}

特徴:
ViewModelはRepositoryからデータを取得し、State(状態)を保持してViewへ流すだけのシンプルな構造です。

メリット:
コード量が少なく、小規模なプロジェクトや単純な画面には最適。

課題:
ビジネスロジックがViewModelに肥大化しやすく(Fat ViewModel)、View側でのユーザー操作(Action)がどう処理されるかが図示されておらず、双方向のやり取りが曖昧になりがちです。

 

🤔 2. Domain/Usecaseの導入による責務の分離


// ビジネスロジックを分離
class GetSortedTasksUseCase(private val repository: TaskRepository) {
    operator fun invoke(): Flow<List<Task>> = repository.getTasks().map { it.sortedBy { t -> t.date } }
}

class TaskViewModel(
    private val getSortedTasksUseCase: GetSortedTasksUseCase,
    private val addTaskUseCase: AddTaskUseCase 
) : ViewModel() {

    val uiState = getSortedTasksUseCase()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    fun onAddTaskClicked(name: String) {
        viewModelScope.launch {
            addTaskUseCase(name)
        }
    }
}


@Composable
fun TaskScreen(viewModel: TaskViewModel = viewModel()) {
    val tasks by viewModel.uiState.collectAsState()

    // UIロジック(ソート済みデータなど)を表示するだけ
    TaskList(
        tasks = tasks,
        onAddClick = { name -> viewModel.onAddTaskClicked(name) }
    )
}

特徴:
ViewModelの中にDomain/Usecaseという概念が登場します。

進化のポイント:
ビジネスロジックをRepository直呼びではなく、Usecaseとして切り出すことで、ViewModelは「UIの状態管理」に専念できるようになります。

メリット:
ロジックの再利用性が高まり、ユニットテストが書きやすくなります。クリーンアーキテクチャに近い考え方です。

 

🤔 3. MVI(Intent/Action)とEffectの導入


// 1. 入力(Action)と出力(Effect)を型定義
sealed interface TaskAction {
    data class AddTask(val name: String) : TaskAction
    object Refresh : TaskAction
}

sealed interface TaskEffect {
    data class ShowSnackBar(val message: String) : TaskEffect
    object NavigateToDetail : TaskEffect
}

class TaskViewModel(private val useCase: TaskUseCase) : ViewModel() {
    private val _effects = Channel<TaskEffect>()
    val effects = _effects.receiveAsFlow()

    // 2. Actionを一箇所で受ける (Intent)
    fun dispatch(action: TaskAction) {
        when (action) {
            is TaskAction.AddTask -> handleAddTask(action.name)
            TaskAction.Refresh -> { /* リフレッシュ処理 */ }
        }
    }

    private fun handleAddTask(name: String) {
        viewModelScope.launch {
            useCase.add(name)
            // 3. 状態変化ではない「副作用」を通知
            _effects.send(TaskEffect.ShowSnackBar("保存しました"))
        }
    }
}


@Composable
fun TaskScreen(viewModel: TaskViewModel = viewModel()) {
    val tasks by viewModel.uiState.collectAsState()
    val snackbarHostState = remember { SnackbarHostState() }

    // 1. 副作用(Effect)のハンドリング
    LaunchedEffect(Unit) {
        viewModel.effects.collect { effect ->
            when (effect) {
                is TaskEffect.ShowSnackBar -> {
                    snackbarHostState.showSnackbar(effect.message)
                }
                TaskEffect.NavigateToDetail -> {
                    // Navigation 3 等での遷移処理
                }
            }
        }
    }

    Scaffold(
        snackbarHost = { SnackbarHost(snackbarHostState) }
    ) { padding ->
        TaskContent(
            tasks = tasks,
            onAction = { action -> 
                // 2. すべての操作を dispatch(Action) に集約
                viewModel.dispatch(action) 
            }
        )
    }
}

@Composable
fun TaskContent(tasks: List<Task>, onAction: (TaskAction) -> Unit) {
    Button(onClick = { onAction(TaskAction.AddTask("New Task")) }) {
        Text("Actionを送る")
    }
}

特徴:
ユーザーの操作をAction/Intentとして定義し、副作用をEffectとして分離した、最もモダンな形です。

進化のポイント:

  • Action/Intent: Viewからの入力が型定義されたイベントとしてViewModelに届く(単方向データフローの強化)。
  • Effect: 画面遷移やトースト表示など、一回限りのイベントをStateとは別に管理する。

メリット:
状態遷移が予測可能になり、デバッグが容易になります。Composeとの相性も抜群です。

 

🤔 まとめ:どれを選ぶべきか?

最初からパターン3を詰め込みすぎると複雑になりますが、長期的な保守を考えるなら、たとえ小規模でも「ViewからのAction」と「UIへのState」を明確に分ける意識が大切です。

まずは「何がViewに属し、何がロジックに属するか」をシンプルに保つことが、良いアーキテクチャへの近道です。


Related Categories :  AndroidAndroidStudioJetpackComposeKMPKotlinNewbieTrending