Skip to content

Commit 623cc9b

Browse files
committed
基于MVVM模式手把手写出可测试性强的SwiftUI代码
1 parent b37d54f commit 623cc9b

16 files changed

Lines changed: 946 additions & 160 deletions

File tree

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
---
2+
date: '2025-10-21T15:43:57+08:00'
3+
draft: true
4+
title: '基于MVVM模式手把手写出可测试性强的SwiftUI代码'
5+
---
6+
# 用SwiftUI框架绘制视图
7+
首先在Xcode里新建一个Swift Package包,要写UI库选择Library,git版本管理可以不勾,带SwiftTesting确保在Swift6的较新环境。
8+
项目结构:
9+
```
10+
ImmutableState
11+
├── Package.swift
12+
├── Sources
13+
│ └── ImmutableState
14+
│ └── ImmutableState.swift
15+
└── Tests
16+
└── ImmutableStateTests
17+
└── ImmutableStateTests.swift
18+
```
19+
Package.swift(模板生成):
20+
```Swift
21+
let package = Package(
22+
name: "ImmutableState",
23+
products: [
24+
// Products define the executables and libraries a package produces, making them visible to other packages.
25+
.library(
26+
name: "ImmutableState",
27+
targets: ["ImmutableState"]),
28+
],
29+
targets: [
30+
// Targets are the basic building blocks of a package, defining a module or a test suite.
31+
// Targets can depend on other targets in this package and products from dependencies.
32+
.target(
33+
name: "ImmutableState"),
34+
.testTarget(
35+
name: "ImmutableStateTests",
36+
dependencies: ["ImmutableState"]
37+
),
38+
]
39+
)
40+
```
41+
上方展示了创建时的工程结构和模板生成Package.swift。
42+
其次在ImmutableState.swift 旁边创建 ImmutableDemo 文件夹安放接下来的整套代码,也就在ImmutableDemo文件夹下使用 SwiftUIView 模板创建代码,然后右键struct的名称,refactor重构rename改名为 ImmutableView。
43+
```Swift
44+
import SwiftUI
45+
46+
struct ImmutableView: View {
47+
var body: some View {
48+
Text("Hello, World!")
49+
}
50+
}
51+
52+
#Preview {
53+
ImmutableView()
54+
}
55+
```
56+
此时报错提到“'View' is only available in iOS 13.0 or newer”等字样,说明库包模板生成的初始代码有可能和SwiftUI所要求的不符,来想办法修复它。
57+
不了解Package.swift的朋友可能想问类似“如何在Swift Package中支持iOS等问题”。
58+
最后通过阅读各种形式的文档搞清楚Package.swift的配置格式后,了解到解决办法是在name和products之间增加一行配置:
59+
`platforms: [.iOS(.v13)],`
60+
这个字段指定整个包支持的版本,代码中指定了iOS13,以后根据报错要求,不妨逐渐提升。
61+
ImmutableView.swift标签页右方尽头,在倒数第2个按钮的多条横线样式的按钮上单击,在弹出的菜单里把Canvas预览画板点上刷新,已经可以看到第一行SwiftUI代码了。
62+
这些UI代码写到库包里,不仅可以把工程结构和依赖关系撘得更清晰,还可以在换地方搬砖时复用(小心违法)。
63+
64+
# SwiftUI绘制原理
65+
所有人都在让SwiftUI新手写下第一个`@State`属性:
66+
```Swift
67+
struct ImmutableView: View {
68+
@State var counter = 0
69+
70+
var body: some View {
71+
Button(action: {
72+
73+
}, label: {
74+
Text("Hello, World!\(counter)")
75+
})
76+
}
77+
}
78+
```
79+
counter代表计数器。
80+
预览画板中将会出现蓝色样式的按钮,按时是灰色。鼠标放到`@State`上按Option键单击,查看快速帮助(也可以在右上角打开右侧边栏点问号那个按钮,或把光标移动到位之后按下Ctrl+Command+?)。
81+
学过Swift的朋友一定知道属性包装器和协议,这种@xx字样的不是attribute特性,就是属性包装器。还是鼠标放到`@State`上按 Command 键单击,进去看看,果然有@propertyWrapper,还发现它是遵循了`DynamicProperty`协议,再点进这个协议,可以看到它要求名为update的可变异方法。
82+
这个方法会在`@State`包装的属性发生变化时,要求`View`重新绘制。
83+
有人说Swift语言为编程引入了一种面向协议的范式。
84+
同样的道理,出来到 ImmutableView.swift 标签页,点击 ImmutableView: View 的`View`进去看看,可以看到`View`也是协议,要求实现body属性,且需要是另一个遵循View协议的类型。
85+
甚至能以一种不推荐的方式改写而不报错:
86+
```Swift
87+
struct ImmutableView {
88+
@State var counter = 0
89+
}
90+
91+
extension ImmutableView: View {
92+
var body: some View {
93+
Button(action: {
94+
95+
}, label: {
96+
Text("Hello, World!\(counter)")
97+
})
98+
}
99+
}
100+
```
101+
这只是展示,还是Command+Z撤销还原回去。为了让counter计数器生效,可以在action闭包中间写入
102+
`counter = counter + 1`
103+
这行代码给counter赋值为比原值大1的值。
104+
把预览画板作为UI测试器,点击按钮,计数器的结果已经如预期增加了。
105+
再写一个一次增加5点计数的按钮:
106+
```Swift
107+
struct ImmutableView: View {
108+
@State var counter = 0
109+
110+
var body: some View {
111+
VStack {
112+
Text("counter: \(counter)")
113+
Button(action: {
114+
counter = counter + 1
115+
}, label: {
116+
Text("+1!")
117+
})
118+
Button(action: {
119+
counter = counter + 5
120+
}, label: {
121+
Text("+5!")
122+
})
123+
}
124+
}
125+
}
126+
```
127+
可以发现写了很多相似的逻辑代码,而且分散在视图之间,编程里的重复总是不太好,可以这么写:
128+
```Swift
129+
struct ImmutableView: View {
130+
@State var counter = 0
131+
132+
var body: some View {
133+
VStack {
134+
Text("counter: \(counter)")
135+
Button(action: tapButtonPlus1, label: {
136+
Text("+1!")
137+
})
138+
Button(action: tapButtonPlus5, label: {
139+
Text("+5!")
140+
})
141+
}
142+
}
143+
144+
func tapButtonPlus1() {
145+
plus(with: 1)
146+
}
147+
148+
func tapButtonPlus5() {
149+
plus(with: 5)
150+
}
151+
152+
func plus(with number: Int) {
153+
counter = counter + number
154+
print(counter)
155+
}
156+
}
157+
```
158+
这次改动把按钮们的action放到方法内,集中在同一块区域写出,并且把相似的逻辑整合到一个方法里。
159+
可以想象到随着视图逐渐变大,在涉及执行未必成功的代码时,还要写相应的异常处理、界面提示,方法区会随着这些的增长大到一定地步。
160+
下面的测试写在/Tests/ImmutableStateTests/ImmutableStateTests.swift中,分别+1和+5之后,counter不等于预期的结果6:
161+
```Swift
162+
@MainActor // ImmutableView要求@MainActor或async/await,这行把测试方法放在主线程的行为体中运行,避免引入await
163+
@Test func example() async throws {
164+
let view = ImmutableView()
165+
166+
view.tapButtonPlus1()
167+
view.tapButtonPlus5()
168+
169+
assert(view.counter == 6) // Thread 1: Assertion failed
170+
}
171+
```
172+
没有人乐意每次都手动点开一个路由很深的页面,甚至维护在XCTest中录下自动打开的过程也会在工程长大之后变得繁琐。
173+
我们需要另外一个语义来让视图与逻辑清晰地区分开,尤其要便于测试。
174+
175+
# M-V-VM模式
176+
SwiftUI原生的模式是M-V模式,这种模式把逻辑集成在视图里,数据流向是Model <-> View,测试起来不太方便,极其依赖预览画板的展示,好处是在写一些小视图时表达性很强。
177+
本文推荐的模式是通过苹果官方支持的Combine响应式框架实现的M-V-VM模式,这种模式把状态和逻辑摘到类中,可以写出不用预览画板就能在单元测试中使用的代码,视图规模但凡稍微上去一点就建议这么写,数据流向是Model <-> ViewModel <-> View。
178+
其中,Model表示结构与逻辑,View表示视图与动作源,ViewModel表示动作与响应源。
179+
工程中只有合适与不合适之分,没有绝对的好坏。
180+
`import SwiftUI`点进去,可以看到SwiftUI自身也有`import Combine`,它背后做了一些事,因此有SwiftUI的地方绝大部分时候不需要导入Combine。
181+
182+
# 用Combine框架发布视图状态更新
183+
简单介绍一下要用到的Combine框架的属性包装器。
184+
`@Published`可以理解为是在可观察类中的`@State`
185+
`ObservableObject`可观察类是一个能在观察到`@Published`成员属性的值发生变化时发出通知的类协议。
186+
`@StateObject`是供可观察类使用的属性包装器,接到存放的可观察类的变化通知时会调用update方法使`View`更新。@StateObject要求iOS14。
187+
试试把counter和那些方法搬到新的可观察类ImmutableViewModel里,然后用`@StateObject`存放类实例viewModel:
188+
```Swift
189+
struct ImmutableView: View {
190+
@StateObject var viewModel = ImmutableViewModel()
191+
192+
var body: some View {
193+
VStack {
194+
Text("counter: \(viewModel.counter)")
195+
Button(action: viewModel.tapButtonPlus1, label: {
196+
Text("+1!")
197+
})
198+
Button(action: viewModel.tapButtonPlus5, label: {
199+
Text("+5!")
200+
})
201+
}
202+
}
203+
}
204+
205+
class ImmutableViewModel: ObservableObject {
206+
@Published var counter = 0
207+
208+
func tapButtonPlus1() {
209+
plus(with: 1)
210+
}
211+
212+
func tapButtonPlus5() {
213+
plus(with: 5)
214+
}
215+
216+
func plus(with number: Int) {
217+
counter = counter + number
218+
print(counter)
219+
}
220+
}
221+
```
222+
这些属性包装器在类型中增加了以下划线为前缀加上原名的属性,在需要手写初始化代码时可以在初始化器中显式赋值。
223+
有发出通知的可观察类`ObservableObject``View`中就要有存放实例并接收通知的属性包装器`@StateObject`
224+
顺便一提,enum枚举体虽然可以实现`View`协议,但是不能含有如@StateObject属性、@State属性等作为视图状态变量存放的存储属性。以enum为载体的`View`讲好听点叫纯函数式,作为单状态视图是可行的,不过它更适合当Model。
225+
好,可以到ImmutableStateTests.swift中测试了:
226+
```Swift
227+
// 这行不需要@MainActor,是因为没有@StateObject等属性包装器要求
228+
// 生产代码中,ViewModel自身和面向View层的成员应当尽量被@MainActor修饰
229+
@Test func example() async throws {
230+
let viewModel = ImmutableViewModel()
231+
232+
viewModel.tapButtonPlus1()
233+
viewModel.tapButtonPlus5()
234+
235+
assert(viewModel.counter == 6)
236+
}
237+
```
238+
分别输出1和6并且测试通过。
239+
这看似很简单的测试,原先M-V代码来是无法运行通过的。
240+
241+
# 对外不可变的封装
242+
有一个很自然而然的想法:既然已经把代码写在ViewModel中了,就不太希望成员被外部的View层修改。当然如TextFeild等双向绑定时依旧要能修改,不过对于其他不需要双向绑定的成员,仍希望在代码中明确它单向发布通知的语义时,可以通过setter设置器的访问控制来达成。写全了是这样的:
243+
```Swift
244+
@Published internal private(set) var counter = 0
245+
```
246+
其中`internal`可以和`private(set)`互换位置,当然也可以照常把`internal`省略以使用类型的总访问级别。
247+
这也还是不全,`counter = 0`被我们写的方法和无小数点的语境推断为`Int`,如果有位数的要求可以写明,并在plus方法中修改:
248+
```Swift
249+
@Published private(set) var counter: Int64 = 0 // 此处以64位为例
250+
```
251+
这个counter的类型就是所谓Model。
252+
一般来说,Model用struct结构体作为载体是比较灵活的,enum枚举体稍显死板却具备语义。实际上大部分Model都会写成struct,其中内嵌一层可解析JSON字段的enum。这里重点介绍enum枚举体的Model写法。
253+
在函数中,只依赖参数值、不依赖外部可变值的函数是**纯函数**
254+
纯函数理念重视把可变的部分转移到函数体外部,保证在函数体中涉及的量都是可预测的,重视可靠性。尽管写程序总是需要可变的部分,不变的部分的保障对代码可靠性提升却不可忽视。
255+
在业务流程图中,总是会有节点面临不同的分支流向,它们往往会在某个节点折返,或者有着错综复杂的变化走向,这些分支流向就是**业务状态**
256+
业务状态的抽象形式与enum枚举体相性很好,它们都接收一些固定的参数,都可以根据自身所在位置推断出某个结果。
257+
另外,ViewModel原则上不应当拥有子类,因此也应在class前添加`final`关键词,这还将使编译速度得到些许提高。
258+
试试写一个以enum枚举体为载体的Model,忽略进位,仅关注个位:
259+
```Swift
260+
enum ImmutableModel {
261+
case zero
262+
case increment(counter: Int)
263+
264+
mutating func plus(with number: Int) {
265+
// 限定number是正整数
266+
guard number > 0 else { return }
267+
let rest = number % 10
268+
switch self {
269+
case .zero:
270+
self = .increment(counter: rest)
271+
case .increment(counter: let counter):
272+
let sumaryRest = (counter + rest) % 10
273+
if sumaryRest == 0 {
274+
self = .zero
275+
} else {
276+
self = .increment(counter: sumaryRest)
277+
}
278+
}
279+
}
280+
}
281+
```
282+
其中.zero用例是初始状态,.increment用例是计数器不为0的状态,在plus方法中,计数器超过9时,跳转到zero状态。
283+
无论你的工程的业务状态有没有复杂到必须要引入enum来管理状态,你都需要在心中建立一个状态模型。边缘状态和最经常出现的状态同样重要。
284+
285+
# 总结
286+
我们建立了一个小型工程,绘制了一个简单的视图并通过迭代使得它易于测试。为了易于测试,我们基于MVVM模式创建了ViewModel,并借助Combine响应式框架抽象、发布、更新视图状态。最后,我们引入了setter的访问级别、final class和纯函数理念,用于使代码明确封装的不可变性。
287+

hugo.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
baseURL: https://example.org/
1+
baseURL: https://winterarch.github.io/
22
languageCode: en-us
3-
title: WinterArch's site
3+
title: WinterArch's WebLog
44
theme: ["PaperMod"]

public/404.html

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,24 @@
11
<!DOCTYPE html>
22
<html lang="en" dir="auto">
33

4-
<head><script src="/livereload.js?mindelay=10&amp;v=2&amp;port=1313&amp;path=livereload" data-no-instant defer></script><meta charset="utf-8">
4+
<head><meta charset="utf-8">
55
<meta http-equiv="X-UA-Compatible" content="IE=edge">
66
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
7-
<meta name="robots" content="noindex, nofollow">
8-
<title>404 Page not found | WinterArch&#39;s site</title>
7+
<meta name="robots" content="index, follow">
8+
<title>404 Page not found | WinterArch&#39;s WebLog</title>
99
<meta name="keywords" content="">
1010
<meta name="description" content="">
1111
<meta name="author" content="">
12-
<link rel="canonical" href="http://localhost:1313/404.html">
12+
<link rel="canonical" href="https://winterarch.github.io/404.html">
1313
<link crossorigin="anonymous" href="/assets/css/stylesheet.a090830a421002426baafbd314e38f149d77b4c48a12ee9312700d770b27fb26.css" integrity="sha256-oJCDCkIQAkJrqvvTFOOPFJ13tMSKEu6TEnANdwsn&#43;yY=" rel="preload stylesheet" as="style">
14-
<link rel="icon" href="http://localhost:1313/favicon.ico">
15-
<link rel="icon" type="image/png" sizes="16x16" href="http://localhost:1313/favicon-16x16.png">
16-
<link rel="icon" type="image/png" sizes="32x32" href="http://localhost:1313/favicon-32x32.png">
17-
<link rel="apple-touch-icon" href="http://localhost:1313/apple-touch-icon.png">
18-
<link rel="mask-icon" href="http://localhost:1313/safari-pinned-tab.svg">
14+
<link rel="icon" href="https://winterarch.github.io/favicon.ico">
15+
<link rel="icon" type="image/png" sizes="16x16" href="https://winterarch.github.io/favicon-16x16.png">
16+
<link rel="icon" type="image/png" sizes="32x32" href="https://winterarch.github.io/favicon-32x32.png">
17+
<link rel="apple-touch-icon" href="https://winterarch.github.io/apple-touch-icon.png">
18+
<link rel="mask-icon" href="https://winterarch.github.io/safari-pinned-tab.svg">
1919
<meta name="theme-color" content="#2e2e33">
2020
<meta name="msapplication-TileColor" content="#2e2e33">
21-
<link rel="alternate" hreflang="en" href="http://localhost:1313/404.html">
21+
<link rel="alternate" hreflang="en" href="https://winterarch.github.io/404.html">
2222
<noscript>
2323
<style>
2424
#theme-toggle,
@@ -55,7 +55,15 @@
5555
}
5656

5757
</style>
58-
</noscript>
58+
</noscript><meta property="og:url" content="https://winterarch.github.io/404.html">
59+
<meta property="og:site_name" content="WinterArch&#39;s WebLog">
60+
<meta property="og:title" content="404 Page not found">
61+
<meta property="og:locale" content="en-us">
62+
<meta property="og:type" content="website">
63+
<meta name="twitter:card" content="summary">
64+
<meta name="twitter:title" content="404 Page not found">
65+
<meta name="twitter:description" content="">
66+
5967
</head>
6068

6169
<body class="list" id="top">
@@ -73,7 +81,7 @@
7381
<header class="header">
7482
<nav class="nav">
7583
<div class="logo">
76-
<a href="http://localhost:1313/" accesskey="h" title="WinterArch&#39;s site (Alt + H)">WinterArch&#39;s site</a>
84+
<a href="https://winterarch.github.io/" accesskey="h" title="WinterArch&#39;s WebLog (Alt + H)">WinterArch&#39;s WebLog</a>
7785
<div class="logo-switches">
7886
<button id="theme-toggle" accesskey="t" title="(Alt + T)" aria-label="Toggle theme">
7987
<svg id="moon" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
@@ -106,7 +114,7 @@
106114
</main>
107115

108116
<footer class="footer">
109-
<span>&copy; 2025 <a href="http://localhost:1313/">WinterArch&#39;s site</a></span> ·
117+
<span>&copy; 2025 <a href="https://winterarch.github.io/">WinterArch&#39;s WebLog</a></span> ·
110118

111119
<span>
112120
Powered by

0 commit comments

Comments
 (0)