要点
Activity / Fragment / ViewModel にビジネスロジックを直接書くコードは、レガシープロジェクトで頻繁に見かける。しかし、これらのクラスにはそれぞれ固有の責務があり、ロジックの置き場所ではない。
本記事では、ロジックを適切な場所に隠蔽する方法を具体的なコード例で示す。
NG: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
|
class ItemListViewModel(
private val repository: ItemRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(ItemListUiState())
val uiState: StateFlow<ItemListUiState> = _uiState.asStateFlow()
fun loadItems(categoryId: String) {
viewModelScope.launch {
val items = repository.getItems()
// ViewModel にフィルタ・ソートが直書きされている
val filtered = items.filter { it.categoryId == categoryId && it.isActive }
val sorted = filtered.sortedByDescending { it.updatedAt }
val displayItems = sorted.map { item ->
DisplayItem(
title = item.name,
subtitle = "${item.price}円",
isHighlighted = item.price >= 10000,
)
}
_uiState.update { it.copy(items = displayItems) }
}
}
}
|
このコードは動作する。だが以下の問題がある。
| 問題 |
影響 |
| テストの困難さ |
フィルタ・ソートのロジックをテストするために ViewModel のインスタンスが必要 |
| 再利用の不可 |
同じフィルタ条件を別の画面で使いたくなったとき、コピーするしかない |
| 見通しの悪さ |
ViewModel が「状態管理」と「データ変換」の両方を担い、コードが肥大化する |
各クラスの責務
Android Developers のアーキテクチャガイドは、冒頭で 関心の分離 を挙げている。各クラスには固有の責務がある。
| クラス |
責務 |
ロジックを書くべきか |
| Activity / Fragment |
UI のライフサイクル管理 |
No |
| ViewModel |
UI の状態管理、ユーザー操作への応答 |
No(委譲する) |
| UiState |
画面が表示するデータの保持、導出プロパティ |
表示に関する変換は OK |
| UseCase / Domain |
ビジネスロジック |
Yes |
| Repository |
データの取得・永続化 |
データ変換は OK |
ViewModel の仕事は「何をするか決めること」であり、「どうやるか」は別のクラスに委ねる。
OK:ロジックを隠蔽する
方法 1:UiState の導出プロパティ
条件判定や表示用の変換は、UiState の computed property として定義する。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
data class ItemListUiState(
val items: List<Item> = emptyList(),
val selectedCategoryId: String? = null,
) {
// 表示に関するロジックは UiState に隠蔽
val displayItems: List<DisplayItem>
get() = items
.filter { it.categoryId == selectedCategoryId && it.isActive }
.sortedByDescending { it.updatedAt }
.map { it.toDisplayItem() }
}
private fun Item.toDisplayItem() = DisplayItem(
title = name,
subtitle = "${price}円",
isHighlighted = price >= 10000,
)
|
ViewModel はデータを UiState に渡すだけになる。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
class ItemListViewModel(
private val repository: ItemRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(ItemListUiState())
val uiState: StateFlow<ItemListUiState> = _uiState.asStateFlow()
fun loadItems(categoryId: String) {
viewModelScope.launch {
val items = repository.getItems()
_uiState.update { it.copy(items = items, selectedCategoryId = categoryId) }
}
}
}
|
方法 2:UseCase に切り出す
ビジネスロジックが複雑な場合や、複数の画面で共有する場合は UseCase として切り出す。
1
2
3
4
5
6
7
8
|
class GetFilteredItems(
private val repository: ItemRepository,
) {
suspend operator fun invoke(categoryId: String): List<Item> =
repository.getItems()
.filter { it.categoryId == categoryId && it.isActive }
.sortedByDescending { it.updatedAt }
}
|
ViewModel は UseCase を呼ぶだけである。
1
2
3
4
5
6
|
fun loadItems(categoryId: String) {
viewModelScope.launch {
val items = getFilteredItems(categoryId)
_uiState.update { it.copy(items = items) }
}
}
|
どちらを使うか
| 基準 |
UiState の導出プロパティ |
UseCase |
| ロジックの複雑さ |
単純な変換・フィルタ |
複数ステップの処理 |
| 再利用の必要性 |
その画面でのみ使用 |
複数画面で共有 |
| テスト |
UiState 単体でテスト可能 |
UseCase 単体でテスト可能 |
どちらが正解というわけではない。重要なのは ViewModel に直書きしないこと である。
Composable にもロジックを書かない
同じ原則は UI 層にも当てはまる。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// NG: Composable 内で条件判定
@Composable
fun ItemScreen(uiState: ItemListUiState) {
if (uiState.items.isNotEmpty() && uiState.selectedCategoryId != null) {
// 表示
}
}
// OK: UiState に導出プロパティを定義
data class ItemListUiState(...) {
val shouldShowItems: Boolean
get() = items.isNotEmpty() && selectedCategoryId != null
}
@Composable
fun ItemScreen(uiState: ItemListUiState) {
if (uiState.shouldShowItems) {
// 表示
}
}
|
まとめ
Activity / Fragment / ViewModel / Composable は、いずれもロジックの置き場所ではない。filter、sort、条件判定といったロジックは、UiState の導出プロパティか UseCase に隠蔽する。
ViewModel の仕事は 何をするか(What) を決めることであり、どうやるか(How) は別のクラスに委ねる。これがアーキテクチャガイドが冒頭で述べている関心の分離である。