inSmartBank

B/43を運営する株式会社スマートバンクのメンバーによるブログです

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

こんにちは!
スマートバンクで今年の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 blog, engineers, product managers, designers, business development, legal, CS, and other members will share their insights.