前言
當我實作畫面流程時,我會為一組「流程」建立一個 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 內部宣告自己的
Eventenum,列出這個 View 會發出的所有事件 - Coordinator 建立 View 時必須提供
(Event) -> Void,漏了就 compile error - Handler 裡 switch 這個 concrete enum,Swift 會強制處理每個 case — 不會有
defaultfallback 的漏洞
命名用 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()、childCoordinators、router 等)反而綁手綁腳,逼你為了滿足 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(UITableViewDelegate、UITextFieldDelegate等),風格一致 - 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 整合?
收到 deep link 時,Coordinator 如果知道怎麼處理就直接顯示對應畫面;如果不知道,就詢問它能建立的 Sub-Coordinator — 每個 Sub-Coordinator 提供一個 static method 來回答自己能不能處理這個 deep link。Parent Coordinator 找到能處理的 Sub-Coordinator 後,建立並呈現它。這個 resolve 過程沿著 tree 從 root 往 leaf 遞迴,跟 Coordinator 管理畫面的結構一致。