一、概览
本篇文章将概述 SwiftUI 的工作原理,以及它与 UIKit 等框架的不同之处。SwiftUI 在概念上与以前的 Apple 平台上开发 app 的方式完全不同,它需要你重新考虑如何将你脑中的构想转换为实际可工作的代码。
UIKit 中的 View 或 ViewController 是长时间存在的UIView或UIViewController 类的实例,是对象。UIKit 中,view 的创建和 view 的更新是两条不同的代码路径。
SwiftUI 中的 View 是值,而非对象。SwiftUI 中没有 Controller 的概念。View 是符合 View 协议的短时存在的值。我们不必编写多余的代码来更新屏幕上的文本标签。每当状态改变时,view 树都会被重建。
二、View的创建
要在 SwiftUI 中创建 view,你需要创建一棵包含 view 的值的树,来描述应该在屏幕上显示的内容。要更改屏幕上的内容,你可以修改用 @State 修饰的值,这样新的 view 值的树会被重新计算。然后, SwiftUI 会更新屏幕,以反映这些新的 view 值。
import SwiftUI
struct Me: View {
@State private var counter = 0
var body: some View {
VStack {
Button(action: { counter += 1 }, label: {
Text("Tap me!")
.padding()
.background(Color(.tertiarySystemFill))
.cornerRadius(5)
})
if counter > 0 {
Text("You've tapped \(counter) times")
} else {
Text("You've not yet tapped")
}
}
}
}
1、View树中可包含switch 和 if 语句,不能使用循环和guard
SwiftUI 利用了称为函数构建器 (function builder) 的 Swift 特性。举个例子, VStack 之后的尾随闭包并不是一个普通的 Swift 函数;它是一个 ViewBuilder (它是由 Swift 的 函数构建器特性实现的)。在 view 的构建闭包中,你只能使用 Swift 的一个有限的子集来编写 程序:例如,你不能使用循环和 guard。但是,你可以像上面示例中的 counter 变量一样,编写 switch 和 if 语句来构造出依赖于 app 当前状态的 view 树。除了布尔值 if 语句以外,你可以使用的还有 if let,if case let。
View 的树不仅只包含当前可见的部分,它包含的是整个结构,这是有优点的:SwiftUI 能够更有效地找出 view 更新后发生了什么变化。
2、ModifiedContent 值(修饰器)的深层嵌套
我们在按钮上使用的 padding、background 和 cornerRadius API 并不是简单地去更改按钮的属性。实际上,这些 方法 (我们通常称其为 “修饰器”) 的调用都会在 view 树中创建新的一层。在按钮上调用 .padding() 会将按钮包装为 ModifiedContent 类型的值,这个值中包含有关应该如何设置 padding 填充的信息。在该值上再调用 .background,又会把现有值包装起来,创建另一个 ModifiedContent 值,这一次将添加有关背景色的信息。
3、顺序通常很重要
调用 .padding().background(...) 与调用 .background(...).padding() 是不一样的。在前一种情况下, 背景将延伸到填充部分的外边缘;而在后一种情况下,背景只会出现在填充范围的内侧。
4、.border 调用在垂直堆栈周围添加了 overlay 的修饰器,该修饰符使用其子元素的大小。
在 SwiftUI 中,你永远不会强迫 view 直接使用一个特定的大小。你只能将其包装在 frame 修饰器中,它的可用空间将被提供给子元素。view 可以 定义自己的理想大小 (类似于 UIKit 的 sizeThatFits 方法),你可以强制让 view 变成它们的理想 大小。
5、更改状态属性是在 SwiftUI 中触发 view 更新的唯一方法。
点击按钮会修改 @State counter 属性,这会触发这种更新 view 的状态更改。触发 view 更新的属性会被用 @State、@ObservedObject 或者 @StateObject 属性标签 进行标记。
我们不能直接更新屏幕上的内容。相反,我们必须修改状态属性 (比如 @State 或 @ObservedObject),然后让 SwiftUI 去找出 view 树的变化方式。
三、View的更新
在大多数面向对象的 GUI 程序,有两条与 view 相关的代码路径: 一条路径处理 view 的初始构造,另 一条路径负责在事件发生时更新 view。由于这些代码路径是分离开的,而且涉及手动更新,所 以很容易出现错误:我们可能会响应事件来更新 view,但却忘了更新 model,反之亦有可能。 无论哪种情况,view 都会与 model 不同步,app 可能会表现出不确定的行为、卡死甚至崩溃。
AppKit 里使用 Cocoa Binding 技术,它是一个可以使 model 和 view 保持同步的双向层。在 UIKit 里,人们使用像是响应式编 程这样的技术来让这两个代码路径 (在大部分情况下) 得到统一。
SwiftUI 的设计完全避免了此类问题。首先,只有 view 的 body 属性这一个代码路径可以构造 初始的 view,而且这条路径也会用于所有的后续更新。其次,SwiftUI 让使用者无法绕过正常 的 view 的更新周期,也无法直接修改 view 树。在 SwiftUI 中,想要更新屏幕上的内容,触发 对 body 属性的重新求值是唯一的方法。
SwiftUI 只会重新去执行那些使用了 @State 属性的 view 的 body 。(对于其他属性包装,例如 @ObservedObject 和 @Environment,也是一样的)。
struct BindingView : View {
@Binding var counter: Int
var body: some View {
Button(action: { counter += 1 }, label: {
Text("Tap me!")
.padding()
.background(Color(.tertiarySystemFill))
.cornerRadius(5)
})
}
}
本质上来说,binding 是它所捕获变量的 setter 和 getter。SwiftUI 的属性包装 (比如 @State, @ObservedObject 等) 都有对应的 binding,你可以在属性名前加上 $ 前缀来访问它。(在属性 包装的术语中,binding 被叫做一个投射值 (projected value))。
四、属性包装
1、操作值类型
当数据是一个值类型的时候 (比如 struct,enum 或者是一个不可变对象),我们有三种选择: 使用普通的属性,使用 @State 属性,或者使用 @Binding 属性。
2、操作对象
当你的数据是一个对象时,你可以让它满足 ObservableObject,这样 SwiftUI 就能够订阅它的 变更。对于可观察的对象,有三个属性包装与它对应:当指向对象的引用可以发生变化时,使用 @ObservedObject;当引用不能改变时,使用 @StateObject;当对象是通过环境进行传递时, 使用 @EnvironmentObject。
3、ObservedObject
ObservableObject 协议的唯一要求是实现 objectWillChange,它是一个 publisher,会在对象 变更时发送事件。通过在 name 和 city 属性前面添加 @Published,框架会为我们创建一个 objectWillChange 的实现,在每次这两个属性发生改变的时候发送事件。
class Model: ObservableObject {
init() { print("Model Created") }
@Published var score: Int = 0
}
五、环境
环境 (environment) 是帮助我们理解 SwiftUI 工作方式的一块重要拼图。简而言之,环境是 SwiftUI 用于将值沿 view 树向下传递的机制。也就是说,值从父 view 传递到其包含的子 view 树,是依靠环境完成的。
1、环境是如何工作的
var body: some View {
VStack {
Text("Hello World!")
}
.font(Font.headline)
.debug()
}
/* ModifiedContent<
VStack<Text>,
_EnvironmentKeyWritingModifier<Optional<Font>> >
*/
这个类型告诉了我们,.font 调用将会把 VStack 包装到另一个叫做 ModifiedContent 的 view 中。这个 view 包含有两个泛型参数:第一个参数是内容本身的类型,第二个是将被应用到这个 内容上的修饰器。在本例中,第二个参数是私有的 _EnvironmentKeyWritingModifier,正如其 名,它负责将一个值写入到环境中。对于 .font 调用来说,一个可选的 Font 值会被写入到环境。 因为环境会依据 view 树向下传递,所以 stack 中的文本标签可以从环境中读取这个字体。
2、自定义环境值
首先需要定义一个新的类型,让它遵守 EnvironmentKey 协议。EnvironmentKey 协议的唯一要求是一个静态的 defaultValue 属性。
因为 .environment API 通过从 EnvironmentValues 的键路径来获取对应类型的值,所我们还要为 EnvironmentValues 添加一个属性,这样我们才能将它用作键路径。
最后去实现这个方法。
private struct MyEnvironmentKey: EnvironmentKey {
static let defaultValue: String = "Default value"
}
extension EnvironmentValues {
var myCustomValue: String {
get { self[MyEnvironmentKey.self] }
set { self[MyEnvironmentKey.self] = newValue }
}
}
extension View {
func myCustomValue(_ myCustomValue: String) -> some View {
environment(\.myCustomValue, myCustomValue)
}
}
3、依赖注入
我们可以把环境看作是一种依赖注入;设置环境值等同于注入依赖,而读取环境值则等同于接收依赖。
不过,环境中通常使用的都是值类型:一个通过 @Environment 属性依赖某个环境值的 view, 只会在一个新的环境值被设置到相应的 key 时才会失效并重绘。如果我们在环境中存储的是一个对象,并通过 @Environment 观察它,view 并不会由于对象中的一个属性变化而重绘,重绘 只在将 key 设置为整个不同的对象时才会发生。然而,当我们在使用对象作为依赖时,完整的 对象替换往往不是我们期望的行为。
4、Preferences
环境允许我们将值从一个父 view 隐式地传递给它的子 view,而 preference 系统则允许我们将值隐式地从子 view 传递给它们的父 view。
我们看到过像是 .font 和 .foregroundColor 这样的修饰器,它们会改变各自的 view 子树的环境。不过,.navigationBarTitle 要做的事情恰好相反:Text 并不关心标题, 不过它的父 view 对此关心,然而,NavigationView 有可能不是它的直接父 view。
最后,我们需要在我们的 MyNavigationView 中读取 preference。要使用这个值,我们需要将它存储在 @State 变量中。