フォーム重視の Android アプリにおける Kotlin MVI と MVVM:UI 状態
Kotlin の MVI と MVVM をフォーム重視の Android アプリ向けに解説。検証、楽観的 UI、エラー状態、オフラインドラフトの実装方法を実践的に説明します。

なぜフォーム重視の Android アプリはすぐに混乱するのか
フォーム中心のアプリは、ユーザーが常に小さな判断を待たされるため、遅く感じたり脆く見えたりします:このフィールドは有効か、保存は成功したか、エラーを表示すべきか、ネットワークが途切れたらどうするか。
フォームは複数の種類の状態を同時に混ぜるので、状態バグを露呈しやすいです:UI 状態(何が見えているか)、入力状態(ユーザーが入力したもの)、サーバ状態(保存済みのもの)、一時状態(進行中の処理)。これらが同期を失うと、ボタンが間違ったタイミングで無効になったり、古いエラーが残ったり、回転後に画面がリセットされたりして「ランダム」に感じます。
問題は大きく四つに集まります:検証(特にフィールド間ルール)、楽観的 UI(処理中の高速なフィードバック)、エラーハンドリング(明確で回復可能な失敗)、オフラインドラフト(未完了の作業を失わないこと)。
良いフォーム UX はいくつかのシンプルなルールに従います:
- 検証はフィールドに近く、助けになるものであること。入力をブロックしないでください。重要な箇所では厳格に、通常は送信時に行う。
- 楽観的 UI はユーザーの操作を即座に反映するが、サーバが拒否したときのクリーンなロールバックが必要。
- エラーは具体的で実行可能、かつユーザーの入力を消さないこと。
- ドラフトは再起動、割込み、悪い接続を越えて残ること。
だからこそフォームのアーキテクチャ議論は熱くなります。選ぶパターンが、プレッシャー下でそれらの状態がどれだけ予測可能に感じるかを決めます。
簡単なおさらい:平易な言葉での MVVM と MVI
MVVM と MVI の本質的な違いは、画面を通る変更の流れです。
MVVM(Model View ViewModel)は通常こう見えます:ViewModel が画面データを保持し、StateFlow や LiveData 経由で UI に公開し、save や validate、load のようなメソッドを提供します。UI はユーザー操作時に ViewModel のメソッドを呼びます。
MVI(Model View Intent)は通常こう見えます:UI がイベント(intent)を送信し、reducer がそれらを処理し、画面は今必要なすべてを表す単一の state オブジェクトからレンダリングされます。ネットワークやデータベースなどの副作用は制御された方法でトリガーされ、結果はイベントとして返されます。
思考の違いを覚える簡単な方法:
- MVVM は「ViewModel はどんなデータを公開し、どんなメソッドを持つべきか?」と問います。
- MVI は「どんなイベントが起きうるか、そしてそれらがどのように状態を変えるか?」と問います。
どちらのパターンも単純な画面では十分に機能します。フィールド間検証、自動保存、リトライ、オフラインドラフトを加えると、誰がいつ状態を変えられるかについてより厳密なルールが必要になります。MVI はデフォルトでそのルールを強制します。MVVM でもうまく行きますが、規律(更新経路の一貫性や一回限りの UI イベントの扱い)が必要です。
予想外を避けるためのフォーム状態のモデリング
最も早く制御を失う方法は、フォームデータをあちこちに置くことです:ビューのバインディング、複数のフロー、そして「もう一つだけ」のブール値。フォーム重視の画面は、一つの真実のソースを持つと予測可能になります。
実用的な FormState の形
生の入力といくつかの信頼できる派生フラグを保持する単一の FormState を目指してください。少し大きく見えても、退屈で完全な構造にしておきます。
data class FormState(
val fields: Fields,
val fieldErrors: Map<FieldId, String> = emptyMap(),
val formError: String? = null,
val isDirty: Boolean = false,
val isValid: Boolean = false,
val submitStatus: SubmitStatus = SubmitStatus.Idle,
val draftStatus: DraftStatus = DraftStatus.NotSaved
)
sealed class SubmitStatus { object Idle; object Saving; object Saved; data class Failed(val msg: String) }
sealed class DraftStatus { object NotSaved; object Saving; object Saved }
これにより、フィールド単位の検証(各入力ごと)とフォームレベルの問題(例えば “合計は > 0 である必要がある”)が分離されます。isDirty や isValid のような派生フラグは UI 側で再実装するのではなく、一箇所で計算してください。
クリーンな精神モデルはこうです:フィールド(ユーザーが入力したもの)、検証(何が間違っているか)、ステータス(アプリが何をしているか)、ダーティネス(最後の保存以降何が変わったか)、ドラフト(オフラインコピーが存在するか)を分けて考えること。
ワンオフ効果はどこに置くか
フォームはスナックバー、ナビゲーション、「保存しました」バナーなどの一度きりのイベントも発生させます。これらを FormState の中に入れると、回転や UI の再購読で何度も発火してしまいます。
MVVM では、効果を別のチャネル(例: SharedFlow)で流します。MVI では、UI が一度だけ消費する Effects(または Events)としてモデル化します。この分離は「幻の」エラーや重複する成功メッセージを防ぎます。
検証フロー:MVVM と MVI の違い
検証はフォーム画面が脆く感じる主要なポイントです。重要な選択はルールをどこに置き、結果をどう UI に返すかです。
単純で同期的なルール(必須チェック、最小長、数値範囲)は UI ではなく ViewModel やドメイン層で実行すべきです。これによりルールはテスト可能で一貫性が保たれます。
非同期ルール(例えば “そのメールは既に使われているか?”)は扱いが難しいです。ロード状態、古い結果、ユーザーが再入力した場合の扱いを考慮する必要があります。
MVVM では、検証はしばしば状態とヘルパーメソッドの混合になります:UI は変更(テキスト更新、フォーカス喪失、送信クリック)を ViewModel に送信し、ViewModel は StateFlow/LiveData を更新してフィールド別エラーや派生した canSubmit を公開します。非同期チェックはジョブを開始し、完了時にロードフラグやエラーを更新します。
MVI では検証がより明示的になる傾向があります。実用的な責任分担の例:
- reducer が同期検証を実行してフィールドエラーを即時に更新する。
- effect が非同期検証を実行し、その結果の intent をディスパッチする。
- reducer はその結果が現在の入力と一致する場合のみ適用する。
最後のステップが重要です。ユーザーが非同期の「一意なメール」チェック中に別のメールを入力したら、古い結果は現在の入力を上書きしてはいけません。MVI は、最後に検証した値を状態に保存して古い応答を無視するロジックを組みやすいことが多いです。
楽観的 UI と非同期保存
楽観的 UI は、ネットワーク応答を待たずに保存が成功したかのように画面が振る舞うことを指します。フォームでは、Save ボタンが “Saving...” に変わったり、小さな “Saved” インジケータが表示されたり、入力を使えるままにする(あるいは意図的にロックする)ことがあります。
MVVM では、isSaving、lastSavedAt、saveError のようなフラグを切り替えて実装することが一般的です。重なる保存によってこれらのフラグが不整合になるリスクがあります。MVI では reducer が単一の state オブジェクトを更新するため、「Saving」と「Disabled」が矛盾する可能性は低くなります。
ダブルサブミットやレースコンディションを避けるために、各保存を識別可能なイベントとして扱ってください。ユーザーが Save を二度タップするか、保存中に編集した場合、どの応答が勝つかのルールが必要です。いくつかの対策はどちらのパターンでも有効です:保存中は Save を無効化(またはタップをデバウンス)する、各保存に requestId(またはバージョン)を付けて古い応答を無視する、画面離脱時に進行中の処理をキャンセルする、保存中の編集を次の保存としてキューするか dirty に戻すかを定義する。
部分的成功もよくあります:サーバが一部のフィールドを受け入れ、他を拒否する場合です。これを明示的にモデル化してください。フィールドごとのエラー(必要ならフィールドごとの同期ステータス)を保持して、全体としては “Saved” を表示しつつも、注意すべきフィールドをハイライトできるようにします。
ユーザーが回復できるエラー状態
フォーム画面は単に「何かがうまくいかなかった」以上に多くの失敗を経験します。すべての失敗を汎用トーストにすると、ユーザーは入力をやり直し、信頼を失い、離脱します。目標は常に同じ:入力を安全に保ち、明確な修正方法を示し、リトライを普通に感じさせることです。
エラーをどこに割り当てるかを分けると役立ちます。形式が間違っているメールとサーバの停止は同じではありません。
フィールドエラーはインラインで該当入力に結びつけます。フォームレベルのエラーは送信アクション付近に置き、何が送信を妨げているのかを説明します。ネットワークエラーはリトライを提供しフォームを編集可能なままにします。認証や権限エラーはドラフトを保持したまま再認証に導くべきです。
核心的な回復ルール:失敗してもユーザー入力を消さないこと。保存が失敗しても、現在の値はメモリとディスクの両方に残します。ユーザーが編集しない限り、リトライは同じペイロードを再送します。
サーバエラーを UI 状態にマップする方法でパターンの違いが出ます。MVVM では複数のフローやフィールドを更新しやすく、気づかないうちに不整合が生まれることがあります。MVI では通常、サーバ応答を一つの reducer ステップで適用して fieldErrors と formError を同時に更新します。
また、何が状態で何がワンオフ効果かを決めておきます。インラインエラーや「送信に失敗しました」は状態に入れるべき(回転しても残る)です。スナックバーや振動、ナビゲーションのような一度きりの動作は効果として扱います。
オフラインドラフトと進行中フォームの復元
フォーム重視のアプリは、ネットワークが良好でも「オフライン」のように感じることがあります。ユーザーはアプリを切り替え、OS がプロセスを殺し、あるいは途中で信号を失うことがあります。ドラフトはそれらから作業を守ります。
まず、ドラフトが何を意味するかを定義します。「クリーンな」モデルだけを保存するのは多くの場合不十分です。半分入力したフィールドを含め、画面が元の見た目どおりに復元されることを望むことが多いです。
保存すべきは主に生のユーザー入力(入力された文字列、選択した ID、添付 URI)、そして後で安全にマージするための十分なメタデータ:サーバの最終スナップショットとバージョンマーカー(updatedAt、ETag、単純な増分など)です。検証は復元時に再計算します。
保存場所の選択は機密性とサイズに依存します。小さなドラフトはプリファレンスで良い場合もありますが、マルチステップフォームや添付ファイルがある場合はローカル DB に入れる方が安全です。個人データを含む場合は暗号化ストレージを使ってください。
最大の設計上の問いは、真実のソースがどこにあるかです。MVVM ではチームがフィールド変更時に ViewModel から永続化することが多いです。MVI では reducer 更新後に保存する方がシンプルになることが多いです(単一の整合性ある state、または派生した Draft オブジェクトを保存するため)。
オートセーブのタイミングは重要です。毎キー入力で保存するとノイズが多く、短いデバウンス(例えば 300〜800ms)とステップ変更時の保存を組み合わせると良いです。
ユーザーがオンラインに戻ったときのマージルールも必要です。実用的なアプローチはこうです:サーバ版が変わっていなければドラフトを適用して送信する。変わっていたら、ドラフトを保持するかサーバを再読み込みするかの明確な選択を提示します。
ステップバイステップ:どちらのパターンでも信頼できるフォームを実装する
信頼できるフォームはまず明確なルールから始まります。すべてのユーザーアクションは予測可能な状態へ導くべきで、すべての非同期結果は一か所に着地するべきです。
画面が反応すべきアクションを紙に書き出してください:入力、フォーカス喪失、送信、リトライ、ステップ移動。MVVM ではこれらが ViewModel のメソッドと状態更新になります。MVI では明示的な intents になります。
次に小さく段階的に組み立てます:
- 編集、blur、submit、save success/failure、retry、restore draft のようなフルライフサイクルのイベントを定義する。
- 一つの状態オブジェクトを設計する:フィールド値、フィールド別エラー、全体のフォームステータス、未保存の変更フラグなど。
- 検証を追加する:編集中は軽めのチェック、送信時は厳密なチェック。
- 楽観的保存ルールを追加する:何が即時に変わるか、何がロールバックを引き起こすかを定義する。
- ドラフトを追加する:デバウンスでのオートセーブ、開いたときの復元、復元時に小さな “ドラフトを復元しました” 表示を出してユーザーに信頼してもらう。
エラーを体験の一部と考えてください。入力は保持し、修正が必要な箇所だけをハイライトし、次に取るアクション(編集、リトライ、ドラフトを保持)を一つに絞ります。
複雑なフォーム状態を Android UI を書く前にプロトタイプしたければ、AppMaster のようなノーコードプラットフォームはワークフローを先に検証するのに便利です。そうすれば同じルールを MVVM や MVI に実装するときに驚きが少なくなります。
例:多段階の経費報告フォーム
4 ステップの経費報告を想像してください:詳細(日付、カテゴリ、金額)、領収書アップロード、メモ、レビュー&送信。送信後は Draft、Submitted、Rejected、Approved のような承認ステータスを表示します。難しい点は検証、失敗する保存、オフライン時のドラフト保持です。
MVVM では通常、ViewModel に FormUiState(多くは StateFlow)を保持します。各フィールド変更で onAmountChanged() や onReceiptSelected() のような ViewModel 関数を呼びます。検証は変更時、ステップ移動時、送信時に実行されます。生の入力とフィールドエラーを保持し、Next/Submit の有効化は派生フラグで制御する構造が一般的です。
MVI では同じ流れが明示化されます:UI は AmountChanged、NextClicked、SubmitClicked、RetrySave のような intent を送ります。reducer が新しい state を返し、副作用(領収書アップロード、API 呼び出し、スナックバー表示)は reducer の外で実行され、その結果がイベントとして返されます。
実務では、MVVM は関数を追加してフローを素早く更新しやすいです。MVI はすべての変更が reducer を通るため、状態遷移を偶発的にスキップすることが難しくなり、安全側に働きます。
よくあるミスと罠
ほとんどのフォームバグは、真実の所有者が曖昧、検証の実行タイミングが不明瞭、非同期結果が遅れて到着した場合の扱いが未定義、といったところから来ます。
最も一般的なミスは真実のソースを混在させることです。テキストフィールドが時々ウィジェットから読み、時々 ViewModel 状態から読み、時々復元されたドラフトから読み取るようになると、入力がランダムにリセットされる問題が起きます。画面の正準状態を一つ選び、そこからすべてを導出してください(ドメインモデル、キャッシュ行、API ペイロードなど)。
もう一つの罠は状態とイベントを混同することです。トースト、ナビゲーション、「Saved!」バナーは一回限りです。編集するまで表示し続けるべきエラーメッセージは状態です。混ぜると回転でイベントが重複したりフィードバックが欠落したりします。
よく出る正確性の問題は二つです:
- 高コストなチェックを毎キー入力で検証しすぎること。デバウンスするか、フォーカス喪失時に検証するか、触ったフィールドだけ検証する。
- 順序の狂った非同期結果を無視すること。ユーザーが二度保存したり、保存後に編集した場合、古い応答が新しい入力を上書きしないように request ID(または「最新のみ」ロジック)を使う。
最後に、ドラフトは「単に JSON を保存するだけ」ではありません。バージョン管理がないとアプリ更新で復元が壊れます。簡単なスキーマバージョンとマイグレーション方針を付けておきましょう。古いドラフトをドロップして最初からにする、という方針でも良いです。
出荷前のクイックチェックリスト
MVVM 対 MVI の議論を始める前に、フォームが一つの明確な真実のソースを持っているかを確認してください。画面上で値が変わりうるものは状態に属します。ビューウィジェットや隠れたフラグに置いてはいけません。
実用的な出荷前チェック:
- 状態に入力、フィールドエラー、保存ステータス(idle/saving/saved/failed)、ドラフト/キュー状況を含め、UI が推測する必要がないようにする。
- 検証ルールは UI に依存せず純粋でテスト可能であること。
- 楽観的 UI にはサーバ拒否時のロールバックパスがあること。
- エラーがユーザー入力を消さないこと。
- ドラフト復元は予測可能であること:自動復元バナーを出すか、明示的な「ドラフトを復元」アクションを用意する。
バグを実際に引き起こすテスト:機内モードをオンにして保存中に切り替え、オフにして二回リトライする。二回目のリトライで重複が発生しないこと。request ID、冪等キー、あるいはローカルの「保留中保存」マーカーを使ってリトライを安全にします。
答えがあやふやなら、まず状態モデルを強化し、その後でルールを強制するのに最も楽なパターンを選んでください。
次のステップ:道筋を選んでより早く構築する
最初に一つの質問を投げかけてください:フォームが変な半更新状態になるコストはどれほど大きいか?コストが低ければシンプルに保ちます。
画面が単純で、状態が主に「フィールド + エラー」で済み、チームが ViewModel + LiveData/StateFlow で既に確実に出荷しているなら MVVM が適しています。
自動保存、リトライ、同期など多くの非同期イベントがあり、状態遷移を厳密にしたいか、バグのコストが高い(決済、コンプライアンス、クリティカルなワークフロー)なら MVI が適しています。
どちらの道を選んでも、フォームに対する最も効果の高いテストは UI に触れないものです:検証のエッジケース、状態遷移(編集→送信→成功→失敗→リトライ)、楽観的保存のロールバック、ドラフト復元と競合時の動作です。
バックエンド、管理画面、API もモバイルと一緒に必要なら、AppMaster (appmaster.io) は一つのモデルから本番レベルのバックエンド、Web、ネイティブモバイルを生成でき、各表面で検証とワークフローのルールを一貫させるのに役立ちます。
よくある質問
フォームの流れがほぼ直線的で、チームが既に ViewModel + StateFlow/LiveData やワンオフイベント、キャンセル処理の慣例を持っているなら MVVM を選びましょう。一方で、自動保存やリトライ、アップロードなど重なり合う非同期処理が多く、状態遷移を厳密に管理したい場合は MVI の方が適しています。
まずは単一の画面状態オブジェクト(例: FormState)を用意し、生のフィールド値、フィールド別エラー、フォームレベルのエラー、Saving/Failed といった明確なステータスを含めます。isValid や canSubmit のような派生フラグは一箇所で計算して、UI はそれを表示するだけにします。
編集中は軽いチェック(必須、範囲、基本的なフォーマット)を行い、厳密なチェックは送信時に行うのが安全です。検証ロジックは UI から切り離してテスト可能にし、エラーは状態に保存して回転やプロセス終了後も残るようにします。
非同期検証は「最新入力を優先」する扱いにします。検証した値(またはリクエスト/バージョンID)を保存し、現在の入力と一致しない古い結果は無視します。これで古い応答が新しい入力を上書きするのを防げます。
操作を即座に反映して Saving… を表示するなどユーザーに素早くフィードバックを返しますが、サーバが拒否した場合に戻せるロールバック経路を必ず用意します。リクエストIDやバージョンを付け、Save ボタンは無効化またはデバウンスし、保存中に編集があった場合の扱い(ロックする、次の保存をキューする、dirty にする)を明確に定義します。
失敗時にユーザー入力を消してはいけません。フィールド固有の問題は該当フィールドにインラインで表示し、フォーム全体を阻む問題は送信付近に置き、ネットワーク障害はリトライ可能にしてフォームは編集中のままにします。リトライはユーザーが編集しない限り同じペイロードを再送するようにします。
ワンオフイベント(スナックバーやナビゲーション)は永続状態に入れず、MVVM では別のストリーム(例: SharedFlow)で流し、MVI では UI が一度だけ消費する Effect として扱います。これにより回転や再購読でイベントが重複するのを防げます。
保存すべきは主にユーザーが入力した生のデータ(入力された文字列、選択したID、添付ファイルのURIなど)で、後で安全にマージできる最小限のメタデータ(サーバの最終スナップショットやバージョンマーカー)を添えます。検証は復元後に再計算し、簡単なスキーマバージョンを付けてアプリ更新時の互換性を確保します。
短いデバウンス(数百ミリ秒程度)とステップ変更時やアプリのバックグラウンド化時の保存を組み合わせるのが実用的です。各キー入力で保存するとノイズが多く、終了時のみ保存だとプロセスキルで作業が失われるリスクがあります。
サーバとドラフト双方にバージョンマーカー(updatedAt、ETag、ローカルインクリメントなど)を持たせます。サーバ版が変わっていなければドラフトを適用して送信し、変わっていれば『ドラフトを保持する』か『サーバを再読み込みする』か明確にユーザーに選ばせます。自動的に上書きするのは避けましょう。


