学计算机的那个

不是我觉到、悟到,你给不了我,给了也拿不住;只有我觉到、悟到,才有可能做到,能做到的才是我的.

0%

SwiftUI 编程思想 上

《SwiftUI编程思想》 onevcat 第一版笔记 上

概览

SwiftUI 中,view 是值,而非对象,它们是不可变的,相较于在面向对象的框架中的处理方式,view 的创建和更新是以完全不同的声明式方式完成的。消除了view 和 app 的状态不同步一整类错误

View的创建

要在 SwiftUI 中创建 view,你需要创建一棵包含 view 的值的树,来描述应该在屏幕上显示的内容。 要更改屏幕上的内容,你可以修改 state 值,这样新的 view 值的树会被重新计算。然后,SwiftUI 会更新屏幕,以反映这些新的 view 值。

1
2
3
4
5
6
7
8
9
10
extension View {
func debug() -> Self {
print(Mirror(reflecting: self).subjectType)
return self
}
}

var body: some View {
VStack { /*... */ }.debug()
}

查看View的返回类型

注意:

  1. body 属性中构造的 view 的类型,包含了整个 view 树的结构:它不仅包含了当前屏幕上显示的部分,还包含了 app 生命周期中可能会在屏幕上显示的所有 view

ViewBuilder是由 Swift 的函数构建器特性实现的,不能使用循环、guardif let,可以编写简单的 if 语句

  1. View 的树不仅只包含当前可见的部分,它包含的是整个结构,这是有优点的:SwiftUI 能够更有效地找出 view 更新后发生了什么变化

  2. Modifys操作都会在 view 树中创建新层,因此它们的顺序通常很重要

  3. UIKit 中,view 的创建和 view 的更新是两条不同的代码路径,SwiftUI 中,这两个代码路径合二为一了

View的布局

布局是自上而下的:父 view 向子 view 提供它们的可用空间,子 view 基于这个空间来决定自己的尺寸

SwiftUI 从最外层的 view 开始布局过程。在 SwiftUI 里,因为我们只是在描述屏幕上应该显示的内容,所以我们永远不会去直接设置一个 viewframe 属性,你只能将其包装在 frame 修饰器中,它的可用空间将被提供给子元素

如果想要实现父 view 的布局依赖子 view 的尺寸,需要用GeometryReaderPreference

View的更新

触发 view 更新的属性会被用 @State@ObservedObject 或者 @EnvironmentObject 属性标签进行标记,更改状态属性是在 SwiftUI 中触发 view 更新的唯一方法,这种新的处理方式消除了 viewapp 状态不同步这一整个类别的常见错误

View更新

面向对象的 GUI 程序 (例如 UIKit app 和浏览器中的 DOM (文档对象模型,Document Object Model) app) 中,有两条与 view 相关的代码路径:
一条路径处理 view 的初始构造,另一条路径负责在事件发生时更新 view

面向对象的问题

这些代码路径是分离开的,而且涉及手动更新,所以很容易出现错误:我们可能会响应事件来更新 view,但却忘了更新 model,反之亦有可能。
无论哪种情况,view 都会与 model 不同步,app 可能会表现出不确定的行为、卡死甚至崩溃。

面向对象的解决

在 AppKit 和 UIKit 编程中,有许多技术可以尝试解决此问题。AppKit 里使用 Cocoa Binding技术,它是一个可以使 model 和 view 保持同步的双向层。在 UIKit 里,人们使用像是响应式编程这样的技术来让这两个代码路径 (在大部分情况下) 得到统一。

SwiftUI的解决

SwiftUI 的设计完全避免了此类问题。首先,只有 view 的 body 属性这一个代码路径可以构造初始的 view,而且这条路径也会用于所有的后续更新。

其次,SwiftUI 让使用者无法绕过正常的 view 的更新周期,也无法直接修改 view 树。在 SwiftUI 中,想要更新屏幕上的内容,触发对 body 属性的重新求值是唯一的方法。

为什么 view 树需要每次都拥有相同的结构?

app 状态发生变化并重新计算 view 树时,SwiftUI 必须找出前一棵树与新树之间发生了什么变化,以便有效地更新显示,这样才能避免从头开始重新构建和渲染所有内容。如果能保证旧树和新树具有相同的结构,那么这个任务将更容易,更高效。

树的 diff 算法

也就是比较两棵树结构之间不同之处的算法)的算法复杂度是 O(n^3),

React 框架做法

为了能控制复杂度,像 React 这样的框架使用了具有 O(n) 复杂的的启发式 diff 算法,它在 diff 的精度和效率之间进行了权衡:这种算法可能会导致实际被重建的部分要比严格意义上真正需要重建的部分更大,开发者们可能会需要提供一些提示,来表明树的哪些部分在更新时是稳定的,从而应对这种影响。

SwiftUI的做法

SwiftUI 针对此问题采用了不同的方法:由于 view 树的结构在更新时始终相同,因此它不需要执行完整的树 diff。只需要比较新旧两个节点上可能会被改变的属性值 (比如 stack 的对齐方式和间距等) 就行了

重新创建的优化

即便 view 树的比较要比执行完整树比较要快得多,但每次重新创建和比较整个 view 树值难道不是依然很浪费吗?

View的创建也就是研究 view 的 body 会在什么时候被运行

SwiftUI 会追踪哪些 view 使用了哪些状态变量, SwiftUI 只会重新去执行那些使用了 @State 属性的 viewbody (对于其他属性包装,例如@ObservedObject@Environment,也是一样的)

Binding

本质上来说,binding 是它所捕获变量的 settergetter。SwiftUI 的属性包装 (比如 @State,@ObservedObject 等) 都有对应的 binding,你可以在属性名前加上 $ 前缀来访问它。(在属性包装的术语中,binding 被叫做一个投射值 (projected value)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct LabelView: View {
@Binding var number: Int
var body: some View {
print("LabelView")
return Group {
if number > 0 {
Text("You've tapped \(number) times")
}
}
}

struct ContentView: View {
@State var counter = 0
var body: some View {
print("ContentView")
return VStack {
Button("Tap me!") { self.counter += 1 }
LabelView(number: $counter)
}
}
}

// 开始时的控制台输出:
// ContentView
// LabelView

// 每次更新的控制台输出:
// LabelView

上例所示,SwiftUI 会追踪哪些 view 使用了哪些 state 变量:它知道 ContentView 在渲染 body 时并没有用到 counter,但 LabelView (通过 binding 间接地) 用到了它。因此,对 counter 属性的更改只会触发对LabelView body 的重新求值。

动态view树

SwiftUI 提供了三种不同的机制来构建一棵树的动态部分:

  1. View builder 中的 if/else 条件
  2. ForEach
  3. AnyView

ForEach

ForEach 中,view 的数量是可以改变的,但它们都需要拥有相同的类型,ForEach 最常见的是和 List (类似 UIKit 中的 table view) 一起使用

1
2
3
4
5
6
7
8
struct ContentView: View {
var body: some View {
ForEach(1...3, id: \.self) { x in
Text("Item \(x)")
}
}
}
// 类型:ForEach<ClosedRange<Int>, Int, Text>

ForEach 的参数
第一个参数是所要显示数据的集合
第二个参数是键路径 (keypath),它指定应该使用哪个属性来标识元素 (集合的元素要么必须遵守 Identifiable 协议,要么我们需要为它指定标识符的键路径)。我们通过指定 \.self 作为标识键路径,将元素本身用作标识符

第三个参数负责从集合中的元素构造 view

由于 ForEach 要求每个元素都是可标识的,因此它可以在运行时 (通过计算 diff) 找出自上次view更新以来所添加或删除的视图。

AnyView

AnyView 是一个可以用任意 view 来初始化的 view,它可以对输入的 view 进行包装并擦除它的类型。

使用 AnyView 会删除有关 view 树的基本静态类型信息,而这些信息将可以帮助 SwiftUI 更高效地执行更新。

高效的View树

SwiftUI 在每次更新时要高效地对 view 树的值进行比较,这需要依赖 view 树结构的静态信息。

1
2
3
4
5
6
7
8
9
var body: some View {
if counter > 0 {
return Text("You've tapped \(counter) times")
} else {
return Image(systemName: "lightbulb")
}
}
// error: Function declares an opaque return type, but the return statements
// in its body do not have matching underlying types

虽然 TextImage 都遵守 View 协议,但是我们无法从 body 的不同分支返回具有不同具体类型的值,必须从所有分支都返回相同类型的内容。

可以通过将整个 body 包装在一个 Group 中来解决此问题。由于 Group的初始化方法参数是 view builder 闭包,因此 if/else 条件被编码为 view 树的一部分。结果类型为:
Group<_ConditionalContent<Text, Image>>

除了使用 Group 外,你也可以为 body 计算属性加上 @ViewBuilder 标签。

状态属性的位置

SwiftUI 会跟踪哪些 view 使用了哪些状态属性,并在 view 更新时,SwiftUI 仅去执行实际上可能被更改了的view 的 body。

想要最好地利用 SwiftUI 的智能 view 树的更新特性,我们应该尽可能地将状态属性放在本地。相反,在根 view 上用一个状态属性表示所有的 model 状态,并以简单的参数形式将所有数据向下传递到 view 树中,会是最糟糕的选择,因为这将导致很多不必要的 view 被重建

状态属性标签

能让 SwiftUI 更新 view 的唯一方法是更改信息源,也就是在我们的示例中那些用 @State 声明的状态属性。 SwiftUI 用来触发 view 更新的所有属性包装器均遵守 DynamicProperty 协议,属性包装器均遵守 DynamicProperty 协议。以下类型实现了它:

1
2
3
4
5
6
7
 Binding
Environment
EnvironmentObject
FetchRequest
GestureState
ObservedObject
State

属性包装

SwiftUI 发布时,为了能写出简洁且易读的 SwiftUI 程序,Swift 中添加了两个新特性:函数构建器 (function builder) 和属性包装器 (property wrapper)。

1
2
3
4
5
6
7
8
9
struct ContentView: View {
@State var counter = 0
var body: some View {
return VStack {
Button("Tap me!") { self.counter += 1 }
LabelView(number: $counter)
}
}
}

因为 body 不是一个 mutating 的函数或者属性,如果我们移除 @State 前缀,那么 body 中的 counter 就不再能变更了。不使用属性包装器语法,来重写上面的示例:

1
2
3
4
5
6
7
8
9
struct ContentView: View {
var counter = State(initialValue: 0)
var body: some View {
return VStack {
Button("Tap me!") { self.counter.wrappedValue += 1 }
LabelView(number: counter.projectedValue)
}
}
}

首先,必须用 State 的显式初始化方法创建我们的 counter,State 是定义在 SwiftUI中的结构体,它被标记了 @propertyWrapper

其次,State 实际上定义了一个被标记为 nonmutating setwrappedValue 属性,这意味着我们可以在不可变方法或属性 (例如 body) 的内部修改它。

最后,我们没有传递 $counter 到我们的 LabelView,而是传递了 counter.projectedValue,它的类型是 Binding<Int>

State 类型还可以启用依赖追踪。当 view 的 body 访问 State 变量的 wrappedValue 时,这个view 会与该 State 变量建立依赖。这意味着 SwiftUI 知道 wrappedValue 更改时要去更新哪些 view。

mutating关键字

Swift的结构体或者枚举的方法中,如果方法中需要修改当前结构体或者枚举的属性值,则需要再func前面加上mutating关键字,否则编译器会直接报错。

1
2
3
4
5
6
struct Point {
var x: Int
func setX(_ value: Int) {
self.x = value // cannot assgin to property: 'self' is immutable
}
}

普通函数传值参数是值传递,mutating关键字本质是包装了inout关键字,加mutating关键字后参数会变成地址传递;

环境

环境是SwiftUI用于将值沿view树向下传递的机制。也就是说,值从父 view 传递到其包含的子 view 树,是依靠环境完成的。

环境是如何工作的

1
2
3
4
5
6
7
8
9
var body: some View {
VStack {
Text("Hello, world!")
.transformEnvironment(\.font) { dump($0) }
}
.font(Font.headline)
}
// ...
// - style: SwiftUI.Font.TextStyle.headline
1
2
3
4
5
6
7
8
9
10
11
12
13
var body: some View {
VStack {
Text("Hello World!")
}
.environment(\.font, Font.headline)
.debug()
}
/*
ModifiedContent<
VStack<Text>,
_EnvironmentKeyWritingModifier<Optional<Font>>
>
*/

调用.environment(\.font, ...)所产生结果的类型和.font(...)调用完全一致

VStack 上调用的 font 方法,其实只是 .environment 函数的一个简单包装而已

环境修饰器只会改变它的直属子 view 树的环境,而绝不会更改同层其他节点或是父 view 的环境。

使用环境

定义一个新的类型,让它遵守 EnvironmentKey 协议:

1
2
3
fileprivate struct PointerSizeKey: EnvironmentKey {
static let defaultValue: CGFloat = 0.1
}

EnvironmentKey 协议的唯一要求是一个静态的 defaultValue 属性,因为 .environment API 通过从 EnvironmentValues 的键路径来获取对应类型的值,所我们还要为 EnvironmentValues 添加一个属性,这样我们才能将它用作键路径:

1
2
3
4
5
6
extension EnvironmentValues {
var knobPointerSize: CGFloat {
get { self[PointerSizeKey.self] }
set { self[PointerSizeKey.self] = newValue }
}
}
1
2
3
4
5
extension View {
func knobPointerSize(_ size: CGFloat) -> some View {
environment(\.knobPointerSize, size)
}
}

依赖注入

可以把环境看作是一种依赖注入;设置环境值等同于注入依赖,而读取环境值则等同于接收依赖。

环境中通常使用的都是值类型:一个通过 @Environment 属性依赖某个环境值的 view,只会在一个新的环境值被设置到相应的 key 时才会失效并重绘。

environmentObject(_:) 修饰器。这个方法接受一个 ObservableObject,它不需要指定EnvironmentKey,因为这个对象的类型会自动被用作 key

Preferences

环境允许我们将值从一个父 view 隐式地传递给它的子 view,而 preference 系统则允许我们将值隐式地从子 view 传递给它们的父 view。

1
2
3
4
5
NavigationView {
Text("Hello")
.navigationBarTitle("Root View")
.background(Color.gray)
}

navigationBarTitle 定义了 preference,这个值被沿着树向上传递,通过 .background 修饰器,一直到达 navigation view,并最终被读取。

一个 preference 也由一个 (由类型表示的) key,一个对应值的关联类型 (在navigation title 的例子中,这是一个包含 Text view 的私有类型) 和一个默认值 (本例中,也许是 nil) 组成。和 EnvironmentKey 不同,PreferenceKey 还需要一种合并多个值的方式,以对
应多个 view 子树中都定义了同一个 preference 的情况。

Demo

重新创建 NavigationView 的一小部分

第一步,创建一个新的 PreferenceKey。对于关联类型,我们选择 String?

1
2
3
4
5
6
struct MyNavigationTitleKey: PreferenceKey {
static var defaultValue: String? = nil
static func reduce(value: inout String?, nextValue: () -> String?) {
value = value ?? nextValue()
}
}

第二步,我们需要一种方式来在任意 view 上定义 navigation title

1
2
3
4
5
extension View {
func myNavigationTitle(_ title: String) -> some View {
preference(key: MyNavigationTitleKey.self, value: title)
}
}

最后,我们需要在我们的 MyNavigationView 中读取 preference。要使用这个值,我们需要将它存储在 @State 变量中:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct MyNavigationView<Content>: View where Content: View {
let content: Content
@State private var title: String? = nil
var body: some View {
VStack {
Text(title ?? "")
.font(Font.largeTitle)
content.onPreferenceChange(MyNavigationTitleKey.self) { title in
self.title = title
}
}
}
}

当 view 首次被渲染时,title 是 nil。当子 view (content) 被渲染时,它将对应 MyNavigationTitleKey 的值沿树向上传递,onPreferenceChange 闭包会被调用。这会更改 title 属性,然后,因为 title 是一个 @State 属性,MyNavigationView 的 body 将被再次执行。

1
2
3
4
5
MyNavigationView(content:
Text("Hello")
.myNavigationTitle("Root View")
.background(Color.gray)
)