SwiftUI Composable Architecture 實作入門:打造第一個簡易 App 的完整步驟

Published on: | Last updated:

所以,TCA 到底好在哪裡?

最近在看 The Composable Architecture,就是大家說的 TCA。很多人說它好,但到底好在哪?看理論有點... 抽象。State、Action、Reducer... 聽起來很厲害,但沒個對照組,感覺不太踏實。

我自己是覺得,學這種東西最好的方法,就是找個爛例子,然後一步步把它變好。這樣最能看出差別。

所以今天來試試看。我們從一個超級單純的 SwiftUI App 開始,一個 BMI 計算機。然後,把它慢慢重構成 TCA 的架構。這樣就能很清楚看到「改造前」跟「改造後」的樣子。

先說結論

簡單講,TCA 把原本混亂的「狀態管理」跟「業務邏輯」,全部收進一個叫 Reducer 的地方統一管理。你的 View 就真的只剩下畫面的事,變得很乾淨、很好維護,也好測試。

第一步:純 SwiftUI 的起點

先來做個最原始的版本。沒有什麼特別的架構,就是用 Xcode 內建的 SwiftUI 樣板來寫。這樣我們才知道我們要解決什麼問題。

這是一個 BMI 計算機,功能很簡單:

  • 輸入身高、體重
  • 即時算出 BMI 跟對應的等級
  • 可以把紀錄存起來
  • 可以查看歷史紀錄

首先是 Model,這部分很單純,就是定義 BMI 這個資料長什麼樣子。

import Foundation

struct BMI: Equatable, Identifiable {
    let id = UUID()
    let height: Double // cm
    let weight: Double // kg
    let date: Date

    var value: Double {
        let heightInMeters = height / 100
        return weight / (heightInMeters * heightInMeters)
    }
    
    // 這邊的分類是參考 WHO 標準
    var category: BMICategory {
        switch value {
        case ..<18.5:
            return .underweight
        case 18.5..<25:
            return .normal
        case 25..<30:
            return .overweight
        default:
            return .obese
        }
    }

    var formattedValue: String {
        String(format: "%.1f", value)
    }
}

enum BMICategory: String, CaseIterable {
    case underweight = "過輕"
    case normal = "正常"
    case overweight = "過重"
    case obese = "肥胖"
}

對了,這邊的 BMI 分級是用世界衛生組織 (WHO) 的標準。不過在台灣,我們國健署的建議有點不一樣喔,比如「正常」範圍是 18.5 到 24 之間,然後「過重」是 24 到 27。所以在做本地 App 的時候,這種小細節就要記得調整,這是使用者體驗的一環。

然後是 View,所有東西都塞在 `ContentView.swift` 裡。

import SwiftUI

struct ContentView: View {
    @State private var height: String = ""
    @State private var weight: String = ""
    @State private var bmiHistory: [BMI] = []
    @State private var showingHistory = false

    // 業務邏輯:計算 BMI
    var calculatedBMI: BMI? {
        guard let heightValue = Double(height),
              let weightValue = Double(weight),
              heightValue > 0,
              weightValue > 0 else {
            return nil
        }
        return BMI(height: heightValue, weight: weightValue, date: Date())
    }

    var body: some View {
        NavigationView {
            Form {
                // ... 省略一堆 UI layout code ...
                
                // 顯示結果,這也算 UI 邏輯
                Section {
                    if let bmi = calculatedBMI {
                        // ...
                    } else {
                        Text("輸入身高體重來計算")
                    }
                }

                // 按鈕跟互動邏輯
                Section {
                    Button("儲存紀錄") {
                        if let bmi = calculatedBMI {
                            bmiHistory.append(bmi) // 直接修改狀態
                            showingHistory = true   // 直接修改狀態
                        }
                    }
                    .disabled(calculatedBMI == nil) // 狀態決定 UI

                    // ...
                }
            }
            .navigationTitle("BMI 計算機")
            .sheet(isPresented: $showingHistory) {
                BMIHistoryView(bmiHistory: $bmiHistory) // 把狀態傳下去
            }
        }
    }
}

// 另一個 View,接收來自上層的 Binding
struct BMIHistoryView: View {
    @Binding var bmiHistory: [BMI]
    // ...
}
純 SwiftUI 開發初期,邏輯容易全部混在一起。
純 SwiftUI 開發初期,邏輯容易全部混在一起。

好了,這段 code 能跑,功能也正常。但問題很明顯...

你看,`ContentView` 裡面有 `@State` 變數、有計算 BMI 的 `calculatedBMI`、有 UI layout、還有按鈕按下去之後要幹嘛的邏輯... 全部混在一起。

這就是所謂的「關注點沒有分離」。現在 App 還小,感覺還好。但功能一多,這個檔案會變得超級大,然後你改一個小東西,很可能就動到其他不相干的邏輯,超可怕。

而且,你怎麼測 `calculatedBMI` 這段邏輯?幾乎沒辦法... 總不能為了測這個,就跑一個 UI 測試吧?太重了。

第二步:用 TCA 來重構

好,現在開始手術。目標是把上面那個混亂的 `ContentView` 整理乾淨。

首先,當然是要把 TCA 的 package 加到專案裡。這部分就去 Xcode 裡 `File > Add Package Dependencies`,然後貼上 TCA 的 GitHub 網址 `https://github.com/pointfreeco/swift-composable-architecture` 就行了。

拆解 Feature

TCA 的核心思想是把 App 拆成一個個獨立的「Feature」。我們這個 App 可以拆成兩個:

  1. 輸入 Feature:就是計算 BMI 的主畫面。
  2. 歷史 Feature:顯示歷史紀錄的列表。

我們先專心處理「輸入 Feature」。

1. 定義 State、Action、Reducer

TCA 的標準起手式,就是定義這三樣東西。我會把它們放在同一個檔案 `BMIInputReducer.swift` 裡,這樣比較好管理。

import ComposableArchitecture
import Foundation

@Reducer
struct BMIInputReducer {
    // MARK: - State
    @ObservableState
    struct State: Equatable {
        var height: String = ""
        var weight: String = ""
        
        // 衍生狀態 (Computed Properties)
        var calculatedBMI: BMI? {
            guard let heightValue = Double(height),
                  let weightValue = Double(weight),
                  heightValue > 0,
                  weightValue > 0 else {
                return nil
            }
            return BMI(height: heightValue, weight: weightValue, date: Date())
        }
    }
    
    // MARK: - Action
    enum Action {
        case heightChanged(String)
        case weightChanged(String)
        case saveButtonTapped
    }
    
    // MARK: - Reducer
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case let .heightChanged(height):
                state.height = height
                return .none

            case let .weightChanged(weight):
                state.weight = weight
                return .none

            case .saveButtonTapped:
                // 這邊先留空,之後處理儲存的副作用
                guard let bmi = state.calculatedBMI else { return .none }
                print("準備儲存 BMI: \(bmi.formattedValue)")
                return .none
            }
        }
    }
}

你看,發生了什麼事?

  • State:原本 View 裡那些 `@State` 變數,還有那個 `calculatedBMI`,全部都搬到 `State` 這個 struct 裡面了。這就是這個 Feature 所有需要的資料。
  • Action:所有可能發生的事情,都定義成 `Action`。使用者輸入身高,就是一個 `heightChanged` action;按下儲存,就是 `saveButtonTapped` action。非常語義化。
  • Reducer:這就是大腦。它只做一件事:接收目前的 `state` 和一個 `action`,然後告訴系統 `state` 應該怎麼改變。它是一個純函數,沒有副作用,這讓它變得超好測試。

2. 改造 View

邏輯都搬走了,那 View 就變得很單純。它只需要做兩件事:顯示 `State` 提供的資料,以及在使用者操作時發送 `Action`。

import SwiftUI
import ComposableArchitecture

struct BMIInputView: View {
    @Bindable var store: StoreOf<BMIInputReducer>

    var body: some View {
        Form {
            Section("輸入你的資料") {
                HStack {
                    Text("身高")
                    Spacer()
                    TextField("170", text: $store.height.sending(\.heightChanged))
                        .keyboardType(.decimalPad)
                        // ...
                    Text("cm")
                }

                HStack {
                    Text("體重")
                    Spacer()
                    TextField("70", text: $store.weight.sending(\.weightChanged))
                        .keyboardType(.decimalPad)
                        // ...
                    Text("kg")
                }
            }

            Section("計算結果") {
                if let bmi = store.calculatedBMI {
                    // ... 顯示 BMI 結果 ...
                } else {
                    Text("請輸入身高與體重")
                }
            }

            Section {
                Button("儲存紀錄") {
                    store.send(.saveButtonTapped)
                }
                .disabled(store.calculatedBMI == nil)
            }
        }
        .navigationTitle("BMI 計算機")
    }
}

乾淨很多吧。`TextField` 的 `text` 綁定 `$store.height.sending(\.heightChanged)`,這行程式碼的意思是:把輸入框的文字跟 `store` 裡的 `height` 雙向綁定,而且只要文字一變,就自動發送一個 `.heightChanged` action 給 Reducer。完全不用自己寫 `.onChange`。

按鈕也是,`action` 裡面就只有一行 `store.send(.saveButtonTapped)`,就是「通知大腦,使用者按了儲存鈕」,至於後續要做什麼,View 完全不管。

第三步:處理副作用 (Dependencies)

剛剛 `saveButtonTapped` 的地方我們留空了。因為「儲存」這件事,它不是單純改變 `state`,它要去跟外部系統溝通(可能是 `UserDefaults`、Core Data 或網路 API)。這種事在 TCA 裡叫做「副作用」(Side Effect),要用 `Dependency` 來處理。

簡單說,就是定義一個「Client」,把所有存取歷史紀錄的動作都包在裡面。

import Foundation
import Dependencies

// 定義一個抽象的 client,包含我們需要的功能
struct BMIHistoryClient {
    var loadHistory: @Sendable () -> [BMI]
    var saveBMI: @Sendable (BMI) -> Void
}

// 實作一個 "Live" 版本,也就是 App 正式執行時要用的版本
// 這裡為了簡單,先用一個記憶體內的陣列來存,實際上可以換成任何資料庫
extension BMIHistoryClient: DependencyKey {
    static let liveValue = Self(
        loadHistory: { InMemoryBMIStorage.shared.loadHistory() },
        saveBMI: { bmi in InMemoryBMIStorage.shared.saveBMI(bmi) }
    )
}

// 把它註冊到 TCA 的 DependencyValues 系統裡
extension DependencyValues {
    var bmiHistoryClient: BMIHistoryClient {
        get { self[BMIHistoryClient.self] }
        set { self[BMIHistoryClient.self] = newValue }
    }
}

// 一個簡單的單例來模擬資料庫
private class InMemoryBMIStorage {
    static let shared = InMemoryBMIStorage()
    private var history: [BMI] = []
    func loadHistory() -> [BMI] { history }
    func saveBMI(_ bmi: BMI) { history.append(bmi) }
}

這樣做的好處是,Reducer 不用知道資料到底存在哪。在正式 App,它用 `liveValue` 存到記憶體;在跑測試的時候,我們可以注入一個 `testValue`,讓它存到一個我們指定的陣列裡,方便驗證結果。這就是「依賴注入」。

然後在 Reducer 裡把它叫出來用:

@Reducer
struct BMIInputReducer {
    // ... State 跟 Action ...

    @Dependency(\.bmiHistoryClient) var bmiHistoryClient // 宣告依賴

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            // ... 其他 case ...

            case .saveButtonTapped:
                guard let bmi = state.calculatedBMI else { return .none }
                // 使用 client 來執行副作用
                bmiHistoryClient.saveBMI(bmi) 
                return .none
            }
        }
    }
}

這樣一來,Reducer 還是保持純粹,它只是「呼叫」一個外部服務,本身沒有做 I/O 操作。

使用 TCA 後,專案結構變得清晰,每個 Feature 都有自己的家。
使用 TCA 後,專案結構變得清晰,每個 Feature 都有自己的家。

第四步:組合 Features

我們現在有了一個獨立的 `BMIInput` feature。同理,我們可以再做一個 `BMIHistory` feature,它有自己的 State(歷史紀錄陣列)、Action(`onAppear` 讀取資料、`delete` 刪除資料)和 Reducer。

最後,需要一個「老大哥」把這兩個 feature 組起來。這通常叫做 `AppReducer`。

@Reducer
struct AppReducer {
    // 老大哥的 State 包含所有小弟的 State
    @ObservableState
    struct State: Equatable {
        var selectedTab: Tab = .input
        var bmiInput = BMIInputReducer.State()
        var bmiHistory = BMIHistoryReducer.State()
    }

    // 老大哥的 Action 也包含所有小弟的 Action
    enum Action {
        case tabSelected(Tab)
        case bmiInput(BMIInputReducer.Action)
        case bmiHistory(BMIHistoryReducer.Action)
    }

    var body: some ReducerOf<Self> {
        // 用 Scope 把小弟的 State 和 Action 對應起來
        Scope(state: \.bmiInput, action: \.bmiInput) {
            BMIInputReducer()
        }
        Scope(state: \.bmiHistory, action: \.bmiHistory) {
            BMIHistoryReducer()
        }

        Reduce { state, action in
            // 處理只有老大哥關心的事:跨 feature 的溝通
            switch action {
            case .bmiInput(.saveButtonTapped):
                // 當「輸入」feature 說它存檔了...
                // 我們就命令 App 切換到「歷史」分頁
                state.selectedTab = .history
                // 並且順便叫「歷史」feature 重新整理一下資料
                return .send(.bmiHistory(.onAppear))

            case let .tabSelected(tab):
                state.selectedTab = tab
                return .none
            
            // 其他小弟自己的事,就不用管了
            case .bmiInput, .bmiHistory:
                return .none
            }
        }
    }
}

這就是 TCA 最強大的地方之一:`Scope`。它像一個轉接頭,把來自 `AppView` 的 action (`.bmiInput(...)`),正確地轉發給 `BMIInputReducer` 處理。`BMIInputReducer` 完全不知道自己是活在一個 TabView 裡面,它可以被重複用在任何地方。

然後 `AppReducer` 自己還可以處理一些跨 feature 的溝通。像是上面那段,當 `BMIInputReducer` 發出 `.saveButtonTapped` 這個 action,`AppReducer` 攔截到之後,就去改變 `selectedTab`,實現了「存檔後自動跳轉到歷史頁面」的功能。而且這個跳轉邏輯寫得非常清楚。

TCA 讓 App 的內部資料流像一個清晰的迴路,不再混亂。
TCA 讓 App 的內部資料流像一個清晰的迴路,不再混亂。

所以,到底差在哪?

說了這麼多,我們來直接比較一下,這樣最有感。

比較項目 純 SwiftUI (@State) Composable Architecture (TCA)
狀態管理 用 @State,變數散落在 View 裡面,東一個西一個,很亂。 全部集中在 State struct 裡,像個專屬的資料庫,一目瞭然。
業務邏輯 直接寫在 View 的 computed property 或 function 裡,跟畫面程式碼混在一起。 全部關在 Reducer 裡面,是一個獨立的純函數,跟畫面完全分開。
可測試性 很難測。邏輯跟 UI 綁死,總不能為了測一個計算,就去跑 UI 測試吧... Reducer 超級好測。就給它一個 state 跟一個 action,看它回傳的新 state 對不對就好。
資料流 有點像蜘蛛網。狀態從哪來、被誰改掉,常常要追半天。 非常清楚的單向資料流。Action → Reducer → State → View,一個圈圈,很好追蹤。
功能擴充 有點怕。加一個新功能,常常要動到舊的 View,深怕改壞什麼。 比較放心。新功能可以做成獨立的 Feature,再用 Scope 組起來,影響範圍很小。
副作用 直接在 View 的 action closure 裡呼叫 API 或存資料庫,測試時很麻煩。 用 @Dependency 抽離出來,正式跑用 live 版,測試時可以換成 mock 版。

一些常見的疑惑

我知道,剛看到 TCA 的 code 會覺得...「蛤?寫一個簡單功能要多這麼多檔案跟程式碼?」。

這很正常。我自己一開始也這樣想。

TCA 的確增加了不少「樣板程式碼」(Boilerplate)。對於一個只有一兩個畫面的超小型 App,用 TCA 可能真的有點殺雞用牛刀。你可能會覺得純 SwiftUI 還比較快。

但只要你的 App 開始變複雜,比如有五個以上的分頁、需要登入、有複雜的表單、需要跟後端 API 大量溝通... TCA 的優勢就會完全展現出來。它前期的「投資」會在後期維護和擴充功能時,加倍奉還給你。

它強迫你用一種有紀律、可預測的方式去寫 code。長期來看,這會讓你的專案健康很多。


換你思考看看:

你覺得這種架構,對於小型、可能不會再擴充的專案來說,是加分還是有點殺雞用牛刀?在下面留言分享你的看法吧!

Related to this topic:

Comments

撥打專線 LINE免費通話