WWDC21 | Demystify SwiftUI

6,935 阅读11分钟

[Demystify SwiftUI](揭开 SwiftUI 的神秘面纱)内容基于 《WWDC21: 10022-Session》

一、知识回顾

SwiftUI 从**《WWDC19》**发布到现在,大家或多或少都接触过了。在讲 Demystify SwiftUI 之前,我们先来简单回顾一下 SwiftUI :

什么是 SwiftUI?

"SwiftUI is a declarative UI framework" -- Apple

"SwiftUI is an innovative, exceptionally simple way to build user interfaces across all Apple platforms with the power of Swift." -- Apple

我们最初认识 SwiftUI 这个词的时候,第一正常反应就是会问,“什么是 SwiftUI?“ ,而 Apple 官方给出的解释是:

  • SwiftUI 是一个声明式的 UI 框架。它基于 Swift,通过一种创新且特别简单的方式去构建用户界面,支持跨所有的 Apple 平台。

单纯看这个简短的描述,我们可能并不能在脑海中把它具象化。我们需要举一个例子,来对这个解释进行补充。声明式简单的来说就是描述式,我们要实现左下图的界面,描述式可以是这样的:

一个界面里有一个垂直的布局(VStack),垂直布局里面有一个开关(Taggle),开关状态和 isOn 绑定,(isOn 就是记录开关状态的);然后布局里面还有一个文本描述(Text),本文内容也和 isOn 进行关联,通过 isOn 的状态来显示开或关。

上面描述的每一句我们都能很直观的从下面代码中找到对应的代码块。我们写的代码有层次结构且越趋同于描述,越趋同于现实表达,这就是声明式。

SwiftUI 基于 Swift,Swift 的语法已经很简单便捷了,但是 SwiftUI 再此基础上又进行深度封装,语法更为简练。以及结合强大的 Xcode,构建界面就如同搭积木一样容易,并且代码能与预览界面实时同步,非常的简单且富有创新。

当然 SwiftUI 惊艳之处还有一个就是可以跨所有 Apple 平台(iOS、ipadOS、tvOS、macOS、watchOS),只需要一套代码就能多端运行。

二、揭开 SwiftUI 的神秘面纱

到这里我们对 SwiftUI 有了初步的认识,从上面小节我们知道,SwiftUI 是一个声明式 UI 框架,我们在最上层通过 SwiftUI 去描述一个 App,体验着 SwiftUI 给我们带来的便捷,但 SwiftUI 在幕后所做的事情,我们还不甚了了。所以我们今天就来揭开 SwiftUI 的神秘面纱,从幕后窥探 SwiftUI 的三大核心要素:

  • Identity (身份标识)- 在程序多次更新中,识别相同或不同视图的方式
  • Lifetime(生命周期)- SwiftUI 随时追踪视图和数据存在的方式
  • Dependencies (依赖项)- 使 SwiftUI 理解界面何时更新以及为何更新

我们就逐一讨论一下这些概念。

Identity (身份标识)

下图里有两张可爱的小狗图片,我们通过什么方式能辨别它们是同一只小狗照片,还是两只不同的小狗照片?

事实上我们并不能通过这两张图片直接辨别出来他们是不是同一只小狗。

那如果我们在图片下边标识出小狗名字,他们用的是同一个名字,我们大致能猜出是同一只小狗。当然再严谨点就是给小狗办身份证。

那如果图片下面标识出小狗的名字不是同一个,那我们能肯定两张图片上面的小狗不是同一只。

这个就是身份标识的好处,SwiftUI 识别视图方式也是一样,但 SwiftUI 使用的身份标识有两种类型:

Explicit Identity(显式身份)

上面我们举的例子,给小狗图片分配名称或者说是标识符,这是显式身份的一种形式。而我们在 UIKit 和 AppKit 常用的显式身份就是指针身份,下面是 UIKit 或 AppKit 的视图层级结构,图上的 UIView 和 NSView ,它们每个都有一个指向它们的内存分配的唯一指针,这个指针就是指针身份,也是显式身份的一种形式。我们可以只使用它们的指针,来引用单个视图。如果两个视图共享同一个指针我们确定这两是视图同一个视图。

但是 SwiftUI 不使用指针,因为 SwiftUI 视图是值类型。为什么使用值类型?一个是值类型相对而言更高效且节约性能,一个是可以使代码更干净且更好的隔离状态。对这块感兴趣的同学可以看一下 《WWDC19: SwiftUI Essentials》这个 session。

虽然 SwiftUI 不使用指针身份,但 SwiftUI 依赖于其他形式的显式身份。比如说 ForEach id 参数是显式标识的一种形式。我们可以通过自定义 id 明确对应的视图。

ForEach(..., id: \.someProperty) { ... }

我们再看一个例子,下面是一个使用了 ScrollViewReader 的视图,在底部有一个按钮。头部文本绑定我们自定义的标识符,点击按钮,按钮直接回到顶部。从代码很直观的看到,我们将该标识符传递给滚动视图代理的 scrollTo 方法,告诉 SwiftUI ,如果点击了按钮,就滚动到该指定视图。

我们并不是都需要明确每个视图的 id,比如说 ScrollViewReaderScrollViewButton 等视图是不需要自定义 id 的,我们只需要给被其他地方引用的视图添加上 id

当然不需要显式身份并不意味着没有身份标识,每个视图都有一个身份标识,即使不是显式身份,也都是有的。这时候引入 Struct Identity (结构身份)的概念。

Struct Identity(结构身份)

SwiftUI 使用的视图层次结构,能自动为视图生成隐式身份,也就是 Struct Identity(结构身份)。我们举小狗的例子,下面是另外两张小狗的图片,假定我们无法知道他们的名字,这时候我们应该如何去辨别他们呢?我们可以通过他们坐的位置来识别他们,比如“左边的狗” 和 “右边的狗”。我们对这种相对排列区分它们的方式,叫做结构身份。

SwiftUI 在整个 API 中都是利用了结构身份。举个常见的例子,我们使用 if...else... 条件语句时候,我们是能清晰的识别每个视图。如下面代码,第一个视图仅在条件为真时显示,而第二个视图仅在条件为假时显示。是不是跟上面小狗的例子很相似,上个例子是通过左右来标识小狗,而这次是通过真假的方式来确定视图。

上面的写的是 if...else... ,但 SwiftUI 内部看到的是确是右下图的样子。编译器会把 if 语句转译为 _ConditionalContent 视图。这种转译是通过 ViewBuilder 实现的,它是 Swift 中的一种结果构建器。View 协议默认将它的 body 属性包装在一个 ViewBuilder中。

代码中 body 属性的 View 返回类型是一个占位符,代表这是一个静态复合类型。使用这种泛型的类型, SwiftUI 可以明确区分两个视图。SwiftUI 也在幕后为它们各分配一个隐式身份。

这里官方也给出了一个建议,就是如果 if...else... 里是同一个 View,但是参数条件不同,我们直接使用三目运算符来代替。虽然两种做法都可以,但使用三目运算符可以让这两个视图保持同一个身份,这样也能提供更流畅的过渡,也有助于保持视图的生命周期和状态。

// 官方不推荐写法
if isGood {
    PawView(tint: .green).frame(maxHeight: .infinity, alignment: .top)
    Spacer()
} else {
    Spacer()
    PawView(tint: .red).frame(maxHeight: .infinity, alignment: .bottom)
}
// 官方推荐写法
PawView(tint: isGood ? .green : .red)
    .frame(maxHeight: .infinity, alignment: isGood ? .top : .bottom)
结构身份的宿敌(AnyView)

了解完结构身份,我们再来谈谈它的宿敌 - AnyView。我们先来看看下面这段使用了 AnyView 的函数,这个函数需要返回一个单一类型,所以它用了 AnyView 来包装各个不同视图。这样就会导致 SwiftUI 内部无法看到代码的条件结构,只能看到一个 AnyView 的返回类型。因为 AnyView 隐藏了它所包装的所有视图的类型,也使代码可读性变差。

我们可以进行一番优化,如下。相对于上面的代码而已,下面的代码使得 SwiftUI 内部获取 some View 的结构不再是单一而是变得清晰。这里应该注意的是要加@ViewBuilderbody 属性是默认(隐式)添加,但是我们自定义的方法,需要自行添加 @ViewBuilder ,不然会报错。当然这里使用 switch 会更直观一些。

所以我们要尽量避免使用 AnyView

  • AnyView 使用太多通常会使代码更难阅读和理解;
  • AnyView 对编译器隐藏了静态的类型信息,导致一些有用的错误和警告不会提示;
  • AnyView 某些情况会导致性能下降。比较合适做法就是使用泛型,来保留静态类型信息。

下面我们来看一下第二要素 LifeTime (生命周期)。

Lifetime(生命周期)

我们人的生命周期是从出生到寿终,这期间是有酸甜苦辣的各种情绪状态表达。视图也是如此,视图一旦被标识了身份,那它就存在一个生命周期,通过视图值的变化,视图在它的生命周期中也会有各种状态。下面图中有一个 bgView,在不同的时间点上有不同的视图值(color),相对应 bgView 在这些点上呈现的状态也不同。

这里需要注意的是,我们不能通过某个短暂的视图值来当作视图 bgView 的生命周期,视图的生命周期必须是由视图的身份决定的。当视图的身份发生变化或者视图被删除时,这就意味它的生命周期结束。也就是说生命周期其实就是一个身份的持续时间,这个身份与视图相关联。且身份是唯一的,多个视图就不能共享一个身份。所以这也体现了身份标识的稳定性至关重要:不稳定的身份会导致更短的视图生命周期;而稳定的身份有助于提高性能,因为 SwiftUI 不需要一直为视图创建存储;

在上述视图生命周期中,我们可以看到视图是可以更改其状态。例如我们最开始使用的例子,当我们滑动开关,该值由 true 变为 false 时,SwiftUI 会先保留旧视图值的副本,执行比较后,再决定是否更新视图。

struct SwitchView: View {
    @State var isOn: Bool = true
    var body: some View {
        Toggle("Switch is \(isOn ? "On" : "Off")", isOn: $isOn)
    }
}

视图的状态与生命周期是如何相关联的呢?通过@State@StateObject 与视图身份相关联,如 isOn, 他们是持久化存储视图状态的方式。在视图标识身份时,也就是第一次创建时候,SwiftUI 会为 @State@StateObject 分配内存中的存储。

Dependencies (依赖项)

依赖项与视图的联系

我们先分析一下下面这段代码的视图结构。顶部有两个属性,一个是 dog(狗),一个是 treat(零食),这两个属性就是视图的依赖项, 除了 body 是主体,其他的属性都是依赖项。

我们将代码转化成图表,我们可以更直观看到,整个视图与依赖项的关系。

虽然上面图表结构是树型结构,但是视图与依赖项之间的关系并不只是如此,我们增加一些依赖项,并让多个视图与他们相关联,就得到一张比之前更复杂的结构图(左下)。我们重新排列它,以避免重叠线条,就能得到右下图结构,我们称之为 “依赖图”。这个结构能帮助 SwiftUI 判断哪些视图的 body 需要更新,哪些不需要更新。

当某个依赖项发生变化时,将会给所有的视图生成一个新的 body 值,然后把依赖项相关联的视图 body 值实例化,当然如果依赖变更不符合视图更新条件,对应的视图也不会更新。这个在我们的 Lifetime(生命周期)中也讲到。

依赖项种类

除了普通结构体属性外,依赖项还包括以下几个属性包装器:

  • @Binding
  • @Environment
  • @State
  • @StateObject
  • @ObservedObject
  • @EnvironmentObject

由这些修饰的属性都被称为依赖项,但是前提是被视图引用。

参考文章

  1. 《Demystify SwiftUI》- Apple 官方
  2. 《关于 SwiftUI,看这一篇就够了》 - 梁启健
  3. 《从 SwiftUI 谈声明式 UI 与类型系统》- Cyandev(字节 iOS)
  4. 《WWDC NOTES: Demystify SwiftUI》- Federico Zanetello