前言

當我實作畫面流程時,我會為一組「流程」建立一個 Coordinator。如果這組流程很複雜,它可以拆分成多個「子流程」,那每一個子流程也會有對應的 Sub-Coordinator。主流程的 Coordinator 可以管理子流程的 Sub-Coordinator。

Coordinator 用來管理流程的畫面,它負責建立畫面、傳遞資料給畫面、回應畫面的請求、移除畫面等等。如果用 tree 來理解畫面流程的話,Coordinator 就是 root,各個畫面就是 leaf。換個角度看,Coordinator 像一個容器 — 它自己不產出畫面內容,而是決定裡面放哪些畫面、什麼時候切換。

UIKit

在 UIKit 實作 Coordinator Pattern 的時候,我喜歡使用 UIViewController 作為 Coordinator,並且在裡頭內嵌一個 UINavigationController

Coordinator 結構

Coordinator 持有 UINavigationController,負責建立畫面、處理事件、決定導航行為:

public final class FeatureCoordinator: UIViewController {
    private let rootNav = UINavigationController()

    public override func viewDidLoad() {
        super.viewDidLoad()

        addChild(rootNav)
        rootNav.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(rootNav.view)
        NSLayoutConstraint.activate([
            rootNav.view.topAnchor.constraint(equalTo: view.topAnchor),
            rootNav.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            rootNav.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            rootNav.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        ])
        rootNav.didMove(toParent: self)

        showHome()
    }

    private func showHome() {
        let homeVC = HomeViewController()
        homeVC.delegate = self
        rootNav.pushViewController(homeVC, animated: false)
    }

    private func showDetail(item: String) {
        let detailVC = DetailViewController(item: item)
        detailVC.delegate = self
        rootNav.pushViewController(detailVC, animated: true)
    }

    private func showSettings() {
        let settingsVC = SettingsViewController()
        settingsVC.delegate = self
        rootNav.pushViewController(settingsVC, animated: true)
    }
}

// MARK: - Event Handlers

extension FeatureCoordinator: HomeViewControllerDelegate {
    func homeViewController(
        _ vc: HomeViewController,
        didSelectItem item: String
    ) {
        showDetail(item: item)
    }

    func homeViewControllerDidTapSettings(
        _ vc: HomeViewController
    ) {
        showSettings()
    }
}

extension FeatureCoordinator: DetailViewControllerDelegate {
    func detailViewController(
        _ vc: DetailViewController,
        didSelectRelatedItem item: String
    ) {
        showDetail(item: item)
    }

    func detailViewControllerDidFinish(
        _ vc: DetailViewController
    ) {
        rootNav.popViewController(animated: true)
    }
}

extension FeatureCoordinator: SettingsViewControllerDelegate {
    func settingsViewControllerDidLogOut(
        _ vc: SettingsViewController
    ) {
        rootNav.popToRootViewController(animated: true)
    }
}

畫面與 Coordinator 的溝通:偏好 Delegate

畫面透過 delegate 與 Coordinator 溝通。我偏好 delegate 而非 closure,因為:

  • Contract 明確:protocol 就是溝通介面的完整定義,一眼就能看出畫面會發出哪些事件
  • 不容易漏:Xcode 會強制你實作每個 protocol method,不會忘記處理某個事件
  • 可讀性:當畫面有多種事件需要回傳時,closure 會變成一堆散落的 property,delegate 則集中在一個 protocol 裡

Closure 適合一次性、單一事件的回傳(例如 completion block),但在 Coordinator pattern 中,畫面通常有多種事件需要通知 Coordinator,delegate 更合適。

// MARK: - View Delegate Protocols

protocol HomeViewControllerDelegate: AnyObject {
    func homeViewController(
        _ vc: HomeViewController,
        didSelectItem item: String
    )
    func homeViewControllerDidTapSettings(
        _ vc: HomeViewController
    )
}

protocol DetailViewControllerDelegate: AnyObject {
    func detailViewController(
        _ vc: DetailViewController,
        didSelectRelatedItem item: String
    )
    func detailViewControllerDidFinish(
        _ vc: DetailViewController
    )
}

protocol SettingsViewControllerDelegate: AnyObject {
    func settingsViewControllerDidLogOut(
        _ vc: SettingsViewController
    )
}

子流程管理與反向溝通

既然 Coordinator 是 UIViewController,子流程的管理就是標準的 UIKit parent-child 關係。呈現子流程用 present,子流程結束後透過 delegate 把結果傳回 parent Coordinator — 溝通方式與畫面 → Coordinator 一致,整個架構的溝通模型是統一的。

// MARK: - Sub-Coordinator Management

protocol SubFeatureCoordinatorDelegate: AnyObject {
    func subFeatureCoordinator(
        _ coordinator: SubFeatureCoordinator,
        didFinishWith result: SubFeatureResult
    )
}

// In parent FeatureCoordinator:
extension FeatureCoordinator {
    func showSubFeature() {
        let subCoordinator = SubFeatureCoordinator()
        subCoordinator.delegate = self
        present(subCoordinator, animated: true)
    }
}

extension FeatureCoordinator: SubFeatureCoordinatorDelegate {
    func subFeatureCoordinator(
        _ coordinator: SubFeatureCoordinator,
        didFinishWith result: SubFeatureResult
    ) {
        coordinator.dismiss(animated: true)
        // Handle result from sub-flow
    }
}

這正好是 UIViewController-based Coordinator 相較 protocol-based 做法的優勢:你不需要手動從 childCoordinators 陣列移除子 Coordinator,dismiss 就是一切。Sub-Coordinator 如果有需要清理的資源,應該在自己的 deinit 處理 — Coordinator 是 self-contained 的,不該讓 parent 操心內部清理。

SwiftUI

在 SwiftUI 實作 Coordinator Pattern 的時候,對應 UIKit 版的「Coordinator = UIViewController」,我使用 SwiftUI View 作為 Coordinator,讓它擁有 NavigationStack@State 導航狀態。

Coordinator 結構

Coordinator View 持有 NavigationStack 的 path 和 sheet 狀態,負責建立畫面、處理事件、決定導航行為:

struct FeatureCoordinatorView: View {
    enum Route: Hashable {
        case home
        case detail(String)
        case settings
    }

    @State private var path: [Route] = []
    @State private var sheetRoute: Route?

    var body: some View {
        NavigationStack(path: $path) {
            HomeView(onEvent: handleHomeEvent)
                .navigationDestination(for: Route.self) { route in
                    view(for: route)
                }
        }
        .sheet(item: $sheetRoute) { route in
            NavigationStack {
                view(for: route)
            }
        }
    }

    @ViewBuilder
    private func view(for route: Route) -> some View {
        switch route {
        case .home:
            HomeView(onEvent: handleHomeEvent)
        case .detail(let item):
            DetailView(item: item, onEvent: handleDetailEvent)
        case .settings:
            SettingsView(onEvent: handleSettingsEvent)
        }
    }

    // MARK: - Event Handlers

    private func handleHomeEvent(_ event: HomeView.Event) {
        switch event {
        case .didSelectItem(let item):
            path.append(.detail(item))
        case .didTapSettings:
            path.append(.settings)
        }
    }

    private func handleDetailEvent(_ event: DetailView.Event) {
        switch event {
        case .didSelectRelatedItem(let item):
            path.append(.detail(item))
        case .didFinish:
            if !path.isEmpty { path.removeLast() }
        }
    }

    private func handleSettingsEvent(_ event: SettingsView.Event) {
        switch event {
        case .didLogOut:
            path.removeAll()
        }
    }
}

畫面與 Coordinator 的溝通:Event Enum + Closure

UIKit 版偏好 delegate,SwiftUI 版則改用每個 View 自己定義的 Event enum 搭配 onEvent closure。精神是一樣的 — contract 明確、不容易漏

具體做法:

  • 每個 View 內部宣告自己的 Event enum,列出這個 View 會發出的所有事件
  • Coordinator 建立 View 時必須提供 (Event) -> Void,漏了就 compile error
  • Handler 裡 switch 這個 concrete enum,Swift 會強制處理每個 case — 不會有 default fallback 的漏洞

命名用 Event 而非 NavigationEvent,因為不是每個事件都跟導航有關。更重要的是,Event 的 case 應該描述發生了什麼事,而非指揮 Coordinator 該做什麼導航 — 讓 View 保持 context-independent。

struct DetailView: View {
    enum Event {
        case didSelectRelatedItem(String)
        case didFinish(DetailResult)
    }

    let item: String
    let onEvent: (Event) -> Void

    var body: some View {
        VStack {
            Text("詳情: \(item)")

            Button("完成") {
                onEvent(.didFinish(.success))
            }
        }
    }
}

子流程管理與反向溝通

與 UIKit 版一樣,子流程用獨立的 Coordinator View 實作,透過 .sheet.fullScreenCover 呈現。子流程完成後透過 onEvent closure 把結果傳回 parent Coordinator — 溝通方式與畫面 → Coordinator 一致。

struct FeatureCoordinatorView: View {
    // ...
    @State private var isShowingSubFeature = false

    var body: some View {
        NavigationStack(path: $path) {
            // ...
        }
        .sheet(isPresented: $isShowingSubFeature) {
            SubFeatureCoordinatorView(
                onEvent: handleSubFeatureEvent
            )
        }
    }

    private func handleSubFeatureEvent(
        _ event: SubFeatureCoordinatorView.Event
    ) {
        switch event {
        case .didFinish(let result):
            isShowingSubFeature = false
            // Handle result from sub-flow
        }
    }
}

// Sub-Coordinator 也是一個 View,擁有自己的 NavigationStack
struct SubFeatureCoordinatorView: View {
    enum Event {
        case didFinish(SubFeatureResult)
    }

    let onEvent: (Event) -> Void

    @State private var path: [SubRoute] = []

    var body: some View {
        NavigationStack(path: $path) {
            // Sub-flow's root view
            // ...
        }
    }
}

Q&A

一定要整個 app 都用 Coordinator 嗎?

不用。Coordinator 可以作為 app 的 root 直接使用,也可以在開發新功能時局部導入 — 為某個功能建立 Coordinator 不需要改動既有架構,侵入性很低。

為什麼選 UIViewController 而不是社群常見的 Protocol-based Coordinator?

社群主流的 Coordinator pattern(如 Soroush Khanlou 原版)是用 Coordinator protocol 搭配 childCoordinators 陣列來管理子流程。我選擇直接用 UIViewController 作為 Coordinator,理由如下:

  • 少一層抽象:Coordinator 的 lifecycle 直接跟 UIKit 綁定,不需要自己維護 start() / stop() 等生命週期方法
  • 不需要手動管理 childCoordinators 陣列:UIKit 的 children(child view controller)已經替你做了這件事,少一個容易遺漏的 bookkeeping
  • present / dismiss 不需要額外 wiring:因為 Coordinator 本身就是 UIViewController,所以呈現子流程就是標準的 present(_:animated:),結束就是 dismiss,不需要額外的 routing 邏輯

為什麼不定義一個所有 Coordinator 都 conform 的 protocol?

我刻意不定義一個所有 Coordinator 都要 conform 的 CoordinatorProtocol。Coordinator 是一個概念,不是一個介面。

每個流程的需求不同 — 有的要處理 deep link,有的不用;有的有子流程,有的只有兩個畫面。硬定義一個共用 protocol(start()childCoordinatorsrouter 等)反而綁手綁腳,逼你為了滿足 protocol 而寫不需要的東西。

依需求開發每一個 Coordinator,比套一個 protocol 更實際。

這樣做對畫面復用有什麼幫助?

Coordinator 全權管理導航,帶來一個實務上很重要的好處:畫面不需要知道自己被放在什麼容器裡

畫面不用管自己是被 push 進 NavigationStack、用 sheet 呈現、還是當 tab 的 root — 它只負責顯示內容、發出事件。導航相關的 UI(back button、close button、toolbar action、tab item)全部由 Coordinator 或容器決定,畫面本身不碰。

主要的例外是 title:畫面可以自己聲明標題。在 UIKit 中是 self.title,在 SwiftUI 中是 .navigationTitle — 兩者的性質一樣,都是「我叫什麼名字」,不是「我要怎麼被導航」。至於標題最終怎麼呈現,是容器的事。

這讓同一個畫面可以在不同情境直接復用,不用為了換容器而改畫面的程式碼。

UIKit 用 delegate、SwiftUI 用 closure,為什麼不統一?

兩個框架選擇不同溝通方式,是因為架構特性不同:

  • UIKit 用 delegate:ViewController 是 reference type(class),closure capture self 容易造成 retain cycle,每個地方都要寫 [weak self]。Delegate 用 weak 一次解決,而且整個 UIKit 框架本身就大量使用 delegate(UITableViewDelegateUITextFieldDelegate 等),風格一致
  • SwiftUI 用 closure:View 是 value type(struct),沒有 retain cycle 的問題。SwiftUI 框架本身就是 closure 慣例 — Button(action:).onTapGesture {}.task {} 全部都是 closure,用 delegate 反而格格不入。而且 View struct 會被頻繁重建,delegate 這種需要 assign reference 的模式不適合這個生命週期

不是 closure 或 delegate 誰絕對更好,而是跟著框架的慣例和型別系統走

Coordinator 要負責 dependency injection 嗎?

Coordinator 負責管理流程,不負責規定 dependency 怎麼到達畫面。Coordinator 建立畫面時注入 dependency、畫面自己從 DI container 取得、或透過 SwiftUI 的 environment 傳遞,都可以 — 視專案的 DI 策略決定。

收到 deep link 時,Coordinator 如果知道怎麼處理就直接顯示對應畫面;如果不知道,就詢問它能建立的 Sub-Coordinator — 每個 Sub-Coordinator 提供一個 static method 來回答自己能不能處理這個 deep link。Parent Coordinator 找到能處理的 Sub-Coordinator 後,建立並呈現它。這個 resolve 過程沿著 tree 從 root 往 leaf 遞迴,跟 Coordinator 管理畫面的結構一致。