まえがき
こんにちは。ゆざく@Zack-yutaponです
3年h前にSwiftを始めたときにはなかなか理解ができなかった、『iOSアプリ設計パターン入門』。
ちょっと時間ができたので、クリーンアーキテクチャ以降をしっかり理解しようと紐解いたところ、宣言的な記載やViewとデータの双方向バインディングを実現したSwiftUIでは、たとえばMVPやVIPERに使われる「Presenter」のような、データとViewを切り離す発想はアンチパターンとなっていないか、と気づきました。
そこで、各アーキテクチャの特徴と、SwiftUIで実装したらどうなるかをぐろ君(旧Twitter、現XのAI)に聞き、これがかなり納得のゆく結果だったので記事にまとめてみます。
この記事について
この記事では、『iOSアプリ設計パターン入門』の記載順序に従って、1.GUIアーキテクチャ(MVP,MVVM)、2.システムアーキテクチャの理解(CleanArchitecture) 3.画面遷移パターン(Rooter,VIPER※,Coordinator)の順で、SwiftUIにどのように適応するか、または適さないかを、ざっくりまとめます。
※VIPERについては、書籍ではMVP+Rooterで理解できるだろうとのことで割愛されています。
なおアーキテクチャの設計により、開発過程のチェッキングやテスティングが容易になるメリットも認識はしていますが、記事が膨大になるため本記事ではスコープ外とし、あくまでアーキテクチャ選定に焦点を当てております。
また、私自信まだ経験の浅い個人開発者であるため、コメントをいただいても適切な返答ができない場合も考えられますが、この記事やコメント欄を通じ、QiitaやTwitterなどで日本人のSwift開発者の議論や検討が進めば幸いです。
**◎3/6追記: ** コメント指摘やコメントで頂いた動画では、書籍にない単なる「MV」とのアーキテクチャパターンの表現もあります。
その方が適切かもしれないですが、アーキテクチャについてはUIKit時代の当該書籍をもとに理解を進めているSwiftエンジニアが大半の現状、悩ましいですが本記事では便宜上「MVVM」としての記載をそのまま使うことにします。
目次
1.モデルとViewを切り離す「GUIアーキテクチャ」
書籍では、前段としてViewとModelを切り離す「GUIアーキテクチャ」の考え方、次にViewを切り離した上で、Modelをどう扱うかの議論としてシステムアーキテクチャの考え方を説明していた。
この記事でもこの流れに沿って、View⇔Modelを切り離す役割を持つ「Presenter」をおくMVPと、View⇔Model双方向を紐づけるViewModelを持つMVVMのアーキテクチャを比較する。
1.1. MVPとSwiftUI
MVP(Model-View-Presenter)は、PresenterがViewを制御するアーキテクチャ。
具体的には、UI層とロジック層を分離するためのアーキテクチャで、以下の構成となる。
構成:
Model: データやビジネスロジック(例: ToDoアイテムの保存や取得)。
View: UIそのもの(例: SwiftUIのView)。
Presenter: ViewとModelをつなぐ仲介者。Viewに依存せず、ロジックを処理してViewに表示用のデータを渡す。
Presenterの役割:
Viewからの入力を受けてビジネスロジックを実行。
Modelから取得したデータをViewが表示しやすい形に変換。
Viewに対して命令的な操作(「これを表示しろ」と指示)を出す。
具体的な動き ※コードは以下コード例を参照
Presenterが主導: PresenterがViewを制御し、表示用のデータを準備。
Viewは受動的: ViewはPresenterの命令に従うだけ(displayTodosなど)。
双方向の接続: PresenterがViewを弱参照で持ち、ViewがPresenterを強参照で持つ。
コード例
// Presenter
class TodoPresenter {
private let repository: TodoRepository
weak var view: TodoViewProtocol?
init(repository: TodoRepository) { self.repository = repository }
func loadTodos() {
let todos = repository.fetchTodos()
view?.displayTodos(todos: todos.map { $0.title })
}
func addTodo(title: String) {
repository.saveTodo(TodoItem(id: UUID().uuidString, title: title))
loadTodos()
}
}
protocol TodoViewProtocol: AnyObject {
func displayTodos(todos: [String])
}
// View
struct TodoView: View, TodoViewProtocol {
private let presenter: TodoPresenter
@State private var todos: [String] = []
init(presenter: TodoPresenter) {
self.presenter = presenter
presenter.view = self
}
var body: some View {
NavigationView {
List(todos, id: \.self) { todo in
Text(todo)
}
.navigationTitle("ToDo List")
.toolbar { Button("Add") { presenter.addTodo(title: "New Task") } }
.onAppear { presenter.loadTodos() }
}
}
func displayTodos(todos: [String]) { self.todos = todos }
}
MVPでなぜ情報変更ごとに「loadTodos」を呼ぶのか?
MVPでは、PresenterがViewを制御する役割を持つ。 このため、データ(Model)が変更された場合、Presenterがその変更を検知し、Viewに新しいデータを反映するよう命令を出す必要が生じる。例:ユーザーが新しいToDoを追加したとき
PresenterはaddTodoを実行した後にloadTodosを呼び出して、最新の状態をViewに渡す。
このプロセスは手動的で、Presenterが「データの更新→Viewの更新」を明示的に管理する形になる。
上記コードの具体的な箇所としては
class TodoPresenter {
private let repository: TodoRepository
weak var view: TodoViewProtocol?
func addTodo(title: String) {
let newTodo = TodoItem(id: UUID().uuidString, title: title)
repository.saveTodo(newTodo)
loadTodos() // 追加後に手動で更新
}
func loadTodos() {
let todos = repository.fetchTodos()
view?.displayTodos(todos: todos.map { $0.title })
}
}
このように、データ変更(saveTodo)とView更新(loadTodos)が分離しており、Presenterが両方を意識的に呼び出す必要がある。
これは、SwiftUIのonAppearやボタンアクションでloadTodosを初回呼び出しするだけでは、リアルタイムな更新に対応しきれないためである。
∴SwiftUIでの評価
課題: Presenterが手動でViewを更新(loadTodosを毎回呼ぶ)する必要があり、SwiftUI固有の@Publishedの自動性が活かせない。
結論: SwiftUIの双方向バインディングと相性が悪く、メリットが薄い。
1.2. MVVMとSwiftUI
MVVM(Model-View-ViewModel)は、ViewModelが状態を管理し、データのバインディングを活用してUIとロジックを分離するアーキテクチャ。
構成:
Model: データやビジネスロジック。
View: UIそのもの。
ViewModel: Viewのためのデータを公開し、状態を管理。Viewと双方向でデータを同期。
ViewModelの役割:
Modelからデータを取得し、Viewがそのまま使える形で公開(通常は@Publishedで状態を管理)。
Viewからの入力を処理し、Modelを更新。
データバインディングを通じて、UIと自動的に同期。
具体的な動き:
ViewModelが状態を公開: @Publishedでデータを公開し、Viewがそれを監視。
Viewが能動的: ViewがViewModelの状態変化に反応してUIを更新。
一方向の依存: ViewがViewModelに依存するが、ViewModelはViewを知らない。
コード例
import SwiftUI
// ModelとRepositoryはMVPと同じ
// ViewModel
class TodoViewModel: ObservableObject {
private let repository: TodoRepository
@Published var todos: [TodoItem] = []
init(repository: TodoRepository) {
self.repository = repository
}
func loadTodos() {
todos = repository.fetchTodos()
}
func addTodo(title: String) {
let newTodo = TodoItem(id: UUID().uuidString, title: title)
repository.saveTodo(newTodo)
loadTodos() // 更新後に再読み込み
}
}
// SwiftUI View
struct TodoView: View {
@ObservedObject private var viewModel: TodoViewModel
init(viewModel: TodoViewModel) {
self.viewModel = viewModel
}
var body: some View {
NavigationView {
List(viewModel.todos, id: \.id) { todo in
Text(todo.title)
}
.navigationTitle("ToDo List")
.toolbar {
Button("Add") { viewModel.addTodo(title: "New Task") }
}
.onAppear { viewModel.loadTodos() }
}
}
}
// 起動
let repository = InMemoryTodoRepository()
let viewModel = TodoViewModel(repository: repository)
let app = TodoView(viewModel: viewModel)
∴SwiftUIでの評価
利点: @Publishedで状態を公開し、SwiftUIが自動反映。シンプルで自然。
結論: SwiftUIに最適。状態駆動型設計と親和性が高い。
1章まとめ(MVPとMVVMの相違点)
相違点の整理
相違点を整理すると、以下のようになる。
項目 | Presenter (MVP) | ViewModel (MVVM) |
---|---|---|
役割 | ViewとModelの仲介者。Viewを制御。 | Viewのためのデータと状態の管理。 |
Viewとの関係 | 双方向(PresenterがViewを参照)。 | 一方向(ViewがViewModelを監視)。 |
データ処理 | View用にデータを変換して渡す。 | 生データやそのまま使える状態を公開。 |
制御の主体 | Presenterが主導。Viewは受動的。 | Viewが状態変化に反応。 |
バインディング | 明示的な命令(メソッド呼び出し)。 | 自動同期(SwiftUIの@Published )。 |
依存性 | PresenterがViewのプロトコルに依存。 | ViewModelはViewに依存しない。 |
◎ToDoアプリでの具体的な違い
データの渡し方:
MVP: PresenterがdisplayTodos(todos: [String])を呼び出してViewに表示を指示。
MVVM: ViewModelが@Published var todosを更新し、Viewが自動で反映。
制御の流れ:
MVP: Presenterが「表示を更新しろ」と命令。
MVVM: Viewが「ViewModelが変わったから更新しよう」と反応。
選定の判断基準
MVP(Presenter)を選ぶ場合:
UIの制御を細かく管理したいとき。
SwiftUI以外の環境(UIKitなど)で命令的なUI更新が必要なとき。
Viewとロジックの分離をより厳密にしたいとき。
MVVM(ViewModel)を選ぶ場合:
SwiftUIのようにデータバインディングが自然に使えるとき。
状態管理をシンプルに保ちたいとき。
Viewが自立して動く設計が好ましいとき。
∴選定基準のまとめ
小さなToDoアプリなら、SwiftUIの特性を活かしてMVVMの方が実装が簡潔で直感的。
MVPは少し手動感が強くなり、PresenterとViewの接続を手動で管理する必要が生じる。
1章の最後に
Presenter(MVP)は「Viewを制御する司令塔」、ViewModel(MVVM)は「Viewが観察するデータホルダー」という違いが大きい。Presenterによって明示的に画面をロードしなおす必要のないSwiftUIであれば、MVVMが自然にフィットしやすいと考えられる。
そこで、このあとPresenterを使うVIPERなどのパターンも検討をしたいが、それにはシステムアーキテクチャの理解が前提となるため、まずシステムアーキテクチャの代表例としてCleanArchitectureを理解したのち、VIPERやRooter、Coordinatorパターンを検討する。
◎MVCについてはUIKitだとイメージがつきやすいものの、SwiftUIだとよくわからないので省略します。
2. システムアーキテクチャ
前章で、SwiftUIにおけるGUIアーキテクチャを整理した。次に、システムアーキテクチャの理解として、CleanArchitectureについてまとめる。
CleanArchitectureは、依存性逆転を活用し層を明確に分離する手法であり、まずGUIアーキテクチャの考えでModelをViewと切り離した上で、複雑なModel部分を扱いやすくする。
なお、書籍ではPresenter層を置くことでGUIと分離していたが、SwiftUIでは前段のようにPresenterをおく意義が薄くなっているため、コード例においてはPresenterを省いた、より軽量な実装としている。
2.1 CleanArchitecture
まず、なぜシステムアーキテクチャを検討する必要があるか。なぜ階層構造を持たせる必要があるのか。
これは『iOSアプリ設計パターン入門』をすでに読んでいるiOSエンジニアであれば、新たに説明する必要はないだろう。
冒頭の書籍の10章『Clean Architecture』の章の冒頭に
この構造を維持してアプリケーションを作ることで、変わりやすい部分を変えやすく、維持 しておきたい部分はそのままにしやすくできます。また、内側にある Entity や Use Case は外 側の Web API サーバーやデバイスドライバなどに依存していないので、それらの完成を待つ ことなくロジックをテストできます。これが Clean Architecture の特徴です。
との記載があり、これはUIKit⇒SwiftUIに移行後も同様に有用な発想かと思われる。
また、CleanArchitectureの理解の肝となるポイントは、上記引用箇所の直後にこのような記載がある。
同じシステムアーキテクチャであるレイヤードアーキテクチャ(第 4 章参照)と比較する と、レイヤードアーキテクチャではデータベースなどの永続化を担う層がドメインを担当する層の下にいて、ドメインが永続化層に依存しています。一方、Clean Architecture はデータベースを円の外側に置き、依存の方向が逆転しています。さらにデータベースとUseCaseの間には変換層を挟んでいて、切替が容易なしくみをもっている点が異なります。
この「依存の方向が逆転」「データベースとUseCaseの間には変換層」のポイントが、3章以降のパターンや他の未知のパターンへの応用にもつながるので、折りたたみの項目の中で紙幅を割いて解説する。
構成(MVVM×CleanArchitectureとした)
Entity: 純粋なデータ構造(例: TodoItem)
UseCase: ビジネスロジック(例: TodoUseCase)
Repository: データ操作の抽象(例: TodoRepository)
ViewModel: UIとUseCaseをつなぐ(SwiftUI特化)
コード例
///1. Entity
struct TodoItem {
let id: String
let title: String
}
///2. Repository(Gateway)のプロトコル定義
///
///UseCaseが依存するのは具体的な実装ではなく抽象(プロトコル)で、ここではTodoRepositoryを定義。
/// - Note:このプロトコルはデータの取得・保存・削除といった操作を抽象化しており、UseCaseはこのプロトコルに依存。
protocol TodoRepository {
func fetchTodos() -> [TodoItem]
func saveTodo(_ todo: TodoItem)
}
///3. UseCaseの定義
///
///UseCaseはアプリのビジネスロジックを扱い、Repositoryを通じてデータを操作。依存性逆転により、具体的な実装ではなくTodoRepositoryプロトコルに依存。
/// - Note: TodoUseCase自体はTodoRepositoryの実装を知らない
/// - Note: コンストラクタインジェクションでrepositoryを受け取る(依存性の注入)
class InMemoryTodoRepository: TodoRepository {
private var todos: [TodoItem] = []
func fetchTodos() -> [TodoItem] { todos }
func saveTodo(_ todo: TodoItem) { todos.append(todo) }
}
///4. InterfaceAdapter層での実装
///
///InterfaceAdapter層では、Repositoryの具体的な実装。
///ここでは簡略的な例として、ローカルの配列を使った簡易的な実装を記載する。
/// - Note: 実際のアプリでは、ここがCoreDataやRealm、外部API(例えばFirebaseやREST API)へのアクセスの具体的な実装に置き換わる。
class TodoUseCase {
private let repository: TodoRepository
init(repository: TodoRepository) { self.repository = repository }
func addTodo(title: String) { repository.saveTodo(TodoItem(id: UUID().uuidString, title: title)) }
func getTodos() -> [TodoItem] { repository.fetchTodos() }
}
// MARK: - UIとの接続例
///例: ViewModel(UIに関するアーキテクチャをMVVMとする場合)
class TodoViewModel: ObservableObject {
private let useCase: TodoUseCase
@Published var todos: [TodoItem] = []
init(useCase: TodoUseCase) {
self.useCase = useCase
}
func loadTodos() { todos = useCase.getTodos() }
func addTodo(title: String) {
useCase.addTodo(title: title)
loadTodos()
}
}
// MARK: アプリのメインクラス
import SwiftUI
@main
struct TodoApp: App {
/// UseCaseを宣言し、init内で具体的な処理を依存させる
private let todoUseCase: TodoUseCase
///UseCase.〜〜の中では、UseCase.repository.〜〜を呼ぶだけで、このRepositoryを大元のmainで定義してある。
///UIへは、今回の例ではContentViewに状態監視を行うViewModelを渡すことで接続している。
/// - Note: ContentViewには、イニシャライザで渡してもよいし、envでわたしても良い。
init() {
// Repositoryの実装を選択(ここではInMemoryを例に)
let repository: TodoRepository = InMemoryTodoRepository()
self.todoUseCase = TodoUseCase(repository: repository)
}
var body: some Scene {
WindowGroup {
ContentView(viewModel: TodoViewModel(useCase: todoUseCase))
//ContentView()
// .environmentObject(TodoViewModel(useCase: todoUseCase))
}
}
}
/// ContentView例
struct ContentView: View {
@ObservedObject var viewModel: TodoViewModel
//@EnvironmentObject var viewModel: TodoViewModel
var body: some View {
NavigationView {
List(viewModel.todos) { todo in
HStack {
Text(todo.title)
Spacer()
Image(systemName: todo.isCompleted ? "checkmark.circle" : "circle")
}
}
.navigationTitle("ToDo List")
.toolbar {
Button("Add") {
Task {
await viewModel.addTodo(title: "New Task")
}
}
}
.task {
await viewModel.loadTodos()
}
}
}
}
長っ💦
∴SwiftUIでのポイント
UseCaseがRepositoryの抽象(プロトコル)に依存し、具体的な実装は分離。
ViewModelがSwiftUIの@Publishedを活用し、状態を自動同期。
大規模な場合は依存性注入コンテナを設けることで依存関係を整理するが、ここでは割愛する。
※補足: CleanArchitectureでUseCaseは何をしているのか
UseCase
他の層からはUseCaseのメソッドを通じてデータを操作するが、UseCaseはプロトコルを通じて実装の大枠だけ知っており、具体的なデータを扱う操作は下層で実現している。
⇒これにより、データを操作する具体的な実装ができていなくても、コンパイルエラーを起こさないし、テストもたとえばView部分単体だけ実装完了した状態でも実施できるようになる
(…とすると、SwiftUIの#Preview { ContentView(viewModel: 〜〜) } での都度確認も軽量に行えそうね)
UseCaseの役割と依存関係の整理
クリーンアーキテクチャにおけるUseCase層の役割は、おもに以下である。
ビジネスロジックの中心: アプリの「何をするか」を定義する。
外部との独立性: 具体的なデータ操作(保存方法や取得方法)やUIに依存しない。
抽象への依存: 下層(Repositoryなど)とのやり取りをプロトコルを通じて行う。
一方で、具体的なデータ操作(例えばデータベースへの保存やAPI呼び出し)はRepository(Gateway)層に委ねられる。UseCaseは「こういう操作をしてほしい」という大枠だけを知っていて、「どうやって実現するか」は下層に任せる。
⇒これが、依存性逆転(Dependency Inversion)の肝!
ToDoリストのデータの流れの整理
ToDoリストで「タスクを追加する」場合、以下の順にコードが呼び出される。
①UI層(例えばSwiftUIのView): ユーザーが「追加」ボタンを押す。
②UseCase層: TodoUseCase.addTodo(title: "New Task")が呼び出され、新しいTodoItemを作ってRepositoryに渡す。
※UseCaseは「タスクを保存して」と指示するだけ。
③Repository層: TodoRepository.saveTodo(_:)が呼ばれ、具体的な保存処理(メモリ、CoreData、APIなど)を実行。
④外部システム: Repositoryの実装に応じてデータが保存される。
◎このとき、UseCaseはTodoRepositoryプロトコルに依存しているだけで、具体的な実装(例えばInMemoryTodoRepositoryかAPITodoRepositoryか)は、上位の @mainで代入されるまで知らない 状態である。
なぜこうするのか?
この設計の利点:
疎結合: UseCaseが下層の具体的な実装に依存しないため、Repositoryの実装を簡単に切り替えられる(例: テスト用モックやAPIへの変更)。
テスト容易性: UseCaseをテストする際、モックRepositoryを注入すれば外部システムなしで動作確認が可能。
責務の分離: UseCaseはビジネスロジックだけに集中し、データ操作の詳細は下層に任せる。
結論(UseCaseは何をしているか)
UseCaseは「何をしたいか」を定義しておき、下層で具体的に「どうやるか」を埋める役割分担となっている。
具体的な処理はrepositoryに定義し、上位のクラスで依存性注入(let repository: TodoRepo() ⇒UseCase(repo: repository) として処理を代入)をすることで、大枠だけだったUseCaseに具体的な処理がはいる仕組み。
まとめ
依存性逆転というと少し言葉が難しいけれども、有線LANの端子みたいな規格を用意して、UseCaseではそれをハメる穴と処理の枠を用意しておくよ、線を繋いで穴と端子がハマったら通信をしたりの処理が走るよ、とイメージするとわかりやすいかもしれない。
書籍のコードでは、Viewに対してはこの穴と端子をRepository(Entity)⇔UseCase、UseCase⇔Viewの二段構えにしていたが、SwiftUIにおいてはUseCase⇔Viewを切り離すPresenterを省きViewModelのインスタンス化時にViewModel(usecase: usecase)のようにすれば済むため、CleanArchitectureは以前より軽量な実装が可能になった。また、Entityからデータ層との間に書籍ではUseCaseとInterfaseAdopterとしてGatewayを設けていたが、これはSwiftUIにおいても有効で、もし明確に分離したければ同様にprotocolで端子と穴の規格を設けて分離すれば良い。ただ本記事ではシステムアーキテクチャの理解を優先したいことや、私自身の個人開発規模ではそこまでやるとオーバーヘッドなので省略した。
もし初学者でデータ層を明確に切り出す仕組みが気になれば、ぐろ君などに上記のコードを渡しつつサンプルコードと解説をお願いすれば快く答えてくれるだろう(ただ、初学者の個人開発規模であれば必要性は薄いと思う)。
3.画面遷移を扱うアーキテクチャ
ここまででGUIアーキテクチャとしてMVP・MVVM、システムアーキテクチャとしてCleanArchitectureを見てきた。
これから記載するVIPER、Router、Coordinatorについては、複雑な画面遷移の扱いも加味したアーキテクチャである。
これらのアーキテクチャを検討する必要性として、理由は大きく2つある。まず1点が、SwiftではJavaなどWebシステムと異なり、画面が小さいため1画面で表示できる情報に限りがあることから、遷移する画面の数が非常に多くなるためである。また2つ目の理由として、Webシステムであれば基本的にユーザーがクリックや入力を行ったら次の処理を実装すればよいが、Swiftではそれ以外に、通信可否や途絶・遅延状態とか、通知・位置情報・購入状態・パレントゲートなどなどなどなど、様々な状態監視が必要になり、その状態によって画面遷移の分岐も複雑になってしまう。
よって、開発規模が大きくなれば、状態や画面遷移の扱いも含めたアーキテクチャの検討が必要になる。
ここでは、まずVIPERを皮切りに、Router、Coordinatorを取り上げる。
3.1.VIPERとSwiftUI
UIKitにおいては、なんか全部実現できそうであったVIPER。
View、Interactor、Presenter、Entity、Routerの5層で構成。
なおSwiftUIにはPresenterを持つVIPERをそのまま持ち込みづらそうだと考えたが、ミスマッチとなる理由をぐろ君は以下の通りまとめてくれた。
(1)Presenterの命令的スタイルとSwiftUIのリアクティブ性の不一致
VIPER: PresenterがViewに「これを表示しろ」と命令(例: displayTodos)。
SwiftUI: @PublishedやObservableObjectで状態が変化すると、Viewが自動的に再描画。命令的な制御が不要。
問題点:
Presenterが手動でViewを更新する仕組みは、SwiftUIのデータ駆動型アプローチと相性が悪い。
たとえば、loadTodosを毎回呼ぶ必要があるのはMVP同様非効率的。
(2)Routerの役割の減少
VIPER: Router※が画面遷移を管理(UIKitではUIViewControllerのpush/popなど)。
SwiftUI: NavigationはNavigationViewやNavigationLinkで宣言的に扱うため、Routerの必要性が薄れる。
※Routerについては、このあと章を立てて解説する
問題: SwiftUIでは遷移ロジックがView内に埋め込まれがちで、VIPERのRouterがオーバーヘッドに感じられる。
(3)モジュール過多による複雑さ
VIPERは5つの層を持つため、小さなアプリでもファイル数が増え、シンプルなToDoアプリには過剰。
SwiftUIの軽量な設計(ViewとViewModelで完結しやすい)と比べると、準備コストが高い。
具体的なVIPERによる実装
上記を念頭に、無理やりかなとは思いつつVIPERで実装を試みてもらおうとした。
構造:
Presenter: ViewとInteractorをつなぎ、表示を指示。
Router: 画面遷移を管理。
コード例
struct TodoView: View, TodoViewProtocol {
private let presenter: TodoPresenter
@State private var todos: [String] = []
init(presenter: TodoPresenter) {
self.presenter = presenter
presenter.view = self
}
var body: some View {
NavigationView {
List(todos, id: \.self) { todo in
Text(todo)
}
.navigationTitle("ToDo List")
.toolbar { Button("Add") { presenter.addTodo(title: "New Task") } }
.onAppear { presenter.loadTodos() }
}
}
func displayTodos(todos: [String]) {
self.todos = todos // Presenterからの命令で更新
}
}
…Presenterくん完全に必要ないよね? addTodoの中に画面更新どうやって実装するつもり?
ってかそもそもRooterはどこ? Interactorは?
∴SwiftUIでの評価
問題点:
Presenterの命令的スタイルがSwiftUIのリアクティブ性とミスマッチ。
Routerの遷移管理は、NavigationLinkにより不要となっている。
結論:
完全適用はオーバーキル。Interactor(UseCase)のみ取り入れる軽量化が現実的。
…とのこと。
VIPERまとめ(率直な感想)
いや、UseCaseのみってもう完全にCleanArchitectureじゃん!!! ぐろ君!!!
冷静に考えれば当たり前だが、Presenterは状態とViewを切り離す役割を持つので、データや状態⇔Viewの双方向を実現しているSwiftUIにとっては邪魔になってしまう。
さて、ここで気になることとして、書籍にあった「Router」はUIKitにおいて何を実現しようとしていたアーキテクチャだったのか。
3.2.RouterとSwiftUI
Routerの目的と役割(UIKit)
VIPERアーキテクチャにおけるRouter(時にはWireframeとも呼ばれる)は、主に画面遷移(Navigation)の責任を担うモジュールである。VIPERが大規模なシステムを想定しているため、UIやロジックとは独立してナビゲーションを管理することで、以下の課題を解決しようとしていた。
(1)画面遷移ロジックの分離
問題: UIKitでは、画面遷移(例: pushViewControllerやpresent)がUIViewController内に埋め込まれがちで、ビジネスロジックや表示ロジックと混ざってしまう。
Routerの解決策: 遷移ロジックをRouterに切り出し、View(UIViewController)から独立させる。これにより、Viewは純粋に表示に集中できる。
コード例(UIKitでのVIPER):
Routerがpushやpopを担当し、Presenterは「どの画面に行くか」を決めるだけ。
class TodoRouter {
weak var viewController: UIViewController?
func showTodoDetail(todo: TodoItem) {
let detailVC = TodoDetailViewController(todo: todo) // 詳細画面
viewController?.navigationController?.pushViewController(detailVC, animated: true)
}
func goBack() {
viewController?.navigationController?.popViewController(animated: true)
}
}
class TodoPresenter {
private let router: TodoRouter
// ...
func didSelectTodo(at index: Int) {
let todos = interactor.fetchTodos()
router.showTodoDetail(todo: todos[index]) // Routerに遷移を依頼
}
}
(2)モジュール間の疎結合
問題: 画面遷移がViewControllerに依存すると、ある画面から別の画面への依存が強くなり、再利用性が下がる。
Routerの解決策: Routerが遷移の「配線(Wiring)」を担当することで、モジュール(View、Presenter、Interactor)が他のモジュールに直接依存しない。
たとえば、ToDoリスト画面から詳細画面への遷移をRouterが管理すると、ToDoリストのPresenterは詳細画面の実装を知らなくて済む。
(3)テスト容易性の向上
問題: 画面遷移がViewControllerに埋め込まれていると、ナビゲーションのテストが難しい(モックを作りにくい)。
Routerの解決策: Routerを独立させることで、遷移ロジックを単体テスト可能に。Presenterが「遷移しろ」と指示した際に、Routerが正しく動くか確認できる。
テストコード例
func testDidSelectTodo_TriggersNavigation() {
let mockRouter = MockTodoRouter()
let presenter = TodoPresenter(interactor: mockInteractor, router: mockRouter)
presenter.didSelectTodo(at: 0)
XCTAssertTrue(mockRouter.didCallShowTodoDetail)
}
(4)大規模アプリでのナビゲーション管理
問題: 大規模アプリでは画面数が多くなり、遷移パターンが複雑化(例: 条件付き遷移、ディープリンク対応)。
Routerの解決策: Routerがナビゲーションのルールやフローを一元管理。条件分岐や依存性の注入もRouterで処理。
コード例(Routerが遷移先を抽象化し、複雑なフローを整理)
class TodoRouter {
func navigate(to destination: TodoDestination) {
switch destination {
case .detail(let todo):
showTodoDetail(todo: todo)
case .settings:
showSettings()
case .loginIfNeeded:
if !userIsLoggedIn { showLogin() }
}
}
}
enum TodoDestination {
case detail(TodoItem)
case settings
case loginIfNeeded
}
UIKitにおけるRouter役割まとめ
Routerの根本的な目的は、「画面遷移の責任を分離し、アプリの構造をクリーンかつスケーラブルに保つ」ことであった。
具体的には、UIKitにおいては以下の役割を果たしていた。
・ViewやPresenterからナビゲーションコードを排除。
・モジュール間の依存を最小化。
・テストやメンテナンスを容易に。
・大規模なナビゲーションフローを一元管理。
UIKitではUIViewControllerがナビゲーションスタックを操作する役割を担うことが多かったため、Routerがその負担を引き受けるのは自然な設計ではあった。
SwiftUIでのRouterの適性
上記のような意義があったRouterだが、SwiftUIになると役割が少し曖昧になってしまう。
その理由は、以下の通りである。
(1)宣言的ナビゲーションとのギャップ
UIKit: 命令的(pushViewControllerを呼ぶ)。
SwiftUI: 宣言的(NavigationLinkやsheetで遷移を定義)。
影響: SwiftUIではナビゲーションがViewの構造に埋め込まれ、状態(@Stateや@Binding)で制御されるため、Routerが独立して遷移を管理する必然性が薄れる。
コード例(遷移がNavigationLinkで完結してしまう)
struct TodoView: View {
@State private var selectedTodo: TodoItem?
var body: some View {
NavigationView {
List(todos) { todo in
NavigationLink(
destination: TodoDetailView(todo: todo),
tag: todo,
selection: $selectedTodo
) {
Text(todo.title)
}
}
}
}
}
…だからさっきVIPERでRouteさんがいなくなってしまったのね!
(2)状態駆動型のアプローチ
SwiftUIでは、ナビゲーションも状態(例: selectedTodo)に依存。
Routerが状態を管理するのではなく、ViewModelやView自身がそれを担当する方が自然。
(3)シンプルなアプリではオーバーヘッド
ToDoアプリのような小さなアプリでは、画面遷移が少なくRouterを設けるメリットが少ない。
また、(1)の通りNavigationLinkにより中規模なアプリもシンプルに書けるようになり、Routerを置くと逆に冗長になってしまう。
では、どのような場合においてRouterを置くメリットがあるのか。
SwiftUIでRouterを活かす場合
それでも大規模なSwiftUIアプリでRouterの思想を取り入れるなら、以下のような工夫が考えられる
ナビゲーション状態の集中管理: ViewModelや独立したNavigationManagerで遷移を制御。
複雑なフローの場合: 条件付き遷移やディープリンクをRouter的なクラスで管理。
コード例(ナビゲーション状態の集中管理)
class NavigationManager: ObservableObject {
@Published var currentScreen: Screen?
enum Screen {
case todoDetail(TodoItem)
case settings
}
func navigate(to screen: Screen) {
currentScreen = screen
}
}
struct RootView: View {
@ObservedObject var navigation: NavigationManager
var body: some View {
NavigationView {
TodoView()
.sheet(item: $navigation.currentScreen) { screen in
switch screen {
case .todoDetail(let todo): TodoDetailView(todo: todo)
case .settings: SettingsView()
}
}
}
}
以前Twitterで、ログイン画面に入る前の画面をどうやって作るのか、という呟きがあった。NavigationViewが最も外側にあると実現不可能だが、外にRouterを置く、がその回答になるかもしれない。
Routerに関する結論
Routerが実現しようとしていたこと: 画面遷移の分離と一元管理で、クリーンでテストしやすい構造を築く。
SwiftUIでの適性: 宣言的ナビゲーションと状態駆動型設計により、独立したRouterの必要性が減る。ただし、大規模アプリで複雑なフローを管理するなら、一部思想を取り入れる価値はある。
なおぐろ君は以下のように話している。
PresenterがSwiftUIの双方向バインディングと相性が悪いように、RouterもSwiftUIのシンプルさに合わない部分がありますね。ToDoアプリ程度なら、MVVMや軽量なクリーンアーキテクチャで十分そうです。(byぐろ君)
SwiftUIの言語仕様にそぐわない部分はあるものの、たとえばログインできなかった場合の画面とか、NavigationViewを必要としない画面のパターンも置きたい時には有効そうである。
RouterでNavigationViewを囲うことで、Navigationに依存しない画面を作ることができる。この辺りは、UIKitからの考え方を知っていると優位性はあるかもしれない。
まとめと次の検討事項
さて、Swift×Routerについてはこれで理解ができた。
NavigationLinkなどでのView間の接続が実現され、かつ双方向の状態管理も実現されているSwiftUIにおいては、Coordinatorパターンも意義がうすれている可能性がある。
もっともCoordinatorについても私の理解が薄いため、実際のところどうなのかと、SwiftUIでも実は有効な部分があれば…と、ぐろ君に説明をお願いした。
3.3.CoordinatorとSwiftUI
Routerの構造が理解できたとのことで良かったですね!次にCoordinatorパターンについて、SwiftUIでの意義や有効性を考えるのは面白い視点です。確かにSwiftUIの特性(NavigationLinkによるView間接続や双方向状態管理)がCoordinatorの必要性を薄くしている可能性はあります。でも、Coordinatorパターンには独自の強みもあるので、まずはその概要を整理しつつ、SwiftUIでの適用性を考えてみましょう。理解が薄くても大丈夫、一緒に紐解いていきます!
(なんかだんだんぐろ君に親近感がわいてきた)
Coodinatorの目的と役割
Coordinatorパターンは、主にナビゲーションとアプリのフローを管理するための設計パターンである。特にUIKitベースのアプリでよく使われ、以下のような課題を解決しようとしていた。
課題
UIViewControllerの肥大化: ナビゲーションや画面遷移がUIViewControllerに詰め込まれると、コードが複雑になりがち。
再利用性の向上: 画面遷移ロジックを独立させて、異なるコンテキストで再利用可能にしたい。
責務の分離: 表示(View)とフローの管理を分けたい。
目的:
ナビゲーションの抽象化: 画面遷移をViewControllerから切り離し、独立したエンティティで管理。
フローの制御: アプリ全体の状態や条件に基づく遷移(例: ログインが必要ならログインページへ)を整理。
モジュール性: 子Coordinatorを使って、独立したフローを再利用可能に。
疎結合: ViewControllerが他の画面の実装を知らなくても遷移できる。
◎特に大規模アプリでは、複数の画面フローを扱う際にCoordinatorが力を発揮する(例: 認証フロー → メイン画面フロー → 設定フロー)。
役割
画面遷移の起点:どの画面を表示するかを決定。
フローの階層管理: 必要に応じて子Coordinatorを生成し、フローを階層的に管理。
独立的: ViewControllerやViewに依存せず、独立したオブジェクトとして動作。
これも、UIKit固有のナビゲーション問題が大きな関心のひとつになっており、SwiftUIにおいては適用範囲を検討する必要がありそうである。
コード例(ToDoアプリ ※UIKit)
◎Coordinatorがナビゲーションを一元管理することで、ViewControllerは表示に集中できる。
protocol Coordinator {
func start()
}
class TodoCoordinator: Coordinator {
private let navigationController: UINavigationController
private var childCoordinators: [Coordinator] = []
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func start() {
let todoVC = TodoViewController()
todoVC.delegate = self // 遷移イベントを受け取る
navigationController.pushViewController(todoVC, animated: false)
}
func showTodoDetail(todo: TodoItem) {
let detailVC = TodoDetailViewController(todo: todo)
navigationController.pushViewController(detailVC, animated: true)
}
func showSettings() {
let settingsCoordinator = SettingsCoordinator(navigationController: navigationController)
childCoordinators.append(settingsCoordinator)
settingsCoordinator.start()
}
}
class TodoViewController: UIViewController {
weak var delegate: TodoCoordinator?
override func viewDidLoad() {
super.viewDidLoad()
// リスト表示など
}
func userDidSelectTodo(_ todo: TodoItem) {
delegate?.showTodoDetail(todo: todo)
}
}
// 起動
let navController = UINavigationController()
let coordinator = TodoCoordinator(navigationController: navController)
coordinator.start()
SwiftUIでのCoordinator
SwiftUIの特性を考えると、Coordinatorパターンの必要性が確かに減っている部分がある。
その理由を整理すると、以下の通り。
(1)NavigationLinkによる簡易な遷移
SwiftUI: NavigationLinkで宣言的に遷移を定義。CoordinatorがなくてもView間で接続が簡単。
影響: Coordinatorが担っていた「画面をpushする」役割が不要に。
(2)双方向状態管理の統合
SwiftUI: @Stateや@Bindingで状態を管理し、遷移も状態変化に連動。
影響: Coordinatorがフローを管理するより、ViewやViewModelが状態を直接制御する方が自然。
(3)シンプルなアプリではオーバーヘッド
小さなToDoアプリなら、NavigationLinkやsheetで十分。Coordinatorを設けるのは準備コストが高すぎる。
コード例(NavigationLinkの例)
struct TodoView: View {
@State private var selectedTodo: TodoItem?
var body: some View {
NavigationView {
List(todos) { todo in
NavigationLink(
destination: TodoDetailView(todo: todo),
tag: todo,
selection: $selectedTodo
) {
Text(todo.title)
}
}
}
}
}
SwiftUIでCoordinatorが有効な場合
それでも、大規模なSwiftUIアプリでは、以下のようにCoordinatorパターンの思想が役立つシーンはあるようだ。
(1)複雑なナビゲーションフローの管理
状況: 条件付き遷移や多層的なフローが必要な場合(例: ログイン状態に応じて異なる画面に遷移)。
Coordinatorの活用: ナビゲーション状態を一元管理し、Viewに依存せずフローを制御。
利点: Coordinatorがフローを一元管理し、条件付き遷移(ログインなど)を整理。
コード例(Coordinator×NavigationView)
class AppCoordinator: ObservableObject {
@Published var path: [Screen] = []
@Published var sheet: Screen?
enum Screen: Hashable {
case todoList
case todoDetail(TodoItem)
case settings
case login
}
func start() {
if !isLoggedIn {
path.append(.login)
} else {
path.append(.todoList)
}
}
func navigateToDetail(todo: TodoItem) {
path.append(.todoDetail(todo))
}
func showSettings() {
sheet = .settings
}
func pop() {
path.removeLast()
}
private var isLoggedIn: Bool { false } // 仮
}
struct RootView: View {
@StateObject private var coordinator = AppCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
TodoView(coordinator: coordinator)
.navigationDestination(for: AppCoordinator.Screen.self) { screen in
switch screen {
case .todoList: TodoView(coordinator: coordinator)
case .todoDetail(let todo): TodoDetailView(todo: todo)
case .settings: SettingsView()
case .login: LoginView()
}
}
}
.sheet(item: $coordinator.sheet) { screen in
switch screen {
case .settings: SettingsView()
default: EmptyView()
}
}
.onAppear { coordinator.start() }
}
}
struct TodoView: View {
@ObservedObject var coordinator: AppCoordinator
let todos = [TodoItem(id: "1", title: "Task 1")]
var body: some View {
List(todos, id: \.id) { todo in
Button(todo.title) { coordinator.navigateToDetail(todo: todo) }
}
.toolbar { Button("Settings") { coordinator.showSettings() } }
}
}
(2)モジュール間の調整
状況: 独立した機能モジュール間で遷移が必要な場合。
Coordinatorの活用: 各モジュールにCoordinatorを設け、親Coordinatorが全体を調整。
(3)テストや再利用性の向上
状況: ナビゲーションロジックをテストしたい場合。
Coordinatorの活用: フローを独立させてモック化し、単体テストを容易に。
∴SwiftUIでの評価
意義の薄れ: SwiftUIにおいては、NavigationLinkや状態駆動型ナビゲーションで単純な遷移はView内で完結するようになった。状態駆動型でフローが簡素化しており、Coordinatorの準備コストが割に合わない。
有効性: 条件付き分岐(例: ログイン状態)や複雑なフロー(例: ログイン → メイン → サブフロー)を管理する際に有用。他に、状態とナビゲーションを分離して、アプリ全体の構造を整理する場合や、大規模アプリでモジュール性を保つ場合に有効。
Coordinatorまとめ
小さなToDoアプリならCoordinatorは不要だが、大規模アプリでフローが複雑化するなら、AppCoordinatorのような形で取り入れる価値はある。SwiftUIでは、純粋なCoordinatorより、状態管理とナビゲーションを融合した、上記のようなNavigationViewと組み合わせた軽量版が現実的かもしれない。
つまり? 自分なりの理解を投げかけてみた(ほめられた)
ゆざく:
Coordinatorについては、条件によって表示ロジックが分岐する箇所をラップする考えのみにおいてSwiftUIでは有効、という結論で理解に相違はないでしょうか。
ぐろ君の回答
この理解は、SwiftUIにおけるCoordinatorの有効性をかなり的確に捉えています。特に「条件によって表示ロジックが分岐する箇所をラップする」という部分は、CoordinatorがSwiftUIで価値を発揮する主なシナリオをよく表しています。以下で、少し補足と微調整を加えて確認します。
この補足と認識の微調整は以下の通り。
正しい部分
条件付き分岐の管理: CoordinatorがSwiftUIで有効なのは、まさに「条件によってどの画面を表示するか」「どのフローに進むか」を整理する場面。例えば、ログイン状態やユーザーの選択に基づく遷移を一元管理する役割。
ラップする考え: ViewやViewModelに直接埋め込むと複雑になるナビゲーションロジックを、Coordinatorで「包む(ラップする)」のは良いアプローチ。
微調整が必要な部分
"のみ"という限定: 「条件付き分岐のラッピングのみが有効」と限定すると、少し狭く感じられるかもしれません。SwiftUIでのCoordinatorは、それ以外にも「大規模なフローの管理」や「モジュール間の調整」といった役割でも有効性が残ります。ただし、小規模アプリでは確かに条件付き分岐が主なユースケースになるので、あなたの結論は実践的な視点として十分適切です。
表示ロジックの範囲: 「表示ロジック」という言葉がViewの描画そのものを指すなら、Coordinatorはむしろ「遷移ロジック」や「フローの制御」に焦点を当てます。表示そのものはSwiftUIのViewに任せ、Coordinatorは「どのViewを表示するか」を決める役割です。
結論に対する相違チェック
あなたの理解と私の補足を照らし合わせると、大きな相違はありません。ただ、「のみ」という表現を少し緩めて、「特に条件付き分岐の管理で有効であり、大規模なフローでも役立つ」と拡張すると、より包括的になります。小規模なToDoアプリ視点なら「条件付き分岐のラッピング」が主な用途で十分なので、実践的にはあなたの結論で問題ないです!
補足説明(上記折りたたみの質問で出た補足)
SwiftUIでのCoordinatorの有効性を再整理
SwiftUIでのCoordinatorの意義を、次のように結論づけられます:
主な有効性: 条件付きナビゲーションや複雑なフローを整理する際に、ViewやViewModelから分離して管理する手段として有用。
具体例: ログインが必要か、特定の設定画面に進むか、ディープリンクで特定画面に飛ぶか、などの条件分岐を扱う。
限界: シンプルな遷移(NavigationLinkで済むもの)では不要で、オーバーヘッドになる。
あなたの「条件によって表示ロジックが分岐する箇所をラップする」という理解は、この「主な有効性」を的確に捉えたものです。
コードで再確認
前回の例:Coordinatorが条件(isLoggedIn)に基づいてフローを制御し、Viewに依存せず分岐をラップしている。
class AppCoordinator: ObservableObject {
@Published var path: [Screen] = []
func start() {
if !isLoggedIn { // 条件分岐
path.append(.login)
} else {
path.append(.todoList)
}
}
}
3章のまとめ
複雑な画面遷移に対応するためのRouterやCoordinatorパターンは、UIKitがナビゲーションによる構成を作ることを苦手としていたことを理由の一つとして発展が進んだアーキテクチャのようである。この課題に対し、SwiftUIにはナビゲーションパターンが組み込まれており、RouterやCoordinatorの意義が薄れている。また、冒頭で書いたVIPERについては、SwiftUIではほぼ意義がないといえよう。
4.SwiftUI時代の設計指針
これまでのまとめとして、PresenterやRouterについては、SwiftUIの双方向バインディングと宣言的ナビゲーションが代替するため、必須性は低いことがわかった。
このことから、SwiftUIでの開発においては、Presenterを使うMVPやVIPERよりも、MVVMとクリーンアーキテクチャを意識し、開発規模によって層を分けると良さそう。
また、より大規模であれば、必要に応じ従来のCoordinatorやRouterパターンの利点も考慮しつつ部分的に取り入れると良いかもしれない。
- 小さなアプリ: MVVM + クリーンアーキテクチャが最適。
-
中規模アプリ:
A.軽量なクリーンアーキテクチャ: UseCaseとRepositoryを活用して依存性を整理。
…機能が複数モジュールに分かれ始め、ビジネスロジックがViewModelに肥大化しそうなとき。
…テストや依存性管理を重視する場合はこちらが有力。
◎例: ToDoアプリが「リスト管理」「カテゴリ管理」「通知設定」など複数の役割を持つ場合。
B.MVVM + Repository:
…まだモジュール性が低く、シンプルなデータ操作で済むとき。
…準備コストを抑えたい中規模アプリに。
◎例: ToDoアプリがシンプルなリスト管理だけで、外部APIが1つ程度の場合。
C. 状態管理が複雑ならTCAも有力。 - 大規模アプリ: CleanArchitectureを採用し、依存性注入コンテナを検討(詳細は割愛)。条件付きフロー管理にCoordinatorなど従来の思想も柔軟に採用。
5.その他のアーキテクチャ(これまでの議論で触れていない有力なアーキテクチャ)
これまでの議論では、MVP、MVVM、VIPER、Coordinator、クリーンアーキテクチャを検討した。
ここではそれ以外に、Twitterで散見するTCAなど、SwiftUIに適した有力なアーキテクチャを2つ記載する。
5.1. TCA (The Composable Architecture)
iOSネイティブのアプリ開発をする企業の多くが注目し採用しているTCA。
https://developers.freee.co.jp/entry/develop-invoice-app-with-tca-and-swiftui ※
サードパーティライブラリが前提であるデメリットはあるが、書籍に記載されているReduxパターンのSwiftUI向け上位互換であり、TCAの提供元にドキュメントや解説動画も充実しており、チームでの認識合わせも比較的しやすい印象がある。
※この記事にはMVVMのデメリットも言及されており、ぜひ一読を!!!
概要: Point-Freeが提唱するアーキテクチャ。状態管理と副作用を一元化し、Reduxに似た単方向データフローを採用。
構造:
State: アプリの全状態を1つの構造体で管理。
Action: ユーザーの操作やイベントを列挙。
Reducer: 状態とアクションを受け取り、次の状態を生成。
Store: 状態を保持し、Viewに公開。
コード例
import ComposableArchitecture
struct TodoState {
var todos: [TodoItem] = []
}
enum TodoAction {
case addTodo(String)
case loadTodos
}
let todoReducer = Reducer<TodoState, TodoAction, Void> { state, action, _ in
switch action {
case .addTodo(let title):
state.todos.append(TodoItem(id: UUID().uuidString, title: title))
return .none
case .loadTodos:
// 仮に固定データをロード
state.todos = [TodoItem(id: "1", title: "Task 1")]
return .none
}
}
struct TodoView: View {
let store: Store<TodoState, TodoAction>
var body: some View {
WithViewStore(store) { viewStore in
NavigationView {
List(viewStore.todos, id: \.id) { todo in
Text(todo.title)
}
.toolbar { Button("Add") { viewStore.send(.addTodo("New Task")) } }
.onAppear { viewStore.send(.loadTodos) }
}
}
}
}
SwiftUIでの有効性:
状態の一元管理が強力で、副作用(API呼び出しなど)の扱いも整理。
中〜大規模アプリで状態が複雑化する際に特に有用。
小規模ではオーバーヘッドになる可能性あり。
検討ポイント: 中規模アプリで状態管理が課題になり始めたら有力候補。
5.2. RIBs (Router-Interactor-Builder)
日本ではタクシー会社のGoが採用しているRIBs。以下記事では、クロスプラットフォームに向いているとの記載もあった。
https://lab.mo-t.com/blog/needle-in-ribs-architecture
読者の中にはタクシー??と思う方もいるかもしれないが、前職で人材紹介のお仕事をしていた時に、この会社は図抜けて強いエンジニアを採用している印象があった。
コード事例は検索してもあまり多く出てこないためぐろ君にまとめてもらったが、このアーキテクチャはUberがライブラリを提供しており、上記記事によれば同じくUberのneedleという依存関係構築のフレームワークを使うとコードの見通しがよくなるとのこと。
https://github.com/uber/needle
概要
Uberが開発したアーキテクチャ。Router、Interactor、Builderを中心に、モジュール性を重視。
構造
Router: ナビゲーションや子RIBの管理を担当。
Interactor: ビジネスロジックを処理し、状態を管理。
Builder: RIB(Router、Interactor、View)の依存性を構築。
View: UIを表示(SwiftUIの場合はView)。
Listener: 親RIBとの通信用(今回は簡略化のため省略)。
実装のポイント
1.Builder:
依存性を注入し、Router、Interactor、Viewを構築。
モジュールの起点として一貫性を確保。
2.Interactor:
ビジネスロジックを担当し、Viewにデータを通知。
Repositoryを通じてデータアクセスを抽象化。
3.Router:
ナビゲーションを管理。SwiftUIでは@Stateと連携して遷移を制御。
routeToDetailでViewの状態を変更し、NavigationStackが反応。
4.View:
SwiftUIの特性を活かしつつ、InteractorとRouterを橋渡し。
updateTodosで状態を更新し、UIに反映。
コード例
SwiftUIでは、Routerのナビゲーション役割がNavigationStackやNavigationLinkに一部置き換わるため、RIBsの完全な形ではなく、SwiftUIに適応させた軽量版として実装します。
プロトコル
// Entity
struct TodoItem {
let id: String
let title: String
}
// Listener(親RIBとの通信、今回は簡略化)
protocol TodoListener: AnyObject {
func didAddTodo()
}
// Interactorプロトコル
protocol TodoInteractable: AnyObject {
func addTodo(title: String)
func loadTodos()
}
// Routerプロトコル
protocol TodoRoutable: AnyObject {
func routeToDetail(todo: TodoItem)
}
// Viewプロトコル(SwiftUI用)
protocol TodoViewControllable: AnyObject {
func updateTodos(_ todos: [TodoItem])
}
1. Builder
Builderは依存性を組み立て、RIBを構築します。
class TodoBuilder {
func build() -> (TodoRoutable, TodoInteractable, TodoViewControllable) {
let repository = InMemoryTodoRepository()
let interactor = TodoInteractor(repository: repository)
let router = TodoRouter(interactor: interactor)
let view = TodoView(interactor: interactor, router: router)
interactor.view = view
return (router, interactor, view)
}
}```
**2. Interactor**
ビジネスロジックと状態を管理
```swift
class TodoInteractor: TodoInteractable {
private let repository: TodoRepository
weak var view: TodoViewControllable?
init(repository: TodoRepository) {
self.repository = repository
}
func loadTodos() {
let todos = repository.fetchTodos()
view?.updateTodos(todos)
}
func addTodo(title: String) {
repository.saveTodo(TodoItem(id: UUID().uuidString, title: title))
loadTodos()
}
}
// Repository(簡易)
protocol TodoRepository {
func fetchTodos() -> [TodoItem]
func saveTodo(_ todo: TodoItem)
}
class InMemoryTodoRepository: TodoRepository {
private var todos: [TodoItem] = []
func fetchTodos() -> [TodoItem] { todos }
func saveTodo(_ todo: TodoItem) { todos.append(todo) }
}
3. Router
ナビゲーションを管理。SwiftUIでは状態ベースで制御。
class TodoRouter: TodoRoutable {
private let interactor: TodoInteractable
private weak var view: TodoView? // SwiftUI Viewへの依存
init(interactor: TodoInteractable) {
self.interactor = interactor
}
func attach(view: TodoView) {
self.view = view
}
func routeToDetail(todo: TodoItem) {
view?.navigateToDetail(todo: todo)
}
}```
**4. View(SwiftUI)**
SwiftUIのViewとして実装し、InteractorとRouterを活用。
```swift
struct TodoView: View, TodoViewControllable {
private let interactor: TodoInteractable
private let router: TodoRouter
@State private var todos: [TodoItem] = []
@State private var selectedTodo: TodoItem? // ナビゲーション用
init(interactor: TodoInteractable, router: TodoRouter) {
self.interactor = interactor
self.router = router
router.attach(view: self) // 双方向接続
}
var body: some View {
NavigationStack {
List(todos, id: \.id) { todo in
Button(todo.title) {
router.routeToDetail(todo: todo)
}
}
.navigationTitle("ToDo List")
.toolbar { Button("Add") { interactor.addTodo(title: "New Task") } }
.navigationDestination(isPresented: Binding(
get: { selectedTodo != nil },
set: { if !$0 { selectedTodo = nil } }
)) {
if let todo = selectedTodo {
TodoDetailView(todo: todo)
}
}
.onAppear { interactor.loadTodos() }
}
}
// TodoViewControllableの実装
func updateTodos(_ todos: [TodoItem]) {
self.todos = todos
}
// Routerからのナビゲーション指示
func navigateToDetail(todo: TodoItem) {
selectedTodo = todo
}
}
struct TodoDetailView: View {
let todo: TodoItem
var body: some View {
Text("Detail: \(todo.title)")
.navigationTitle("Todo Detail")
}
}
アプリ起動
@main
struct TodoApp: App {
let (router, interactor, view) = TodoBuilder().build()
var body: some Scene {
WindowGroup {
view
}
}
}
∴SwiftUIでのRIBsの特徴と評価
特徴
- Routerがナビゲーションを一元管理する思想は維持。
- SwiftUIの宣言的ナビゲーション(NavigationStack)と統合するため、状態ベースで調整。
有効性
利点: 大規模アプリでモジュール性が求められる場合、Builderによる依存性構築とRouterによるフロー管理が有用。
課題: 小〜中規模では、NavigationLinkで済む遷移をRouterで管理するのはオーバーヘッド。SwiftUIのシンプルさと一部競合。
結論: 中規模アプリでは過剰だが、大規模で複雑なフローを扱うなら検討価値あり。
本記事のまとめ
アーキテクチャの大まかなまとめは4章に記載した通りです。
TCAの詳細や、UIKit混在のアプリではどうすべきか、Swift6時代ではどのような差分が生じるかなど興味はつきませんが、すでに16,000字程度となったこと、私自身の知識や技術の限界もあり、一旦ここまでで筆を置くことにします。
最後に
末筆ながらではありますが。
当初は「生成AIを使えば記事なんて簡単では」と思って書き始めた本記事。確かにコードや特徴のまとめ、知識不足の確認は生成AIにより相当手間が短縮されたものの、ちゃんとした記事にまとめようとすると、裏どりや自分なりの見解、平仄あわせに想定以上に手間がかかりました。
AIで手軽に専門知識のまとめができるようになった昨今、文章の長さや目次からは一見充実したコンテンツに見えても、同じ文言を繰り返しており内容の薄い記事が散見されるようになりました。
そのような中、本記事引用元のような、検討を重ね推敲の手間をかけた各エンジニアブログの執筆者様皆様には、本当に頭が下がります。
そして、『iOSアプリ設計パターン入門』著者の皆様。
Swiftエンジニアは日本でも人数が非常に限られ、なおかつ個人開発では、学習は非常に困難をきわめました。この中で、日本語で書かれた『iOSアプリ設計パターン入門』は、私にとって大きな希望であり指標でもありました。
他にも、似たような気持ちを持つSwiftエンジニアは少くないはずです。
本文の執筆やコード作成など、多大な労力を要したことと存じます。
業務に追われる中執筆いただき、ありがとうございました。
皆様を個別にフォローさせていただいていますが、この場を借りて御礼申し上げます。
エンジニアの業務は体調を崩すこともあるかと思いますが、どうかご自愛くださいませ。