Kotlin/Androidから学ぶSwiftUIでのスケーラブルなiOSアプリ設計
Kotlin/AndroidのアーキテクチャパターンをSwiftUIに適用し、スケーラブルなiOSアプリ開発を実現する方法を提案。
キーポイント
クロスプラットフォームのアーキテクチャ原則はプラットフォームに依存しないという主張
Android/Kotlinのアーキテクチャパターン(ViewModel、Repositoryなど)をSwiftUIに適用する方法
iOS開発におけるスケーラブルなアーキテクチャの不足とAndroidコミュニティからの学習の重要性
影響分析・編集コメントを表示
影響分析
この記事は、iOS開発者がAndroidコミュニティから学ぶことで、よりスケーラブルで保守性の高いアプリケーションを構築できる可能性を示しています。クロスプラットフォームのアーキテクチャ原則の共有は、モバイル開発全体の成熟度を高める可能性があります。
編集コメント
プラットフォーム間の知識共有の重要性を強調した実践的な記事で、特にiOS開発者にとってAndroidコミュニティの成熟したプラクティスから学べる点が多い。
InfoQ ホームページ記事 SwiftUI でスケーラブルな iOS アプリをアーキテクチャする:Kotlin/Android から学ぶ
Kotlin/Android から学んで SwiftUI でスケーラブルな iOS アプリをアーキテクチャする
2026 年 2 月 26 日 16 分間読み
セルジオ・デ・シモーネ
InfoQ に投稿する
この記事の音声再生 - 0:00 再生準備完了。お使いのブラウザはオーディオ要素をサポートしていません。0:00 0:00 通常 1.25 倍速 1.5 倍速 お気に入りリストに追加
優れたアーキテクチャはプラットフォームに依存しません。Android アプリを保守可能にする原則は、iOS でも同様に機能します。
アクションベースの ViewModels は明確な契約を作成します:すべての変更を単一メソッド経由でルーティングすることで、集中型のログ記録、テストの容易さ、そして ViewModel が実際に何を行うのかを示す文書化された「API」が得られます。
明示的な状態は、最初から不可能な状態を排除します:Loadable(読み込み可能)
画面とコンテンツの分離は責任を明確にします:"ViewModel を所有する" 責任(Screen/画面)と "UI をレンダリングする" 責任(Content/コンテンツ)を分割することで、ビューの再利用性が高まり、個別にプレビューしやすくなります。
リアクティブなリポジトリは自動的な UI 同期を可能にします:リポジトリがデータを所有し、パブリッシャー経由で公開する場合、更新は自動的にすべての観測中の ViewModels に伝播します。
私たち iOS 開発者にとって、Apple の単純な 1 ページのサンプルアプリからスケーラブルなアーキテクチャを作成するのはしばしば困難です。確かにシンプルなアプリには機能しますが、スケーラブルなものを作りたい際に次に何をすべきかについては、いつも苦労してきました。
周囲を探ってみたところ、私は Android の世界を発見しました。Google が開発者に提供しているものについて、Apple と比較して驚かされました。Android 開発者には明確なガイドラインとパターンがあり、何より重要なのは、玩具のようなプロジェクトではなく、本番環境のアプリをどのように構築すべきかを示す実世界の例が用意されていることです。
Android コミュニティは以下の点で恩恵を受けています:
明確なドキュメントを持つ公式アーキテクチャコンポーネント
スケーラブルなベストプラクティスを示す Now in Android などのサンプルアプリ
エコシステム全体にわたる一貫したパターン(Repository、ViewModel)
関連するスポンサー
クラウド向けスケーラブルエンタープライズJava - eBook をダウンロード
関連スポンサー
複雑さから制御へ。インフラの混沌をデプロイの簡素さと完全な自律性で置き換える統一プラットフォーム上で、Jakarta EE、Spring、Quarkus のアプリケーションを実行・スケールさせましょう。詳しくはこちら。
これに対し、iOS 開発者はしばしばブログ記事や Apple のサンプルアプリから断片的に解決策を組み立てることに頼らざるを得ません。これらの解決策は単独では有用ですが、現実世界のアプリのアーキテクチャがどのように進化するかを反映することは稀であり、アプリが大きくなるにつれて私たちのアーキテクチャが崩壊しないことを願うばかりとなっています。
しかし、ここには励みになることがあります:優れたアーキテクチャはプラットフォームに依存しません。Android アプリを保守可能にする原則は、iOS においても同じように機能します。
本記事では、現代的な Kotlin および Android 開発からインスピレーションを得たアーキテクチャパターンを用いて iOS アプリを構築する方法を探ります。これらのパターンが Swift や SwiftUI にどのように適用されるかを示します。
まず、ビュー内の状態管理という根本的な問題から始めましょう。この問題には、変更に対する単一のエントリーポイントを強制することや、ログ出力やデバッグといった横断的関心事を可能にすることが含まれます。
次に、再利用性、テスト容易性、プレビュー容易性を向上させるために、1 つレイヤー上位へ移動し、ビューとそのビューモデルを分離します。
最後に、単一の真実の源という概念を実現し、データがアプリ全体で自動的に伝播する方法を示すために、アクティブなリポジトリ層を導入します。
従来の iOS ビューモデルの問題点
SwiftUI で iOS アプリを開発したことがあるなら、おそらくこのようなコードを書いたことがあるでしょう:
class DashboardViewModel: ObservableObject {
@Published var workouts: [Workout] = []
@Published var isLoading = false
@Published var error: Error?
func loadWorkouts() {
isLoading = true
error = nil
Task {
do {
workouts = try await api.fetchWorkouts()
isLoading = false
} catch {
self.error = error
isLoading = false
}
}
}
}
このコードは単純な画面では機能しますが、ビューモデルが大きくなったときに何が起きるか考えてみてください。
状態の問題
互いに矛盾する可能性のある複数のプロパティが存在します。これを防ぐものは何もありません:
viewModel.isLoading = true
viewModel.workouts = cachedWorkouts // これで「データを読み込み中」の状態になる
viewModel.error = NetworkError.timeout // しかもエラー?
UI はどの状態を表示すべきでしょうか?コンパイラはここであなたを助けてくれません。開発者それぞれが異なる選択を行い、バグが生じます。
変更の問題
loadMore() などのメソッドを追加することになります
deleteWorkout()
filterWorkout()
selectWorkout()
機能の開発に取り組んでいると想像してください。あなたが 6 ヶ月間見ていない ViewModel、あるいは初めて見る新しい ViewModel に触れることになります。ファイルを開くと、そこには 600 行のコードと 20 のメソッドがあります。このものは何をするのでしょうか?ビューから呼び出されるメソッドはどれで、内部ヘルパーはどれでしょうか?理解するためにはクラス全体を読み込む必要があります。要約も、契約(コントラクト)も、「この ViewModel が何ができるか」のリストもありません。これを他の 100 の ViewModel で掛け算してみてください。
状態の問題を解決する:明示的な状態
Kotlin では、状態の問題は型レベルで解決されます:
sealed interface UiState { data object Loading : UiState() data class Success(val data: T) : UiState() data class Error(val message: String) : UiState() }
val workouts: StateFlow<UiState> = ...
状態は単一の真実の源(ソース・オブ・トゥルース)によって定義されます。その型が可能な状態を排他的にし、コンパイラがこれを強制します。Loading 状態と他の状態に同時にいることはできません。
Swift の同等の実装はシンプルです:
enum Loadable { case loading case finished(T) case error(U) }
class DashboardViewModel: ObservableObject {
@Published var workouts: Loadable = .loading
}
変更の問題を解決する:単一のエントリーポイント
明示的な状態は矛盾した状態を防ぎますが、複数の変更メソッドによる問題はどのように解決すればよいでしょうか?Kotlin の答えは、すべての処理を単一のエントリーポイントに集約することです:
fun onAction(action: DashboardAction) {
when (action) {
is DashboardAction.Refresh -> loadWorkouts()
is DashboardAction.SelectWorkout -> selectWorkout(action.id)
is DashboardAction.Delete -> deleteWorkout(action.id)
}
}
すべての変更は onAction() を通じて流れます。
DashboardAction を見てみましょう。
sealed class DashboardAction {
object Refresh : DashboardAction()
data class SelectWorkout(val id: String) : DashboardAction()
data class Delete(val id: String) : DashboardAction()
data class FilterBy(val type: WorkoutType) : DashboardAction()
}
これは ViewModel 内のすべてのアクションの完全なリストです。新しいエンジニアがこのファイルを開き、クラスを読み込むだけで、即座に ViewModel の機能を理解できます。600 行ものコードをスクロールする必要も、どのメソッドが公開されているかを推測する必要も、これが View から呼び出されるのか内部のみで使われるのかを疑問視する必要もありません。
sealed class(シールドクラス)は契約です。ここに宣言されていないアクションであれば、ViewModel はそれを実行できません。このポリシーはまた、ViewModel の責任について考えることを強要します。新しいアクションを追加する際は、まず sealed クラスに追加します。これは意識的な決定であり、ファイルのどこかに静かに現れるメソッドではありません。
では、DashboardAction には具体的に何を入れるべきでしょうか?View がトリガーできるアクションであれば、それはアクションとして宣言されるべきです。ユーザーがアイテムを削除するためにタップするのでしょうか?ユーザーがアイテムを選択するのでしょうか?何が除外されるべきなのでしょうか?loadWorkouts() のような内部ヘルパーは除外されます。
enum Action { case refresh case selectWorkout(String) case delete(String) }
// Not actions — internal implementation
private func loadWorkouts() async {
...
}
private func updateCache(_ workouts: [Workout]) {
...
}
数年間 iOS アプリを開発してきた方にとっては、このパターンは不要に思えるかもしれません。なぜなら、直接そのメソッドを呼び出せるのに、すべてを一つのメソッド経由で通す必要があるのかと疑問に思うからです。しかし、答えはこうです。チームが小さく、画面が 3〜5 枚程度しかないうちは問題ありません。しかし、チームやコードベースが大きくなると、この問題は深刻になります。
従来の iOS パターンは、@StateObject を使用するような単純なケースを最適化するように設計されています。
しかし、スケールアップする際には、これらの直接メソッド呼び出しには問題が生じます。すべてのメソッドが潜在的なエントリーポイントとなります。各エントリーポイントは状態が変更される場所です。エントリーポイントが増えれば増えるほど、ViewModel の振る舞いを推論することが困難になります。
アクションを一元化することで、コードベースが大きくなった際にそれ以外では管理が極めて困難になる機能が可能になります。具体的には、ログ出力(logging)、デバッグ(debugging)、テスト(testing)、および分析(analytics)です。
基底クラスに 1 行記述するだけで、すべての ViewModel におけるすべてのアクションを把握できます。複数のメソッドに print 文を追加する必要はありません。
func perform(_ action: Action) {
print("[\(Self.self)] Action: \(action)")
// handle action……
}
状態が間違っている場合、perform() メソッド内にブレークポイント(breakpoint)を 1 つ設定するだけで済みます。
テストの可読性も向上します。すべてのアクションが同じパスを経由するため、実際のアプリが使用するコードパスと同じものをテストしていることになります。
viewModel.perform(.refresh)
viewModel.perform(.selectWorkout("123"))
viewModel.perform(.delete("123"))
XCTAssertEqual(viewModel.state.workouts, .finished([]))
すべてのユーザー操作は自動的にキャプチャされます。
func perform(_ action: Action) {
analytics.track(action)
// handle action...
}
この関数は新しいものではなく、Android 開発における標準的なプラクティスであり、Google の公式アーキテクチャガイドでも推奨されています。Android 開発者はこれを「双方向でないデータフロー(unidirectional data flow)」と呼びます。イベントは下流へ流れ(View → ViewModel → Repository)、状態は上流へ流れます(Repository → ViewModel → View)。onAction() は
Google の「Now in Android」サンプルアプリがこのパターンを採用しています。Kotlin コミュニティのほとんどが同様です。Android 開発者が新しいプロジェクトに参加すると、Action 列挙型と onAction() メソッドが見つかることを期待します。
Swift での実装
このパターンを iOS に持ち込む方法は以下の通りです:
class ViewModel: ObservableObject {
@Published private(set) var state: State
init(state: State) {
self.state = state
}
func perform(_ action: Action) {
// サブクラスでオーバーライド
}
func updateState(changing keyPath: WritableKeyPath, to value: some Any) {
state[keyPath: keyPath] = value
}
}
状態はどこからでも読み取り可能ですが、書き込みは ViewModel 内部のみで許可されます。このアプローチにより、双方向でないデータフローが強制され、View は状態を読み取ることはできますが、直接変更することはできません。すべての変更は perform() を通じて行われます。
このアプローチを拡張子として定義することも可能ですが、基底クラスを使用することで、ログ記録、分析、共通の状態更新パターンなどの共有ロジックを配置する場所を得ることができます。すべての ViewModel はこの振る舞いを継承します。
サブクラスはこの基底クラスのメソッドを上書きして、特定のアクションを処理します。
このパターンを使用した完全な ViewModel の例は以下の通りです:
class DashboardViewModel: ViewModel {
struct State {
var workouts: Loadable = .loading
var selectedTab: Tab = .dashboard
}
enum Action {
case refresh
case selectTab(Tab)
case deleteWorkout(String)
}
override func perform(_ action: Action) {
switch action {
case .refresh:
Task { await loadWorkouts() }
case .selectTab(let tab):
updateState(\.selectedTab, to: tab)
case .deleteWorkout(let id):
Task { await deleteWorkout(id) }
}
}
private func loadWorkouts() async {
updateState(\.workouts, to: .loading)
do {
let workouts = try await repository.fetchWorkouts()
updateState(\.workouts, to: .finished(workouts))
} catch {
updateState(\.workouts, to: .error(error))
}
}
private func deleteWorkout(_ id: String) async {
// 実装
}
}
構造に注目してください:
- State は ViewModel のすべてのデータを含む struct です。
- Action は考えられるすべてのユーザーの意図を含む enum です。
- プライベートメソッドが実際の処理を行います。
Action enum がパブリックな契約であり、プライベートメソッドは実装の詳細です。このファイルを見ると、即座にその機能が何であるかがわかります。
画面とビュー:欠落しているレイヤー
状態管理とアクションルーティングの問題は解決しましたが、もう一つの課題があります。それは密結合です。ビューが自身の ViewModels を所有しているため、プレビューが機能しなくなり、再利用性が制限されてしまいます。
この標準的なビューをご覧ください:
struct DashboardView: View { @StateObject private var viewModel = DashboardViewModel() var body: some View
原文を表示
InfoQ Homepage Articles Borrowing from Kotlin/Android to Architect Scalable iOS Apps in SwiftUI
Borrowing from Kotlin/Android to Architect Scalable iOS Apps in SwiftUI
Feb 26, 2026 16 min read
Sergio De Simone
Write for InfoQ
Listen to this article - 0:00 Audio ready to play Your browser does not support the audio element. 0:00 0:00 Normal1.25x1.5x Like Reading list
Good architecture is platform agnostic.The principles that make Android apps maintainable work equally well on iOS.
Action-based ViewModels create a clear contract: routing all mutations through a single method gives you centralized logging, easier testing, and a documented "API" of what your ViewModel actually does.
Explicit state eliminates impossible states at the outset: Loadable<T>
The Screen/Content separation clarifies responsibilities: splitting the "owns the ViewModel" concern (Screen) from "renders the UI" concern (Content) makes views more reusable and easier to preview in isolation.
Reactive repositories enable automatic UI synchronization: when the repository owns the data and exposes it via publishers, any update propagates to all observing ViewModels automatically.
For us iOS developers, it’s often hard to create scalable architecture out of simple one-page example apps from Apple. Sure it works for a simple app, but I have always struggled with what to do next when you want to build something scalable.
After looking around, I discovered the Android world. I was surprised by what Google provides for developers compared to Apple. Android developers have clear guides and patterns, and most importantly, real-world examples that show how to structure production apps and not just toy projects.
The Android community benefits from:
Official Architecture Components with clear documentation
Sample apps like Now in Android that demonstrate best practices at scale
Consistent patterns across the ecosystem (Repository, ViewModel)
Related Sponsors
Scalable Enterprise Java for the Cloud - Download the eBook
Related Sponsor
Move from complexity to control. Run and scale your Jakarta EE, Spring, and Quarkus applications on a unified platform that replaces infrastructure chaos with deployment simplicity and total autonomy. Learn More.
In comparison, iOS developers are often left piecing together solutions from blog posts and Apple’s sample apps. These solutions are useful in isolation, but rarely represent how a real-world app’s architecture evolves, leaving us hoping our architecture doesn’t collapse as the app grows.
But here is the encouraging thing: Good architecture is platform agnostic. The principles that make Android apps maintainable work just as well on iOS.
This article explores how iOS apps can be built using architecture patterns inspired by modern Kotlin and Android development. It demonstrates how these patterns translate to Swift and SwiftUI.
We will start with a fundamental problem: managing state inside a view. This problem includes enforcing a single entry point for mutations and enabling cross-cutting concerns such as logging and debugging.
Next, we will move one layer up and separate the view from its view model to improve reusability, testability, and previewability.
Finally, we will introduce an active repository layer to bring the concept of a single source of truth to life and show how data can automatically propagate across the app.
The Problem with Traditional iOS ViewModels
If you’ve built iOS apps with SwiftUI, you’ve probably written something like this:
class DashboardViewModel: ObservableObject { @Published var workouts: [Workout] = [] @Published var isLoading = false @Published var error: Error? func loadWorkouts() { isLoading = true error = nil Task { do { workouts = try await api.fetchWorkouts() isLoading = false } catch { self.error = error isLoading = false } } } }
This code works for a simple screen, but consider what happens as the ViewModel grows.
The State Problem
Multiple properties that can contradict each other. Nothing stops this:
viewModel.isLoading = true viewModel.workouts = cachedWorkouts // Now we're "loading" with data viewModel.error = NetworkError.timeout // And also errored?
Which state should the UI show? The compiler doesn’t help you here. Developers make different choices and bugs are created.
The mutation problem
You will add more methods such as loadMore()
deleteWorkout()
filterWorkout()
selectWorkout()
Imagine you are working on a feature. It touches a ViewModel you haven’t seen in six months, or a new one that you have never seen. You open the file and it has six hundred lines and twenty methods. What does the thing do? Which methods are called from the view and which ones are internal helpers? You will have to read the whole class to understand it. There is no summary, no contract, no list of "here’s what this ViewModel can do". Now multiply that by 100 other ViewModels.
Solving the State Problem: Explicit State
In Kotlin, the state problem is solved at the type level:
sealed interface UiState<out T> { data object Loading : UiState<Nothing>() data class Success<T>(val data: T) : UiState<T>() data class Error(val message: String) : UiState<Nothing>() } val workouts: StateFlow<UiState<List<Workout>>> = ...
The state is defined by a single source of truth. Its type makes the possible states mutually exclusive, and the compiler enforces this. Being in both Loading
The Swift equivalent is straightforward:
enum Loadable<T, U> { case loading case finished(T) case error(U) } class DashboardViewModel: ObservableObject { @Published var workouts: Loadable<[Workout]> = .loading }
Solving the Mutation Problem: Single Entry Point
Explicit state prevents contradictory states, but what about the problem of multiple mutating methods? Kotlin’s answer is to funnel everything through a single entry point:
fun onAction(action: DashboardAction) { when (action) { is DashboardAction.Refresh -> loadWorkouts() is DashboardAction.SelectWorkout -> selectWorkout(action.id) is DashboardAction.Delete -> deleteWorkout(action.id) } }
Every mutation flows through onAction()
Look at the DashboardAction
sealed class DashboardAction { object Refresh : DashboardAction() data class SelectWorkout(val id: String) : DashboardAction() data class Delete(val id: String) : DashboardAction() data class FilterBy(val type: WorkoutType) : DashboardAction() }
This is a complete list of every action in the ViewModel. A new engineer can open this file, read the class and immediately understand the ViewModel’s capabilities. No scrolling through six hundred lines of code, no guessing which methods are public, no wondering if this is called from the View or if it is internal only.
The sealed class is the contract. If an action isn’t declared there, the ViewModel can’t execute it. This policy also forces you to think about your ViewModel’s responsibilities. When you add a new action, you add it to the sealed class first. It’s a conscious decision and not a method that just quietly appears somewhere in the file.
But what exactly goes in DashboardAction? If the View can trigger an action, it should be declared as an action. Does the user tap to delete an item? Does the user select an item? What stays out? Internal helpers, such as loadWorkouts()
enum Action { case refresh case selectWorkout(String) case delete(String) } // Not actions — internal implementation private func loadWorkouts() async { ... } private func updateCache(_ workouts: [Workout]) { ... }
If you have been writing iOS apps for some years, this pattern feels unnecessary. Why funnel everything through one method when you can just call that method directly. Well, the answer is that it doesn’t matter when the team is small and you have just three to five screens. It does matter, however, when the team and the codebase grow. Traditional iOS patterns optimize for the simple case, such as using @StateObject
But when scaling up, those direct method calls are problematic. Every method is a potential entry point. Every entry point is a place where state can change. The more entry points, the harder it is to reason about your ViewModel.
Centralizing actions enables things that are genuinely hard to manage otherwise when the codebase grows, including logging, debugging, testing, and analytics.
One line in the base class, and you see every action across every ViewModel. It is not necessary to add print statements to multiple methods.
func perform(_ action: Action) { print("[\(Self.self)] Action: \(action)") // handle action…… }
If the state is wrong, then you need to set just one breakpoint in perform()
Tests become readable. Because every action goes through the same path, you are testing the same code path the real app uses.
viewModel.perform(.refresh) viewModel.perform(.selectWorkout("123")) viewModel.perform(.delete("123")) XCTAssertEqual(viewModel.state.workouts, .finished([]))
Every user interaction is captured automatically.
func perform(_ action: Action) { analytics.track(action) // handle action... }
This function isn't something new. It is a standard practice in Android development and is recommended in Google's official architecture guide. Android developers call it unidirectional data flow Events flow downward (View → ViewModel → Repository) and state flows upward (Repository → ViewModel → View). The onAction()
Google's "Now in Android" sample app uses this pattern. So does most of the Kotlin community. When an Android developer joins a new project, they expect to find an Action enum and an onAction()
The Swift Implementation
Here's how to bring this pattern to iOS:
class ViewModel<State, Action>: ObservableObject { @Published private(set) var state: State init(state: State) { self.state = state } func perform(_ action: Action) { // Override in subclass } func updateState(changing keyPath: WritableKeyPath<State, some Any>, to value: some Any) { state[keyPath: keyPath] = value } }
The state is readable from anywhere, but only writable from inside the ViewModel. This approach enforces unidirectional data flow where Views can read state, but they can't mutate it directly. All changes go through perform()
You could define this approach as an extension, but a base class gives you a place to put shared logic such as logging, analytics, and common state update patterns. Every ViewModel inherits that behavior.
Subclasses override this base class method to handle their specific actions.
Here is a complete ViewModel using this pattern:
class DashboardViewModel: ViewModel<DashboardViewModel.State, DashboardViewModel.Action> { struct State { var workouts: Loadable<[Workout]> = .loading var selectedTab: Tab = .dashboard } enum Action { case refresh case selectTab(Tab) case deleteWorkout(String) } override func perform(_ action: Action) { switch action { case .refresh: Task { await loadWorkouts() } case .selectTab(let tab): updateState(\.selectedTab, to: tab) case .deleteWorkout(let id): Task { await deleteWorkout(id) } } } private func loadWorkouts() async { updateState(\.workouts, to: .loading) do { let workouts = try await repository.fetchWorkouts() updateState(\.workouts, to: .finished(workouts)) } catch { updateState(\.workouts, to: .error(error)) } } private func deleteWorkout(_ id: String) async { // implementation } }
Notice the structure:
State is a struct with all the ViewModel's data
Action is an enum with all possible user intents
Private methods do the actual work
The Action enum is the public contract. The private methods are implementation details. Looking at this file, you immediately know what it does.
Screen vs. View: The Missing Layer
We've solved state management and action routing, but there is another problem: tight coupling. Views own their ViewModels, which breaks Previews and limits reusability.
Look at this standard View:
struct DashboardView: View { @StateObject private var viewModel = DashboardViewModel() var body: some View
関連記事
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み