SwiftUI 工程師在新專案導入 Flow-Based Navigation 架構時的實戰經驗與常見挑戰

Published on: | Last updated:

Apple 在 WWDC 2022 把 NavigationStack 端上來,直接取代舊的 NavigationView。很多人那時候只顧著改 API。手滑一改,畫面能跑就算。然後專案越長越像麻花。你也懂。

一套在 SwiftUI 做「可個人化導覽」的做法,核心是用 FlowNode(可 Hash 的畫面狀態)加 FlowController(管理 NavigationPath 與 modal flow 的協調器),把導覽從 View 抽離,讓路徑能依使用者意圖動態組合,還能測試、能 deep link、也比較不會撞 runtime。

  • 檢查點:畫面用 enum 或 type 定義,別用字串亂飛
  • 檢查點:導覽決策放 coordinator,不塞在 View 的按鈕 closure 裡
  • 檢查點:NavigationStack 走線性,fullScreenCover 走巢狀 flow
  • 檢查點:每個 flow 對應一個使用者意圖,像 onboarding、練習、複習
  • 檢查點:先想好 reset、present、dismiss 的狀態邊界
圖片 Type 1:把使用者從進站到進入不同學習路徑的判斷畫成流程圖
圖片 Type 1:把使用者從進站到進入不同學習路徑的判斷畫成流程圖

我記得那次被小孩打臉後才懂 導覽不是UI長相

個人化 UX 不只換主題色或暗黑模式,個人化 UX 也包含「使用者怎麼走」與「哪些路徑被打開」。同一個 Home 版型,tab bar 給的是秩序,dashboard 給的是自由,兩種人會吵架,產品也會跟著裂開。

我記得當時做語言學習那種 side project,Home 試了兩套。傳統 tab。另一套像儀表板,功能一口氣攤開。測試者很小。反應很真。真的。

一個喜歡 tab。因為不會迷路。另一個喜歡全都看得到,想去哪就點哪。你看。使用者不是「學不學得會」的問題。是他想走的路不同。

講到「想走的路」,我就會想到很多團隊最愛的那句:先把 IA 做漂亮。做完才發現漂亮是給自己看的。使用者只在意他按下去會不會到目的地。就這樣。

圖片 Type 2:把「tab 型使用者」與「dashboard 型使用者」偏好整理成 2x2 懶人包
圖片 Type 2:把「tab 型使用者」與「dashboard 型使用者」偏好整理成 2x2 懶人包

FlowNode 把每個畫面變成可宣告的狀態 不用猜

FlowNode 是一個 Hashable 的 enum,用來表示「目前所在畫面」與「該畫面需要的關聯資料」。FlowNode 讓導覽從 push/pop 的命令式操作,變成更新狀態的宣告式切換,deep link 也比較能對得起型別系統。

你把每個 screen 當成 case。login。welcomeSetup。modeSelection。topicPrep。greeting(mode: LearningMode)。speaking(mode: LearningMode)。savedList。settings。看起來像清單。其實是你的「導覽語言」。

我自己最在意的是 associated value 這個點。像 greeting(mode: )、speaking(mode: )。你把 mode 綁在狀態上。畫面就不用自己去猜「我現在是 review 還是 topic talk」。那種猜來猜去,最後一定變成 if else 亂舞。很醜。也很難測。

enum FlowNode: Hashable {
    case login
    case welcomeSetup
    case modeSelection
    case topicPrep
    case greeting(mode: LearningMode)
    case speaking(mode: LearningMode)
    case savedList
    case settings
}

然後你做一個 mapping。view(for:context:)。把狀態映射成 View。集中管理。不要每個 View 都帶一段「我要去哪」的邏輯。

這裡我會碎念一下。型別安全(編譯期就能抓錯)不是為了炫技。是為了減少 runtime navigation error。那種 error 在 QA 才炸。你又要追 stack。又要猜 path 裡面塞了什麼。很累。

把畫面當成型別,導覽就不再靠祈禱。

圖片 Type 3:多視角示意 FlowNode 與 view mapping 與資料流
圖片 Type 3:多視角示意 FlowNode 與 view mapping 與資料流

FlowController 管 path 與 modal flow View 只負責長相

FlowController 是 ObservableObject,負責管理 NavigationPath(堆疊路徑)與 presentedFlow(巢狀 modal flow)。FlowController 提供 go、reset、presentFlow、dismissModalFlow 這些操作,讓 View 保持被動,導覽邏輯集中可測。

我記得第一次把 @Published var path = NavigationPath() 拉出來的時候,整個人鬆一口氣。因為 View 終於不用在按鈕裡面塞「下一步要去哪」。

你可以 go(to:)。就是 append node。你可以 reset(to:)。把 path 清空。startNode 換掉。modal 也清掉。那個 reset 超像「回到入口重跑一遍」,用在 onboarding 特別順。

final class FlowController: ObservableObject, Identifiable {
    @Published var path = NavigationPath()
    @Published var startNode: FlowNode
    @Published var presentedFlow: FlowController?

    func go(to node: FlowNode) { path.append(node) }

    func reset(to route: AppRoute) {
        path = NavigationPath()
        startNode = route.entryNode
        presentedFlow = nil
    }

    func presentFlow(startingAt route: AppRoute) {
        self.presentedFlow = FlowController(startAt: route)
    }

    func dismissModalFlow() { self.presentedFlow = nil }
}

講到 modal,我腦子會跳去一個老毛病。很多人把 fullScreenCover 當「另一種 push」。結果關閉時狀態沒收乾淨。殘留。下一次打開又跑到奇怪的地方。使用者會覺得 app 鬧鬼。

風險提醒:如果你讓 View 自己持有導覽狀態,畫面一多,狀態就散到各處,debug 會像在找襪子。找不到那只。你明明記得有洗。

risk_matrix:Flow-based 導覽導入時常見風險與對策
風險 觸發情境 影響 嚴重度 緩解方式
FlowNode 膨脹 把所有畫面塞同一個 enum 編譯慢、switch 爆長、協作痛 依意圖拆成多個 flow 的 node 集合,入口用 AppRoute 聚合
associated value 過度複雜 把大坨 model 直接塞進 case Hashable 困難、狀態不可預期 只塞識別用資料,重物件放在 store 或 context 取用
modal flow 退出不乾淨 dismiss 沒清 presentedFlow 回到舊狀態、重入錯亂 集中在 FlowController 做 dismissModalFlow,禁止 View 私自改狀態
deep link 對不上畫面狀態 用字串 route 或臨時 mapping 路徑不穩、容易 runtime crash 用型別化 route 與 node,建立單一 mapping 入口
測試寫不下去 導覽散在 View closure 只能跑 UI test,慢又脆 針對 flow state 做單元測試,模擬 handle event 驗證 path

FlowRootView 就是一個小引擎 導覽可以遞迴

FlowRootView 用 NavigationStack 綁定 FlowController.path,並用 navigationDestination 針對 FlowNode 渲染畫面。FlowRootView 也用 fullScreenCover 呈現 presentedFlow,讓每個 modal flow 以巢狀的 FlowRootView 自我運行。

這段其實很漂亮。也很危險。漂亮是因為你把「導覽渲染」集中在一個容器。危險是你一不小心就會把所有責任都丟進去,然後它變成新上帝物件。

struct FlowRootView: View {
    @ObservedObject var controller: FlowController
    let context: FlowContext

    var body: some View {
        NavigationStack(path: $controller.path) {
            FlowNode.view(for: controller.startNode, context: context)
                .navigationDestination(for: FlowNode.self) { screen in
                    FlowNode.view(for: screen, context: context)
                }
        }
        .fullScreenCover(item: $controller.presentedFlow) { modalController in
            FlowRootView(
                controller: modalController,
                context: FlowContext(controller: modalController)
            )
        }
    }
}

你看。modal 也是 FlowController。它不是例外。它只是另一個 flow。巢狀。自包含。關掉就丟掉。乾淨。

圖片 Type 4:比較 View-owned navigation vs Flow-based coordinator 的差異
圖片 Type 4:比較 View-owned navigation vs Flow-based coordinator 的差異

意圖驅動設計 讓每個 FlowController 只管一件事

Intent-Driven Design 是把 FlowController 對齊使用者目標的做法,例如 onboarding、練習口說、複習收藏。每個 flow 只處理一個目標,狀態轉換就更可觀測,也更容易寫測試,架構上接近 VIPER 的 Interactor 但不強迫加 Presenter 或 Router。

我講白一點。你如果把 onboarding、settings、練習、付款全部塞進同一條導覽線,最後會變成「一個人扛全家的家務」。那個人就是你。你會爆。

VIPER 的影子在。Interactor 很像「某個用例的掌管者」。差別是 SwiftUI 已經很會渲染。你不一定要再塞 Presenter 去轉字串。你只要守住導覽邊界。守住就好。

一個 flow 對應一個意圖,App 才不會像迷宮還硬說自己是探索。

然後我會突然想到台灣的開發現場。PM 喜歡加一個「快速入口」。設計師又想要一個「推薦卡片」。工程師被迫把入口塞進 Home。你用 tab 的話,入口很固定。你用 flow-based 的話,你可以依「進度」或「角色」改路徑。這才是個人化真的會用到的地方。

工具:Xcode 的 SwiftUI Previews 對 flow 其實蠻好用,你可以用不同 startNode 做 preview,快速看每條路徑第一屏長怎樣。然後單元測試就針對 FlowController 的 path 變化寫。不要只靠 UI test。會老。

分眾決策建議 你別再一套導覽想吃全世界

不同族群對「入口」的容忍度不一樣。你做 flow-based 導覽,是為了讓路徑可組裝,不是為了寫更多程式碼取悅自己。

  • 外食族:如果使用者常常零碎時間打開 App,入口要短。If 打開後 10 秒內就想開始練習 Then 把「繼續上次」做成 flow 的直達入口,減少 modeSelection 的停留。
  • 夜班族:如果使用者作息顛倒、注意力破碎 Then 把 dashboard 的「一眼看到所有」留著,tab 也保留,讓他用習慣的方式走,flow 用來調整順序而不是逼他學新路。
  • 親子共用:如果一支手機多個人輪流用 Then FlowNode 要帶上 profile 或 session 的識別,reset 要乾淨,避免 A 小孩練到一半,B 小孩進來看到 A 的 speaking(mode: )。
  • 銀髮:如果使用者怕迷路 Then tab 的穩定感要給,flow coordinator 做「防呆回收」,像是任何 modal 結束都回到明確的 savedList 或 home node。

你看。這些其實都不是 UI 皮膚。是路徑。是「下一步」。講到這裡我又想碎念,個人化不是推薦系統的專利。導覽也能個人化。也該。

圖片 Type 5:常見誤解與新理解的雙欄對照
圖片 Type 5:常見誤解與新理解的雙欄對照

FAQ 你會卡的點 我先幫你碎念完

規則:每題先直答,後面才補一句碎念。

FlowNode 一定要用 enum 嗎?

FlowNode 不必限定 enum,但 FlowNode 必須是可 Hash、可比較的型別,讓 NavigationStack 的 navigationDestination 能穩定映射畫面,deep link 與測試也能直接對狀態下手。

你用 struct 也行。你別用字串。拜託。

FlowController 會不會變成新的上帝物件?

FlowController 會變肥的原因是把多個使用者意圖塞同一個 controller,解法是按 Intent-Driven Design 拆 flow,並讓每個 controller 只管一段目標路徑與狀態轉換。

胖了就拆。別硬撐。

fullScreenCover 的巢狀 flow 會不會很難 debug?

fullScreenCover 的巢狀 flow 只要把 presentedFlow 當成另一個 FlowController,並集中用 dismissModalFlow 清狀態,debug 反而更直覺,因為每個 flow 的 path 都是獨立的。

最怕的是你關了 modal,狀態還在。那才可怕。

這套對測試到底幫在哪?

這套把導覽變成可觀測的狀態轉換,測試可以直接對 FlowController.path 與 startNode 做斷言,模擬 go/reset/presentFlow 的結果,不必每次都跑 UI 測試才能驗證路徑。

跑 UI test 跑到天荒地老。你會懂。

要做 GraphQL 或 feature flag 驅動的路徑,會卡嗎?

GraphQL 或 feature flag 要驅動路徑時,只要後端或設定層回傳「意圖與節點序列」,前端用型別化的 AppRoute 轉成 FlowNode 與 flow 組合,就能維持型別安全與可測性。

你別直接回傳「要跳哪個 View 名字」。那是災難。

免責:本文談的是 SwiftUI 導覽架構與工程實作取捨,不構成任何商業承諾或效能保證;實際行為仍取決於專案規模、團隊協作方式與版本相容性。

小挑戰:你挑一條你專案裡最亂的導覽路徑。就一條。把它改成 FlowNode + FlowController。寫一個測試只驗證 path 的變化。跑過就收工。明天再擴。你不要一次想把整個 app 救起來。會累死。

Related to this topic:

Comments

撥打專線 LINE免費通話