コネヒト開発者ブログ

コネヒト開発者ブログ

Android版ママリアプリのリファクタ事情 ~ ViewState編 ~

こんにちは。2017年11月にAndroidエンジニアとしてjoinした@katsutomuです。

昨年のエントリーで緑髪 -> 赤髪 -> 金髪と定期的なアップデートをご報告いたしましたが、今は髪が伸びて、3分の2が黒髪になってきています。

さて今回は、Android版ママリアプリのリファクタリングの事情の第二弾として、ViewState *1 導入提案時のお話を共有します。

導入背景

ママリアプリの実装は、ViewModelにUI要素ごとの状態をLiveDataで持ち、状態管理を行っています。この場合、UI要素が増えるたびにViewModeにLiveDataが増やすことになるため、UIの状態管理の難易度が高い状態になっていました。

この解決策としてViewの状態を1つにまとめたViewStateの導入方針を提案したところ、LiveEventの扱いをどうするか?が主な議題になりました。

なぜLiveEventが必要か?

まず、LiveEventが必要とされている理由についておさらいしておきます。

github.com

ママリアプリではViewModelとViewの間のやりとりの中で、一度だけ実行したいイベントの場合、LiveEventで実装をしています。例えば、以下のようなユースケースです。

  • メッセージ表示
    • 自動的で消えるトースト表示
    • 操作が伴う入力フォームやエラーダイアログ表示
  • 画面ナビゲーション
    • 質問投稿後の画面移動など

この場合LiveDataで実装すると、困ったことがおきえます。LiveDataが常に最新の状態を保持し、Activityがバックグラウンドからフォアグラウンドに戻った場合にも、最新の状態を受け取る性質を持つため、一度きり表示したいメッセージが再び表示される実行済みの画面ナビゲーションが再実行されるといったことが起きてしまいます。

これらの解決策として、一度だけイベントが送信されることを保証するLiveEventを利用しています。

導入方針の検討

今回はこのLiveEventが必要な事情を考慮に入れ、ViewStateの導入後の設計を2パターン比較して、方針を相談しました。

1. 状態とイベントを区別するパターン

f:id:katsutomu0124:20220204153730p:plain 社内で上がってきたアイディアの一つです。ViewModel → Viewに反映するべき状態をViewStateで保持し、一度きりのイベントをViewCommandで伝えます。

pros

  • UIの状態とイベントで使い分けができる。特に画面ナビゲーションはその方が直感的。

cons

  • Viewに影響を与える要素が複数になり、複雑度が増す

2. 状態とイベントを一緒に扱うパターン

f:id:katsutomu0124:20220204153809p:plain

最近アップデートされた、Googleのアーキテクチャガイドラインでのアプローチです。ViewModel → Viewに反映する状態や画面遷移のトリガーは全てViewStateで持ちます。

pros

  • Viewに影響を与える要素が一つになり、複雑度が下がる

cons

上記の2つのパターンで比較し、Googleのアーキテクチャガイドに従うことや、Viewに影響を与える要素を一つにすることが、今後はメリットが大きいと判断し、状態とイベントを一緒に扱うパターンを選択することにしました。

その後、想定される3つのユースケースの具体的な実装方法をつめて、結果的に以下のルールを選定しました。

1. 自動で消えるメッセージ表示

ViewStateに表示メッセージを持たせる。表示したら表示完了をViewModelに伝える。

2.ユーザーの操作を伴う入力ダイアログやエラーダイアログ表示。

ViewStateに表示メッセージを持たせる。重複表示はUI側で制御し、再表示が不要になったらViewModelに伝える。

3.画面ナビゲーション

ViewStateに入れることが違和感を感じるため、別のイベントとして扱う。

結果的に、Googleのアーキテクチャガイドのパターンをベースにしつつも、現時点では画面ナビゲーションはViewStateと分けて管理する方が、コネヒトのAndroidチームでは違和感が少ないと判断し、このルールを選定しました。今後はこのルールをベースにリファクタリングを進めていく予定です。

f:id:katsutomu0124:20220204153842p:plain

参考までに、相談時の議事録を共有しておきます。

議事録メモ

- 自動的に消えるようなトーストメッセージ表示
    - ViewStateのみで扱う違和感ない
- ユーザーの操作を伴う入力ダイアログやエラーダイアログ表示
    - ViewStateのみで扱う場合、少し違和感
    - 表示してすぐにonShown呼び出す。
        - UI側で表示制御をするのはあり
            1. ViewModelから表示したいメッセージを送る
                - uistateに表示したいメッセージが入る
            2. Viewはメッセージを受け取ってダイアログを表示する。
                - View側で、多重表示しない制御を入れる
            3. ダイアログ非表示
                - ユーザーが操作してダイアログを非表示にする
                    - viewModel.onShown()
                    - uiStateから表示したいメッセージ消す
                - ViewModelから消したいパターン
                    - uiStateから表示したいメッセージ消す
        - DialogFragment:tagで表示確認した上で表示する。
        - AlertDialog:UI側でフラグ管理する?ちょっと冗長。そもそも今回の場合はAlertDialogじゃなく、Toastや入力フォームエラー表示するのがベターかも?
            - Alert Dialogは単体で使わないほうが良い認識。
            - DialogFragmentに内包して使うとリーク問題が解決する。
        `そもそもダイアログは本当に必要な場合だけ使うように限定したい`
- 画面ナビゲーション
    - ViewStateの方法だと違和感あるかも。これだけViewCommandで扱うのはあり。
        - その場合は名前を変えた方が良さそう
        - 将来的にはJetpack Navigationで実装したいが・・・。
    - ActivityからActivityに切り替えるパターン
        - ViewCommandの名前をNavigationCommandにしてワンショットでイベントを送る
    - 1Activityで複数Fragmentを切り替えるパターン
        - こちらはViewStateにシーンの概念を持たせるのが良さそう

提案前にやっていること

筆者は、新しい設計や機能を試すときに、弊社のGitHub上にAndroidのコードを実験するシンプルな構成のAndroidプロジェクトを用意し、一度実験した上で、提案を進めるようにしています。

せっかくなので、今回のViewState導入の提案前に実験したサンプルコードを抜粋して紹介します。

サンプルコード

// 画面全体のUIの状態を表したクラス。
data class MamariUiState (
    val contents: List<ListViewItem> = listOf(),
    val errorMessage: List<ErrorMessage> = listOf()
)
class MamariViewModel: ViewModel() {
    // メッセージの内容をViewStateで
    private val _viewState = MutableStateFlow(MamariUiState(
        errorMessage = listOf(
            ErrorMessage(
                UUID.randomUUID().mostSignificantBits,
                "errorだよ"
            )
        )
    ))
        val viewState = _viewState.asStateFlow()

        // 表示されたらメッセージを破棄する。
    fun onErrorShown(errorId: Long) {
        _viewState.update { current ->
            val newErrorMessage = current.errorMessage.filterNot { it.id == errorId }
            current.copy(
                errorMessage = newErrorMessage
            )
        }
    }       
}

終わりに

今回はリファクタリングの2歩目を紹介いたしました。最終的なアーキテクチャは過去の記事で触れていますので、是非そちらもご一読ください!

tech.connehito.com

PR

コネヒトでは、バリバリとリファクタリングを進めてくれるAndroidエンジニアを募集中です!

hrmos.co

*1:UIStateと呼ばれることも多いですが、弊社ではViewModelと近しい存在と捉えて名前も寄せています