こんにちは!
スマートバンクで今年の8月から Android エンジニアをしております yokomii です。
弊社が配信している家計簿アプリ 「B/43(ビーヨンサン)」 が、12月9日に新バージョン(v18.0.0)をリリースしました🎉
本リリースには、今後の家計管理機能の拡充を見据えた、「ホーム画面リニューアル&新カード画面の追加」が含まれています。
今回の主要な画面の再開発にあたり、
AAC(Android Architecture Component) ViewModel の「複雑さを減らす」ことを重要視しました。
ViewModel の詳細な設計方針については、デベロッパーに委ねられる部分が大きいです。
特に意識せずに使っていると、容易に複雑なコードが混入しがちです。
本記事が皆様にとって、 ViewModel の設計方針を見直すきっかけとなれば幸いです。
🐶 複雑さを排除する ViewModel の設計方針
1️⃣ UI state を唯一の情報源とする
新ホーム画面の ViewModel では、SSOT(信頼できる単一の情報源)の原則に則り、UI state を UI 状態の唯一の情報源としています。
つまり、UI では UI state のみを監視し、それ以外の状態は監視していません。
data class UiState( val listItems : List<Item> = emptyList(), ) class HomeViewModel() : ViewModel() { val _uiState = MutableStateFlow<UiState>(UiState()) val uiState: StateFlow<UiState> = _uiState.asStateFlow() } @Composable fun HomeScreen(viewModel: HomeViewModel) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() HomeList(listItems = uiState.listItems) }
情報源が複数存在するだけで、下記のような複雑さにつながる恐れがあります。
- 状態がどの情報源に含まれるかを知る必要がある
- 情報源同士で状態を同期するためのロジックが生じる
- 状態をどの情報源に持たせるべきか検討の必要性が生じる
- テストや Compose Preview 用に、情報源のモックを複数用意する手間につながる
あらかじめ情報源を一つに絞っておくことで、これらの複雑さを回避することができます。
2️⃣ UI state を適切な大きさで分割する
UI state を唯一の情報源とすると、UI state が肥大化することで複雑さにつながる恐れがあります。
それを回避するため、 UI state を一定の大きさで分割しています。
ここでいう「分割」とは、 「情報源を増やす」という意味ではなく、情報源内の「状態を分ける」という意味です。
分割方法は下記の2種類があります
- 表示 UI 単位で分割する
- 画面の表示状態単位で分割する
表示 UI 単位で分割する
新ホーム画面には主に下記のような UI があります
これらのUIごとに UI state クラスを生成し、画面の情報源となる側の UI state でそれらを包含します。
data class HomeBannerUiState( val icon: B43Icon, val title: String, val description: String, ) data class UiState( val banner : HomeBannerUiState, val monthlySummaryPanel : HomeMonthlySummaryPanelUiState, val cardPanel : HomeCardPanelUiState, val timeline : HomeTimelineUiState, ) class HomeViewModel( bannerRepository: BannerRepository, monthlySummaryRepository: MonthlySummaryRepository, cardRepository: CardRepository, timelineRepository: TimelineRepository, ) : ViewModel() { ... private val banner = bannerRepository.get() .map { HomeBannerUiState(it) } private val monthlySummaryPanel = monthlySummaryRepository.get() .map { HomeMonthlySummaryPanelUiState(it) } private val cardPanel = cardRepository.get() .map { HomeCardPanelUiState(it) } private val timeline = timelineRepository.get() .map { HomeTimelineUiState(it) } init { combine( banner, monthlySummaryPanel, cardPanel, timeline ) { -> banner, monthlySummaryPanel, cardPanel, timeline _uiState.update{ it.copy(banner, monthlySummaryPanel, cardPanel, timeline) } }.launchIn(viewModelScope) }
各 UI の状態管理の責務を適切に分離することで、拡張容易性や、テスト容易性の向上にも繋がります。
画面の表示状態単位で分割する
画面の表示状態によっては、不要な UI 状態 が存在することがあります。
例えば、データのロード中に Progress indicators だけが画面に表示されているようなときは、その他の UI の状態は実質不要となります。
このように「画面に表示される UI の差異」を境界として、UI state を分割します。
sealed interface UiState { data object Loading : UiState data class Success( val banner : HomeBannerUiState, val monthlySummaryPanel : HomeMonthlySummaryPanelUiState, val cardPanel : HomeCardPanelUiState, val timeline : HomeTimelineUiState, ): UiState data class Error(val e: B43Exception) : UiState }
こうすることで、各 UI state の関心と責務を分離することができます。
ただしこの分割によって、「とある UI 状態 を更新したい」ときに、「その状態が現在の UI state に含まれているか」を都度確認する必要性が生じます。
class HomeViewModel() : ViewModel() { fun dismissBanner() { val currentState = _uiState.value // 現在の UI state を確認 if(currentState is UiState.Success) { currentState.banner.dismiss() } } }
ボイラープレートコードを回避するために、ViewModelState
というフラットな状態クラスを別途用意しています。
状態の更新は ViewModelState
に対して実行し、ViewModelState
を UI state に変換することでこの問題に対処しています。
data class ViewModelState( val banner : HomeBannerUiState? = null, val monthlySummaryPanel : HomeMonthlySummaryPanelUiState? = null, val cardPanel : HomeCardPanelUiState? = null, val timeline : HomeTimelineUiState? = null, val e: B43Exception? = null, ) : ViewModel() { fun toUiState() : UiState { return when { e != null -> UiState.Error( e = e, ) banner != null && monthlySummaryPanel != null && cardPanel != null && timeline != null && -> UiState.Success( banner = banner, monthlySummaryPanel = monthlySummaryPanel, cardPanel = cardPanel, timeline = timeline, ) else -> UiState.Loading } } } class HomeViewModel(...) { private val viewModelState = MutableStateFlow(ViewModelState()) private val uiState = viewModelState.map { it.toUiState() } fun dismissBanner() { viewModelState.value.banner?.dismisss() } }
この手法は compose-samples/JetNews でも用いられています。
3️⃣ StateFlow の 多用を避ける
「UI state を唯一の情報源とする」の項で述べたように、情報源が複数存在すると、それだけでコードの複雑性に繋がります。
StateFlow
は value
をキャッシュする情報源であるため、多用することで複雑さの原因となります。
例えば、下記のような StateFlow
から別の StateFlow
に変換するようなコードがあるとき、
変換後の StateFlow
の状態のみを更新して、変換前の StateFlow
の状態を更新し忘れるなどの不具合につながる恐れがあります。
data class UiState(val isLiked: Boolean) class MyViewModel(likeRepository: LikeRepository) : ViewModel() { val isLiked = MutableStateFlow<Boolean>(false) val uiState = MutableStateFlow<UiState>(UiState(false)) init { isLiked.onEach { uiState.value = UiState(it) } .launchIn(viewModelScope) load() } fun load() { isLiked.value = likeRepository.isLiked() } fun updateLike(isLike: Boolean) { uiState.update { it.copy(isLiked = isLike) } } // updateLike() による状態変更が反映されていない fun isLike() = isLiked.value }
StateFlow
を新しく ViewModel に追加する際には、本当に情報源を増やすべきか、慎重に検討する必要があります。
情報を保持しておく必要がないのであれば、 SharedFlow
に置き換えるなどの対応をしましょう。
4️⃣ Flow.combine の常用を避ける
Flow.combine
は Flow のデータを合成する上で便利な関数ですが、思わぬ不具合に繋がりやすいです。
下記は Flow.combine
によって、各種アプリデータを待ち合わせた後に、 ViewModelState
を更新する例です。
sealed interface Result { class Success<T>(value: T) : Result class Error(e: B43Exception): Result } class CardViewModel( userRepository: UserRepository, cardRepository: CardRepository, ) : ViewModel() { ... private val user: Flow<Result> = userRepository.get() private val card: Flow<Result> = cardRepository.get() private val error: B43Exception = merge( user.filterIsInstance<Result.Error>, card.filterIsInstance<Result.Error>, ) { it.e } init { combine( user.filterIsInstance<Result.Success<User>>.map{ it.value }, card.filterIsInstance<Result.Success<Card>>.map{ it.value }, error, ) { -> user, card, error _uiState.update{ it.copy(user, card, error) } }.launchIn(viewModelScope) } }
Flow.combine
は、すべてのFlowが少なくとも1つ値をエミットするまでは合成をしません。
つまり上記のコードでは、 Result が Success と Exception の両条件になることはあり得ないため、 ViewModelState
が永遠に更新されません。
その対処として、 合成される Flow 側で必ず何らかの値を返す方法があります。
private val error: B43Exception = merge( user.filterIsInstance<Result>, card.filterIsInstance<Result>, ) { if(it is Result.Error) it.e else null }
しかし、「合成される側」が「合成する側」の実装に合わせて振る舞いを変更することになり、間接的な依存関係が生じてしまいます。
また、局所的に対処しても、他にも同様の「値を常に返さない Flow」 が生まれる可能性が十分あり得ます。
そのため新画面においては、 Flow.combine
で待ち合わせる Flow を限定することにしました。
具体的には、画面の初期表示時に必要なデータのみを Flow.combine
で待ち合わせて、それ以外は別のオペレーターを用いています。
class CardViewModel(...) : ViewModel() { ... init { // 初期表示のデータのみを combine combine( user.filterIsInstance<Result.Success<User>>.map{ it.value }, card.filterIsInstance<Result.Success<Card>>.map{ it.value }, ) { -> user, card _uiState.update{ it.copy(user, card) } }.launchIn(viewModelScope) // 常に発生し得ないエラーは個別に処理 error.onEach { e -> viewModelState.update { it.copy(e = e) } } .launchIn(viewModelScope) } }
コードの記述量は増えますが、関係を疎に保ったまま安全に待ち合わせができます。
5️⃣ 初期化処理を SharedFlow.onSubscription
で実行する
ViewModel で初期化処理を実行するメジャーなトリガーポイントとして、下記の二箇所が挙げられます。
- ViewModelの
init
ブロックで実行 - UI のライフサイクルイベント (Compose の
LaunchedEffect
など) で実行
これらの方法には、下記のメリデメが存在します。
実行箇所 | メリット | デメリット |
---|---|---|
ViewModel.init | トリガーポイントが ViewModel 内に収まる | 実行タイミングが UI ライフサイクルとずれる |
LaunchedEffect | UI ライフサイルに密接した実行タイミング | 構成の変更などでリコンポーズが生じた際に、不要な再実行が発生する可能性がある |
これらのデメリットによる問題は、パフォーマンスを意識するようになる開発終盤に顕在化しがちです。
問題を未然に防ぐため、別のトリガーポイントとして、 SharedFlow.onSubscription()
を採用しています。
class HomeViewModel(...) : ViewModel() { ... val uiState: StateFlow<UiState> = viewModelState .onSubscription { // 初期化処理 initLoad() } .map { it.toUiState() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), viewModelState.value.toUiState()) private fun initLoad() { ... } } @Composable fun HomeScreen(viewModel: HomeViewModel) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() HomeContent(uiState = uiState) }
Flow.stateIn
の started
引数に SharingStarted.WhileSubscribed
を設定することで、最初の Subscriber が現れるまでロードを遅延できます。
この Flow を collectAsStateWithLifecycle()
で監視することで、UIライフサイクルに紐づいた実行が可能となります。
また、SharingStarted.WhileSubscribed
の引数の stopTimeoutMillis
に5000ミリ秒を設定することで、Subscriber がいなくなっても5秒間はストリームが継続します。
それにより、画面回転などで再 Subscribe されても初期化処理の再実行を防ぐことができます。
この手法は nowinandroid でも用いられています。
このように処理を整えておくことで、安定した初期化処理実行が可能となります。
ただし、コードの可読性はメジャーな手法群に劣るため、チームでの合意やドキュメント化が重要です。
終わりに
コードの複雑性を減らすことは、保守容易性や拡張容易性の向上に繋がるため、サービスの成長速度に影響します。
スマートバンクのモバイルアプリ部では、コードをシンプルに保つことを常に意識しながら、日々の開発に勤しんでいます。
最新のモバイルアプリ部については下記の記事をご参照ください👇
同様のモチベーションで開発をしたいぜ!という方は、カジュアル面談や採用にご応募いただけると嬉しいです🙏
また、12月18日(水)にはエンジニア向けのオフラインイベントを開催します。
アプリエンジニアも登壇予定なので、チームや事業の雰囲気を掴みたい方は、こちらへのご参加もおすすめします👇👇