AndroidでViewModelのUiStateを再設計する
はじめに
最近Androidアプリ開発のViewModelで管理するUiStateの構造をもう少しいい感じにできないかと試行錯誤していました。
アプリの設計や、少しでも現状から保守運用しやすくなるきっかけになれたら嬉しいです。
UiStateについて
UiStateとは、ViewModelでStateを扱うときにサンプルコードでも度々登場するdata class
のことを指しています。
https://developer.android.com/topic/architecture/ui-layer/state-production?hl=ja こちらの公式ドキュメントにある例を紹介します。
data class DiceUiState(
val firstDieValue: Int? = null,
val secondDieValue: Int? = null,
val numberOfRolls: Int = 0,
)
class DiceRollViewModel : ViewModel() {
private val _uiState = MutableStateFlow(DiceUiState())
val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()
// Called from the UI
fun rollDice() {
_uiState.update { currentState ->
currentState.copy(
firstDieValue = Random.nextInt(from = 1, until = 7),
secondDieValue = Random.nextInt(from = 1, until = 7),
numberOfRolls = currentState.numberOfRolls + 1,
)
}
}
}
ViewModelで管理したいStateを1つのdata class
にまとめています。
ViewModelではUiStateをStateFlowで持ってUIに公開しています。
なにかプロパティを更新したいときは_uiState
に対してupdate
を実行してdata class
の.copy
を使用することで実現しています。
UiStateで起きうる課題
実際のアプリでは読み込み中か、エラーがあったか、表示するコンテンツなどをUiStateでまとめると思います。
data class HomeUiState(
val loading: Boolean,
val error: Throwable?,
val contentList: List<String>,
)
1つのdata class
のプロパティに画面全体の全ての状態を持たせたとき、UIでのハンドリングが少し複雑になり、保守運用がしにくいことがあるなと感じていました。
さきほどのHomeUiState
を例に考えてみます。
各アプリの仕様に左右されるのであくまで一例ですが、UI側はこんな感じになるかもしれません。
@Composable
private fun HomeScreen(
contentPadding: PaddingValues,
uiState: HomeUiState,
) {
Box(modifier = Modifier.padding(contentPadding)) {
if (uiState.loading) {
LoadingIndicator()
return@Box
}
if (uiState.error != null) {
Text("Error")
return@Box
}
LazyColumn {
items(uiState.contentList.size) { content ->
Text(uiState.contentList[content])
}
}
}
}
ローディングとエラーとそれ以外の画面の中身を切り替えています。
これはもしかしたら、すでにLazyColumn
が表示されているときにPullToRefreshすると切り替えるのではなく、そのLazyColumn
の上に重ねてローディングを表示したい要件があるかもしれません。
そのときは以下のようにすれば実現できそうです。
@Composable
private fun HomeScreen(
contentPadding: PaddingValues,
uiState: HomeUiState,
) {
Box(modifier = Modifier.padding(contentPadding)) {
LazyColumn {
items(uiState.contentList.size) { content ->
Text(uiState.contentList[content])
}
}
// LazyColumnの上に重ねて表示されるように移動
if (uiState.loading) {
LoadingIndicator()
}
if (uiState.error != null) {
Text("Error") // Snackbarなど
}
}
}
これくらいであれば、1つのdata class
のプロパティに全ての状態を持たせても問題ないと思います。
ですが、contentList
が空のときはEmptyの表示もしたい、ログインのときと非ログインのときでUIを変えたい。contentList
はログインのときだけ使用するのような仕様のとき工夫をせずに作ると、UIを切り替えるロジックがUIに寄っていきます。
@Composable
private fun HomeScreen(
contentPadding: PaddingValues,
uiState: HomeUiState,
) {
Box(modifier = Modifier.padding(contentPadding)) {
if (uiState.isLoggedIn) {
if (uiState.contentList.isEmpty()) {
Text("No content")
} else {
LazyColumn {
items(uiState.contentList.size) { index ->
Text(uiState.contentList[index])
}
}
}
} else {
Text("Not logged in")
}
if (uiState.loading) {
LoadingIndicator()
}
if (uiState.error != null) {
Text("Error") // Snackbarなど
}
}
}
サンプルなのでUIがシンプルですが、実際は意味のあるひとまとまりでprivateなComposableに切り出したり、行数が長くなったりして見通しが悪くなってきます。
サンプルの段階でもif文のネストが深くなる箇所も出てきて、宣言的UIならではのどこの{}
でUIが閉じられているかわかりにくくなってきます。
更に、追加でログインしているユーザーの中でもプレミアムユーザーができてプレミアムユーザーは別のリスト表示をしたくなったり、リストの検索機能を作りたくなったりするとどんどんUIも複雑になってネストも更に深くなることが予想されます。
これらが自分が感じていた1つのdata class
のプロパティで全ての状態を持たせたときの課題です。
nowinandroidのUiState
話を少し変えてここでnowinandroidがどうなっているのか確認したいと思います。
nowinandroid/feature/bookmarksを参考に見てみます。
BookmarksViewModelではfeedUiState
をUIに公開しています。
NewsFeedUiStateがどのような構造になっているのか見てみます。
data class
ではなく、sealed interface
で設計しているようです。
状態がLoading
とSuccess
で切り替わるようで、Success
のときは実際に表示するときに使うであろうfeed
を持っています。
UIであるBookmarksScreenも確認してみます。
when
式でUIを切り替えています。
Sucess
では空のときのEmpty表示も行っているようです。
この程度なら気にならないですが、仕様によってはSucess
の中で更にif文がネストする可能性があります。
また、ローディングのときは一度BookmarksGrid
からLoadingState
に完全に切り替わるようになっていますが、場合によってはコンテンツは表示したまま上に重ねてローディング表示したいときもあるかもしれません。
ですが、Success
のラムダの中ではfeedState.feed
にのみアクセスできて上手く関心の分離ができている印象を持ちました。
ローディングが終了してエラーも起きていない成功の場合は成功のためのUI構築に専念したいですよね。
data class
のプロパティに全ての状態を持たせるとどの状態でも全てのプロパティにアクセスできてしまいました。
UiStateを再設計する
nowinandroidの設計を参考に考えてみます。
サンプルとして以下の仕様を実現してみようと思います。
- 画面は状態ごとに表示を切り替える
- ローディング
- エラー
- コンテンツ
- コンテンツは条件で切り替わる
- ログイン時
- リストの表示
- リストが空のときはEmpty表示
- 未ログイン時
- 未ログイン用の表示
以下のようにUiStateを設計してみます。
sealed interface HomeUiState {
data object Init : NewHomeUiState
data object Loading : NewHomeUiState
data class Error(val error: Throwable) : NewHomeUiState
data class Success(
val content: Content,
) : NewHomeUiState {
sealed interface Content {
data object Guest : Content
data class Default(val contentList: List<String>) : Content
data object Empty : Content
}
}
}
Sucess
のときだけ画面の仕様に合うようにContent
を持たせています。
これにより未ログインのGuest
時とそれ以外のコンテンツを表示するとき、空の表示などを表現しています。
Content
は3つの表示状態があることや、Guest
時はcontentList
に関しては無関心なこと、そして画面大枠としてはLoading
、Error
、Success
の状態があることがUiState
の構造を見るだけでわかるようになったと思います。
これをStateFlowでUIに公開しますが、UIではこのような記述になります。
@Composable
fun HomeScreen(
uiState: HomeUiState,
) {
when (uiState) {
HomeUiState.Init -> {}
is HomeUiState.Success -> HomeScreenContent(uiState.content)
HomeUiState.Loading -> LoadingIndicator()
is HomeUiState.Error -> Text("Error")
}
}
@Composable
private fun HomeScreenContent(
content: HomeUiState.Success.Content,
) {
when (content) {
is HomeUiState.Success.Content.Default -> LazyColumn {
items(content.contentList.size) { index ->
Text(content.contentList[index])
}
}
HomeUiState.Success.Content.Guest -> Text("Guest")
HomeUiState.Success.Content.Empty -> Text("Empty")
}
}
それぞれの状態をwhen
式で分岐していますが、それぞれどのようなロジックでsealed interface
が分岐されているかはViewModelにカプセル化できています。
これによってUIでネストが深くなりにくくなりそうです。
機能改修する際もViewModelの中でどのようにUiStateを返すかだけを考えればいいので、仮にEmptyになる条件を改修したいときはViewModelを修正するだけで済みます。
UIはそのロジックを意識せず、ViewModelから公開されるUiStateに従うだけです。
個人的には宣言的UIの考え方であるUI=f(State)
っぽい表現ができているなと感じています。
Flutterの公式ドキュメントでも紹介があります。
ViewModel内では、RepositoryからFlowが返ってくるのかなどプロジェクトごとによりますが、以下のようなイメージでUiStateの分岐ロジックを書いています。
viewModelScope.launch {
_state.update {
HomeUiState.Loading
}
getData().map { result ->
result.fold(
onSuccess = { data ->
_state.update {
if (data.isEmpty()) {
HomeUiState.Success(
HomeUiState.Success.Content.Empty,
)
} else {
HomeUiState.Success(
HomeUiState.Success.Content.Default(data),
)
}
}
},
onFailure = { error ->
_state.update {
HomeUiState.Error(error)
}
},
)
}
}
では、仕様が変わって画面の上にローディングやエラーを表示したくなったとします。
- 画面はコンテンツの上にローディングやエラーを表示する
- コンテンツは条件で切り替わる
- ログイン時
- リストの表示
- リストが空のときはEmpty表示
- 未ログイン時
- 未ログイン用の表示
常にローディングやエラーの状態にアクセスできるようにする必要があります。
そのため全体をsealed interface
からdata class
にしてプロパティにローディングやエラーと一緒に画面の中身のコンテンツの状態を持つように変更します。
Content
はさきほどと同じです。
data class HomeUiState(
val loading: Boolean = false,
val error: Throwable? = null,
val content: Content = Content.Init,
) {
sealed interface Content {
data object Init : Content
data object Guest : Content
data class Default(val contentList: List<String>) : Content
data object Empty : Content
}
}
UI側は以下のようになります。
HomeScreenContent
の上にローディングやエラー表示が可能になりました。
ここは多少エラー表示をSnackbarにするのかなどによって書き方は変わるかもしれません。
@Composable
fun HomeScreen(
uiState: HomeUiState,
) {
Box {
HomeScreenContent(uiState.content)
if (uiState.error != null) Text("Error")
if (uiState.loading) LoadingIndicator()
}
}
@Composable
private fun HomeScreenContent(
content: HomeUiState.Content,
) {
when (content) {
is HomeUiState.Content.Default -> LazyColumn {
items(content.contentList.size) { index ->
Text(content.contentList[index])
}
}
HomeUiState.Content.Empty -> Text("Empty")
HomeUiState.Content.Guest -> Text("Guest")
HomeUiState.Content.Init -> {}
}
}
最後にViewModel側の実装です。
UiStateがdata class
になったので、it.copy
で状態を更新しています。
viewModelScope.launch {
_state.update {
it.copy(loading = true)
}
getData().map { result ->
result.fold(
onSuccess = { data ->
_state.update {
if (data.isEmpty()) {
it.copy(
loading = false,
content = HomeUiState.Content.Empty,
)
} else {
it.copy(
loading = false,
content = HomeUiState.Content.Default(data),
)
}
}
},
onFailure = { error ->
_state.update {
it.copy(loading = false, error = error)
}
},
)
}
}
今回は詳細に触れませんが、ViewModelのテストもRepositoryなどが返してくる値によって、HomeUiState.Content
がEmpty
なのかDefault
なのかなどをチェックすることができて、Contentが切り替わることを確認することになり見やすくなると思っています。
例えばGuest
のときは他のContent
のプロパティや状態にアクセスが制限されるので、Repositoryから返ってきたリストの値がemptyかどうかなど必要のないassertを書いてしまう可能性も排除することができます。
チーム開発でどう設計するか
UiStateを再設計する例を紹介してみましたが、チーム開発の場合はチーム状況によっては従来よりも考えることが増えて大変になるかもしれないと思っています。
主にContent
の設計です。
例えばEmpty
の表示を今回1つのContent
の状態として分けました。
しかし、これは専用の状態を用意せずDefault
とまとめてUI側で分岐ロジックを書いてもいいのではないかという意見も出てくると思います。
@Composable
private fun HomeScreenContent(
content: HomeUiState.Content,
) {
when (content) {
is HomeUiState.Content.Default -> {
// UI側でisEmptyのチェック
if (content.contentList.isEmpty()) {
Text("Empty")
} else {
LazyColumn {
items(content.contentList.size) { index ->
Text(content.contentList[index])
}
}
}
}
HomeUiState.Content.Guest -> Text("Guest")
HomeUiState.Content.Init -> {}
}
}
実際のアプリではもっと状態も多様かつ複雑でこのようにどっちにするか迷うケースが多発するのではと思っています。
ここはチームごとにルールをよく考える必要があるかなと思っています。
Empty
表示に関わるロジックが複数のRepositoryの結果から計算されたり、ロジックが複雑な場合はContent
としてEmpty
の状態を用意してあげてViewModel内でロジックを書いたほうがやりやすいなどありそうです。
また、実装前にこの画面がEmptyがあって、Guest表示があって、、、となり得る状態を理解できている必要があります。
仕様理解が浅かったり、仕様をKotlinで表現するスキルがないとここは設計段階で時間が取られることが増えるかもしれません。
個人的にここは設計段階で考えられているべきで、ふわふわした状態で実装をしたほうが後で手戻りやバグにもなるので自然と考える時間がシフトレフトされることはいいことだと思っています。
しかし、新規開発など速度が極端に求められるプロジェクトでは少しやり過ぎ感が出ることもあるかもしれないです。
さいごに
今回はこれまでとりあえずdata class
に画面の状態すべてをプロパティとして保持させていた作りを再設計することを考えてみました。
全てのエンジニアやプロジェクトに当てはまる完璧なやりかたは存在しないのですが、皆さんのアプリの設計がより良く、保守運用しやすいものになるきっかけになれば嬉しいです。
自分も検討段階なので、コメントなどいただいてブラッシュアップできたら嬉しいです。
Discussion