SwiftUI TCA簡化BMI計算APP開發流程,輕鬆管理狀態與歷史紀錄

核心行動建議 - 幫你用 TCA 提升 SwiftUI BMI APP 的維護效率與互動彈性

  1. 優先拆分 BMI 計算、歷史查詢等功能,單一 Reducer 控管對應 State。

    功能集中管理可減少狀態混淆,降低日後調整出錯機率。

  2. 設定每次資料異動自動同步至儲存層,不超過 1 秒內完成。

    即時保存 BMI 歷史紀錄,用戶切換畫面也不怕資料遺失。

  3. 限制每個 View 僅訂閱必要 State 欄位,避免全域監控。

    最小化無謂重繪,App 流暢度明顯提升[2][3]。

  4. BMI 輸入流程明確規劃 Action,每次變更都觸發狀態檢查與 UI 更新。

    `一步一回饋` 增加互動感,用戶操作更直觀省時。

瞭解 SwiftUI Composable Architecture 核心概念與應用

Swift Composable Architecture(TCA),這東西的核心啊……坦白講,主要就是 `State`、`Action`、`Reducer` 跟 `Store` 這幾個元件,再加上它們彼此那種奇特的資料流轉方法。前面在 [SwiftUI Composable Architecture: 1 - Introduction] 已經有稍微聊過那套理論,不過老實說,光看概念會飄掉啦。今天要動手示範:直接從零拼一個 BMI 計算器 App,而且會先用純 SwiftUI 填出底稿,等一下才一段段重構到 TCA 架構裡。所以為什麼要「改造前後」這樣對比?欸——沒看就真的不知道差多大。

拉回正題。你搞到文章尾巴時,大概可以曉得:
- 怎麼靠 TCA 拆分功能模組(不然真的容易卡關)
- 狀態怎麼控管?行為怎麼丟進來處理?
- 外部系統那套依賴注入…咳,很重要但新手通常亂掉
- 各個小功能間能不能溝通清楚?其實有套路
- 至於大專案嘛——該注意哪些最佳習慣

整體專案設計抓重點:
- 做一個能驗證輸入、不至於出錯的 BMI 小工具,好像挺無聊…
- 給你查歷史紀錄,也許很少人用但就是標配
- 可以切分頁,每個子功能各自安好(咦?)
- 結構不能打結,要容易維護,大型團隊其實最怕這種事

嘿,所以流程上,一律按照「階段」然後再塞進「步驟」排序,反正照著走,多數情況都摸得到門路。

第一階段:用最原生 SwiftUI 實作
啟動這部分,是故意讓大家用傳統寫法拼出基礎 App,你可能開始覺得一切還 OK,可是後面就知道 TCA 怎解圍。有沒有強烈比較感,那才真叫學得到精髓喔。

至於開專案環境…步驟我隨便寫,不過請耐心看一下:
1. 開啟 Xcode → 點「新增專案」→ 選擇 iOS 平台 → 選 App。
2. 隨便吧,我取名 "BMICalculator" ,每次都這名稱也怪煩,但懶得換。
3. 不要忘了!記得選 Use SwiftUI。(有時候莫名就勾錯)

BMI 模型怎做?別緊張,再給一次詳細流​​程讓大家死記:
1. 專案檔內空白處按右鍵 → 選「新增檔案」→ 指定 Swift File 格式。
2. 檔名敲 `BMI.swift` 。拜託別少打一碼,不然自己找麻煩。

啟動專案並打造基本 BMI Calculator 範本

唔,這段東西其實,老實講,一開始還蠻容易卡住的。你如果沒有用過 TCA(The Composable Architecture),大多數人都會先照著 SwiftUI 的本來那套方式寫程式 —— 就是自己去建一個 BMI 結構,有個 BMICategory 枚舉嘛,每次算一下身高體重、什麼時候記錄、然後自動吐出分類,反正東西全包在一起。有些人可能第一次搞表單輸入覺得麻煩,其實很快就會發現 ContentView 裡頭那種用 @State 綁定然後 Form 裡抓資料,用戶輸入資訊啦、計算 BMI 這塊還有歷史紀錄通通一起解決。好像挺順,但有時細想……嗯?查紀錄、刪記錄也都混在這啊?

不過真的說來,像這種用 SwiftUI 自帶的狀態綁定+基本事件流程玩下去,一開始小專案行,可以做完還頗有成就感(如果你不是特別討厭寫表單)。可是一旦功能膨脹,很多事馬上浮現問題:譬如 UI 跟邏輯兜在一起,下意識職責混亂;再者,那些所有的運作細節──全部貼死在畫面狀態內,要寫單元測試幾乎是惡夢級困難。另外只要哪裡同時能改狀態,錯誤來源超難查,而且整體沒什麼「流」的架構性可言。

對了,文中直接點破了啦——這一套只合小規模玩票或新手自學。如果哪一天你 App 規模一大、需求變複雜,「慣用方案」維護痛苦指數暴增。(嗯,我真的踩過…)所以呢,作者接下來乾脆打算直接讓 BMI 計算器導進 TCA,只針對這塊先試驗看看,用這個框架修補上述問題,到底會不會比較爽,比較清楚。

至於怎麼安裝 The Composable Architecture,其實很死板但得照規矩走:Xcode 開起來,選 File,再按 Add Package Dependencies,就一步步灌進專案裡面,不太可能搞錯。

啟動專案並打造基本 BMI Calculator 範本

建立 BMI 模型並掌握 SwiftUI 初始架構

嗯,這步驟沒想像中複雜,不過講起來還是有點冗贅(腦袋剛睡醒)。專案(BMICalculator)裡你先設個 "Features" 資料夾,然後在這下面再開一層叫 "Input" 的新資料夾。嘖…別忘了在專案根目錄那兒直接多建一個 "Models" 資料夾,把 `BMI.swift` 整包搬到那裡——不要漏哦,我以前常會放錯位置。

結果弄完應該是長這副德性:

BMICalculator/
├── Models/
│ └── BMI.swift
├── Features/
│ └── Input/
├── ContentView.swift
└── BMICalculatorApp.swift

然後關於 BMI Input reducer,要動手就照下列方式做:跑去 "Features/Input",右鍵新增 Swift File,檔名打 `BMIInputReducer.swift`,內容塞進下面那串程式(我也納悶每次都得複製貼上這段,但唉,就是如此)。

swift
import ComposableArchitecture
import Foundation


@Reducer
struct BMIInputReducer {
@ObservableState
struct State: Equatable {
var height: String = ""
var weight: String = ""
var isSaveButtonEnabled: Bool = false
var showingSaveAlert: Bool = false

var heightValue: Double? {
Double(height)
}

var weightValue: Double? {
Double(weight)
}css
var calculatedBMI: BMI? {
guard let heightValue = heightValue,
let weightValue = weightValue,
heightValue > 0,
weightValue > 0 else {
return nil
}
return BMI(
height: heightValue,
weight: weightValue,
date: Date()
)
}


mutating func updateSaveButtonState() {
isSaveButtonEnabled = calculatedBMI != nil
}
}

檢視傳統寫法面臨的挑戰與維護困難

`Equatable` 這工具,嗯...它就像是幫忙判斷某東西有沒有改變的門檻吧。TCA 利用 `Equatable` 去比對新舊狀態,只要比較完發現一模一樣,畫面根本不用更新。有點偷懶的做法?其實反過來看,這招可以擋掉很多沒意義的重繪,在應用程式越大時這份「偷懶」意外地蠻重要——你說奇怪嗎?

### 步驟2:定義 Actions

Actions,就是每一個功能裡頭可能會出現的所有事件啦:

swift
@Reducer
struct BMIInputReducer {
// 前一個 State 的程式碼
enum Action {
case heightChanged(String)
case weightChanged(String)
case saveButtonTapped
case dismissAlert
case alertDismissed(Bool)
}
}


簡單講,Action 就好像 app 裡面的大小事,不過也僅止於陳述「剛才發生了啥」;至於怎麼回應,那還沒輪到他們決定。比如,當使用者按下 `saveButtonTapped` 或系統觸發 `alertDismissed` 時,我們都先切分開來思考。啊,你想得很細,是不是有點複雜?

### 步驟3:實作 Reducer

Reducer,就是處理腦袋運算邏輯的大本營啦:

@Reducer
struct BMIInputReducer {
// 前面的 State 程式碼
// 前面的 Action 程式碼
var body: some ReducerOf
 {
Reduce { state, action in
switch action {
case let .heightChanged(height):
state.height = height
state.updateSaveButtonState()
return .none

<pre><code class="language-css">case let .weightChanged(weight):
state.weight = weight
state.updateSaveButtonState()
return .nonecss
case .saveButtonTapped:
guard let bmi = state.calculatedBMI else { return .none }
// 儲存相關流程晚點再加上去喔
state.showingSaveAlert = true
return .none


case .dismissAlert:
state.showingSaveAlert = false
return .none

case let .alertDismissed(isPresented):
state.showingSaveAlert = isPresented
return .none
}
}
}
}

Reducer 是純粹、冷靜(但不一定不犯錯)的函數,吃進目前狀態加 action,再 spit 出新的狀態。`.none` 表示此刻無事可做。如果遇到 API 呼叫或頁面導航等等,可以挑其他回傳值來製造副作用。不過現在就是這麼單純。

### 步驟4:更新 View

接著,就來生出負責 BMI 輸入的新介面 view 檔:

1. 在 "Features/Input" 按右鍵 → 新增 Swift 檔。
2. 名字就叫 `BMIInputView.swift` 吧。
3. 貼上這段程式:

swift
import SwiftUI
import ComposableArchitecture


struct BMIInputView: View {
@Bindable var store: StoreOf<BMIInputReducer>css
var body: some View {
NavigationView {
Form {
Section {
inputFields
} header: {
Text("Enter Your Measurements")
}
css
Section {
bmiResultView
} header: {
Text("BMI Result")
}
css
Section {
saveButton
}
}
.navigationTitle("BMI Calculator")
.alert("BMI Saved!", isPresented: $store.showingSaveAlert.sending(\.alertDismissed)) {
Button("OK") {
store.send(.dismissAlert)
}
} message: {
Text("Your BMI has been saved to history.")
}
}
}
css
@ViewBuilder
private var inputFields: some View {
HStack {
Text("Height")
Spacer()
TextField("170", text: $store.height.sending(\.heightChanged))
.keyboardType(.decimalPad)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
Text("cm")
}
css
HStack {
Text("Weight")
Spacer()
TextField("70", text: $store.weight.sending(\.weightChanged))
.keyboardType(.decimalPad)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
Text("kg")
}
}


@ViewBuilder
private var bmiResultView: some View {
if let bmi = store.calculatedBMI {
VStack(alignment: .leading) {
Text("BMI: \(bmi.formattedValue)")
.font(.title2)
.fontWeight(.bold)css
Text("Category: \(bmi.category.rawValue)")
.foregroundColor(.secondary)
}
} else {
Text("Enter your height and weight to calculate BMI")
.foregroundColor(.secondary)
}
}
css
@ViewBuilder
private var saveButton: some View {
Button(action: {
store.send(.saveButtonTapped)
}) {
Text("Save to History")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(!store.isSaveButtonEnabled)
}
}


寫到這裡,你會感覺畫面乾淨多了吧,有點慵懶直接,看起來都集中管理起來了呢。不論是驗證啊、計算還是儲存按鈕是否能按,都集中靠 reducer 跑完邏輯了,以後維護起來好多。有種鬆口氣又忍不住想繼續拆更細小功能的衝動……(噢,但是記得還有提醒事項呢!)

檢視傳統寫法面臨的挑戰與維護困難

導入 TCA 建立集中式 State 與 Reducer 邏輯

老實說,這樣一改,view(視圖)基本完全不用理會商業邏輯了。就很簡單嘛,它只負責自己該顯示什麼、甚麼時機要發送 action,其它真的都拋開。現在,你看我們採用 `@Bindable` 和 `.sending(...)` 這類語法後,視圖的輸入就直接連到 reducer 那邊去了。每當使用者在畫面上有什麼操作,比方填寫資料,一下子 TCA 自己就幫你丟出像 `.heightChanged` 或 `.weightChanged` 之類的 action,reducer 收到了再按照指令去改 state(狀態)。所以啊,view 僅僅需要反映那份最新 state 就行。

目前 alert 顯示與否,也跟 state 綁在一起(showingSaveAlert),而你要把提示收掉,就發個 `.dismissAlert` action。不太需要多費腦筋。整體來說,只要 UI 有變化,都能被預期並容易寫測試;哪怕是個按鈕或彈跳提示框,都如此。

唔……想補充點個人感覺:其實這種做法還挺俐落欸!你留意到了沒?view 只是描述怎麼呈現,以及遇到哪些情境該推送那些 actions。例如 `$store.height.sending(\.heightChanged)` 就相當於建一條隨時監聽值更新又會自動送對應 action 的 binding。像是過去得手動寫 onChange,那種冗長對照程式碼全省了,不覺得好爽嗎?

### 測試我們的第一個 TCA 功能

找到 `BMIInputView.swift` 檔案最下面,插進 preview:

swift
#Preview {
BMIInputView(
store: Store(initialState: BMIInputReducer.State()) {
BMIInputReducer()
}
)
}


### 更新 ContentView

接下來暫時先改 `ContentView.swift`,讓它採用剛剛我們弄好的 TCA 視圖。

1. 請打開 `ContentView.swift`
2. 把檔案原本內容全換成底下這些:

<pre><code class="language-css">swift
import SwiftUI
import ComposableArchitecture
css
struct ContentView: View {
var body: some View {
BMIInputView(
store: Store(initialState: BMIInputReducer.State()) {
BMIInputReducer()
}
)
}
}
css
#Preview {
ContentView()

設計明確 Action 流程,提升 BMI 輸入互動性

一個很直觀的 TCA 小好處,其實是「可以超快做出不同狀態下的預覽」。比方說,我臨時想檢查一下「儲存按鈕有沒有被啟用」這件事嘛,只要初始化參數直接給個身高跟體重,預覽立刻就跳成對應狀態。是不是很省麻煩?

## 第三階段:新增相依性

現階段計算器本體都跑得還算順啦,沒什麼明顯錯誤,但事情遠遠沒結束—我要把 BMI 的歷史記錄也弄進來才行。有件事情要注意哦,在 TCA 這套裡頭,為了測試彈性跟未來可替換性,一般不建議你硬插手去碰 UserDefaults 或 Core Data,所以普遍都是借由所謂的依賴注入(dependency injection)走比較穩。

### 步驟 1:建立 Dependency Client

接下來…呃,也不確定會有人特別嫌繁瑣,但還是要先新開一個專門放依賴內容的檔案:

1. 在專案資料夾空白處點右鍵 → 新增 Swift 檔案
2. 命名選擇 `BMIHistoryClient.swift`
3. 放上底下這些程式碼:swift
import Foundation
import Dependencies
css
struct BMIHistoryClient {
var loadHistory: @Sendable () -> [BMI]
var saveBMI: @Sendable (BMI) -> Void
var deleteBMI: @Sendable (BMI.ID) -> Void
}
css
extension BMIHistoryClient: DependencyKey {
static let liveValue = BMIHistoryClient(
loadHistory: {
return InMemoryBMIStorage.shared.loadHistory()
},
saveBMI: { bmi in
InMemoryBMIStorage.shared.saveBMI(bmi)
},
deleteBMI: { id in
InMemoryBMIStorage.shared.deleteBMI(id)
}
)
}
css
extension DependencyValues {
var bmiHistoryClient: BMIHistoryClient {
get { self[BMIHistoryClient.self] }
set { self[BMIHistoryClient.self] = newValue }
}
}
python
// MARK: - 記憶體內部存取版本
class InMemoryBMIStorage: ObservableObject {
static let shared = InMemoryBMIStorage()


private var bmiHistory: [BMI] = []

private init() {}css
func loadHistory() -> [BMI] {
return bmiHistory.sorted { $0.date > $1.date }
}


func saveBMI(_ bmi: BMI) {
bmiHistory.append(bmi)
}

func deleteBMI(_ id: BMI.ID) {
bmiHistory.removeAll { $0.id == id }
}
}
雖然看起來對單純塞資料的小功能多此一舉吧?但真照著 dependency pattern 寫,只要踩過一次坑就知道值回票價了。測試時能塞 mock client,上線時想切換成 Core Data、CloudKit 都沒障礙,reducer 本身完全不用去動刀。

### 步驟 2:在 Reducer 裡面用剛寫好的依賴

好,再來調整 `BMIInputReducer.swift` 這支,加入依賴注入的實際運作方式:

@Reducer
struct BMIInputReducer {
// ... 一堆 state 定義 ...
@Dependency(\.bmiHistoryClient) var bmiHistoryClient
var body: some ReducerOf
 {
Reduce { state, action in
switch action {
case let .heightChanged(height):
state.height = height
state.updateSaveButtonState()
return .none

<pre><code class="language-css">case let .weightChanged(weight):
state.weight = weight
state.updateSaveButtonState()
return .nonecss
case .saveButtonTapped:
guard let bmi = state.calculatedBMI else { return .none }
bmiHistoryClient.saveBMI(bmi)
state.showingSaveAlert = true
return .none


case .dismissAlert:
state.showingSaveAlert = false
return .none

case let .alertDismissed(isPresented):
state.showingSaveAlert = isPresented
return .none
}
}
}
}

其實這邊利用 `@Dependency` property wrapper 搞定全部,相依性的細節會根據環境自動注入合適物件,像是在正式執行、單元測試或預覽情境都不用再操心。嗯...設計那麼抽象沒啥浪漫感,但真的遇到需求變動反而更鬆手收腳。(咦,我是不是扯太多了?總之大致就是如此啦!)

設計明確 Action 流程,提升 BMI 輸入互動性

把商業邏輯獨立於 View,享受純粹元件化開發

在加進歷史功能的時候,其實是採用一個徹底分離、但又能串起來運作的TCA架構啦。我有點懶,所以流程只記重點,還請見諒。
 
### 步驟1:History Reducer
第一件事,是在 "Features" 資料夾裡生出 "History" 這個子資料夾,然後建一個 `BMIHistoryReducer.swift` 檔案。內容大概長這樣:

swift
@Reducer
struct BMIHistoryReducer {
@ObservableState
struct State: Equatable {
var bmiHistory: [BMI] = []
var isLoading: Bool = false
}
css
enum Action {
case onAppear
case delete(at: IndexSet)
}


@Dependency(\.bmiHistoryClient) var bmiHistoryClient

var body: some ReducerOf
 {
Reduce { state, action in
switch action {
case .onAppear:
state.isLoading = true
let history = bmiHistoryClient.loadHistory()
state.bmiHistory = history
state.isLoading = false
return .none

case let .delete(at: offsets):
let bmisToDelete = offsets.map { state.bmiHistory[$0] }
for bmi in bmisToDelete {
bmiHistoryClient.deleteBMI(bmi.id)
}
state.bmiHistory.remove(atOffsets: offsets)
return .none
}
}
}
}
順便說一下,上面的reducer完全沒和輸入那邊纏在一起──它自有自己的state和action,但依賴用的是同一個client,也就是資料來源這個部分有共用。奇怪?明明很獨立、測試也方便,又不失連結性。

### 步驟2:History View
再來,要到 "Features/History" 底下新增 `BMIHistoryView.swift` 這份檔案,內容像這樣:

<pre><code class="language-css">struct BMIHistoryView: View {
@Bindable var store: StoreOf<BMIHistoryReducer>css
var body: some View {
NavigationView {
Group {
if store.isLoading {
ProgressView("Loading history...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if store.bmiHistory.isEmpty {
emptyStateView
} else {
historyList
}
}
.navigationTitle("BMI History")
.onAppear {
store.send(.onAppear)
}
}
}


@ViewBuilder
private var emptyStateView: some View {
VStack(spacing: 16) {
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.system(size: 60))
.foregroundColor(.secondary)

Text("No BMI Records")
.font(.headline)
.foregroundColor(.secondary)

Text("Calculate and save your first BMI to see it here")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}css

@ViewBuilder
private var historyList: some View {
List {
ForEach(store.bmiHistory) { bmi in
BMIHistoryRow(bmi: bmi)
}
.onDelete {
store.send(.delete(at: $0))
}
}
.listStyle(InsetGroupedListStyle())
}


}

struct BMIHistoryRow: View {
let bmi: BMI

var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(bmi.formattedValue)
.font(.title2)
.fontWeight(.bold)

Spacer()

Text(bmi.category.rawValue)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.1))
..cornerRadius(4)
}

HStack {

Text("Height: \(Int(bmi.height)) cm")
.font(.caption)
..foregroundColor(.secondary)

Spacer()

Text("Weight: \(Int(bmi.weight)) kg")
..font(.caption)
..foregroundColor(.secondary)

}

Text(bmi.date, style: .date)
..font(.caption2)

..foregroundColor(.secondary)

}

..padding(.vertical, 4)

}

}
你仔細看會發現,這裡整個view寫法基本上套用了和BMI輸入時類似的狀態管理流,全部都是讓state決定畫面跟回傳動作。小地方像loading狀態,正好展現TCA對各種UI切換的掌控力(是啊,多狀態就能明確拆開嘛)。

## Phase 5:特徵組合
最後,在往下一階段時,我們要把這些彼此獨立的小元件──也就是BMI計算表單與歷史紀錄──用父reducer包裝起來,就能變成可靈活管控、擴展維護都不頭疼的大結構了。

整合 TCA Dependency Injection 儲存歷史紀錄更彈性

在 TCA(The Composable Architecture)這種設計下,把龐大、繁瑣的 app 直接拆散成一堆聚焦又能檢驗的小區塊,還可以再很乾脆地拼湊起來。說到 SwiftUI 專案嘛,其實要讓不同螢幕溝通,有時還滿麻煩──你如果沒特別注意,大概會用 manual binding、state lifting 什麼老法子,偶爾不得已就跑去依賴 singleton services。啊,可是你只要把 TCA 用進來,邏輯一下全換了:那個父 reducer 不僅上頭攬事兒,還能調度底下所有功能區,每個模組都有它自己的規矩,也方便抽換重用。唔,就是不太累贅。

步驟1:建立 App Reducer

1. 先在專案根目錄裡建個叫「App」的資料夾。
2. 接著右鍵新建檔案→選 Swift 文件。
3. 命名好——就取 `AppReducer.swift` 吧,沒必要想太多花俏名詞。
4. 然後丟下面這段程式碼進去:

@Reducer
struct AppReducer {
@ObservableState
struct State: Equatable {
var bmiInput = BMIInputReducer.State()
var bmiHistory = BMIHistoryReducer.State()
var selectedTab: Tab = .input

enum Tab: String, CaseIterable {
case input = "Input"
case history = "History"css
var systemImage: String {
switch self {
case .input:
return "person.crop.circle.fill"
case .history:
return "chart.line.uptrend.xyaxis"
}
}
}
}
css
enum Action {
case bmiInput(BMIInputReducer.Action)
case bmiHistory(BMIHistoryReducer.Action)
case tabSelected(State.Tab)
}


var body: some ReducerOf
 {
Scope(state: \.bmiInput, action: \.bmiInput) {
BMIInputReducer()
}

Scope(state: \.bmiHistory, action: \.bmiHistory) {
BMIHistoryReducer()
}

<pre><code class="language-css">Reduce { state, action in
switch action {
case .bmiInput(.saveButtonTapped):
// 一旦有 BMI 被存下來,就自動跳轉過去歷史紀錄頁,而且同步刷新內容,不囉嗦。
state.selectedTab = .history
return .send(.bmiHistory(.onAppear))css
case let .tabSelected(tab):
state.selectedTab = tab
if tab == .history {
return .send(.bmiHistory(.onAppear))
}
return .none


case .bmiInput:
return .none

case .bmiHistory:
return .none
}
}
}
}

`AppReducer` 相當於整個 app 的主調控器。一口氣持有全部子 reducer 的狀態,你想得到的基本都包在裡面。而且透過 `Scope` 指令分流每一份動作出去,各自跑各自的小邏輯,大家井水不犯河水;同時間只要需要互動、串聯事情時(像剛才 saveButtonTapped 那件事),由父級出手幫忙切標籤和觸發刷新,所以不用每個 module 自己搞死自己搞互綁。不覺得這設計比什麼東拼西湊、一條龍還直觀嗎?測試變得順手,要改功能也不用一直解 bug,好像終於有種 “嗯,可以呼吸”的感覺。話說回來啦,其實 TCA 強調那種靈活組裝、彈性調整,也是省心省力,誰都受得住啦。

整合 TCA Dependency Injection 儲存歷史紀錄更彈性

學會 TCA 下多功能組合:BMI 計算與歷史查詢協作

幾行 Scope 的程式碼,就可以讓各種功能元件像樂高那樣組起來。其實,它們還是互不干擾,各自過活,只是有點「爸媽」(父層)在上面調度大家罷了。這些東西本身都是可拆、可疊加——獨立循環,彼此間沒什麼糾纏,所以未來萬一哪天想換零件,或改架構,也沒那麼令人頭痛啦。

### 步驟 2:App View

我們總得把主畫面搭出來,不然程式就跟倉庫裡的零件一樣堆著,有點茫然欸。

1. 在 "App" 資料夾上按右鍵,新建 Swift 檔案。
2. 取名 `AppView.swift`,名字自己決定?噢我跑題了,其實固定下來比較方便追蹤。
3. 貼入這段程式:

struct AppView: View {
@Bindable var store: StoreOf<AppReducer>css
var body: some View {
TabView(selection: $store.selectedTab.sending(\.tabSelected)) {
BMIInputView(
store: store.scope(state: \.bmiInput, action: \.bmiInput)
)
.tabItem {
Label("Calculate", systemImage: "person.crop.circle.fill")
}
.tag(AppReducer.State.Tab.input)
css
BMIHistoryView(
store: store.scope(state: \.bmiHistory, action: \.bmiHistory)
)
.tabItem {
Label("History", systemImage: "chart.line.uptrend.xyaxis")
}
.tag(AppReducer.State.Tab.history)
}
.accentColor(.blue)
}
}


喔對,其實 `store.scope()` 就是個分配者,每個子 view 各管各的小國土與規則。不知不覺有點像 microservice 概念,就是拿自己的道具,做該做的事;避免搞混,不會偷用鄰居的材料。

## 階段六:最後修飾

現在來到最終步,小細節別忘了搞定,我偶爾會鬼打牆一直重複某些 TCA 建議,嗯……

### App 入口點

切回主 app,那支檔案你沒改掉吧?

1. 開啟 `BMICalculatorApp.swift`
2. 整份內容直接換成以下寫法:

swift
import SwiftUI
import ComposableArchitecture
css
@main
struct BMICalculatorApp: App {
var body: some Scene {
WindowGroup {
AppView(
store: Store(initialState: AppReducer.State()) {
AppReducer()
}
)
}
}
}


### 最終專案結構

整體專案分類,如果你搞亂順序…其實偶爾也懶得整理,但底下這樣會讓焦慮感少一點:

BMICalculator/
├── App/
│ ├── AppReducer.swift
│ └── AppView.swift
├── Models/
│ └── BMI.swift
├── Features/
│ ├── Input/
│ │ ├── BMIInputReducer.swift
│ │ └── BMIInputView.swift
│ └── History/
│ ├── BMIHistoryReducer.swift
│ └── BMIHistoryView.swift
├── BMIHistoryClient.swift
├── ContentView.swift (現可刪除)
└── BMICalculatorApp.swift

## 結論

經過本文操作,一個原本單純到快發呆的 SwiftUI BMI 計算器,被拆封成完整 TCA 結構包。講白了就是,你能看到如下事情發生:
- 全部資料和業務邏輯綁進 reducer,看得到更集中;
- 行為(action)列明,不必害怕功能膨脹後誰打誰;
- 視圖透過 @Bindable 配合 .sending 與 state 綁緊緊;
- 像 @Dependency 注入依賴資源,比 global variable 健康很多;
- 歷史紀錄 feature 獨立模組而且很乾淨。
(話說,光是把父 reducer 拿去搬磚用都行。)

設計模式說穿了,是為了幫你頂住代碼長大時的壓力,其它事就隨興啦。有時想到 headless backend,一種功能切割感油然而生——可是哎呀拉回正題!

**備註:本範例原始碼找得到 [Github Link]**

## 接下來

接下篇要聊新的應用情境——Mood Tracker,本人其實常需要記錄心情狀態。有好處啦,可以多測 TCA 實戰技,比如:
- 深入路由流程與導航技巧
- 跑非同步副作用
- 模組化大型 project 怎麼降低炸鍋風險
- 避免 error 淹沒 console、怎麼查 log、效能省電指令什麼的…

未必馬上一口氣塞完,但至少本文幫你的 TCA 上場預熱打底。如果之後有靈感或者雷區踩爆,再分享更多心得好了。

收斂開發經驗,掌握進階 TCA 架構思維與自我成長

嗯,說真的,一路走過來,有些累,不過還是很想把這點經歷記下來。Muhammad Rezky Sulihin——就是我本人啦——一路以來嘗試許多事,也時常回頭思考自己走過的路。老實說,如果你有一丁點感覺想要給我意見,哪怕只是隨便一兩句,我都會心存感激。(直接寫信也好:[. ](mailto:) 反正不怕被唸!)無論是關於英文表達、文中的不夠流暢、或甚至謬誤什麼的,只要願意聊聊討論,我完全樂見。不知道大家會不會在看完之後發現哪些小缺失呢?人難免疏忽,何況我還常自己懷疑「欸?是不是又漏掉了?」

咦,再說一下近況吧,其實目前我的大學主修電腦科學,偏行動開發方向混,很常在 Apple Developer Academy @IL 奔波;畢業已成往事,但未來很彈性。對了,各種合作方案如果有人有興趣——接案協作啦、尋找實習機會、不管兼職還全職都可以——麻煩揮個手,一起交流一下總是不壞。我本身一直抱持著好奇心(希望沒冷場),更想多元嘗試讓人生不要太單一枯燥。

然後,有個提醒想講:保持那份愛挖掘的心情別散落喔,世界變快了,人偶爾容易丟掉熱情。所以啊,即使目前文章所談都是自我學習旅途裡的一部分,其實遠遠稱不上圓滿完美。不敢保證內容全對,但我的確希望每一點淺淺分享多少能幫助同圈裡認真摸索的人。如果有更多建議、更精準補充或哪句話得調整,都請隨時說——畢竟只有不停糾錯,自問自省才能漸漸踏穩下一步。期望哪天我們能再次交流不同話題,到時再一起笑吧。

Related to this topic:

Comments