《SwiftUI编程思想》 onevcat 第一版笔记 上
概览
在 SwiftUI
中,view
是值,而非对象,它们是不可变的,相较于在面向对象的框架中的处理方式,view
的创建和更新是以完全不同的声明式方式完成的。消除了view 和 app 的状态不同步一整类错误
View的创建
要在 SwiftUI
中创建 view
,你需要创建一棵包含 view
的值的树,来描述应该在屏幕上显示的内容。 要更改屏幕上的内容,你可以修改 state
值,这样新的 view
值的树会被重新计算。然后,SwiftUI
会更新屏幕,以反映这些新的 view
值。
1 | extension View { |
查看View的返回类型
注意:
- 在
body
属性中构造的view
的类型,包含了整个view
树的结构:它不仅包含了当前屏幕上显示的部分,还包含了app
生命周期中可能会在屏幕上显示的所有view
ViewBuilder
是由Swift
的函数构建器特性实现的,不能使用循环、guard
或if let
,可以编写简单的if
语句
View
的树不仅只包含当前可见的部分,它包含的是整个结构,这是有优点的:SwiftUI
能够更有效地找出view
更新后发生了什么变化Modifys
操作都会在view
树中创建新层,因此它们的顺序通常很重要在
UIKit
中,view
的创建和view
的更新是两条不同的代码路径,SwiftUI
中,这两个代码路径合二为一了
View的布局
布局是自上而下的:父 view
向子 view
提供它们的可用空间,子 view
基于这个空间来决定自己的尺寸
SwiftUI
从最外层的 view
开始布局过程。在 SwiftUI
里,因为我们只是在描述屏幕上应该显示的内容,所以我们永远不会去直接设置一个 view
的 frame
属性,你只能将其包装在 frame
修饰器中,它的可用空间将被提供给子元素
如果想要实现父 view 的布局依赖子 view 的尺寸,需要用GeometryReader
和 Preference
View的更新
触发 view
更新的属性会被用 @State
、@ObservedObject
或者 @EnvironmentObject
属性标签进行标记,更改状态属性是在 SwiftUI 中触发 view 更新的唯一方法,这种新的处理方式消除了 view
和 app
状态不同步这一整个类别的常见错误
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
属性的 view
的 body
(对于其他属性包装,例如@ObservedObject
和 @Environment
,也是一样的)
Binding
本质上来说,binding
是它所捕获变量的 setter
和 getter
。SwiftUI 的属性包装 (比如 @State,@ObservedObject 等) 都有对应的 binding,你可以在属性名前加上 $ 前缀来访问它。(在属性包装的术语中,binding 被叫做一个投射值 (projected value)。
1 | struct LabelView: View { |
// 开始时的控制台输出:
// ContentView
// LabelView
// 每次更新的控制台输出:
// LabelView
上例所示,SwiftUI 会追踪哪些 view 使用了哪些 state 变量:它知道 ContentView
在渲染 body
时并没有用到 counter
,但 LabelView
(通过 binding
间接地) 用到了它。因此,对 counter
属性的更改只会触发对LabelView
body
的重新求值。
动态view树
SwiftUI 提供了三种不同的机制来构建一棵树的动态部分:
View builder
中的if/else
条件ForEach
AnyView
ForEach
在 ForEach
中,view
的数量是可以改变的,但它们都需要拥有相同的类型,ForEach
最常见的是和 List (类似 UIKit 中的 table view) 一起使用
1 | struct ContentView: View { |
ForEach
的参数
第一个参数是所要显示数据的集合
第二个参数是键路径 (keypath
),它指定应该使用哪个属性来标识元素 (集合的元素要么必须遵守 Identifiable
协议,要么我们需要为它指定标识符的键路径)。我们通过指定 \.self
作为标识键路径,将元素本身用作标识符
第三个参数负责从集合中的元素构造 view
由于
ForEach
要求每个元素都是可标识的,因此它可以在运行时 (通过计算 diff) 找出自上次view
更新以来所添加或删除的视图。
AnyView
AnyView
是一个可以用任意 view
来初始化的 view
,它可以对输入的 view
进行包装并擦除它的类型。
使用 AnyView 会删除有关 view 树的基本静态类型信息,而这些信息将可以帮助 SwiftUI 更高效地执行更新。
高效的View树
SwiftUI
在每次更新时要高效地对 view
树的值进行比较,这需要依赖 view
树结构的静态信息。
1 | var body: some View { |
虽然 Text
和 Image
都遵守 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 | → Binding |
属性包装
SwiftUI
发布时,为了能写出简洁且易读的 SwiftUI
程序,Swift
中添加了两个新特性:函数构建器 (function builder
) 和属性包装器 (property wrapper
)。
1 | struct ContentView: View { |
因为 body 不是一个 mutating
的函数或者属性,如果我们移除 @State
前缀,那么 body 中的 counter
就不再能变更了。不使用属性包装器语法,来重写上面的示例:
1 | struct ContentView: View { |
首先,必须用 State 的显式初始化方法创建我们的 counter,State 是定义在 SwiftUI中的结构体,它被标记了 @propertyWrapper
其次,State 实际上定义了一个被标记为 nonmutating set
的 wrappedValue
属性,这意味着我们可以在不可变方法或属性 (例如 body) 的内部修改它。
最后,我们没有传递 $counter
到我们的 LabelView
,而是传递了 counter.projectedValue
,它的类型是 Binding<Int>
。
State
类型还可以启用依赖追踪。当 view 的 body 访问 State 变量的 wrappedValue 时,这个view 会与该 State 变量建立依赖。这意味着 SwiftUI 知道 wrappedValue 更改时要去更新哪些 view。
mutating关键字
Swift
的结构体或者枚举的方法中,如果方法中需要修改当前结构体或者枚举的属性值,则需要再func
前面加上mutating
关键字,否则编译器会直接报错。
1 | struct Point { |
普通函数传值参数是值传递,mutating关键字本质是包装了inout关键字,加mutating关键字后参数会变成地址传递;
环境
环境是SwiftUI
用于将值沿view
树向下传递的机制。也就是说,值从父 view 传递到其包含的子 view 树,是依靠环境完成的。
环境是如何工作的
1 | var body: some View { |
1 | var body: some View { |
调用.environment(\.font, ...)
所产生结果的类型和.font(...)
调用完全一致
VStack 上调用的
font
方法,其实只是.environment
函数的一个简单包装而已
环境修饰器只会改变它的直属子 view
树的环境,而绝不会更改同层其他节点或是父 view
的环境。
使用环境
定义一个新的类型,让它遵守 EnvironmentKey
协议:
1 | fileprivate struct PointerSizeKey: EnvironmentKey { |
EnvironmentKey
协议的唯一要求是一个静态的 defaultValue
属性,因为 .environment
API 通过从 EnvironmentValues
的键路径来获取对应类型的值,所我们还要为 EnvironmentValues
添加一个属性,这样我们才能将它用作键路径:
1 | extension EnvironmentValues { |
1 | extension View { |
依赖注入
可以把环境看作是一种依赖注入;设置环境值等同于注入依赖,而读取环境值则等同于接收依赖。
环境中通常使用的都是值类型:一个通过 @Environment
属性依赖某个环境值的 view,只会在一个新的环境值被设置到相应的 key
时才会失效并重绘。
environmentObject(_:)
修饰器。这个方法接受一个 ObservableObject
,它不需要指定EnvironmentKey
,因为这个对象的类型会自动被用作 key
Preferences
环境允许我们将值从一个父 view 隐式地传递给它的子 view,而 preference 系统则允许我们将值隐式地从子 view 传递给它们的父 view。
1 | NavigationView { |
navigationBarTitle
定义了 preference
,这个值被沿着树向上传递,通过 .background
修饰器,一直到达 navigation view
,并最终被读取。
一个 preference 也由一个 (由类型表示的) key,一个对应值的关联类型 (在navigation title 的例子中,这是一个包含 Text view 的私有类型) 和一个默认值 (本例中,也许是 nil) 组成。和 EnvironmentKey 不同,PreferenceKey 还需要一种合并多个值的方式,以对
应多个 view 子树中都定义了同一个 preference 的情况。
Demo
重新创建 NavigationView
的一小部分
第一步,创建一个新的 PreferenceKey
。对于关联类型,我们选择 String?
1 | struct MyNavigationTitleKey: PreferenceKey { |
第二步,我们需要一种方式来在任意 view 上定义 navigation title
1 | extension View { |
最后,我们需要在我们的 MyNavigationView
中读取 preference
。要使用这个值,我们需要将它存储在 @State
变量中:
1 | struct MyNavigationView<Content>: View where Content: View { |
当 view 首次被渲染时,title 是 nil。当子 view (content) 被渲染时,它将对应 MyNavigationTitleKey 的值沿树向上传递,onPreferenceChange 闭包会被调用。这会更改 title 属性,然后,因为 title 是一个 @State 属性,MyNavigationView 的 body 将被再次执行。
1 | MyNavigationView(content: |