Featured image of post [Android] ロジックを Activity や ViewModel に書かない

[Android] ロジックを Activity や ViewModel に書かない

filter や sort を ViewModel に直接書くのをやめる。ロジックの置き場所を関心の分離から考える

要点

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) は別のクラスに委ねる。これがアーキテクチャガイドが冒頭で述べている関心の分離である。

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