Upgrade to Pro — share decks privately, control downloads, hide ads and more …

為什麼你並不需要ViewModel / No, you don't need a ViewModel

為什麼你並不需要ViewModel / No, you don't need a ViewModel

No, in SwiftUI you don't need a ViewModel in the MVVM manner!

Avatar for Elvis Shi

Elvis Shi

July 25, 2026

More Decks by Elvis Shi

Other Decks in Programming

Transcript

  1. 初代 iPhone 诞⽣ 初代 Macintosh 诞⽣ 9 Years 1978 第⼀個專為

    GUI 打造的架構誕⽣ 1984 1998 初代 iMac G 诞⽣ 2007
  2. 原初 MVC 逐漸⾯臨嚴峻考驗 ·傳統 View 的設計開始難以應對新的需求 ·OS 對視窗管理⽇益成熟,GUI 框架開始主 導畫⾯的執⾏流程,使本來只負責畫⾯的

    View 必須回應框架的呼叫,職責愈發複雜 ·原初 MVC 的實作⾼度仰賴 Smalltalk 語⾔的 獨有機制 ·事實上,Smalltalk 的 Dependents 等機制 正是為了⽀援原初 MVC ⽽⽣
  3. 原初 MVC 逐漸⾯臨嚴峻考驗 ·傳統 View 的設計開始難以應對新的需求 ·OS 對視窗管理⽇益成熟,GUI 框架開始主 導畫⾯的執⾏流程,使本來只負責畫⾯的

    View 必須回應框架的呼叫,職責愈發複雜 ·原初 MVC 的實作⾼度仰賴 Smalltalk 語⾔的 獨有機制 ·事實上 Smalltalk 的 Dependents 等機制 就是為了原初 MVC ⽽⽣
  4. Cocoa MVC 的演化 ·將 Controller 與 View 的地位對調 ·原本由 View

    承擔的回應 GUI 框架的職責被 移交給 Controller,使 View 能重新專注於 畫⾯本⾝ ·原初 MVC 的實作⾼度仰賴 Smalltalk 語⾔的 獨有機制 ·事實上 Smalltalk 的 Dependents 等機制 就是為了原初 MVC ⽽⽣
  5. Cocoa MVC 的演化 ·將 Controller 與 View 的地位對調 ·原本由 View

    承擔的回應 GUI 框架的職責被 移交給 Controller,使 View 能重新專注於 畫⾯本⾝ ·Controller 同時參與協調 View 與 Model 之間 的互動 ·資料同步的實作開始不再依賴特定語⾔所 提供的機制
  6. Cocoa MVC 的演化 ·將 Controller 與 View 的地位對調 ·原本由 View

    承擔的回應 GUI 框架的職責被 移交給 Controller,使 View 能重新專注於 畫⾯本⾝ ·Controller 同時參與協調 View 與 Model 之間 的互動 ·資料同步的實作開始不再依賴特定語⾔所 提供的機制 權限 職責 能⼒愈⼤,責任愈⼤
  7. MVP 的實際結構 使用者操作 View Controller Presenter Controller 更新 通知 操作

    Model ※微軟在 21 世紀初採用的架構 ※很顯然它和 Cocoa MVC 有著類似的 Fat Presenter 問題
  8. MVVM on Cocoa 的實際結構 使用者操作 View Controller 手搓 Data Binding

    (e.g. RxSwift) 更新 Controller ViewModel Controller 通知 更新 Model Model Model
  9. MVVM on Cocoa 的痛點 ·沒有框架原⽣的 Data Binding 機制⽀援 ·只能依靠⼯程師⼿搓 Data

    Binding 機制並 ⼿動設置 Binding 節點 ·並未從結構上杜絕將 Controller 誤⽤成 God Object 的可能性 ·不過也的確為 Controller 的職責邊界⼈為 划出了⼀條可執⾏度更⾼的判斷依據
  10. MVVM on Cocoa 的痛點 ·沒有框架原⽣的 Data Binding 機制⽀援 ·只能依靠⼯程師⼿搓 Data

    Binding 機制並 ⼿動設置 Binding 節點 ·並未從結構上杜絕將 Controller 誤⽤成 God Object 的可能性 ·不過也的確為 Controller 的職責邊界⼈為 划出了⼀條可執⾏度更⾼的判斷依據
  11. SwiftUI 的構成元件與互動 View struct SomeView: View { Model ※ /*

    struct SomeView: View */ { @State private var someState// ... // ... } @AppStorage("SomeAppStorage") var someAppStorage// ... @Environment(\.someEnvironment) var someEnvironment// ... // ... } ※這裡為了展示互動,代碼是 Model 的取得與持有,而非 Model 的實裝
  12. SwiftUI 的特點 SwiftUI 不需要協調 View 和 Model 的中介層 也就不存在像 UIKit

    的 FatVC 那樣需要 由 MVVM 解決的問題 View struct SomeView: View { // ... } Model /* struct SomeView: View */ { @State private var someState// ... @AppStorage("SomeAppStorage") var someAppStorage// ... @Environment(\.someEnvironment) var someEnvironment// ... // ... }
  13. Cocoa MVC 各元件的職責(理論) Controller 協調畫面流程 維護 View Hierarchy View 取得/持有

    Model 接收 Model 結果 Model 畫面組合 將結果傳遞給 View 業務邏輯 畫面排版 …… 資料存取 呼叫 Model 畫面呈現 網路連接 …… ……
  14. Cocoa MVC 各元件的職責(實務) Controller 協調畫面流程 維護 View Hierarchy View 取得/持有

    Model 接收 Model 結果 Model 畫面組合 將結果傳遞給 View 業務邏輯 畫面排版 畫面組合 資料存取 畫面呈現 畫面排版 網路連接 …… …… …… 呼叫 Model
  15. Cocoa MVC 的 Model 以外各元件的職責(實務) View 畫面組合 畫面排版 Controller 的

    越權實作 導致職責邊界 逐漸模糊化 Controller 協調畫面流程 維護 View Hierarchy 取得/持有 Model 呼叫 Model 接收 Model 結果 將結果傳遞給 View 畫面組合 畫面呈現 畫面排版 …… ……
  16. Cocoa MVC 如何更改畫⾯排版 正確實作 final class ProfileViewController: UIViewController { final

    class ProfileViewController: UIViewController { @IBOutlet private weak var profileView: ProfileView! @IBOutlet private weak var avatarTop: NSLayoutConstraint! @IBAction func expandProfileDisplay() { profileView.setAvatarLower(true) } @IBAction func expandProfileDisplay() { avatarTop.constant = 32 view.layoutIfNeeded() } } final class ProfileView: UIView { @IBOutlet private weak var avatarTop: NSLayoutConstraint! func setAvatarLower(_ isLower: Bool) { avatarTop.constant = isLower ? 32 : 8 layoutIfNeeded() } } 常⾒實作 }
  17. 職責模糊化的危害 final class ProfileViewController: UIViewController { @IBOutlet private weak var

    profileView: ProfileView! & 這個功能我寫在哪兒來著? final class ProfileViewController: UIViewController { @IBOutlet private weak var avatarTop: NSLayoutConstraint! ' 怎麼修好了一個 bug,結果 又跑出來了另一個 bug? @IBAction func expandProfileDisplay() { profileView.setAvatarLower(true) } } @IBAction func expandProfileDisplay() { avatarTop.constant = 32 view.layoutIfNeeded() } } final class ProfileView: UIView { ( 怎麼我明明改好了,畫面卻 完全沒有變化? @IBOutlet private weak var avatarTop: NSLayoutConstraint! func setAvatarLower(_ isLower: Bool) { avatarTop.constant = isLower ? 32 : 8 layoutIfNeeded() } } ) 怎麼到處都是重複實作,想 重構都不知道從哪裡下手!
  18. 同樣處理在 SwiftUI 中的實作 struct ProfileView: View { @State private var

    expanded = false var avatarTopPadding: CGFloat { expanded ? 32 : 8 } var body: some View { VStack { Button("Expand") { expanded = true } AvatarView() .padding(.top, avatarTopPadding) } } }
  19. 同樣處理在 SwiftUI 中的實作 SwiftUI 從設計上就 杜絕了讓其他元件 越權處理 View 職責 的機會

    struct ProfileView: View { @State private var expanded = false var avatarTopPadding: CGFloat { expanded ? 32 : 8 } var body: some View { VStack { Button("Expand") { expanded = true } AvatarView() .padding(.top, avatarTopPadding) } } } 至於越權處理 Model 職責的問題,MVVM 的 ViewModel 也是如此
  20. SwiftUI 的 Model 以外各個元件的職責 Controller View 協調畫面流程 維護 View Hierarchy

    取得/持有 Model 呼叫 Model 接收 Model 結果 將結果傳遞給 View 畫面組合 畫面排版 畫面呈現 …… ……嗎?
  21. SwiftUI 的 Model 以外各個元件的職責 Controller View 協調畫面流程 維護 View Hierarchy

    取得/持有 Model ※ 呼叫 Model ※ 接收 Model 結果 ※ 畫面組合 struct ProfileView: View { @State private var expanded = false var avatarTopPadding: CGFloat { expanded ? 32 : 8 } var body: some View { VStack { Button("Expand") { expanded = true } AvatarView() .padding(.top, avatarTopPadding) 畫面排版 畫面呈現 } } } …… ※準確來說這裡的 `expanded` 的職責更接近 View State 而非 Model,不過 Model 也可以 完全相同的方式取得/持有/呼叫/接收結果
  22. SwiftUI 的 Model 以外各個元件的職責 View ※ View 不負責協調畫面的更新流程 struct ProfileView:

    View { @State private var expanded = false var avatarTopPadding: CGFloat { expanded ? 32 : 8 } 協調畫面流程 維護 View Hierarchy View 不直接維護 View Hierarchy✳ 取得/持有 Model var body: some View { VStack { Button("Expand") { expanded = true } 呼叫 Model 接收 Model 結果 ☆ View 不直接控制畫面呈現 畫面組合 AvatarView() .padding(.top, avatarTopPadding) 畫面排版 } 畫面呈現 …… } } ※畫面的更新流程均由 SwiftUI 引擎自行控制 ✳View Hierarchy 的建立、更新與移除均由 SwiftUI 引擎自動處理 ☆畫面的具體渲染均由 SwiftUI 引擎自動處理
  23. SwiftUI 的 Model 以外各個元件的職責 SwiftUI 引擎 協調畫面流程 維護 View Hierarchy

    View 取得/持有 Model 呼叫 Model 接收 Model 結果 畫面組合 畫面排版 …… 畫面呈現 …… 后 容 內 的 決 解 己 自 多 擎 別 引 I 特 U t 不 f i 並 w S 實 其 刨除 責 職 的 w e i V . I U t f i Sw
  24. SwiftUI 的 View 与 MVVM 的 ViewModel 的職責 SwiftUI View

    MVVM ViewModel 取得/持有 Model 取得/持有 Model 呼叫 Model 呼叫 Model 接收 Model 結果 接收 Model 結果 畫面組合 連接畫面 畫面排版 …… ……
  25. SwiftUI 的 View 与 MVVM 的 ViewModel 的職責 SwiftUI View

    取得/持有 Model 呼叫 Model 接收 Model 結果 畫面組合 畫面排版 …… SwiftUI 不存在 必須額外引⼊ ViewModel 的理由 MVVM ViewModel 取得/持有 Model 呼叫 Model 接收 Model 結果 連接畫面 ……
  26. 眾所周知,畫⾯不好寫測試 使用者操作 顯示 輸入 輸出 / 狀態改變 操作 畫⾯ 資料

    Model 那我把重要的處理 ViewModel 交給 ViewModel 新 更 然後給 ViewModel 料 資 令 指 務 寫測試不就好了 業 傳遞 傳遞
  27. 捋⼀捋邏輯 使用者操作 顯示 畫⾯ 資料 操作 傳遞 傳遞 ViewModel 輸入

    輸出 / 狀態改變 Model 料 資 新 更 務 業 令 指 因為畫面不好寫測試 所以我創建了一個全新 的 ViewModel 對象,然 後給這個新對象寫測試 現在我說我給畫面寫好 測試了
  28. 捋⼀捋邏輯 使用者操作 顯示 畫⾯ 資料 操作 傳遞 傳遞 ViewModel 輸入

    輸出 / 狀態改變 Model 料 資 新 更 務 業 令 指 因為畫面不好寫測試 所以我創建了一個全新 的 ViewModel 對象,然 後給這個新對象寫測試 ? 現在我說我給畫面寫好 測試了
  29. ViewModel 並沒有⽅便給畫⾯寫測試 使用者操作 顯示 輸入 輸出 / 狀態改變 畫⾯ Model

    資料 操作 傳遞 你測到的是 ViewModel, ViewModel 不是畫⾯。 傳遞 料 資 新 更 務 業 令 指
  30. 眾所周知,畫⾯不好寫測試 ……嗎? 使用者操作 struct ProfileView: View { 畫⾯ var body:

    some View { VStack { Button("Expand") { expanded = true } 顯示 操作 @State private var expanded = false var avatarTopPadding: CGFloat { expanded ? 32 : 8 } } } } AvatarView() .padding(.top, avatarTopPadding) 資料 傳遞 傳遞 ViewModel 輸入 輸出 / 狀態改變 Model 料 資 新 更 務 業 令 指
  31. 眾所周知,畫⾯不好寫測試 ……嗎? 沒有生命週期的 struct 畫⾯ struct ProfileView: View { Swi

    ftUI @State private var expanded = false var avatarTopPadding: CGFloat { expanded ? 32 : 8 } 的畫 理論上 SwiftUI 的畫⾯ 應該很好寫測試 面 var body: some View { VStack { Button("Expand") { expanded = true } 狀態的集合 AvatarView() .padding(.top, avatarTopPadding) } } }
  32. 眾所周知,畫⾯不好寫測試 畫⾯ @State 等 Property Wrapper 需要依賴 SwiftUI Runtime struct

    ProfileView: View { Swi ftUI @State private var expanded = false var avatarTopPadding: CGFloat { expanded ? 32 : 8 } 的畫 面 var body: some View { VStack { Button("Expand") { expanded = true } 不還是沒法 寫測試! AvatarView() .padding(.top, avatarTopPadding) } } } View 實例本身拿不到 View Tree
  33. 眾所周不知,畫⾯完全可以寫測試 struct ProfileView: View { var _executionOnUnitTest: ((Self) -> Void)?

    @State private var expanded = false var avatarTopPadding: CGFloat { expanded ? 32 : 8 } var body: some View { VStack { Button("Expand") { expanded = true } 只需加入少量程式碼 建立僅供 Unit Test 使用的 Hook AvatarView() .padding(.top, avatarTopPadding) } .task { _executionOnUnitTest?(self) } } } ※不會改變正式環境中的畫面行為
  34. 眾所周不知,畫⾯完全可以寫測試 @MainActor @Test func test_avatarTopPadding() async throws { var sut

    = ProfileView() struct ProfileView: View { var testFinished = false let _ = sut.on(\._executionOnUnitTest) { inspected in defer { testFinished = true } var _executionOnUnitTest: ((Self) -> Void)? do { @State private var expanded = false var avatarTopPadding: CGFloat { expanded ? 32 : 8 } var body: some View { VStack { Button("Expand") { expanded = true } // 初始狀態應為 8 let initial = try inspected .actualView() .avatarTopPadding #expect(initial == 8) // 按下 Expand 按鈕 try inspected .find(button: "Expand") .tap() 直接測試 View // 按下按鈕後應為 32 let after = try inspected .actualView() .avatarTopPadding #expect(after == 32) } catch { #expect(false, "Unexpected error: \(error)") } AvatarView() .padding(.top, avatarTopPadding) } .task { _executionOnUnitTest?(self) } } ViewHosting.host(view: sut) defer { ViewHosting.expel() } await Task.yield() #expect(testFinished) } } }
  35. 眾所周不知,畫⾯完全可以寫測試 照常建立 View 實例 struct ProfileView: View { @MainActor @Test

    func test_avatarTopPadding() async throws { var sut = ProfileView() var testFinished = false let _ = sut.on(\._executionOnUnitTest) { inspected in defer { testFinished = true } var _executionOnUnitTest: ((Self) -> Void)? 透過 Hook 注入 View 的測試邏輯 @State private var expanded = false do { var avatarTopPadding: CGFloat { expanded ? 32 : 8 } 驗證屬性的初始狀態 var body: some View { VStack { Button("Expand") { expanded = true } 給定輸入(按下按鈕) // 初始狀態應為 8 let initial = try inspected .actualView() .avatarTopPadding #expect(initial == 8) View 的 單元測試 // 按下 Expand 按鈕 try inspected .find(button: "Expand") .tap() // 按下按鈕後應為 32 let after = try inspected .actualView() .avatarTopPadding #expect(after == 32) } catch { #expect(false, "Unexpected error: \(error)") } AvatarView() .padding(.top, avatarTopPadding) 驗證屬性的更新後狀態 } .task { _executionOnUnitTest?(self) } } ViewHosting.host(view: sut) defer { ViewHosting.expel() } await Task.yield() #expect(testFinished) } } }
  36. 眾所周不知,畫⾯完全可以寫測試 @MainActor @Test func test_avatarTopPadding() async throws { var sut

    = ProfileView() struct ProfileView: View { var testFinished = false let _ = sut.on(\._executionOnUnitTest) { inspected in defer { testFinished = true } var _executionOnUnitTest: ((Self) -> Void)? do { @State private var expanded = false var avatarTopPadding: CGFloat { expanded ? 32 : 8 } // 初始狀態應為 8 let initial = try inspected .find(AvatarView.self) .padding(.top) #expect(initial == 8) 你甚至可以跳過 avatarTopPadding 屬性 var body: some View { View 的 直接驗證 VStack { AvatarView 的實際渲染值 單元測試 // 按下 Expand 按鈕 try inspected .find(button: "Expand") .tap() Button("Expand") { expanded = true } // 按下按鈕後應為 32 let after = try inspected .find(AvatarView.self) .padding(.top) #expect(after == 32) } catch { #expect(false, "Unexpected error: \(error)") } AvatarView() .padding(.top, avatarTopPadding) } .task { } _executionOnUnitTest?(self) ViewHosting.host(view: sut) } ※透過 ViewInspector,我們還能針對 View 測試更多渲染結果與互動行為。 defer { ViewHosting.expel() } } await Task.yield() } 有興趣的朋友,可以參考 GitHub 上的 ViewInspector 專案。 #expect(testFinished) }
  37. 眾所周不知,畫⾯完全可以寫測試 @MainActor @Test func test_avatarTopPadding() async throws { var sut

    = ProfileView() struct ProfileView: View { 你完全不需要 為了給畫⾯寫測試 引⼊ ViewModel var testFinished = false let _ = sut.on(\._executionOnUnitTest) { inspected in defer { testFinished = true } var _executionOnUnitTest: ((Self) -> Void)? do { @State private var expanded = false var avatarTopPadding: CGFloat { expanded ? 32 : 8 } var body: some View { VStack { Button("Expand") { expanded = true } // 初始狀態應為 8 let initial = try inspected .find(AvatarView.self) .padding(.top) #expect(initial == 8) View 的 單元測試 // 按下 Expand 按鈕 try inspected .find(button: "Expand") .tap() // 按下按鈕後應為 32 let after = try inspected .find(AvatarView.self) .padding(.top) #expect(after == 32) } catch { #expect(false, "Unexpected error: \(error)") } AvatarView() .padding(.top, avatarTopPadding) } .task { _executionOnUnitTest?(self) } } ViewHosting.host(view: sut) defer { ViewHosting.expel() } await Task.yield() #expect(testFinished) } } }
  38. struct ItemsView: View { @State private var viewModel: ViewModel! @Query

    private var items: [Item] @Environment(\.modelContext) private var modelContext @Environment(NetworkController.self) private var networkController var body: some View { NavigationStack { Group { if let viewModel { if viewModel.isLoading { // Display the loading view } else if viewModel.errorOccurred { // Display the error view } else { List(items) { item in // Display the list of restaurants } } } } .task { guard viewModel == nil else { return } viewModel = ViewModel( modelContext: modelContext, networkController: networkController ) await viewModel.load() } } } }
  39. struct ItemsView: View { @State private var viewModel: ViewModel! @Query

    private var items: [Item] @Environment(\.modelContext) private var modelContext @Environment(NetworkController.self) private var networkController var body: some View { NavigationStack { Group { if let viewModel { if viewModel.isLoading { // Display the loading view } else if viewModel.errorOccurred { // Display the error view } else { List(items) { item in // Display the list of restaurants } } } } .task { guard viewModel == nil else { return } viewModel = ViewModel( modelContext: modelContext, networkController: networkController ) await viewModel.load() } } } }
  40. struct ItemsView: View { @State private var viewModel: ViewModel! 原本不需要的驚嘆號!※

    @Query private var items: [Item] @Environment(\.modelContext) private var modelContext @Environment(NetworkController.self) private var networkController var body: some View { // ... .task { guard viewModel == nil else { return } viewModel = ViewModel( modelContext: modelContext, networkController: networkController ) await viewModel.load() } } } } 被迫延後的初始化! ※SwiftUI 好不容易把來自 UIKit 的 `!` 從程式碼中去掉,為什麼要用 MVVM 把它帶回來!?
  41. struct ItemsView: View { @State private var viewModel: ViewModel! @Query

    private var items: [Item] @Environment(\.modelContext) private var modelContext @Environment(NetworkController.self) private var networkController var body: some View { // ... 來自 @Environment .task { guard viewModel == nil else { return } viewModel = ViewModel( modelContext: modelContext, 依賴 networkController: networkController ) await viewModel.load() } } } @Environment 由 SwiftUI 控制 建⽴ View 時未必已可取得 } 因此只能延後初始化 ViewModel
  42. struct ItemsView: View { @State private var viewModel: ViewModel! Some

    Super @Query private var items: [Item] View @Environment(\.modelContext) private var modelContext @State @Environment(NetworkController.self) private var networkController Model var body: some View { View // ... 參照 Hierarchy .task { guard viewModel == nil else { return } @Environment viewModel = ViewModel( Model modelContext: modelContext, View networkController: networkController ) 複製 @State await viewModel.load() ViewModel } ※ Model } } 由 @State 管理的 Model,被複製到 另⼀個 @State 的 ViewModel ⾥, 是典型的 Single Source of Truth 原則違反 } ※即使兩者最初指向同一個 instance,保存 reference 的 state slot 仍然已被複製; 當原本的 @State 被替換時,ViewModel 中的 reference 不會自動更新。
  43. MVVM 的 ViewModel 並不契合 SwiftUI 的設計 struct ItemsView: View {

    @State private var viewModel: ViewModel! @Query private var items: [Item] @Environment(\.modelContext) private var modelContext @Environment(NetworkController.self) private var networkController var body: some View { // ... .task { guard viewModel == nil else { return } viewModel = ViewModel( modelContext: modelContext, networkController: networkController ) await viewModel.load() } } } } ViewModel 的依賴建⽴⽅式 與 SwiftUI 的⽣命週期衝突 ViewModel 的依賴持有⽅式 與 SwiftUI 的 SSOT 原則衝突
  44. struct ItemsView: View { @State private var isLoading = false

    @State private var errorMessage: String? @Query(sort: \Item.name) private var items: [Item] @Environment(\.loadItems) var loadItems: LoadItemsAction var _executionOnUnitTest: ((Self) -> Void)? var body: some View { // ... .task { _executionOnUnitTest?(self) } .task { isLoading = true defer { isLoading = false } do { errorMessage = nil try await loadItems() } catch { errorMessage = error.localizedDescription } } } } 直接持有 View State 僅取得職責單一且 必要的 Action 僅供測試的 Hook 直接控制 Action 執行期間的 View State
  45. struct ItemsView: View { // ... @Environment(\.loadItems) var loadItems: LoadItemsAction

    // ... } struct LoadItemsAction { // Another file in Domain scope private let operation: @MainActor () async throws -> Void private init(operation: @escaping @MainActor () async throws -> Void = {}) { self.operation = operation } init( modelContext: ModelContext, networkController: NetworkController?, ) { self.init { // 真正的行為... 真正持有的執行內容 } } 只有正式環境 知道真正的依賴 func callAsFunction() async throws { try await operation() } 不對外開放的 initializer 對外開放的正式環境用 initializer 對外開放的呼出處理 (Callable value) static func stub(_ operation: @escaping @MainActor () async throws -> Void) -> Self { .init(operation: operation) } Preview 與 Test 專用的客製化入口
  46. struct ItemsView: View { // ... @Environment(\.loadItems) var loadItems: LoadItemsAction

    // ... } 由既有 EnvironmentValues 組合出的 Computed Property extension EnvironmentValues { // Another file @Entry var networkController: NetworkController? var loadItems: LoadItemsAction { LoadItemsAction( modelContext: modelContext, networkController: networkController, ) } } 直接使用 EnvironmentValues 既有的 modelContext 真正由 @Entry 保存於 EnvironmentValues 的可注入依賴
  47. struct ItemsView: View { // ... @Environment(\.loadItems) var loadItems: LoadItemsAction

    @Environment(\.deleteItems) var deleteItems: DeleteItemsAction // ... } extension EnvironmentValues { // Another file @Entry var networkController: NetworkController? ※ 功能增加時,就新增一個 Action var loadItems: LoadItemsAction { LoadItemsAction( modelContext: modelContext, networkController: networkController, ) } var deleteItems: DeleteItemsAction { DeleteItemsAction( modelContext: modelContext, networkController: networkController, ) } } ※反過來,若其他 View 需要相同功能,也可以直接从 Environment 取得同一個 Action。 Action 本就是最小執行單位,不會複製狀態,也不會讓 View 知道不需要的知識。
  48. struct ItemsView: View { // ... @Environment(\.loadItems) var loadItems: LoadItemsAction

    // ... ※ } // ... #Preview("Failure") { ItemsView( loadItems: .init(\.preview.loadFailureAction), ) } private struct ItemsPreviewValues { enum Error: Swift.Error { case loadFailure } // ... var loadFailureAction: LoadItemsAction { .stub { throw Error.loadFailure } } } private extension EnvironmentValues { var preview: ItemsPreviewValues { ItemsPreviewValues() } } ※由於 Action 並非透過 @Entry 定義,因此無法使用一般的 .environment(\.loadItems, 強制替換 loadItems (好孩子請勿在正式環境中模仿喔) 自訂 Preview 要呈現的行為 Preview 專用的命名空間 (非必要,這部分只是個人很喜歡) value) 注入; 此替換方式僅限 Preview 與測試環境。