riceIsland

学習メモ・備忘録

← 記事一覧に戻る

UI状態の設計

内容(取り組んだこと)

UiStateの設計

1. UI状態とは何か

UIレイヤのドキュメントによると、UIはユーザーが目にするものであるUI要素とユーザーが目にするべきとアプリがみなすものとしてのUI状態で構成されている。

UI構成図 (https://developer.android.com/topic/architecture/ui-layer?hl=jaの図3より)

ユーザーが目にするものとか目にするべきとアプリとみなすものという言葉だけではよくわからなかった。

  • 自分の覚え方:具体例として部屋をUIで表現する。
    UI要素:ベット・椅子・机・本棚とか物質的に存在しているもの
    UI状態;電気が付いているか・椅子に人が座っているか・本棚に本が積まれているかなどの時間ごとに変化しうるもの

2. なぜdata class + valを使うのか

  • data classUiStateを定義する理由は、状態ホルダーであるViewModelクラスでcopy()を使って部分的にUI状態を更新できるようにするため。
  • valを使う理由は、不変オブジェクトとしてUI状態を定義することでSSOT原則に従うことになるため。varは読み書き可能なため、再代入できてしまう。
    *追記
    data classを使う理由:初期状態を引数なしで定義できる。
    valで定義する理由:UI内でデータソースを保持してしまうと、データの不整合やわかりにくいバグの原因になってしまう恐れがあり、その問題を生まないために 状態ホルダー内で読む取り専用のこのvalキーワードを使用することで、SSOT原則に従う形が理想。

3. 状態の変更を1箇所に限定する仕組み

private+valで限定する。

  • privateがない場合、外から_uiState.update{}が呼べてしまう。
class  HomeViewModel: ViewModel() {
    // privateがない場合
    val _uiState = MutableStateFlow(HomeUiState())
    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
}

// Composableから直接更新できてしまう。
@Comopable
fun HomeScreen(viewModel: HomeViewModel) {
    viewModel._uiState.update { it.copy(isLoading = true) }
}
  • privateがある場合
class  HomeViewModel: ViewModel() {
    // privateがある場合
    private val _uiState = MutableStateFlow(HomeUiState())
    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
}


@Comopable
fun HomeScreen(viewModel: HomeViewModel) {
    viewModel._uiState.update { it.copy(isLoading = true) } // コンパイルエラー
    viewModel.uiState // 読み取りだけ可能
}
  • valがない場合
data class HomeUiState(
    var isLoading: Boolean = true,
    var balance: Int = 0,
)

// Composableから直接書き換えられる
@Composable
fun HomeScreen(viewModel: HomeViewModel) {
    val uiState = viewModel.uiState.value
    uiState.isLoading = true // SSOTが崩れる。
}
  • valがある場合
data class HomeUiState(
    val isLoading: Boolean = true,
    val balance: Int = 0,
)

@Composable
fun HomeScreen(viewModel: HomeViewModel) {
    val uiState = viewModel.uiState.value
    uiState.isLoading = false // コンパイルエラー(書き換えできない)
}

// 状態の変更は`ViewModel`内だけ
class HomeViewModel: ViewModel() {
    private val _uiState = MutableStateFlow(HomeUiState())

    fun onLoaded() {
        _uiState.update { it.copy(isLoading = false) }  
    }
}

4. UiStateの設計で意識したこと

  • a:UiStateは画面1つにつき、1個だけ定義する。 →個別に状態変数を定義することも可能だけど、SSOT原則から崩れてしまう。
  • b:UiStateのデータ型にDBエンティティを直接せずにUIモデルを定義してデータ型に指定する。
    以下のコードにおいて、HomeCardsUiStateというUI状態クラスのtargetExpenditureの型にDBエンティティとして定義したExpenditure?が用いられている。 DBエンティティをUIとして表示するために必要なUI状態のデータ型に指定すると、DBスキーマの変更が起きた場合などにUIもそれに応じた変更をしないといけなくなってしまうため避けるべき。 また、関心の分離原則も崩れてしまうので、UIレイヤー内でデータレイヤーのコードを持ち込まないようにするべき。
// import com.rintaroo.afrel.database.entity.App  ← DBエンティティをインポート

data class HomeCardsUiState(
    // 目標となる支出データ
    val targetExpenditure: Expenditure?
)

代わりに、UIモデルという画面に表示するために必要な情報のみを集約したクラスを定義する。

// package com.rintaroo.afrel.model

data class Expenditure(
    val id: Int,
    val name: String,
    val amount: Double
)
  • c:マジックナンバーを使わない。 実際に追加画面(AddItemScreen)で収益データor支出データのどちらに関するデータかどうかを選択するセグメントボタンの状態を以下のように定義していた。
data class AddItemFormState(
    val selectedIndex: Int = 0 //この0という数字が特定の意味を持っていない。
)
  • 推奨:enum定数を用いる(意味のある言葉にする)。
enum class ItemType {
    App,
    Expenditure
}

data class AddItemFormState(
    val selectedIndex: ItemType = ItemType.App
)

5. 命名規則

画面の機能+UiStateとする。

独り言

  • UI状態の設計でもアプリアーキテクチャの原則として唱えられているSSOTや関心の分離原則が登場してきており、こういったコードベースの細部もそういった原則を考慮することで全体としてスケーラブルなアプリになると感じた。
  • そもそもdata classclassの違いを忘れてしまったので、確認する。
  • enumsealed interfaceの違いをまとめる。