Coordinator(コーディネーター)— 画面間の遷移ロジックをViewControllerから個別のクラスに移すアーキテクチャパターンです。このパターンは2015年にSoroush Khanlouによって提案され、iOSコミュニティで広く採用されました。Coordinatorはアプリケーションのフローを管理します。ViewControllerの作成と表示、画面間のデータ受け渡し、フロー完了の処理を行います。このパターンはコントローラーからナビゲーションを抽出することでMassive View Controller問題を解決します。詳細はCoordinatorの原著記事をご覧ください。
重要ポイント
Coordinator — iOSアプリケーションのナビゲーション責任を担うパターンです。標準のUIKitでは、ViewController自身が遷移を管理します。present、push、show segue — すべてのナビゲーションメソッドはUIViewControllerから呼び出されます。Coordinatorはこのロジックを抽出します。ViewControllerがイベントを報告し(例:「ユーザーがログインボタンをクリック」)、Coordinatorが次に表示する画面を決定します。ViewControllerはUIロジックのみを保持し、ナビゲーションをコーディネーターに委譲します。
パターン構造 — start()メソッドとfinish()メソッドを持つCoordinatorProtocol。start() — フローの開始:最初のViewControllerの作成と表示。finish() — 親コーディネーターへの通知を伴うフローの完了。Router — UINavigationController(またはUISplitViewController)のラッパーで、show、push、pop、dismissメソッドを提供します。コーディネーターはRouterを介してのみ動作し、UINavigationControllerを直接操作しません。これによりナビゲーションのテストが可能になり、UIフレームワークの切り替えも容易になります。
| コンポーネント | 役割 | 例 |
|---|---|---|
| Coordinator | ナビゲーションフローの管理 | AuthCoordinator, ProfileCoordinator |
| Router | UINavigationControllerの抽象化 | push, present, pop, dismiss |
| ViewController | UI + Coordinatorへのイベント委譲 | LoginViewController.delegate |
Coordinatorが解決する問題 — Massive View Controller(ナビゲーションはコントローラー肥大化の一般的な原因)。標準のUIKitでは、ViewControllerにprepareForSegue、ナビゲーションデリゲート、unwind segue処理が含まれます。Coordinatorはこれらを排除します。StoryboardのSegueは画面間の静的な接続ですが、Coordinatorは条件付きの動的ナビゲーションを提供します。Router呼び出しの順序を検証することで、UIなしでCoordinatorをテストできます。
Swiftの基本Coordinator — Router用の関連型とstart/finishメソッドを持つプロトコル。Router — UINavigationControllerを抽象化するプロトコル。Routerの具象実装はUINavigationControllerをラップし、メソッドを委譲します。CoordinatorはinitでRouterを受け取り、ナビゲーションに使用します。子コーディネーターはライフサイクル管理のためにchildCoordinators配列に格納されます。
// Router — ナビゲーションの抽象化
protocol RouterProtocol: AnyObject {
func push(_ viewController: UIViewController, animated: Bool)
func pop(animated: Bool)
func present(_ viewController: UIViewController, animated: Bool)
func dismiss(animated: Bool)
}
final class NavigationRouter: RouterProtocol {
private let navigationController: UINavigationController
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func push(_ vc: UIViewController, animated: Bool) {
navigationController.pushViewController(vc, animated: animated)
}
func pop(animated: Bool) {
navigationController.popViewController(animated: animated)
}
func present(_ vc: UIViewController, animated: Bool) {
navigationController.present(vc, animated: animated)
}
func dismiss(animated: Bool) {
navigationController.dismiss(animated: animated)
}
}
// Coordinator — フロー管理
protocol CoordinatorProtocol: AnyObject {
var childCoordinators: [CoordinatorProtocol] { get set }
var router: RouterProtocol { get }
func start()
func finish()
}
class AuthCoordinator: CoordinatorProtocol {
var childCoordinators: [CoordinatorProtocol] = []
let router: RouterProtocol
init(router: RouterProtocol) {
self.router = router
}
func start() {
let loginVC = LoginViewController()
loginVC.onLogin = { [weak self] in
self?.showHome()
}
router.push(loginVC, animated: true)
}
private func showHome() {
let homeCoordinator = HomeCoordinator(router: router)
childCoordinators.append(homeCoordinator)
homeCoordinator.start()
}
func finish() {
childCoordinators.removeAll()
router.pop(animated: true)
}
}
AppDelegate/SceneDelegateでのCoordinator作成 — AppDelegateまたはSceneDelegateがUINavigationControllerを作成し、NavigationRouterでラップし、ルートCoordinator(AppCoordinator)を作成してstart()を呼び出します。AppCoordinatorはアプリケーションの状態に応じて、オンボーディング、ログイン、メイン画面のいずれを表示するかを決定します。Coordinatorはナビゲーションの唯一のエントリポイントであり、ViewControllerは他の画面について知りません。
コーディネーター階層 — AppCoordinator → AuthCoordinator/MainCoordinator → ProfileCoordinator/SettingsCoordinator。子コーディネーターは親によって作成され、childCoordinators配列に格納されます。子コーディネーターが作業を完了すると、親のfinish()を呼び出し、親はchildCoordinatorsから削除します。これによりメモリリークを防止します。CoordinatorはRouterを介してViewControllerへの強い参照を持つため、childCoordinatorsから削除しないとオブジェクトは解放されません。
// Coordinator -> Parent通信用Delegate
protocol AuthCoordinatorDelegate: AnyObject {
func authCoordinatorFinished(_ coordinator: AuthCoordinator)
}
class AuthCoordinator: CoordinatorProtocol {
weak var delegate: AuthCoordinatorDelegate?
func finish() {
delegate?.authCoordinatorFinished(self)
}
}
// AppCoordinator — 親
class AppCoordinator: AuthCoordinatorDelegate {
func startAuthFlow() {
let authCoordinator = AuthCoordinator(router: router)
authCoordinator.delegate = self
childCoordinators.append(authCoordinator)
authCoordinator.start()
}
func authCoordinatorFinished(_ coordinator: AuthCoordinator) {
childCoordinators.removeAll { $0 is AuthCoordinator }
startMainFlow()
}
}
childCoordinatorsの管理 — Coordinatorを配列から削除することが解放する唯一の方法です。完了したCoordinatorを削除し忘れると、ViewControllerとともにメモリに残り続けます。推奨アプローチ:親のdidMove(toParent:)、完了時のcallback、またはCombine publisherによる自動削除。Coordinatorパターンは通知メカニズムを指定しません — delegate、closure、Combineのいずれかで、開発者の選択に委ねられます。
delegateによるデータ受け渡し — 子Coordinatorがdelegateプロトコルを定義し、そのメソッドを通じて結果を渡します:func authCoordinator(_:didLoginWith user: User)。親がプロトコルを実装し、子フロー完了時にデータを受け取ります。型安全で明示的です。欠点:子Coordinatorごとに個別のプロトコルが必要。10以上のCoordinatorがあるプロジェクトではファイル数が増加します。
Result型によるデータ受け渡し — finishメソッドはResult
| 方法 | 利点 | 欠点 |
|---|---|---|
| Delegate | 型安全、明示的、個別プロトコル | プロトコル多数、大量のボイラープレート |
| Closure | コンパクト、ファイル数削減 | retain cycleのデバッグが困難 |
| Combine/Rx | リアクティブ、組み合わせ容易 | ライブラリ依存、デバッグが複雑 |
共有データ層 — Coordinatorはデータを直接渡さず、共有サービス/リポジトリを使用します。AuthCoordinatorはトークンをKeychain/UserDefaultsに保存し、ProfileCoordinatorはそこから読み取ります。Coordinatorは直接呼び出しではなく、共有状態(Dependency Injectionコンテナ)を介して通信します。これによりCoordinator間の結合度は低下しますが、共有状態への暗黙的な依存関係が生じます。
Coordinator vs Router — RouterはCoordinatorのコンポーネントでUINavigationControllerを抽象化します。Coordinatorはフロー(どの画面を表示するか)に責任を持ち、Routerはメカニクス(どのように表示するか:push/present)に責任を持ちます。Routerは「方法」、Coordinatorは「何を」です。RouterはCoordinatorなしで使用できますが(例:Navigatorシングルトン)、CoordinatorをRouterなしで使用すると、単に異なる抽象化を持つViewControllerになります。通常は両方のパターンを一緒に使用します。
Coordinator vs VIPER — VIPERにはナビゲーションを担当するWireframeコンポーネントがあり、Coordinatorに類似しています。VIPERではWireframeはモジュールの一部ですが、Coordinatorはモジュール上の個別の層です。VIPERモジュール(View-Interactor-Presenter-Entity-Router)はナビゲーションをモジュールの一部として含みます。Coordinatorはモジュールの外部にあり、モジュールを作成して接続しますが、その一部ではありません。Coordinatorは異なるフローでの画面再利用においてより柔軟です。
MVVM-C — Coordinatorを使用したMVVMの拡張。ViewModelはCoordinatorを直接知りません — ViewControllerがViewModelを介してナビゲーションを委譲し、ViewModelがプロトコルを介してcoordinatorを呼び出します。MVVM-CはSwiftUIを用いたiOSプロジェクトの標準的アプローチです。CoordinatorはNavigationStackまたはfullScreenCoverを管理し、ViewModelは状態を公開することでcoordinatorを呼び出します。AppleはSwiftUIでのCoordinatorを推奨していません — NavigationStackとNavigationPathは組み込みのナビゲーションメカニズムです。
// MVVM-C: ViewModelがプロトコル経由でCoordinatorを呼び出す
protocol AuthNavigationProtocol: AnyObject {
func showMainScreen()
func showForgotPassword()
}
class AuthViewModel: ObservableObject {
weak var navigation: AuthNavigationProtocol?
func loginTapped() {
// ロジック...
navigation?.showMainScreen()
}
}
よくある質問
SwiftUIでは、組み込みのナビゲーション(NavigationStack、NavigationPath)がCoordinatorを置き換えることがよくあります。Appleはpath-basedナビゲーションを推奨しています。Coordinatorは複雑な条件分岐を伴うフロー(ロールに応じたオンボーディング-ログイン-メイン画面)に意味があります。シンプルなSwiftUIアプリケーションではCoordinatorは過剰です — NavigationPathを使用してください。
いいえ、これらは異なるパターンです。Coordinatorはナビゲーションフローを管理します:どの画面を表示するかを決定し、ViewControllerを作成して接続します。RouterはUINavigationControllerの抽象化です:push、present、pop、dismiss。CoordinatorはRouterを使用してナビゲーションを実行します。一部の実装ではRouterがCoordinatorのロジックを含みますが(Router-per-screen)、これは元のパターンからの逸脱です。
リークの主な2つの原因:childCoordinators(親が子を保持したまま削除を忘れる)とRouter(UINavigationControllerがViewControllerを保持)。解決策:finish()時に子Coordinatorを必ず配列から削除。delegateにはweak参照を使用。Routerについては、UINavigationControllerがすでにwindow階層にある場合は強参照を保持しない。Coordinatorのdeinitをテストしてください。
3〜5画面のアプリケーションではCoordinatorは過剰です — segueまたは単純なnavigationController.pushViewControllerで十分です。NavigationStackを使用するSwiftUIアプリケーションでも同様に過剰です。Coordinatorは15画面以上、複雑なフロー(分岐のあるオンボーディング、パスワードリカバリのある認証)、およびUIKit/SwiftUI混合プロジェクトで正当化されます。
Mock Router — どのメソッドがどのパラメータで呼び出されるかを確認。childCoordinatorsの確認:start()後に配列が空でないこと、finish()後に空であること。CoordinatorはUIなしでテスト可能:Routerはプロトコルであり、そのモックはUIKitを必要としません。非同期フローにはXCTestExpectationを使用。Androidでも同様に、モックナビゲーションを使用したNavigationControllerとNavHostのテストが行われます。
まとめ
ターンキー方式のモバイルアプリケーションを開発します
IT Sectrは2017年からスタートアップや企業向けにiOS・Androidアプリケーションを開発しています。私たちがご相談に乗り、最適なソリューションをご提案します。