inSmartBank

AI家計簿アプリ「ワンバンク」を開発・運営する株式会社スマートバンクの Tech Blog です。より幅広いテーマはnoteで発信中です https://note.com/smartbankinc

なるべく複雑さを排除する AAC ViewModel 設計

https://cdn-ak.f.st-hatena.com/images/fotolife/s/smartbank/20251008/20251008172716.png

こんにちは! 株式会社スマートバンクで今年の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 を唯一の情報源とする」の項で述べたように、情報源が複数存在すると、それだけでコードの複雑性に繋がります。 StateFlowvalue をキャッシュする情報源であるため、多用することで複雑さの原因となります。

例えば、下記のような 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 で初期化処理を実行するメジャーなトリガーポイントとして、下記の二箇所が挙げられます。

  1. ViewModelの init ブロックで実行
  2. 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.stateInstarted 引数に SharingStarted.WhileSubscribed を設定することで、最初の Subscriber が現れるまでロードを遅延できます。 この Flow を collectAsStateWithLifecycle() で監視することで、UIライフサイクルに紐づいた実行が可能となります。

また、SharingStarted.WhileSubscribed の引数の stopTimeoutMillis に5000ミリ秒を設定することで、Subscriber がいなくなっても5秒間はストリームが継続します。 それにより、画面回転などで再 Subscribe されても初期化処理の再実行を防ぐことができます。

この手法は nowinandroid でも用いられています。

このように処理を整えておくことで、安定した初期化処理実行が可能となります。 ただし、コードの可読性はメジャーな手法群に劣るため、チームでの合意やドキュメント化が重要です。


終わりに

コードの複雑性を減らすことは、保守容易性や拡張容易性の向上に繋がるため、サービスの成長速度に影響します。 株式会社スマートバンクのモバイルアプリ部では、コードをシンプルに保つことを常に意識しながら、日々の開発に勤しんでいます。 最新のモバイルアプリ部については下記の記事をご参照ください👇

同様のモチベーションで開発をしたいぜ!という方は、カジュアル面談や採用にご応募いただけると嬉しいです🙏

また、12月18日(水)にはエンジニア向けのオフラインイベントを開催します。 アプリエンジニアも登壇予定なので、チームや事業の雰囲気を掴みたい方は、こちらへのご参加もおすすめします👇👇


参考資料

We create the new normal of easy budgeting, easy banking, and easy living.
In this tech blog, engineers and other members will share their insights.