SwiftUI + TCA (Onevcat) 下集

更新於 發佈於 閱讀時間約 97 分鐘
New Zealand - Deer Park Heights Queenstown

New Zealand - Deer Park Heights Queenstown

這篇是閱讀了喵神的文章 TCA — SwiftUI 的救星?,這一系列喵神分成了四篇來講解,每篇都有許多重點,而且也是從易到難,很好閱讀,以下是個人的學習筆記,內容幾乎原文搬過來的,但也有是學習過程中額外補充的,因為有語法的更新。 TCA 的核心思想還是大同小異,所以建議可以先閱讀原文,假如遇到語法不支援之類的問題,在參考這邊。

因為篇幅的關係,分成上下兩集,上集在此

以下程式碼語法已更新至 TCA v1.20.2

附上完整 demo,使用 Xcode 16.4

第三章

Effect

Elm-like 的狀態管理之所以能夠保持可測試及可擴展,核心要求是 Reducer 的純函數特性

Environment 透過提供依賴解決了 reducer 輸入階段的副作用(比如 reducer 需要獲取某個 Date 等),而 Effect 解決的則是 reducer 輸出階段的副作用:如果在 Reducer 接收到某個行為之後,需要做出非狀態變化的反應,比如發送一個網路請求,像硬盤寫一些數據,或者監聽某個通知等,都需要透過返回 Effect 進行。

Effect 定義了需要在純函數外執行的程式碼,以及處理結果的方式:一般來說這個執行過程會是一個耗時行為,行為的結果透過 Action 的方式在未來某個時間再次觸發 reducer 並更新最終狀態。TCA 在運行 reducer 的程式碼,並獲取返回的 Effect 後,負責執行他所定義的程式碼,然後按照需要發送新的 Action 。

Time Effect

接下來我們想做一個這樣的功能,倒數計時,並且會顯示開始時間

raw-image

首先定義 TimerFeature,其中的 State 很單純

@Reducer

struct TimerFeature {
@ObservableState
struct State: Equatable {
var started: Date? = nil

var duration: TimeInterval = 0
}
}

然後是 Action 的定義,開始計時結束計時,這兩個 action 很明確,問題在於我們該如何更新 TimeState.duration 呢?按照 TCA 的架構方式,reducer 是唯一能夠設置 State 的地方,而 reducer 又需要接收某個 action 進行驅動。因此,很顯然還需要一個 action,來表示每次 time duration 的更新,在這裡我們把它叫做 timeUpdated

@Reducer

struct TimerFeature {
@ObservableState
struct State: Equtable {
// ...

enum Action {
case start
case stop
case timeUpdated
}

有了 State 和 Action,接下來就是 Reducer 了, .timeUpdated 是最簡單的,假設每次 .timeUpdated 發生時,讓 state.duration 增加 0.01s

var body: some ReducerOf<Self> {
Reduce { state, action in
switch action { switch action {
case .start:
fatalError("Not implemented")

case .timeUpdated:
state.duration += 0.01
return .none

case .stop:
fatalError("Not implemented")
}
}
}

現在,我們只要想辦法在 .start 的 case 進行一些奇妙的「設定」,讓 TCA 運行時每隔 10ms 發送一次 .timeUpdated action 就可以了。把這類行為進行抽象:在處理 Action 時,進行一些 TCA 系統之外的操作,把結果轉換為新的 Action 反饋到 TCA 系統裡,這類行為就是一個 Effect。

對於 Timer,TCA 框架直接定義了 Effect.timer 。在 timeReducer 中,我們直接使用它來返回一個按時間觸發的 effect:

struct TimerEnvironment {
+ // 1
+ var date: () -> Date
+ var mainQueue: AnySchedulerOf<DispatchQueue>
+ static var live: TimerEnvironment {
+ .init(
+ date: Date.init,
+ mainQueue: .main
+ )
+ }
}

let timerReducer = Reducer<TimerState, TimerAction, TimerEnvironment> {
state, action, environment in
+ // 2
+ struct TimerId: Hashable {}
switch action {
case .start:
- fatalError("Not implemented")
+ if state.started == nil {
+ state.started = environment.date()
+ }
+ // 3
+ return Effect.timer(
+ id: TimerId(),
+ every: .milliseconds(10),
+ tolerance: .zero,
+ on: environment.mainQueue
+ ).map { time -> TimerAction in
+ // 4
+ return TimerAction.timeUpdated
+ }
case .timeUpdated:
state.duration += 0.01
return .none
case .stop:
fatalError("Not implemented")
}
}

以上為舊寫法,新寫法可以直接這樣用

@Dependency(\.date) var date
@Dependency(\.continuousClock) var clock

var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .start:
if state.started == nil {
state.started = date()
state.duration = 0
}
return .run { send in
for await _ in self.clock.timer(
interval: .milliseconds(10))
{
await send(.timeUpdated)
}
}
.cancellable(id: TimerID())
case .timeUpdated:
state.duration += 0.01
return .none
// ...
  1. 類似上一篇文中,對於外部輸入,我們使用環境值來進行注入
  2. 為了能夠實現 Effect 的取消,我們需要創建的 Effect 指定一個 id。這裡 TimerId 是一個最簡單的滿足了 Hashable 的類型
  3. TCA 中直接提供建立一個 timer 的方法,建立一個 TimerId 的實例作為這個 Effect 的 id。
  4. Effect.timer 返回類型 Effect<DispathchQueue.SchedulerTimeType, Never> 。而在 timerReducer 中,我們要求返回值為 Effect<Action, Never> 。TCA 為 Effect 的 output 轉換提供人見人愛的 map 方法。用它就可以把返回結果轉為我們需要的類型了。

遇到 .start 後,reducer 返回一個 timer Effect,開啟一個「副作用」。之後每隔 10ms, .timeUpdated 就會被發送一次,reducer 獲取到這個 action,並用它來更新 duration 。

Effect 取消

在 .stop 中我們需要讓 timer 停止,可以透過返回一個特殊的 Effect.cancel 來取消操作:

let timerReducer = Reducer<TimerState, TimerAction, TimerEnvironment> {
state, action, environment in

struct TimerId: Hashable {}
switch action {
// ...
case .stop:
- fatalError("Not implemented")
+ return .cancel(id: TimerId())

// ...
}

透過把 hash value 相同的 TimerId 內部類型實例傳遞個 .cancel ,TCA 就會幫我們尋找到之前開始的 timer,並停下來了。

最困難的 reducer 部分已經搞定了,接下來建立 TimerLabelView ,並按要求畫 UI,就很簡單了。

struct TimerLabel: View {
@Bindable var store: StoreOf<TimerFeature>

var body: some View {
VStack(alignment: .leading) {
Label(
store.state.started == nil
? "-"
: "\(store.started!.formatted(date: .omitted, time: .standard))",
systemImage: "clock"
)
Label(
"\(store.duration, format: .number)s",
systemImage: "timer"
)
}
}
}

而且我們可以很簡單的在 preview 上測試結果

raw-image
#Preview {
let store = Store(initialState: TimerFeature.State()) {
TimerFeature()
}
VStack {
TimerLabel(store: store)
HStack {
Button("Start") { store.send(.start) }
Button("Stop") { store.send(.stop) }
}.padding()
}
}

在上面的例子中,多次點擊 Start 按鈕也不會出事,因為在透過 Effect.timer 建立新的計時 Effect 時,它的內部已經使用吃入的 id 先進行了一次 .cancel 處理。

我們有時候想要阻止 User 因為連點按鈕,造成連續 call API 也可以善用 Cancel 功能。

測試 Effect

在把 TimerLabelView 組合到 app 之前,先來看看怎麼測試。經常寫測試就會遇到這樣的難題:如何寫好一個異步操作的測試。這類異步操作不僅僅涉及到像是本例中 timer 這種類型,也可能有像是網路請求或者等待遇用戶輸入等更具體普遍意義的情形。在傳統做法中,我們往往會依靠 test stub 和 mock 對象加上一定的注入,或乾脆直接等待固定的時間,然後再驗證結果,這些手段是有效的,但是 stub 和 mock 不僅為測試帶來更多的外部依賴和複雜度,也許要我們對實際程式碼進行修改,讓他可以被注入,而強行等待的方法,不僅會拉長測試所需要的時間,而且隨著環境不同,這些測試失效也面臨著失效的可能性。

在 TCA 中,由於存在 Environment 類型,我們「天然」擁有一個系統外部的注入點。這一部分,我們會來看看如何使用注入的 scheduler 完成 timeReducer 的測試。

在定義 TimerEnvironment 時,我們將 State 系統外部的部分都囊括了起來,包括 date 和 mainQueue 。在實際的 app 程式碼裡,我們把 AnySchedulerOf<DispatchQueue>.main (他其實就是 DispatchQueue.main )賦給了 mainQueue ,來讓 timer 的事件運行在主隊列上。 .main 是和 app 以及真實世界綁定的隊列,對 State 體系來說,這是一個巨大的「副作用」。在測試中,我們需要一個能夠被我們精確控制和操作的隊列,來保證測試不被外界影響。TCA 中我們定義裡一個簡單好用的類型, TestScheduler 。

為 TimerLabel 新增測試:

// TimerLabelTests.swift
import XCTest
import ComposableArchitecture
@testable import CounterDemo

class TimerLabelTests: XCTestCase {
let scheduler = DispatchQueue.test

// ...
}

DispatchQueue.test 是 TCA 專門為測試定義的,他的型別為 TestSchedulerOf<DispatchQueue> 。 TestSchedulerOf 不像 .main 這樣的隊列,會隨著 app 和真實時間向前運行,他上面定義了一系列操作方法,讓我們可以手動控制時刻。

class TimerLabelTests: XCTestCase {
let scheduler = DispatchQueue.test

func testTimerUpdate() throws {
let store = TestStore(
initialState: TimerState(),
reducer: timerReducer,
environment: TimerEnvironment(
date: { Date(timeIntervalSince1970: 100) },
mainQueue: scheduler.eraseToAnyScheduler()
)
)
// ...
}
}

最後操作就是 scheduler ,然後判斷狀態的部分

 func testTimerUpdate() throws {
  // ...
  store.send(.start) {
    $0.started = Date(timeIntervalSince1970: 100)
  }
  // 1
  scheduler.advance(by: .milliseconds(35))
  // 2
  store.receive(.timeUpdated) {
    $0.duration = 0.01
  }

  store.receive(.timeUpdated) {
    $0.duration = 0.02
  }

  store.receive(.timeUpdated) {
    $0.duration = 0.03
  }

  // 3
  store.send(.stop)
}
  1. advance(by:) 將這個 scheduler 的「時針」前進給定了時間,也就是說,讓時間流逝。不在依賴不精確的現實世界,也不依賴運行這個測試的具體設備和環境,而可以準確將計時器調整到 35ms 的位置。
  2. 使用 .recive 來斷言接收到了某個事件,並且在閉包中驗證 State 的改變。這裡由於 1 中 scheduler.advance 的原因,我們會期望收到三次 .timeUpdated (因為在 timerReducer 的實現中我們只定了 10ms 觸發一次 timer)。
  3. 最後,向 store 發送 .stop action 來取消 timer。

在上面的斷言中,刪除 2 的其中任意一個 receive 或者移除 3 的 send(.stop) ,都會導致測試的失敗。TCA 在對應 Effect 測試時,會對還未被 receive 的 action 以及還在運行的 Effect 進行斷言,這個特性非常優秀,保證了涉及的異步操作「萬無一失」。

其他 Effect 和測試

除了 Timer 之外,在實際開發中還有各種異步操作,例如網路請求,TCA 都提供一系列方法處理,來把基於閉包或者 Publisher 的異步操作封裝成一個可以 reducer 返回的 Effect 。

網路請求 Effect

import Combine

// 原文,用到 Combine 的 Publisher,最後會再轉成 Effect
let sampleRequest = URLSession.shared
.dataTaskPublisher(for: URL(string: "https://example.com")!)
.map { element -> String in
return String(data: element.data, encoding: .utf8) ?? ""
}

// 我們直接包成一個 Effect,因為只是 demo 用,沒有 catch error
var sampleRequest: Effect<Result<String, URLError>> {
.run { send in
let request = URLRequest(url: URL(string: "https://example.com")!)
let (data, _) = try await URLSession.shared.data(for: request)
let result = String(data: data, encoding: .utf8) ?? ""
await send(.success(result))
}
}

一個常見的 request publisher,在 TCA 中,我們已經看到很多將外部作用放在 Environment 中的例子,網路請求是一個非常的大副作用

// 原文
struct SampleTextEnvironment {
var loadText: () -> Effect<String, URLError>
var mainQueue: AnySchedulerOf<DispatchQueue>
static let live = SampleTextEnvironment(
loadText: { sampleRequest.eraseToEffect() },
mainQueue: .main
)
}

// 新做法,直接返回 Effect,原文的 eraseToEffect() 已棄用
struct Environment {
var loadText: () -> Effect<Result<String, URLError>>
var queue: AnySchedulerOf<DispatchQueue>

static var liveValue = Self(
loadText: { sampleRequest },
queue: .global()
)
}

effectToEffect 是 TCA 定義在 Publisher 上的輔助方法

剩下的部分就是定義相關的 State 和 Reducer 了:

enum SampleTextAction: Equatable {
case load
case loaded(Result<String, URLError>)
}

struct SampleTextState: Equatable {
var loading: Bool
var text: String
}

let sampleTextReducer = Reducer<SampleTextState, SampleTextAction, SampleTextEnvironment> {
state, action, environment in
switch action {
case .load:
state.loading = true
// 1
return environment.loadText()
.receive(on: environment.mainQueue)
.catchToEffect(SampleTextAction.loaded)
case .loaded(let result):
// 2
state.loading = false
do {
state.text = try result.get()
} catch {
state.text = "Error: \(error)"
}
return .none
}
}

// 在原文 1 的部分,在新的寫法已經沒有 Effect.receive(on:) 以及 .catcheToEffect()
// 目前想到類似的寫法是這樣,先用 queue 卡住,等他完成後再來去做 loadText()
switch action {
case .load:
state.loading = true
return .run { send in
try await environment.queue.sleep(for: .zero)
}.concatenate(with:
environment.loadText()
.map { .loaded($0) }
)
// ...
  1. 這種做法在 TCA 處理 Effect 很常見,對於一個接收 Effect 結果的 Action,將關聯值定義為 Result<Value, Error> 的形式,可以讓 reducer 的部分程式碼簡化很多。
  2. 在 .load 中返回 Effect 執行完成,並經由轉換後 .loaded action 被發送。這給了 Reducer 一個處理 Effect 結果和更新狀態的機會。在 TCA 中,對於異步操作我們會看到大量這種模式。

最後

 struct SampleTextView: View {

let store: Store<SampleTextState, SampleTextAction>

var body: some View {
WithViewStore(store) { viewStore in
ZStack {
VStack {
Button("Load") { viewStore.send(.load) }
Text(viewStore.text)
}
if viewStore.loading {
ProgressView().progressViewStyle(.circular)
}
}
}
}
}

測試網路請求

網路請求 Effect 的測試,和之前 Timer 測試相似,透過注入 Environment 注入的方式,提供合適的 loadText 和 mainQueue ,就能控制 Effect 的行為了

class SampleTextTests: XCTestCase {

let scheduler = DispatchQueue.test

func testSampleTextRequest() throws {
let store = TestStore(
initialState: SampleTextState(loading: false, text: ""),
reducer: sampleTextReducer,
environment: SampleTextEnvironment(
// 1
loadText: { Effect(value: "Hello World") },
mainQueue: scheduler.eraseToAnyScheduler()
)
)
store.send(.load) { state in
state.loading = true
}
// 2
scheduler.advance()
store.receive(.loaded(.success("Hello World"))) { state in
state.loading = false
state.text = "Hello World"
}
}
}
  1. 提供一個實際的 dataTask publisher,這裡直接返回一個 “Hello World” 作為完成值的 Effect 。代表一個「即將發生」的外部「返回值」
  2. 和上面 timer 的例子相似,使用 .test 和 advance 讓測試向前運行。假如新增參數時, .zero 會被使用,這代表 scheduler 不會發生時間流逝,但會把所有當前「堆積」的 Effect 事件都發送出去。TCA 也準備了另一個特殊的 .immediate 來簡化流程:
class SampleTextTests: XCTestCase {

- let scheduler = DispatchQueue.test

func testSampleTextRequest() throws {
let store = TestStore(
initialState: SampleTextState(loading: false, text: ""),
reducer: sampleTextReducer,
environment: SampleTextEnvironment(
loadText: { Effect(value: "Hello World") },
- mainQueue: scheduler.eraseToAnyScheduler()
+ mainQueue: .immediate
)
)
store.send(.load) { state in
state.loading = true
}
- scheduler.advance()
store.receive(.loaded(.success("Hello World"))) { state in
state.loading = false
state.text = "Hello World"
}
}
}

.immediate 會忽視 Effect(或者說 Publisher)中有關時間的部分,而立即讓這些 Effect 完成,因此可以把 scheduler 移除,簡化程式碼。

更多類型的 Effect 以及 Effect 操作

除了 Timer, Publisher 以外,像是基於 closure callback 的異步方式,或者全新的 Swift Concurrency 的操作,TCA 都在 Effect 類型中為他們提供相應的封裝方式。

假如是多個異步操作的情況,TCA 有 concatenate (這是就是我們新寫法用到的,順次執行多個 Effect )和 merge (同時執行多個 Effect )的手動。假如不關心返回值也不需要再完成時觸發新的 action,則可以使用 fireAndForget (已棄用)操作。

以 TCA 的角度來看 Combine Publisher 的用法似乎逐漸汰換,使用 Swift Concurrency async/await 似乎比較是趨勢。

Composable

現在有猜數字的 CounterView 和表示時間的 TimerLabelView ,要怎麼把它們結合?

raw-image

小組件是由 State, Action, Reducer 和 Environment 組成,然後把小組件組裝成大組件也是一樣的方式。

Game State

先從 State 開始

@Reducer
struct GameFeature {
@ObservableState
struct State: Equatable {
var counter: Counter = .init()
var timer: TimerState = .init()
}
}

Game Action

接下來是 Action

enum Action {
case counter(CounterAction)
case timer(TimerAction)
}

Game Environment

先暫時用空的,後續看官方的例子,假如是官方定義的那幾個 Dependency 幾乎不使用 Environment 了,而是直接宣告需要什麼 Dependency,例如 @Dependency(\.continuousClock) var clock 直接使用,不用包在 Environment,然後例如是 call API,也應該會有一個獨立的 Dependency,加進來。

struct Environment { }

實際開發中,我們重複定義了一些相同的環境值,比如 date 或 mainQueue 。這類相同環境其實可以新增包裝,讓他們更好 reuse。但由於 GameState 的狀態變化並「不涉及更多的外部副作用」,所以為了簡單說明,暫時留空。

實際上「不涉及副作用」這個說法是錯誤的,更準確來說,GameState 内部的 Counter 和 TimerState都是有副作用的。這些副作用,在 Game 的層級上不應該由 CounterEnvironment.live 或者 TimerEnvironment.live 來定義,而應該從 GameEnvironment 中轉換過去。

Game Reducer

是最困難的部分,核心思想有三條:

  1. 組件的行為都由 reducer 定義的。子組件行為,也應該由子組件的 reducer 自己決定。因此需要使用已有的 counterReducer 和 timerReducer ,並把 GameAction 轉換為子組件所需的 CounterAction 或 TimerAction 並傳遞給他們
  2. 子組件對各種 State 進行修改的結果,需要反應到父組件中,才能完成父組件 View 的刷新。這個例子中, counterReducer 和 timerReducer 會更改個字的 Counter 和 TimerState ,但是 GameState 中的 counter 和 timer 並不會被子組件的 reducer 更改(因為 GameState 是一個 struct)因此需要一種方式讓子組件 reducer 能夠設置父組件對應的 state
  3. 多個組件需要聯合起來工作,各組件的 reducer 需要進行合併

在 SwiftUI,記得程式碼的順序,會影響執行結果,例如下方的 code,假設 Reduce 與 Scope 交換,在 let result = Result(…) 拿 state.counter 的結果不一樣, Scope 先執行的話就等於會把 state.counter 更新重置

		var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .counter(.playNext):
let result = Result(counterState: state.counter, spentTime: state.timer.duration - state.lastTimestamp)
state.results.append(result)
state.lastTimestamp = state.timer.duration
return .none
default: return .none
}
}
Scope(state: \.counter, action: \.counter) {
Counter()
}.transformDependency(\.counterEnvironment) { dependency in
dependency.generateRandom = environment.generateRandom
dependency.uuid = environment.uuid
}
Scope(state: \.timer, action: \.timer) {
TimerFeature()
}.transformDependency(\.date) { dependency in
dependency.now = environment.date()
}
}

View 的部分很單純

struct GameView: View {

@Bindable var store: StoreOf<GameFeature>

var body: some View {
VStack {
resultLabel(store.state.resultState.results)
Divider()
TimerLabel(store: store.scope(state: \.timer, action: \.timer))
CounterView(store: store.scope(state: \.counter, action: \.counter))
}.onAppear {
store.send(.timer(.start))
}
}
}

紀錄結果並顯示數據

在 CounterView 的 “Next” 按鈕按下後,開啟新的題目。但我們需要把之前的遊玩結果記錄下來,所以在宣告一個

struct GameResult: Equatable {
let secret: Int
let guess: Int
let timeSpent: TimeInterval
}

舉例來說,如果第一個數字是 10,我們在按下 “Next” 之前已經讓 counter 變成了 10,且耗時 5 秒,那麼我們需要記錄 GameResult(secret: 10, guess: 10, timeSpent: 5.0);對於沒有猜對就繼續的情況,我們也用同樣的類型記錄下來。記錄的結果保存在 GameFeature.State 的一個陣列中:

struct GameState: Equatable {
var counter: Counter = .init()
var timer: TimerState = .init()
+ var results: [GameResult] = []
}

第四章

使用 IdentifiedArray

與 Array 相比的優點:

  • 一樣有 Element 順序,index 的 O(1) 存取,且跟 Array 一樣兼容的 API
  • 但和 Array 還是有小差異,其中的 element 都要遵守 Identifiable protocol,要確保每個元素都是唯一的
  • 因為有唯一性,所以查找效率很高

使用 Array 的一些壞處,在 app 資料很簡單的時候很好,但只要一複雜,處理 Array 的效能問題或者出錯。

  • 要根據相等 (也就是 Array.firstIndex(of:) ) 來找出其中的某個元素會需要 O(n) 的複雜度。
  • 使用 index 來取得元素雖然是 O(1),但是如果處理異步的情況,非同步操作開始時的 index 有可能和之後的 index 不一致,導致錯誤 (試想在異步期間,以同步的方式刪除了某些元素的情況:異步操作之前保存的 index 將會失效,訪問這個 index 可能獲取到不同的元素,甚至引起 crash)。

IdentifiedArray 可以處理上述兩個問題,所以建議能使用 IdentifiedArray 就使用。

- struct GameResult: Equatable {
+ struct GameResult: Equatable, Identifiable {
- let secret: Int
- let guess: Int
+ let counter: Counter
let timeSpent: TimeInterval

- var correct: Bool { secret == guess }
+ var correct: Bool { counter.secret == counter.count }
+ var id: UUID { counter.id }
}

然後更新,讓編譯通過

let gameReducer = Reducer<GameState, GameAction, GameEnvironment>.combine(
.init { state, action, environment in
switch action {
case .counter(.playNext):
let result = GameResult(
- secret: state.counter.secret,
- guess: state.counter.count,
+ counter: state.counter,
timeSpent: state.timer.duration - state.lastTimestamp
)
// ...
},
// ...
)

struct GameView: View {
var body: some View {
// ...
- resultLabel(viewStore.state)
+ resultLabel(viewStore.state.elements)
}

// ...
}

使用獨立 feature 的方式進行構建

幾乎每個 View 都會搭配一個 feature,他的組成最基本的就是 state, reducer, environment 和 action,TCA 最優秀的一點:我們只需要著眼於創建簡單的小組件,然後透過組合的方式把它們添加到大組件中

Feature:

import ComposableArchitecture
import Foundation

@Reducer
struct GameResultListFeature {
@ObservableState
struct State: Equatable {
var results: IdentifiedArrayOf<GameResult> = []
}

enum Action {
case remove(offset: IndexSet)
}

var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .remove(let offset):
state.results.remove(atOffsets: offset)
return .none
}
}
}
}

View:

import ComposableArchitecture
import SwiftUI

struct GameResultListView: View {

@Bindable var store: StoreOf<GameResultListFeature>

var body: some View {
List {
ForEach(store.state.results) { result in
HStack {
Image(systemName: result.correct ? "checkmark.circle" : "x.circle")
Text("Secret: \(result.counter.secret)")
Text("Answer: \(result.counter.count)")
}.foregroundColor(result.correct ? .green : .red)
}
}
}
}

#Preview {
GameResultListView(
store: Store(initialState: GameResultListFeature.State(results: [
GameResult.init(counter: .init(count: 10, secret: 10, id: .init()), spentTime: 100),
GameResult.init(counter: .init(), spentTime: 100)
])) {
GameResultListFeature()
}
)
}
raw-image

支持刪除

在 SwiftUI 上要加上預設的刪除操作很單純,只要為 cell 加上 onDelete 就可以了,我們額外再加一個顆 EditButton 。

struct GameResultListView: View {

@Bindable var store: StoreOf<GameResultListFeature>

var body: some View {
List {
ForEach(store.state.results) { result in
HStack {
Image(systemName: result.correct ? "checkmark.circle" : "x.circle")
Text("Secret: \(result.counter.secret)")
Text("Answer: \(result.counter.count)")
}.foregroundColor(result.correct ? .green : .red)
}.onDelete { store.send(.remove(offset: $0)) }
}
.toolbar {
EditButton()
}
}
}

Navigation 導航

基本導航

接下來就是用導航的方式顯示這新頁面 (GameResultListView),在 app 主頁面中,已經有將小組件使用 pullback 的方式進行組合了,將 list feature 和 app 其他部分的 feature 組合的方式並沒有什麼不同:也就是把子組件的 state,action,reducer 和 view 都整合到父組件去。

在這,我們計劃在 navigation bar 上新增一個 Detail 按鈕,透過 NavigationLink 的方式顯示 GameResultListView。首先,在 CounterDemoApp.swift 中新增一個 NavigationView,作為整個 app 的容器:

struct CounterDemoApp: App {
var body: some Scene {
WindowGroup {
+ NavigationStack {
GameView(
store: Store(
initialState: GameFeature.State(),
reducer: {
GameFeature()
}
)
)
+ }
}
}
}

在這 NavigationView 即將棄用,所要改用 NavigationStack 。

State:

在 GameFeature.State 將 var results = IdentifiedArrayOf<GameResult>() 改成 var resultState: GameResultListFeature.State = .init()

struct GameFeature {
@Dependency(\.gameFeatureEnvironment) var environment

@ObservableState
struct State: Equatable {
var counter: Counter.State = .init()
var timer: TimerFeature.State = .init()

var resultState: GameResultListFeature.State = .init()
var lastTimestamp = 0.0
}

Action:

在 GameResultListView 操作結果數據時,我們希望將結果拉回 GameFeature.State.results 裡,因此我們需要處理 GameResultListAction 的 action,在 GameFeature.Action 新增一個 case listResult(GameResultListFeature.Action)

@Reducer
struct GameFeature {
@Dependency(\.gameFeatureEnvironment) var environment

@ObservableState
struct State: Equatable {
...
}

enum Action {
case counter(Counter.Action)
case timer(TimerFeature.Action)
+ case listResult(GameResultListFeature.Action)
}

Reducer:

更新 GameFeature 的 reducer 部分

    var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .counter(.playNext):
let result = GameResult(
counter: state.counter,
spentTime: state.timer.duration - state.lastTimestamp
)
state.resultState.results.append(result)
state.lastTimestamp = state.timer.duration
return .none
default: return .none
}
}
...
Scope(state: \.timer, action: \.timer) {
TimerFeature()
}.transformDependency(\.date) { dependency in
dependency.now = environment.date()
}
Scope(state: \.resultState, action: \.listResult) {
GameResultListFeature()
}
}

這樣接收到 .listResult 這 action 時,在 GameResultListFeature 造成的結果(新的 result list state 會更新)

View:

最後,在 body 中建立 NavigationLink,用 scope 把 results 切割出來,把新的 store 傳遞給 GameResultListView 作為目標 view,導覽就完成了:

struct GameView: View {

@Bindable var store: StoreOf<GameFeature>

var body: some View {
VStack {
resultLabel(store.state.resultState.results)
Divider()
TimerLabel(store: store.scope(state: \.timer, action: \.timer))
CounterView(store: store.scope(state: \.counter, action: \.counter))
}.onAppear {
store.send(.timer(.start))
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink("Detail") {
GameResultListView(store: store.scope(state: \.resultState, action: \.listResult))
}
}
}
}

這時就可以執行專案看看,玩了幾場猜數字後,可以到 Detail 頁面查看結果,而且跟 GameView 的資料是有連動的,全部刪除後會發現最上面的 Result label 有變回 0/0 correct。

raw-image

pullback (新版改用 Scope)的行為

raw-image


存在的問題

TCA 這類類似 Elm 的架構形式,一大特點是 State 完全決定 UI,這也是在進行 UI 測試時很重要的手段:只要我們能建立出合適的 State (model 層),我們就能期待固定的 UI,這讓整個 app 的介面成為一個「純函數」: UI = F(State) 。

但像上面這個簡單的導航範例,會破壞這公式,顯示主頁面時的 State 和顯示清單頁面時的 State 是無法區分的,同一種狀態可能會對應不同的 UI。這是因為管理導航的狀態存在於 SwiftUI 內部,它在我們的 State 中沒有體現出來。

如果不是很計較 app 的嚴肅性,那麼這種簡單的導航關係也不是不能接受。不過為了滿足純函數的要求,我們來看看 SwiftUI 提供的另一種導航方式,也就是基於 Binding 值控制的導航,要如何與 TCA 協同工作。

個人理解:跳轉頁面的過程盡可能是可以控制 State (model 層),這為了方便測試為主,也是 TCA 很重要的特性,盡可能由狀態驅動。

基於 Binding 的導航

除了上述最簡單的 init(_:destination:) 以外, NavigationLink 還有另外一個版本,帶有 Binding 。

init(
_ titleKey: LocalizedStringKey,
isActive: Binding<Bool>,
@ViewBuilder destination: () -> Destination
)

init<V>(
_ titleKey: LocalizedStringKey,
tag: V,
selection: Binding<V?>,
@ViewBuilder destination: () -> Destination
) where V : Hashable

前者 isActive: Binding<Bool>,這個 Binding 可以透過兩種方式控制導覽狀態:

  • 由 SwiftUI 控制:當使用者透過 UI 觸發導航時,SwiftUI 負責將這個值設為 true。在使用回退按鈕返回時,SwiftUI 負責將這個值設為 false
  • 由我們自行控制:我們也可以透過程式碼把這個 Binding 值設為 true 或 false 來觸發對應的導覽和回退行為。

而後者,相較於前者的 Bool,後者接受 V? 的綁定值和一個代表當前 NavigationLink 的 tag 值:當 selection 的 V 和 tag 的 V 相同時,導航生效並展示 destination 的內容。為了判斷這個相同,SwiftUI 要求 V 滿足 Hashable

這兩個變體為 TCA 提供了機會,可以透過 State 來控制導航狀態:只要我們在 GameState 中新增一個代表的導航狀態的變數,就可以透過把這個變數轉換為 Binding 並設定它,來讓狀態和 UI 一一對應:即 state 為 true 或者 non-nil 值為時,顯示詳細頁面;否則為 false 或 nil 時,顯示詳細頁面

Identified

在這個例子中,我們選用 Binding<V?> 的方法來控制。在 GameState 中新增一個屬性:

@Reducer
struct GameFeature {
@Dependency(\.gameFeatureEnvironment) var environment

@ObservableState
struct State: Equatable {
...
var resultListState: Identified<UUID, GameResultListFeature.State>?
}

Binding<V?> 中需要 V 滿足 Hashable,這裡我們原本的目標是讓 GameResultListFeature.State.results (也就是 IdentifiedArrayOf<GameResult>) 滿足 Hashable

這是一個相對困難的任務:我們可以為 IdentifiedArray 新增 Hashable 實現,但這並不是一個好選擇:這兩個型別定義都不屬於我們,我們無法控制將來 TCA 是否會為 IdentifiedArray 引入 Hashable 實作。

TCA 中將一個任意值轉為 Hashable 更簡單的方式就是用 Identified 包裝它,手動為它賦予一個 id 值,用它作為 V 的類型。在我們的例子中,導航只有一個單一的狀態,所以我們完全可以定義一個通用的 UUID 作為 NavigationLink 的 tag,在 GameView.swift 的頂層 scope 添加下面的定義:

let resultListStateTag = UUID()

使用 Binding<V?> 和 tag 的版本,更多是為了區分多個可能的導航情況 (例如一個清單中的各個選項都可能導航至下一個頁面)。 實際上,對於我們這裡的例子,因為只有一個可能的觸發導航的情況它,所以並沒有必要使用 tag 的方式控制,只需要使用 Binding<Bool> 就可以了。不過我們還是選擇 Binding 的版本作為例子,因為它更具一般性,更通用。

Binding 和導航 Action 處理

我們需要在 reducer 中捕獲這個 action 並為 resultListState 設定合適的值。在 GameAction 裡加入控制導航的 action 成員:

    enum Action {
case counter(Counter.Action)
case timer(TimerFeature.Action)
case listResult(GameResultListFeature.Action)
+ case setNavigation(UUID?)
}

然後更新 body 中 NavigationLink 的部分,改為使用 Binding

struct GameView: View {

@Bindable var store: StoreOf<GameFeature>

let resultListStateTag = UUID()

var body: some View {
...
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink(
"Detail",
tag: resultListStateTag,
selection: .init(
get: { store.resultListState?.id },
set: { id in store.send(.setNavigation(id)) }
),
destination: {
Text("Sample")
}
)
}
}
}

目前使用這 NavigationLink API 會有警告 'init(_:tag:selection:destination:)' was deprecated in iOS 16.0: use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView

也沒找到其他替代方案 https://github.com/pointfreeco/swift-composable-architecture/issues/3420

當 NavigationLink 的 selection 被觸發時, .setNavigation(resultListStateTag) 被發送,在 GameFeature.Reducer 中,捕獲這個 action 並進行處理:

    var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .counter(.playNext):
...
case .setNavigation(.some(let id)):
state.resultListState = .init(state.resultState, id: id)
return .none
case .setNavigation(.none):
if let newState = state.resultListState?.value {
state.resultState = newState
}
state.resultListState = nil
return .none
default: return .none
}
}

接收到帶有 id 的 .setNavigation action 時,我們手動設定 resultListState,這會觸發 Navigation

所以在有 id 時,點進到 detail 頁面時,把自身 state.resuateState 傳給 resultListState 。

而再返回時再把 resultListState?.value.results 的資料回傳回 state.resuateState.results ,在把 resultListState 清除。

現在, GameFeatureReducer pullback (Scope) ,將結果拉回 resultState

    var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .counter(.playNext):
...
return .none
case .setNavigation(.some(let id)):
state.resultListState = .init(state.resultState, id: id)
return .none
case .setNavigation(.none):
if let newState = state.resultListState?.value {
state.resultState = newState
}
state.resultListState = nil
return .none
default: return .none
}
}
...
Scope(state: \.resultListState!.value, action: \.listResult) {
GameResultListFeature()
}
}

如果你還記得 pullback (Scope) 的初衷,它的目的是把原本作用在本地域上的 reducer 轉換為能夠作用在全域域的 reducer

在這裡,我們想要做的是把 GameResultListFeature.Reducer 對 GameResultListFeature.State 造成的變更,拉回 GameFeatureState.resultListState!.value 中。

IfLetStore

整個過程的最後一步,就是在 NavigationLink 的 destination 裡建立正確的 GameResultListView

和上面 pullback (Scope) 的情況類似,我們不再選擇使用 results,而是使用 \.resultListState?.value 來切割 store

store.scope(
state: \.resultListState?.value,
action: \.listResult
)

但這樣做得到的是一個可選值 state 的類型 Store<GameResultListFeature.State?, GameResultListFeature.Action>,它並不能滿足 GameResultListView 所需的 Store<GameResultListFeature.State, GameResultListFeature.Action>

TCA 在處理 store 中可選值屬性的切割時,使用 IfLetStore 來進行包裝,它會根據其中狀態可選值是否為 nil 來建立不同的 view,新寫法可以直接使用 if let store = store.scope(...)

var body: some View {
// ...
NavigationLink(
"Detail",
tag: resultListStateTag,
selection: viewStore.binding(get: \.resultListState?.id, send: GameAction.setNavigation),
destination: {
- Text("Sample")
+ if let store = store.scope(
+ state: \.resultListState?.value,
+ action: \.listResult
+ ) { GameResultListView(store: store) }
}
)
}

至此,我們完成了最完整的使用 Binding 進行導航的方式。

運行 app,你會發現看起來整個 app 的行為和簡單導航時並沒有什麼區別。

但是我們現在可以透過建立合適的 GameFeature.State,來直接顯示結果詳細頁面。

這在追蹤和調試 app 中帶來巨大便利,也正是 TCA 的強大之處。例如,在 CounterDemoApp 中,我們可以加入一些 sample:

import ComposableArchitecture
import SwiftUI

@main
struct SwiftUI_TCA_PracticeApp: App { // CounterDemoApp
var body: some Scene {
WindowGroup {
NavigationStack {
GameView(
store: Store(
initialState: testState,
reducer: {
GameFeature()
}
)
)
}
}
}
}

let sample = GameResultListFeature.State(results: [
.init(counter: .init(count: 10, secret: 10, id: .init()), spentTime: 100),
.init(counter: .init(), spentTime: 500),
])

let testState = GameFeature.State(
counter: .init(),
timer: .init(),
resultState: sample,
lastTimestamp: 100,
resultListState: .init(sample, id: resultListStateTag)
)

現在運行 app,我們會被直接導航到結果頁面。

確保唯一的 state 對應的唯一 UI,可以讓開發快速定位問題:只需要提供 app 出現問題時的 state,理論上就可以穩定重現並立即開始調試。

⚠️ 理論上應該是可以開啟 app 後導航到結果頁面,但目前以結果來說是並沒有,而且反而會造成按鈕失效,根本原因個人也還在研究,將 resultListStateTag 換成 UUID() 按鈕會正常,又或者 resultListState: nil 也是,假如不管開啟 app 直接跳轉進去的話, resultListState 可以直接不要 init。

更多討論

SwiftUI 導航最佳實踐

雖然 Apple 在 SwiftUI 導航上做了不少努力,但是傳統的幾種導航方式有一定缺失:不論是 navigation 還是 sheet,對於基於 Binding 的導航,控制導航狀態的 Binding 值並不會被傳遞到 NavigationLink 的 destination 還是 View.sheet 的 content 中,這導致後續頁面無法有效修改前置頁面的資料來源,從而造成數據源不統一。

在 TCA 中因為無法直接修改 state,我們選擇透過在 Binding 變更時傳送 action 的方式來更新 state。這種方法在 TCA 裡非常合適,但在普通的 SwiftUI app 裡雖然也可行,卻顯得有點格格不入。 TCA 的維護者對此專門開源了一套工具,來補充原生 SwiftUI 架構在導航上的不足,其中也包含了對於這個主題的更深入的討論。

ViewStore 的各種形式

在上面的例子中,我們看到了在 View 中使用 IfLetStore(if let store = store.scope(...) 來切分 state 中的可選值的方法。

另一個特殊的 Store 形式是 ForEachStore,它針對 State 中的 IdentifiedArray,將每個元素切割成一個新的 Store。如果 List 中的每個 cell 自成一套 feature 的話 (例如範例的猜數字 app 中,允許結果清單頁面的每個結果 cell 再點進去,並顯示一個 CounterView 來修改內容的話),這種方式將讓我們很容易把 List 和 TCA 進行結合。與 IfLetStore 的關係類似,在組合 reducer 時,TCA 也為 IdentifiedArray 的屬性準備了 forEach 方法來把陣列中的各個元素變更拉回全域狀態的對應元素中。我們將把關於數組切分和拉回的課題作為練習留給讀者。

另外,對於 enum 形式的 State,TCA 也準備了相應的 SwitchStore 和 CaseLet,可以讓我們以相似的語法根據不同 State 屬性創建 view。關於這些內容,在理解了 TCA 的工作原理後,就都是一些類似語法糖的存在,可以在實際用到時再加以確認。

Alert 和结果儲存

可能有細心的同學會問,在上面 Binding 導航的時候,為什麼不直接選擇在 .setNavigation(.some(let id)) 的時候單獨只設置一個 UUID,而保持將結果直接 pullback 到 results 呢? resultListState 存在的意義是什麼?或者甚至,為什麼不直接使用 Binding<Bool> 的 NavigationLink 版本呢?

對於很多情況,在 list view 裡直接操作 results 是完全可行的,不過如果我們有需要暫時保留原來資料的場景的話,在 .setNavigation(.some(let id)) 中複製一份 results (在例子中我們透過建立新的 Identified 值進行複製,在編輯過程中保持原有 results 的穩定,並在完全結束後再把更改後的 resultListState 重新賦給 results 就是必要的了。

我們透過一個例子來說明,例如現在我們希望在從列表頁面回後多加一次 alert 彈跳窗確認,當使用者確認更改後透過網路請求向 Server「報告」這次更改,然後成功後再刷新 UI。如果使用者選擇放棄修改的話,則維持原來的結果不變。

AlertState

顯示一個 alert 在 app 開發中是非常常見的,TCA 為此內建了一個專門用來管理 alert 的類型: AlertState。為了讓 alert 能夠運作,我們可以為它新增一組 action,描述 alert 的按鈕點擊行為。在 GameFeature.swift 中新增:

    enum GameAlertAction: Equatable {
case alertSaveButtonTapped
case alertCancelButtonTapped
}

然後在 GameFeature.State 新增 alert 屬性,以及 Action。

    @ObservableState
struct State: Equatable {
...
@Presents var alert: AlertState<GameAlertAction>?
}

enum Action {
case counter(Counter.Action)
...
case alertAction(PresentationAction<GameAlertAction>)
}

這邊有兩個 keyword 簡單介紹一下,其他細節可以參考 TCA 教學文件

@Presents 是 TCA 用來管理彈出式 UI 狀態(像是 alert、sheet、popover)的屬性包裝器。

  • 用 @Presents 宣告的狀態,TCA 會自動處理顯示與消失,減少手動管理的錯誤。
  • 它能和 .alert.sheet 等 SwiftUI 修飾器自動整合,讓 UI 狀態和資料狀態同步。
  • Reducer 會自動收到彈窗相關的 action,讓你更容易處理使用者互動。
  • 建議用 @Presents 來管理所有彈出式 UI 狀態,讓程式碼更安全、簡潔且不易出錯。

PresentationAction 它是一個 enum,代表兩個操作 presented(_:) 和 dismiss ,而且可以讓 parent 觀察 child 的事件變化。

和處理導航關係時一樣,在 reducer 裡設定 alert 可選值,就可以控制 alert 的顯示和隱藏。我們計劃在從結果列表頁面返回時展示這個 alert,修改 GameFeature.Reducer 的 setNavigation(.none) 分支:

case .setNavigation(.none):
if state.resultListState?.value.results != state.resultState.results {
state.alert = .init(
title: { TextState("Save Changes?") },
actions: {
ButtonState<GameFeature.GameAlertAction>.init(
action: .send(.alertSaveButtonTapped),
label: { .init("OK") }
)
ButtonState<GameFeature.GameAlertAction>.init(
role: .cancel,
action: .send(.alertCancelButtonTapped),
label: { .init("Cancel") }
)
}
)
} else {
state.resultListState = nil
}
return .none

最後在 GameView 加上 alert

    var body: some View {
...
.toolbar {
...
}
.alert($store.scope(state: \.alert, action: \.alertAction))
}

Dismiss 以及處理按鈕事件

現在 build 起來之後,到 detail 頁面做修改後,按下返回,確實會跳 alert,但點擊 alert 按鈕是沒有作用的。

.alertAction(.dismiss): 會在 alert dismiss 時觸發,所以不管按下 OK 還是 Cancel 都會執行,順序是由 OK or Cancel 先,最後才是 dismiss 執行。

    var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
...
case .alertAction(.dismiss):
state.alert = nil
return .none
case .alertAction(.presented(.alertSaveButtonTapped)):
if let newState = state.resultListState?.value {
state.resultState = newState
}
state.resultListState = nil
return .none
case .alertAction(.presented(.alertCancelButtonTapped)):
state.resultListState = nil
return .none
default: return .none
}

最後執行,就可以得到一個有正確功能的 alert。

Effect 和 Loading UI

最後我們來嘗試發起一個 request ,並完成時更新 results 。為了單純一點,這邊就用 delay 的 Effect 來模擬 request。

先新增 Action

    enum Action {
case counter(Counter.Action)
...
case saveResult(Result<Void, URLError>)
}

再來是 State

    @ObservableState
struct State: Equatable {
var counter: Counter.State = .init()
...
var savingResults: Bool = false
}

接下來就是我們的邏輯核心,加上 @Dependency(\.mainQueue) var mainQueue 就可以在寫測試時控制 delay 時間。

@Reducer
struct GameFeature {
@Dependency(\.mainQueue) var mainQueue

var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
...
case .alertAction(.presented(.alertSaveButtonTapped)):
state.savingResults = true
return .run { send in
try await mainQueue.sleep(for: .seconds(2))
await send(.saveResult(.success(Void())))
}
...
case .saveResult(let result):
state.savingResults = false
if let newState = state.resultListState?.value {
state.resultState = newState
}
state.resultListState = nil
return .none
default: return .none
}
}

最後就是 View 的部分

        .toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink(
...
}, label: {
if store.savingResults {
ProgressView()
} else {
Text("Detail")
}
})
}
}

結果就出來了

raw-image


總結

在這 TCA 教成已告一段落,我們看到 TCA 各組件以及他們組裝方式,還有常見的用法和模式,並對他的思想進行了探索。 我们理解和弄清了架構的思想,那麼使用頂層 API 就只是手到擒来。

對於更大且更複雜的 app 架構,TCA 框架會面臨其他一些問題,例如資料在多個 feature 間共享的方式,state 過於龐大後可能帶來的效能問題,以及跨越多個層級傳遞資料的方式等。本文寫作時,這些問題都沒有特別完美且通用的解決方式。不過,TCA 並沒有到達 1.0 版本,它本身也在快速發展和演進中,幾乎每個月都會有全新的特性甚至破壞性的變化被引入。如果你遇到了棘手的問題,或者對最佳實踐有所疑問,不妨到 TCA 的專案和 issue 頁面中尋求答案或幫助。將你的心得和體會總結,並透過某種方式回饋給社區,也會為這個計畫的建造帶來好處。

想要進一步學習 TCA 的話,除了它本身帶有的幾個 demo 以外,Point-Free 實際上還開源了一個相當完整的專案:isowords。另外,他們主持的每週教學節目,也對包括 TCA 在內的許多 Swift 主題進行了非常深刻的討論,如果學有餘力,我個人十分推薦。

個人心得:SwiftUI + TCA 確實是很有挑戰,本人是從 UIKit + MVC 架構開始學習 iOS 開發,從中從 MVC 到 MVVM,還是基於 UIKit,所以學習曲線很順,但現在 SwiftUI + TCA 學習曲線真的很抖,而且 TCA 的變化很快,目前最新版是 2025/06/07 的 1.20.2,在透過王巍 (onevcat) 大大的文章學習時,有很多語法已經變化很多,另外還有 SwiftUI 變化也很快,尤其從 iOS 17 之後有了 Observation 的特性,在 model 不用遵守 Observable Object 的寫法,效能就相差巨大,而且 TCA 的寫法也變很多,例如可以使用 Observation 的 @Bindable ,但近來隨著 iOS 版本的上升,很多舊版本開始棄用,很多 app 可能最低支援都是 iOS 15, 16 了,所以現在是個好時機,好好使用 SwiftUI,可以把專案內一些比較不重要,且單純又獨立的頁面或功能,嘗試 SwiftUI + TCA 了。

最後附上 demo,使用 Xcode 16.4,TCA 1.20.2

參考了

留言
avatar-img
留言分享你的想法!
avatar-img
CHENGYANG的沙龍
0會員
17內容數
CHENGYANG的沙龍的其他內容
2025/06/08
這篇是閱讀了喵神的文章 TCA - SwiftUI 的救星?,這一系列喵神分成了四篇來講解,每篇都有許多重點,而且也是從易到難,很好閱讀,以下是個人的學習筆記,內容幾乎原文搬過來的,但也有一些是學習過程中額外補充的,因為有語法的更新,所以建議可以先閱讀原文,假如遇到語法不支援之類的問題,在參考這邊。
Thumbnail
2025/06/08
這篇是閱讀了喵神的文章 TCA - SwiftUI 的救星?,這一系列喵神分成了四篇來講解,每篇都有許多重點,而且也是從易到難,很好閱讀,以下是個人的學習筆記,內容幾乎原文搬過來的,但也有一些是學習過程中額外補充的,因為有語法的更新,所以建議可以先閱讀原文,假如遇到語法不支援之類的問題,在參考這邊。
Thumbnail
2025/05/17
What is Observation? Observation 是 Swift 中用於追蹤屬性變化的新功能,透過 macro 對它們進行轉換。在 SwiftUI 裡針對特別的 property 發生變化時,才重新計算 View 的 body。
Thumbnail
2025/05/17
What is Observation? Observation 是 Swift 中用於追蹤屬性變化的新功能,透過 macro 對它們進行轉換。在 SwiftUI 裡針對特別的 property 發生變化時,才重新計算 View 的 body。
Thumbnail
2025/01/09
觀看 WWDC23 Write Swift macros 筆記 Overview Apple 一開始提出一個範例,把計算的過程顯示成字串,用一個 tuple 組合起來,但這種方式很明顯,有個大缺點,就是容易有人為疏失,導致錯誤發生,還無法用 compiler 檢查字串是否相等於左邊的算數。
Thumbnail
2025/01/09
觀看 WWDC23 Write Swift macros 筆記 Overview Apple 一開始提出一個範例,把計算的過程顯示成字串,用一個 tuple 組合起來,但這種方式很明顯,有個大缺點,就是容易有人為疏失,導致錯誤發生,還無法用 compiler 檢查字串是否相等於左邊的算數。
Thumbnail
看更多
你可能也想看
Thumbnail
2025 vocus 推出最受矚目的活動之一——《開箱你的美好生活》,我們跟著創作者一起「開箱」各種故事、景點、餐廳、超值好物⋯⋯甚至那些讓人會心一笑的生活小廢物;這次活動不僅送出了許多獎勵,也反映了「內容有價」——創作不只是分享、紀錄,也能用各種不同形式變現、帶來實際收入。
Thumbnail
2025 vocus 推出最受矚目的活動之一——《開箱你的美好生活》,我們跟著創作者一起「開箱」各種故事、景點、餐廳、超值好物⋯⋯甚至那些讓人會心一笑的生活小廢物;這次活動不僅送出了許多獎勵,也反映了「內容有價」——創作不只是分享、紀錄,也能用各種不同形式變現、帶來實際收入。
Thumbnail
嗨!歡迎來到 vocus vocus 方格子是台灣最大的內容創作與知識變現平台,並且計畫持續拓展東南亞等等國際市場。我們致力於打造讓創作者能夠自由發表、累積影響力並獲得實質收益的創作生態圈!「創作至上」是我們的核心價值,我們致力於透過平台功能與服務,賦予創作者更多的可能。 vocus 平台匯聚了
Thumbnail
嗨!歡迎來到 vocus vocus 方格子是台灣最大的內容創作與知識變現平台,並且計畫持續拓展東南亞等等國際市場。我們致力於打造讓創作者能夠自由發表、累積影響力並獲得實質收益的創作生態圈!「創作至上」是我們的核心價值,我們致力於透過平台功能與服務,賦予創作者更多的可能。 vocus 平台匯聚了
Thumbnail
先前提到 Quasar 的 Dialog Plugin 很好用,再讓我補充一個用法。
Thumbnail
先前提到 Quasar 的 Dialog Plugin 很好用,再讓我補充一個用法。
Thumbnail
本文介紹瞭如何在SwiftUI中調整元件的對齊方式,包括置中、向左/向右/向上/向下對齊的方法。透過調整HStack、VStack以及frame的maxWidth、maxHeight和alignment屬性,可以達到想要的對齊效果。
Thumbnail
本文介紹瞭如何在SwiftUI中調整元件的對齊方式,包括置中、向左/向右/向上/向下對齊的方法。透過調整HStack、VStack以及frame的maxWidth、maxHeight和alignment屬性,可以達到想要的對齊效果。
Thumbnail
本文檔介紹了在Swift中使用套件的詳細方法,包括如何引用第三方套件和自定義模組,如何創建自定義套件,以及一些常見的Swift套件。這些套件可以幫助開發者快速添加功能到項目中,提高開發效率和程式碼品質。
Thumbnail
本文檔介紹了在Swift中使用套件的詳細方法,包括如何引用第三方套件和自定義模組,如何創建自定義套件,以及一些常見的Swift套件。這些套件可以幫助開發者快速添加功能到項目中,提高開發效率和程式碼品質。
Thumbnail
此章節旨在解釋Swift語言中函數的基本結構和操作方式,包括函數的聲明、呼叫、參數和返回值。閱讀這個章節可以幫助你理解並掌握如何在Swift編程中有效地使用和管理函數。
Thumbnail
此章節旨在解釋Swift語言中函數的基本結構和操作方式,包括函數的聲明、呼叫、參數和返回值。閱讀這個章節可以幫助你理解並掌握如何在Swift編程中有效地使用和管理函數。
Thumbnail
本章節介紹了如何建立並設置Swift項目以及如何選擇和設置Swift代碼編輯器。這包括在Xcode和命令行中建立Swift項目,選擇Xcode、Visual Studio Code或AppCode作為編輯器,以及如何使用SPM安裝插件。
Thumbnail
本章節介紹了如何建立並設置Swift項目以及如何選擇和設置Swift代碼編輯器。這包括在Xcode和命令行中建立Swift項目,選擇Xcode、Visual Studio Code或AppCode作為編輯器,以及如何使用SPM安裝插件。
Thumbnail
本章節旨在為讀者提供Swift程式語言的基礎知識,包括其基本語法、註解方法和變數使用方式,並通過具體的程式碼示例來說明這些概念。這將幫助讀者理解Swift的基本結構,並學會如何在Swift中定義變數並使用註解。
Thumbnail
本章節旨在為讀者提供Swift程式語言的基礎知識,包括其基本語法、註解方法和變數使用方式,並通過具體的程式碼示例來說明這些概念。這將幫助讀者理解Swift的基本結構,並學會如何在Swift中定義變數並使用註解。
Thumbnail
這份文件的目的是介紹Swift語言,包括它的特性、應用範疇,以及誰在使用它。它也提供了一些學習Swift的資源和工具,以及一些常見的Swift庫和框架。
Thumbnail
這份文件的目的是介紹Swift語言,包括它的特性、應用範疇,以及誰在使用它。它也提供了一些學習Swift的資源和工具,以及一些常見的Swift庫和框架。
Thumbnail
Part.1 搞定基本的 UI 開始開發 iOS App。 首先準備一台 Mac,然後安裝 Xcode,新增專案,系統即刻生成基本的專案結構。coding 的起點在檔案 ContentView.swift: import SwiftUI struct ContentView: View {  
Thumbnail
Part.1 搞定基本的 UI 開始開發 iOS App。 首先準備一台 Mac,然後安裝 Xcode,新增專案,系統即刻生成基本的專案結構。coding 的起點在檔案 ContentView.swift: import SwiftUI struct ContentView: View {  
Thumbnail
這是為了搭建自己想要的工作流而開始的研究工作。
Thumbnail
這是為了搭建自己想要的工作流而開始的研究工作。
Thumbnail
ComfyUI教學第一階段之[全面安裝指南],帶你一步一步從頭做起,它確實沒有那麼簡單,卻也沒有那麼困難。本篇介紹從安裝前準備、安裝步驟,到添加擴充功能。帶你開啟AI算圖的深度旅程。配有影片。
Thumbnail
ComfyUI教學第一階段之[全面安裝指南],帶你一步一步從頭做起,它確實沒有那麼簡單,卻也沒有那麼困難。本篇介紹從安裝前準備、安裝步驟,到添加擴充功能。帶你開啟AI算圖的深度旅程。配有影片。
追蹤感興趣的內容從 Google News 追蹤更多 vocus 的最新精選內容追蹤 Google News