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 對不對就好。
資料流 有點像蜘蛛網。狀態從哪來、被誰改掉,常常要追半天。 非常清楚的單向資料流ActionReducerStateView,一個圈圈,很好追蹤。
功能擴充 有點怕。加一個新功能,常常要動到舊的 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免費通話