UI状態の設計
内容(取り組んだこと)
UiStateの設計
1. UI状態とは何か
UIレイヤのドキュメントによると、UIはユーザーが目にするものであるUI要素とユーザーが目にするべきとアプリがみなすものとしてのUI状態で構成されている。
(https://developer.android.com/topic/architecture/ui-layer?hl=jaの図3より)
ユーザーが目にするものとか目にするべきとアプリとみなすものという言葉だけではよくわからなかった。
- 自分の覚え方:具体例として部屋をUIで表現する。
UI要素:ベット・椅子・机・本棚とか物質的に存在しているもの
UI状態;電気が付いているか・椅子に人が座っているか・本棚に本が積まれているかなどの時間ごとに変化しうるもの
2. なぜdata class + valを使うのか
data classでUiStateを定義する理由は、状態ホルダーである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 classとclassの違いを忘れてしまったので、確認する。 enumとsealed interfaceの違いをまとめる。
(