Upgrade to Pro
— share decks privately, control downloads, hide ads and more …
Speaker Deck
Features
Speaker Deck
PRO
Sign in
Sign up for free
Search
Search
為什麼你並不需要ViewModel / No, you don't need a ViewModel
Search
Elvis Shi
July 25, 2026
Programming
5
0
Share
Embed
Copy iframe code
Copy JS code
Copy link
Start on current slide
為什麼你並不需要ViewModel / No, you don't need a ViewModel
No, in SwiftUI you don't need a ViewModel in the MVVM manner!
Elvis Shi
July 25, 2026
More Decks by Elvis Shi
See All by Elvis Shi
@Environment(\.keyPath)那么好我不允许你们不知道! / atEnvironment keyPath is so good and you should know it!
lovee
0
500
ゼロから始めるPreferenceの実装 / Let's implement Preferences from scratch
lovee
0
150
Kotlin エンジニアへ送る:Swift 案件に参加させられる日に備えて~似てるけど色々違う Swift の仕様 / from Kotlin to Swift
lovee
1
390
個人アプリを2年ぶりにアプデしたから褒めて / I just updated my personal app, praise me!
lovee
0
750
How did I build an Open-Source SwiftUI Toast Library
lovee
1
170
SwiftUIで使いやすいToastの作り方 / How to build a Toast system which is easy to use in SwiftUI
lovee
3
1.3k
SwiftUIで二重スクロール作ってみた / When I tried to make a dual-scroll-ish view in SwiftUI
lovee
1
390
Observation のあれこれ / A brief introduction about Observation
lovee
3
440
ChatGPT 時代の勉強 / Learning under ChatGPT era
lovee
27
9k
Other Decks in Programming
See All in Programming
Prismを使った型安全な暗号化_関数型まつり2026
_fhhmm
0
150
Claude Team Plan導入・ガイド
tk3fftk
0
220
AIキャラアプリkaiwaの低遅延音声通話基盤をどう作ったか - AWS Gravitonで支える低遅延・低コストAI Agent基盤
mogamit
0
180
【やさしく解説 設計編・中級 #4】ルールの寿命と、システムの年輪
panda728
PRO
2
160
yield再入門 #phpcon
o0h
PRO
0
700
Foundation Models frameworkで画像分析
ryodeveloper
1
130
Laravel Boostに学ぶ、AIにPHPを書かせる技術 〜OSSの実装から蒸留するエージェント制御の王道〜
kentaroutakeda
3
520
ITヒヤリハットを整理してみた ~ライフサイクルと原因から考える再発防止策~
koukimiura
1
110
AIエージェントで 変わるAndroid開発環境
takahirom
2
700
Apache Hive: そしてCloud Native Lakehouseへ
okumin
1
160
信頼性について考えてみる(SRE NEXT 2026 miniLT)
hayama17
0
200
才能?センス?知らん、 続けたもん勝ちだ。-- 結婚・出産・癌を越えてなお、私がプロダクトを創り続ける理由
16bitidol
2
890
Featured
See All Featured
Large-scale JavaScript Application Architecture
addyosmani
515
110k
Navigating Weather and Climate Data
rabernat
0
390
How to Create Impact in a Changing Tech Landscape [PerfNow 2023]
tammyeverts
55
3.4k
Bioeconomy Workshop: Dr. Julius Ecuru, Opportunities for a Bioeconomy in West Africa
akademiya2063
PRO
1
180
Design of three-dimensional binary manipulators for pick-and-place task avoiding obstacles (IECON2024)
konakalab
0
490
Mobile First: as difficult as doing things right
swwweet
225
10k
The AI Revolution Will Not Be Monopolized: How open-source beats economies of scale, even for LLMs
inesmontani
PRO
3
3.6k
Site-Speed That Sticks
csswizardry
13
1.3k
Visual Storytelling: How to be a Superhuman Communicator
reverentgeek
2
590
個人開発の失敗を避けるイケてる考え方 / tips for indie hackers
panda_program
123
22k
GraphQLの誤解/rethinking-graphql
sonatard
75
12k
SEO Brein meetup: CTRL+C is not how to scale international SEO
lindahogenes
1
2.8k
Transcript
為什麼你並不需要 ViewModel
下 架 框 I U t 在Swif 為什麼你並不需要 ViewModel MVVM架構意
義的
2022 年
None
None
MVVM
什么是MVVM
我知道MVVM !
我⽤过MVVM !
MVVM " FatVC
FatVC on MVC
1978年 第⼀個專為 GUI 打造的架構誕⽣#
CLI GUI ⭕ ⭕ 頻繁變動的畫⾯邏輯 ❌ ⭕ 額外增加的畫⾯互動 ❌ ⭕
⻑期穩定的業務邏輯
⻑期穩定的業務邏輯 Model 頻繁變動的畫⾯邏輯 View 額外增加的畫⾯互動 Controller
⻑期穩定的業務邏輯 Model 頻繁變動的畫⾯邏輯 View 額外增加的畫⾯互動 Controller
MVC 的 Mental Model 顯示 View 更新 Model 使用 Controller
操作
MVC 的實際結構 View View View 輸入 繪製 Controller Controller Controller
監看 更新 操作 Model
你胡扯! 我仔细阅读过苹果的开发者⽂档! ⾥⾯对MVC的结构描述根本不是这 样!
None
1978年 第⼀個專為 GUI 打造的架構誕⽣#
初代 Macintosh 诞⽣ 6 Years 1978 第⼀個專為 GUI 打造的架構誕⽣ 1984
初代 Macintosh 诞⽣ 14 Years 1978 第⼀個專為 GUI 打造的架構誕⽣ 1984
1998 初代 iMac G 诞⽣
初代 iPhone 诞⽣ 初代 Macintosh 诞⽣ 9 Years 1978 第⼀個專為
GUI 打造的架構誕⽣ 1984 1998 初代 iMac G 诞⽣ 2007
原初 MVC 逐漸⾯臨嚴峻考驗 ·傳統 View 的設計開始難以應對新的需求 ·OS 對視窗管理⽇益成熟,GUI 框架開始主 導畫⾯的執⾏流程,使本來只負責畫⾯的
View 必須回應框架的呼叫,職責愈發複雜 ·原初 MVC 的實作⾼度仰賴 Smalltalk 語⾔的 獨有機制 ·事實上,Smalltalk 的 Dependents 等機制 正是為了⽀援原初 MVC ⽽⽣
Cocoa MVC 的實際結構 使用者操作 View Controller Controller Controller 更新 通知
更新 Model
原初 MVC 逐漸⾯臨嚴峻考驗 ·傳統 View 的設計開始難以應對新的需求 ·OS 對視窗管理⽇益成熟,GUI 框架開始主 導畫⾯的執⾏流程,使本來只負責畫⾯的
View 必須回應框架的呼叫,職責愈發複雜 ·原初 MVC 的實作⾼度仰賴 Smalltalk 語⾔的 獨有機制 ·事實上 Smalltalk 的 Dependents 等機制 就是為了原初 MVC ⽽⽣
Cocoa MVC 的演化 ·將 Controller 與 View 的地位對調 ·原本由 View
承擔的回應 GUI 框架的職責被 移交給 Controller,使 View 能重新專注於 畫⾯本⾝ ·原初 MVC 的實作⾼度仰賴 Smalltalk 語⾔的 獨有機制 ·事實上 Smalltalk 的 Dependents 等機制 就是為了原初 MVC ⽽⽣
Cocoa MVC 的演化 ·將 Controller 與 View 的地位對調 ·原本由 View
承擔的回應 GUI 框架的職責被 移交給 Controller,使 View 能重新專注於 畫⾯本⾝ ·Controller 同時參與協調 View 與 Model 之間 的互動 ·資料同步的實作開始不再依賴特定語⾔所 提供的機制
Cocoa MVC 的演化 ·將 Controller 與 View 的地位對調 ·原本由 View
承擔的回應 GUI 框架的職責被 移交給 Controller,使 View 能重新專注於 畫⾯本⾝ ·Controller 同時參與協調 View 與 Model 之間 的互動 ·資料同步的實作開始不再依賴特定語⾔所 提供的機制 權限 職責 能⼒愈⼤,責任愈⼤
Cocoa MVC 的痛點 Controller 不僅有最⾼權限 且職責不斷膨脹 極易被誤⽤成 God Object 質
本 F 的 C V t a
MVP 的實際結構 使用者操作 View Controller Presenter Controller 更新 通知 操作
Model ※微軟在 21 世紀初採用的架構 ※很顯然它和 Cocoa MVC 有著類似的 Fat Presenter 問題
MVVM 的實際結構 View 資料繫結(Data Binding) Controller ViewModel Controller 通知 更新
Model Model Model
MVVM 的修改 ViewModel 不再有最⾼權限 即使作為中介層 也再無法形成 God Object
MVVM 的局限性 ViewModel ⾼度依賴 框架提供的 Data Binding ※ 機制 ※很顯然若框架不提供就只能自己手搓这个机制
手搓 Data Binding
MVVM on Cocoa 的實際結構 使用者操作 View Controller 手搓 Data Binding
(e.g. RxSwift) 更新 Controller ViewModel Controller 通知 更新 Model Model Model
MVVM on Cocoa 的痛點 ·沒有框架原⽣的 Data Binding 機制⽀援 ·只能依靠⼯程師⼿搓 Data
Binding 機制並 ⼿動設置 Binding 節點 ·並未從結構上杜絕將 Controller 誤⽤成 God Object 的可能性 ·不過也的確為 Controller 的職責邊界⼈為 划出了⼀條可執⾏度更⾼的判斷依據
MVVM on Cocoa 的痛點 ·沒有框架原⽣的 Data Binding 機制⽀援 ·只能依靠⼯程師⼿搓 Data
Binding 機制並 ⼿動設置 Binding 節點 ·並未從結構上杜絕將 Controller 誤⽤成 God Object 的可能性 ·不過也的確為 Controller 的職責邊界⼈為 划出了⼀條可執⾏度更⾼的判斷依據
沒有完美的架構模式 每⼀個架構模式都在解決⽬標問題的同時存在⾃⾝ 的痛點或局限性 ⸺以史為鑒,可以知興替
架構模式不是⽬的,解決問題才是⽬的 ⸺以史為鑒,可以知興替
SwiftUI 有需要⽤ MVVM 解決的 問題嗎?
SwiftUI 的構成元件與互動 View struct SomeView: View { Model ※ /*
struct SomeView: View */ { @State private var someState// ... // ... } @AppStorage("SomeAppStorage") var someAppStorage// ... @Environment(\.someEnvironment) var someEnvironment// ... // ... } ※這裡為了展示互動,代碼是 Model 的取得與持有,而非 Model 的實裝
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// ... // ... }
SwiftUI 的 View 本質不就是 UIKit 的 View + Controller! 怎麼把那麼多職責都丟給
View 就 不怕 Fat View 了!
Cocoa MVC 的痛點 Controller 不僅有最⾼權限 且職責不斷膨脹 極易被誤⽤成 God Object 質
本 F 的 C V t a
Cocoa MVC 的 Controller 問題不在於其有最⾼權限,也不在於 其職責太寬泛 問題在於當上述兩個條件同時成⽴時,Controller 有能⼒、且 很容易越權承擔本該由其他元件承擔的職責 ⸺何為誤⽤
Cocoa MVC 各元件的職責(理論) Controller 協調畫面流程 維護 View Hierarchy View 取得/持有
Model 接收 Model 結果 Model 畫面組合 將結果傳遞給 View 業務邏輯 畫面排版 …… 資料存取 呼叫 Model 畫面呈現 網路連接 …… ……
Cocoa MVC 各元件的職責(實務) Controller 協調畫面流程 維護 View Hierarchy View 取得/持有
Model 接收 Model 結果 Model 畫面組合 將結果傳遞給 View 業務邏輯 畫面排版 畫面組合 資料存取 畫面呈現 畫面排版 網路連接 …… …… …… 呼叫 Model
Cocoa MVC 的 Model 以外各元件的職責(實務) View 畫面組合 畫面排版 Controller 的
越權實作 導致職責邊界 逐漸模糊化 Controller 協調畫面流程 維護 View Hierarchy 取得/持有 Model 呼叫 Model 接收 Model 結果 將結果傳遞給 View 畫面組合 畫面呈現 畫面排版 …… ……
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() } } 常⾒實作 }
職責模糊化的危害 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() } } ) 怎麼到處都是重複實作,想 重構都不知道從哪裡下手!
同樣處理在 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) } } }
同樣處理在 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 也是如此
SwiftUI 的 Model 以外各個元件的職責 Controller View 協調畫面流程 維護 View Hierarchy
取得/持有 Model 呼叫 Model 接收 Model 結果 將結果傳遞給 View 畫面組合 畫面排版 畫面呈現 …… ……嗎?
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 也可以 完全相同的方式取得/持有/呼叫/接收結果
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 引擎自動處理
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
SwiftUI 的 View 与 MVVM 的 ViewModel 的職責 SwiftUI View
MVVM ViewModel 取得/持有 Model 取得/持有 Model 呼叫 Model 呼叫 Model 接收 Model 結果 接收 Model 結果 畫面組合 連接畫面 畫面排版 …… ……
SwiftUI 的 View 就是 ViewModel! ⸺今⽇暴論。 如果你選擇接受,講者將否認任何知情。這份訊息將在五秒後⾃動銷毀。
SwiftUI 的 View 与 MVVM 的 ViewModel 的職責 SwiftUI View
取得/持有 Model 呼叫 Model 接收 Model 結果 畫面組合 畫面排版 …… SwiftUI 不存在 必須額外引⼊ ViewModel 的理由 MVVM ViewModel 取得/持有 Model 呼叫 Model 接收 Model 結果 連接畫面 ……
好吧確實可能 SwiftUI 並沒有主動 引入 ViewModel 的理由 但你不寫 ViewModel 怎麼給畫⾯ 寫測試!
“ViewModel ⽅便給畫⾯寫測試” 是 iOS 開發史上最⼤的謊⾔ ⸺可能沒有之⼀
當我們寫測試,我們是在做什麼 當我們寫測試時, 畫⾯ 我們是在驗證當我們 給定⼀個輸⼊,我們 資料更新 業務指令 是否能得到⼀個預期 的結果(即輸出或者 Model
狀態改變等) 使用者操作 顯示 輸入 輸出 / 狀態改變 測試對象
眾所周知,畫⾯不好寫測試 使用者操作 顯示 輸入 輸出 / 狀態改變 操作 畫⾯ 資料
Model 那我把重要的處理 ViewModel 交給 ViewModel 新 更 然後給 ViewModel 料 資 令 指 務 寫測試不就好了 業 傳遞 傳遞
捋⼀捋邏輯 使用者操作 顯示 畫⾯ 資料更新 輸入 輸出 / 狀態改變 業務指令
Model 因為畫面不好寫測試
捋⼀捋邏輯 使用者操作 顯示 畫⾯ 資料 操作 傳遞 傳遞 ViewModel 輸入
輸出 / 狀態改變 Model 料 資 新 更 務 業 令 指 因為畫面不好寫測試 所以我創建了一個全新 的 ViewModel 對象,然 後給這個新對象寫測試 現在我說我給畫面寫好 測試了
捋⼀捋邏輯 使用者操作 顯示 畫⾯ 資料 操作 傳遞 傳遞 ViewModel 輸入
輸出 / 狀態改變 Model 料 資 新 更 務 業 令 指 因為畫面不好寫測試 所以我創建了一個全新 的 ViewModel 對象,然 後給這個新對象寫測試 ? 現在我說我給畫面寫好 測試了
ViewModel 並沒有⽅便給畫⾯寫測試 使用者操作 顯示 輸入 輸出 / 狀態改變 畫⾯ Model
資料 操作 傳遞 你測到的是 ViewModel, ViewModel 不是畫⾯。 傳遞 料 資 新 更 務 業 令 指
眾所周知,畫⾯不好寫測試 ……嗎? 使用者操作 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 料 資 新 更 務 業 令 指
眾所周知,畫⾯不好寫測試 ……嗎? 沒有生命週期的 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) } } }
眾所周知,畫⾯不好寫測試 畫⾯ @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
https://github.com/nalexn/ViewInspector
眾所周不知,畫⾯完全可以寫測試 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) } } } ※不會改變正式環境中的畫面行為
眾所周不知,畫⾯完全可以寫測試 @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) } } }
眾所周不知,畫⾯完全可以寫測試 照常建立 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) } } }
眾所周不知,畫⾯完全可以寫測試 @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) }
眾所周不知,畫⾯完全可以寫測試 @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) } } }
好吧確實給 SwiftUI 引入 MVVM 可能並沒有什麼好處 但我就是喜歡 MVVM,難道不能⽤ 嗎!
None
None
我仍舊不推薦在 SwiftUI 框架下 引⼊ MVVM 架構的 ViewModel ⸺哪怕蘋果認為 SwiftUI 可以適應任何架構
None
None
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() } } } }
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() } } } }
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 把它帶回來!?
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
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 不會自動更新。
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 原則衝突
None
None
邏輯錯誤!※ ※作者後來已修正此錯誤;不過真正值得思考的是,它為什麼能一路存在到文章發表?
你⼀直批評 ViewModel,那你倒是 說說看,到底該⽤什麼架構啊! 你可以使⽤任何不與 SwiftUI 衝突 的架構 不過接下來我想分享⼀種更貼合 SwiftUI 設計的職責分割
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
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 專用的客製化入口
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 的可注入依賴
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 知道不需要的知識。
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 與測試環境。
完整實作、Preview 與 Test 請⾒ GitHub https://github.com/el-hoshino/TestableSwiftUIWithoutViewModelsDemo
完整設計理念請⾒ Qiita(暫時只有⽇⽂,抱歉*) https://qiita.com/lovee/items/a eb f ca da e
下 架 框 I U t 在Swif 你並不需要 ViewModel MVVM架構意
義的