本文へ移動

ホーム / App / 第3部 アプリの設計

第3部 アプリの設計・第5章

アーキテクチャと状態管理

★★★ 必須9トピック・面接 5問

大きくなっても壊れにくいアプリの分け方を押さえる。

ひとことで

画面は状態を表示するだけにし、操作はイベントとして ViewModel に送る。状態は一方向に流れる。

アプリの設計で一番大事なのは、状態(画面に何を出すか)をどこに置き、どう変えるかを決めることです。今の主流は単方向データフローで、「操作 → 状態を変える → 状態から画面を描く」と一方向にだけ流します。画面が状態を直接書き換えないので、「なぜこの表示になったか」を追いやすくなります。

操作 → 状態の更新 → 再描画、の一方向画面(View)ViewModelUiState を持つRepositoryAPI端末の DB操作UiState画面は状態を直接書き換えない。「なぜこの表示になったか」を ViewModel だけ見て追える正しい値の置き場所は1か所(オフライン対応なら端末の DB)
操作は ViewModel へ、状態は画面へ。データは一方向に流れる
流れ例:いいねボタン
1. 操作をイベントとして送る画面が onLikeClick(id) を ViewModel に送る
2. ViewModel が状態を変えるRepository に保存を頼み、UiState の該当の項目を「いいね済み」にする
3. 画面は状態を描くだけ新しい UiState を受け取って描き直す。画面は自分で表示を書き換えない

MVVM・MVI などの形の違い、状態の置き場所は、この後のトピックで扱います。

面接ではこう答える

単方向データフローは、操作をイベントとして ViewModel に送り、ViewModel が状態を変え、画面はその状態を表示するだけ、と一方向にだけデータを流す設計です。画面が状態を直接書き換えないので、なぜこの表示になったかを追いやすく、テストもしやすくなります。MVVM はこの形の代表で、今は画面の状態を1つの UiState にまとめる形がよく使われます。

5.2MVVM・MVI・TCA の比較

用語★★★ 必須
ひとことで

MVVM は ViewModel が状態を公開し、MVI は操作を順に処理して不変の状態を1つ作り、TCA はそれを Swift で決まった形に書くライブラリ。

単方向データフローを実現する形はいくつかあり、違いは状態をどうまとめ、操作をどう処理するかです。

観点MVVMMVITCA
考え方ViewModel が状態を公開し、画面は購読して表示する状態を1つの不変のデータにまとめ、操作(Intent)を順番に処理して新しい状態を作るSwift 向けのライブラリ。状態・操作・処理(Reducer)・副作用を決まった形で書く
よく使う場所Android・iOS どちらでも標準的Android(Compose と相性がよい)iOS(SwiftUI)
よい点分かりやすく情報が多い状態の変化が追いやすい。テストしやすい副作用まで含めてテストしやすい。大きなアプリで形がそろう
注意点状態がばらばらの変数に分かれがち書く量が増える学ぶことが多く、ライブラリに強く依存する

実際は、MVVM で UiState を1つのデータクラスにまとめる「MVI 寄りの MVVM」が多く使われています。

面接で「どれを選んだか」を聞かれたら、名前よりも「チームの人数・アプリの大きさ・テストのしやすさ」で選んだ理由を話します。

状態の作り方の違いMVVM画面ViewModelisLoading / itemserror(別々の値)関数を呼ぶMVI画面reduce(状態, 操作)新しい UiState1つの不変のデータIntent
MVVM は状態を公開して購読させる。MVI は操作を順に処理して新しい状態を1つ作る

面接ではこう答える

MVVM は、ViewModel が状態を公開して画面が購読する形で、分かりやすく情報も多いですが、状態がばらばらの変数に分かれがちです。MVI は、画面の状態を1つの不変のデータにまとめ、操作を順番に処理して新しい状態を作るので、状態の変化を追いやすくテストしやすい反面、書く量が増えます。TCA は Swift 向けのライブラリで、副作用まで決まった形で書けます。実際は MVVM で状態を1つの UiState にまとめる、MVI 寄りの MVVM が多く使われています。

ひとことで

画面の状態は、読み込み中・成功・空・エラーのような「ありうる状態」だけを sealed クラスや enum で表す。

画面の状態は、ばらばらの変数(isLoading・items・error)で持つと「読み込み中なのにエラーも出ている」のようなありえない組み合わせが起きます。そこでありうる状態だけを型で表します。

sealed interface UiState {          // Kotlin(Swift なら enum)
  data object Loading : UiState
  data class Success(val items: List<Item>) : UiState
  data object Empty : UiState
  data class Error(val message: String) : UiState
}
悩みどころよくある答え
読み込み中も前のデータを見せたいSuccess に isRefreshing を持たせる。毎回空にしない
画面遷移・トーストのような一度だけのこと状態に「表示待ちのメッセージ」として入れ、表示したら消す。イベント用の流れを別に持つ方法もある
一部だけ失敗した画面全体のエラーにせず、その部分だけにエラーと再試行を出す

デザイナーと話すときも、「読み込み中・空・エラーの画面はどうしますか」と4つの状態で確かめると、作り漏れが減ります。

画面の状態は4つ用意する読み込み中成功(データ)空(0件)エラー再試行ボタン押すと読み込み中に戻る
読み込み中・成功・空・エラーの4つを用意し、どの状態でも画面を決めておく

面接ではこう答える

画面の状態は、isLoading や error のような別々の変数で持つと、ありえない組み合わせが生まれます。そこで Kotlin の sealed interface や Swift の enum で、読み込み中、成功、空、エラーのようにありうる状態だけを型で表し、画面はその状態ごとに表示を決めます。再読み込み中も前のデータを見せたいときは成功の状態にフラグを持たせ、画面遷移のような一度だけのことは状態として持って処理したら消します。

ひとことで

同じデータの「正しい値」を1か所に決め、他はそこから読むだけにする。オフライン対応なら端末の DB を正にする。

状態をどこに置くかは、誰が読むかとどれだけ長く残したいかで決めます。

状態置き場所
開閉・フォーカス・スクロール位置など見た目だけ部品の近く(@State・remember)
画面に出すデータ・読み込み中・エラーViewModel(UiState)
複数の画面で共有するデータ(ログイン中の利用者・カート)Repository や共有の状態(アプリ全体で1つ)
アプリを閉じても残したいものDB・設定の保存先。Repository が窓口になる

よくある失敗は、同じデータを ViewModel と画面と DB にそれぞれ持ってずれることです。「正しい値はどこにあるか(Single Source of Truth)」を1か所に決め、オフライン対応するアプリなら DB を正にして、API の結果は DB に入れてから画面に流します。

オフラインファーストと同期

面接ではこう答える

状態は、誰が読むかと、どれだけ長く残したいかで置き場所を決めます。見た目だけの状態は部品の近く、画面のデータは ViewModel、複数の画面で使うものは Repository、閉じても残したいものは DB です。大事なのは Single Source of Truth で、同じデータの正しい値を1か所に決め、ほかはそこから読むだけにします。オフライン対応のアプリなら、API の結果はいったん DB に入れ、画面は DB を読むようにすると、表示のずれが起きません。

5.5レイヤーと Repository

用語★★★ 必須
ひとことで

UI・Domain・Data に分け、Repository が「どこからデータを取るか」を画面から隠す。

アプリのコードは、大きくUI・Domain・Dataの3つのレイヤーに分けると整理しやすくなります。Android の公式のアーキテクチャガイドもこの形を勧めています。

3つのレイヤーと依存の向きUI レイヤー:画面(SwiftUI・Compose)と ViewModelDomain レイヤー(任意):UseCase。複数の画面で使う業務の処理Data:Repository(窓口)データソース:API・DB・設定
依存は上から下へ。Domain は必要になったときだけ置く
レイヤー役割知ってよいもの
UI表示と操作の受け取り。ViewModel が画面の状態を作るDomain・Data
Domain(任意)複数の画面で使う業務の処理(UseCase)Data
DataRepository がデータの窓口。API と DB のどちらから取るか、キャッシュ、同期を隠すデータソースだけ

Repository の役割は、画面から「どこから取ったか」を隠すことです。ViewModel は「メモの一覧をください」と頼むだけで、それが DB からか API からかを知りません。こうすると、オフライン対応やキャッシュの方針を変えても画面のコードは変わらず、テストでは Repository を偽物に差し替えられます。

Domain レイヤーは、小さなアプリでは置かなくてかまいません。ViewModel が重くなったり、同じ処理を複数の画面で書いていたりしたら、UseCase に切り出します。

面接ではこう答える

アプリのコードは、画面と ViewModel の UI レイヤー、業務の処理の Domain レイヤー、データを扱う Data レイヤーに分けます。Data レイヤーの Repository はデータの窓口で、API と DB のどちらから取るか、キャッシュや同期のやり方を画面から隠します。そのため、オフライン対応を足しても画面のコードを変えずに済み、テストでは Repository を偽物に差し替えられます。Domain レイヤーは必要になってから置けば十分です。

通信や DB のエラーをそのまま画面に出すと、「HTTP 503」のように利用者には意味の分からない表示になります。エラーは層ごとに意味のある種類に変えて上に渡します。

エラーは層ごとに意味のある形に変える通信・DBタイムアウト・401503・解析失敗Repositoryオフライン・要ログインサーバー障害・不明画面「再試行」ボタンログイン画面へ想定外のエラーはクラッシュ収集ツールに「落ちていないエラー」として記録する
下の層の細かいエラーを、利用者が次に何をすればよいか分かる種類に変えて渡す
種類例画面での扱い
オフライン・タイムアウト電波がない、応答が遅い前のデータを残し、再試行のボタンを出す
認証切れ401 でリフレッシュもできないログイン画面へ。入力中の内容は残す
入力の誤り400・422どの項目が悪いかをその場に出す
サーバーの障害500・503時間をおいて試すよう伝える。自動の再試行は控えめに
想定外解析の失敗など汎用のメッセージを出し、記録して調べる

Kotlin では Result や sealed クラス、Swift では throws と独自の Error の enum でこの種類を表します。catch して何もしない書き方は、問題が見えなくなるのでレビューで止めます。

ひとことで

全部を書き直さず、新しい画面から新しい書き方にし、古い部品は包んで使いながら少しずつ置き換える。

何年も続くアプリには、UIKit や View(XML)で書かれた画面、古い設計のコードが必ず残っています。全部を一度に書き直すと、その間は新しい機能が出せず、不具合も増えます。そこで少しずつ置き換えるのが現実的です。

一度に書き直さず、少しずつ置き換える1. 新しい画面からSwiftUI・Compose で書く2. 包んで混ぜるHosting・ComposeView3. 触る画面を置き換えテストを先に足す4. 古い部品を消す使われなくなったら置き換えの前後で、クラッシュ率と表示の速さが悪くなっていないかを数字で確かめる
新しい画面から新しい書き方にし、古い画面は触るときに置き換える
やり方iOSAndroid
新しい画面を新しい書き方で載せるUIHostingControllerComposeView・setContent
古い部品を新しい画面で使うUIViewRepresentableAndroidView
画面遷移を混ぜるUIKit の遷移の中に SwiftUI の画面Fragment の中に Compose

順番は、変更が多い画面や新しく作る画面からにします。触らない画面を書き直しても、利用者にもチームにも得がありません。置き換える前に、その画面のふるまいを確かめるテスト(スナップショットや UI テスト)を足しておくと、安心して進められます。

面接ではこう答える

古いアプリを新しい書き方に移すときは、一度に書き直さず、新しく作る画面や変更が多い画面から少しずつ置き換えます。iOS なら UIHostingController で SwiftUI の画面を載せ、Android なら ComposeView で Compose を載せ、古い部品は逆に包んで使います。置き換える前にテストを足し、置き換えの前後でクラッシュ率や表示の速さが悪くならないかを数字で確かめます。

ひとことで

機能ごとにモジュールを分け、依存は外から注入する。ビルドが速くなりテストしやすくなる。

アプリが大きくなると、1つのモジュールに全部を入れたままではビルドが遅くなり、どこがどこに依存しているか分からなくなります。そこでコードをモジュール(Android なら Gradle のモジュール、iOS なら Swift Package など)に分けます。

分け方

種類中身
app起動・画面遷移・DI の組み立て。各機能をつなぐだけ
feature(機能ごと)ログイン・検索・設定など。画面と ViewModel。feature どうしは直接依存しない
core(共通)ネットワーク・DB・デザインの部品・ログなど
依存は上から下へ。feature どうしはつながない:app:feature:login:feature:search:feature:settings:core:network:core:database:core:ui× 直接の依存
依存は上から下への一方向。feature どうしは直接依存しない

機能どうしの画面遷移は、app が間に入るか、遷移先を表すインターフェースだけを共通のモジュールに置いてつなぎます。変更したモジュールとそれに依存するものだけがビルドし直されるので、ビルドが速くなり、担当の範囲もはっきりします。最初から細かく分けすぎると手間ばかり増えるので、ビルド時間や人数が増えてきたら分けるくらいで十分です。

面接ではこう答える

アプリが大きくなったら、コードを機能ごとの feature、共通の core、それらをつなぐ app のようにモジュールに分けます。依存は上から下への一方向にし、feature どうしは直接依存させません。変更したモジュールとそれに依存するものだけがビルドし直されるので、ビルドが速くなり、担当の範囲もはっきりします。ただし最初から細かく分けすぎると手間が増えるので、ビルド時間や人数が増えてから分けます。

ひとことで

部品を自分で作らず外から渡してもらう書き方。テストで偽物に差し替えられる。Android は Hilt、iOS は初期化の引数が基本。

依存性注入(DI)は、クラスが使う部品を自分で作らず、外から渡してもらう書き方です。

部品は外から渡す(差し替えられる)ViewModelinit(repo: Repository)本物の Repository偽の RepositoryアプリテストHilt・初期化の引数誰が組み立てるか
ViewModel は部品を自分で作らず、外から受け取る。テストでは偽物を渡す

DI は、クラスが使う部品(API クライアント・Repository など)を自分で作らず、外から渡してもらう書き方です。テストのときに本物の通信を偽物に差し替えられるのが一番の利点です。

  • Android:Hilt(Dagger をもとにした Google 推奨の仕組み)が定番です。@HiltViewModel と @Inject constructor で ViewModel に Repository を渡せ、つながっていない依存はコンパイル時にエラーになります。手軽さを重視して Koin を使うチームもあります。
  • iOS:決まった公式の仕組みはなく、初期化の引数で渡す(イニシャライザー注入)のが基本です。数が増えたら、依存をまとめて差し替えられるライブラリを使うこともあります。

DI の仕組みを入れるかどうかより、「作る場所」と「使う場所」を分けることが本質です。小さなアプリなら、起動時に手で組み立てて渡すだけでも十分に効果があります。

面接ではこう答える

依存性注入は、クラスが使う API クライアントや Repository を自分で作らず、外から渡してもらう書き方です。一番の利点はテストで、本物の通信を偽物に差し替えて ViewModel の動きを確かめられます。Android では Hilt が定番で、つながっていない依存はコンパイル時にエラーになります。iOS には公式の仕組みがないので、初期化の引数で渡すのが基本です。大事なのは仕組みの名前より、作る場所と使う場所を分けることです。

この章のまとめ

🖨 まとめシート(印刷・PDF)
  • MVVM・単方向データフローで画面とロジックを分ける
  • テストしやすく変更に強い構成

この章のクイズ

面接ではこう聞かれる

まず答えを見ずに声に出して答え、そのあと開いて比べる。

Q1アプリのアーキテクチャをどう選びましたか?

模範回答

例えば、Android と iOS それぞれ3人ほどのチームで考えると、こう話せます。そのプロジェクトでは、MVVM に単方向データフローを組み合わせた形を選びました。画面の状態を1つの UiState にまとめ、画面からは操作だけを ViewModel に送ります。チームに新しく入る人も多かったので、情報が多く、公式のガイドにも沿っている形を優先しました。iOS で TCA も検討しましたが、学ぶ量とライブラリへの依存が大きく、その規模では効果より負担が大きいと判断しました。

データは Repository を窓口にし、オフラインで見せたい画面は DB を正しい値の置き場にしています。機能が増えてビルドが遅くなってきた段階で、機能ごとにモジュールを分けました。最初から完璧な形にするより、チームが迷わず同じ書き方をできることと、後から分けられる余地を残すことを大事にしました。

深掘りされたら

  • その設計で困ったことと、どう直しましたか?
  • ViewModel が大きくなりすぎたらどうしますか?

見られているポイントチームと規模に合わせた理由

Q2MVVM と MVI の違いは何ですか?

模範回答

どちらも画面と処理を分ける形ですが、状態の持ち方が違います。MVVM は ViewModel が画面に出す値を公開し、画面はそれを購読します。値がいくつもの変数に分かれやすいのが弱点です。MVI は画面の状態を1つの不変のデータにまとめ、操作(Intent)を順番に処理して新しい状態を作ります。

MVI は状態の変化を追いやすく、テストも書きやすい反面、書く量が増えます。実際には MVVM で UiState を1つのデータクラスにまとめる、両者の中間の形がよく使われています。

深掘りされたら

  • UiState を1つにまとめると、何がうれしいですか?
  • TCA を知っていますか?

見られているポイント状態の持ち方の違いと、実務での落としどころ

Q3依存性注入(DI)を使う理由は? Hilt の利点は?

模範回答

DI は、クラスが使う部品を自分で作らず外から渡してもらう書き方です。一番の理由はテストで、本物の API クライアントを偽物に差し替えて、ViewModel だけを速く確かめられます。部品の作り方が1か所にまとまり、依存関係も見えやすくなります。

Hilt は Dagger をもとにした Android 向けの仕組みで、@HiltViewModel や @Inject constructor を書くだけで ViewModel や Activity に依存を渡せます。足りない依存はコンパイル時にエラーになるので、実行してから落ちる心配が減ります。その代わりビルド時間は少し増えます。

深掘りされたら

  • iOS ではどう DI していますか?
  • シングルトンとの違いは?

見られているポイントテストしやすさという目的を説明できるか

Q4ログイン中の利用者のように、複数の画面で使う状態はどこに持ちますか?

模範回答

画面ごとの ViewModel ではなく、アプリ全体で1つの Repository(やセッションの管理役)に持ち、各画面の ViewModel はそこを購読します。こうすると、ログアウトしたときに全部の画面が同じ値を見て、一斉に切り替わります。

保存が必要なものは DB や Keychain・Keystore に置き、Repository が窓口になります。注意点は、どこからでも書き換えられるグローバル変数にしないことです。書き換えるのは決まった関数だけにして、ほかは読むだけにすると、状態がずれる不具合を防げます。

深掘りされたら

  • 画面をまたいで値を渡すとき、引数と共有の状態はどう使い分けますか?
  • ログアウトのときに消し忘れやすいものは?

見られているポイント正しい値の置き場所を1か所にする考え方

Q5モジュール分割をした経験を教えてください。

模範回答

例えば、アプリが大きくなってビルドに数分かかるようになった段階で、機能ごとにモジュールを分けたとします。まずネットワークや DB、デザインの部品を core のモジュールに出し、次にログインや検索などの機能を feature のモジュールに分けました。feature どうしは直接依存させず、画面遷移は app のモジュールでつなぎました。

結果として、変更したモジュールだけがビルドし直されるようになり、ビルド時間が短くなりました。担当の範囲もはっきりしました。反省点は、最初に細かく分けすぎて、似たモジュールが増えたことです。

深掘りされたら

  • 分ける単位はどう決めましたか?
  • 循環した依存はどう解消しますか?

見られているポイント目的・進め方・結果と反省を話せるか