Featured image of post 揭秘 SwiftUI

揭秘 SwiftUI

当声明的 SwiftUI 视图被渲染时,首先 SwiftUI 会创建相应的 Render Tree 节点。接着,视图会运行 body 属性中的代码,并创建一个 View Tree。View Tree 就是我们编写的代码经过处理的产物,它是一个结构体或多个结构体值。View Tree 被用于渲染视图后,便被释放。而 Render Tree 则是持久的。

View Tree 和 Render Tree

当声明的 SwiftUI 视图被渲染时,首先 SwiftUI 会创建相应的 Render Tree 节点。(SwiftUI internally calls this the attribute graph.)接着,视图会运行 body 属性中的代码,并创建一个 View Tree。View Tree 就是我们编写的代码经过处理的产物,它是一个结构体或多个结构体值。View Tree 被用于渲染视图后,便被释放。而 Render Tree 则是持久的。

Identify

Value Type 与 Reference Type

Swift 中,对 Value Type 和 Reference Type 进行 相等 判定和 等同于 判定时有着不同的结果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct Point {
  var x: Int
  var y: Int
}

let point1 = Point(x: 2, y: 3)
let point2 = Point(x: 2, y: 3)
print(point1 == point2) 	// 输出: true,值相同

let point3 = point1 		// 创建 point1 的副本
print(point1 === point3) 	// 输出: false,不同的实例

可以看到,对 Value Type 进行 相等 判定时,只会比较值本身;并且由于赋值时会对值进行复制,导致产生赋值行为后使用 === 进行 等同于 判定时,结果一定为 false

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Person {
  var name: String
  init(name: String) {
    self.name = name
  }
}

let person1 = Person(name: "Alice")
let person2 = Person(name: "Alice")

print(person1 === person2) 	// 输出: false,不同的实例

let person3 = person1 		// 创建 person1 的引用
print(person1 === person3) 	// 输出: true,同一个实例

而 Reference Type 在进行 等同于 判定时,结果是由对象在内存中的地址决定的。

SwiftUI

SwiftUI 中的 View 是结构体,那么当 View 的值发生改变时,我们需要 SwiftUI 知晓 View 变化前后是否是同一个结构体,进而决定是否变更 Render Tree 中的结点。

Apple 在 SwiftUI 中引入了显示赋予的 Explicit identity 与从结构中获取的 Structural identity 来作为 Identify 进行识别。

Explicit identity

1
2
3
4
5
protocol Identifiable {}

List(050, id: \.self) { idx in
  Text("Row \(idx)")
}

SwiftUI 定义了 Identifiable 这个协议,在需要时也会要求内容提供可以判断身份的属性。使用 id 修饰符也可以给一个 View 显式声明 Identity。

Structural identity

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
struct ContentView: View {
  @State var isRounded: Bool = false

  var body: some View {
    if isRounded {
      AImageView()
        .cornerRadius(25)
    } else {
      AImageView()
        .cornerRadius(0)
    }

    BImageView()
      .cornerRadius(isRounded ? 25 : 0)
  }
}

如果视图没有 Explicit identity,则它具有 Structural identityStructural identity 是指使用视图的类型及其在视图层次结构中的位置来标识视图。SwiftUI 使用视图层次结构来生成视图的隐式标识。

分析上面的视图代码,第一种方法根据 isRounded 创建两个完全不同的视图,它们具有不同的视图标识;而第二种方式只创建了一个 BImageView 视图。在状态发生改变时,由于第一种方法中视图具有不同的视图标识,所以 SwiftUI 会先销毁原先的 AImageView 视图,再创建新的 AImageView 视图。而第二种方法只会调整视图的属性。

LifeCycle

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
struct ContentView: View {
  var body: some View {
    AsyncImageView(url: URL(string: "..."))
  }
}

struct AsyncImageView: View {
  var url: URL
  @State private var imageData: Data? = nil

  var body: some View {
    if
      let data = imageData,
      let image = UIImage(data: data)
    {
      Image(uiImage: image)
    } else {
      ProgressView()
    }
  }
}

在上面的代码中,SwiftUI 首先会创建 ContentView 的 Render Tree 结点,再运行 body 中的代码,创建一个 View Tree,再根据 View Tree 创建相应的 Render Tree 结点,随后 View Tree 被释放。

此时 Render Tree 有一个 ContentView 结点,ContentView 结点的 body 属性为一个 AsyncImageView 结点。

接下来,SwiftUI 会开始处理 Render Tree 中的 AsyncImageView 结点。

我们可以注意到,AsyncImageView 中有一个 imageData 的 状态 属性。由于它被用 @State 属性包装器包装了,SwiftUI 会在 Render Tree 中为其分配内存,并且它的初始值为 nil。

接着,SwiftUI 会运行 AsyncImageView 的 body 中的代码来创建一个 View Tree。在这里,View Tree 将会是一个包含图像和进度视图的条件内容,并且 SwiftUI 知道图像和进度视图是互斥的,不可能同时出现或者同时不出现。

为了加载数据,我们需要添加一个 task 修饰符,而 if / let 不能直接添加修饰符,因此我们使用 ZStack 来包装。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
struct AsyncImageView: View {
  var url: URL
  @State private var imageData: Data? = nil

  var body: some View {
    ZStack {
      if
        let data = imageData,
        let image = UIImage(data: data)
      {
        Image(uiImage: image)
      } else {
        ProgressView()
      }
    }
    .task {
      imageData = try? await URLSession.shared.data(from: url).0
    }
  }
}

SwiftUI 运行 body 中的代码,于是我们可以得到这样一个视图树。

接着,SwiftUI 根据 View Tree 处理 Render Tree,对于 task,我们可以理解为 Render Tree 中有某种 didAppear 属性,最开始时该属性为 false,当视图渲染完成时,该属性变为 true,并且开始执行 task 中的代码。

当属性加载完成时,它就会更新 imageData 属性。而每当属性发生更改时,无论是常规属性(如 URL)还是状态属性(如 imageData),SwiftUI 都会使整个主体失效。

它不会丢弃这些节点,而是使它们无效,这意味着它需要重新获取 View Tree。因此我们可以查看代码并重新获取 View Tree。

我们可以看到,View Tree 中生效的视图由 ProgressView 变成了 Image。而当我们更新 Render Tree 时,SwiftUI 将删除 ProgressView 及其所有关联的状态和动画,并插入 Image,随后 View Tree 被销毁。

但实际上,我们编写的 AsyncImageView 存在一些问题:当它成功加载图像后,更改 url,图像并不会发生变化。首先因为这里的 .task 行为类似于 .onAppear,只有视图第一次渲染后时才会执行。并且这里没有显式赋予 ZStack identity,SwiftUI 会认为 ZStack 前后是同一个视图,创建新的 View Tree 并用于渲染 Render Tree 时,Render Tree 中的实例并没有发生变化,因此不会触发重新渲染视图。以上两点使得 task 不会被再次执行。

很明显,我们可以使用显式赋予 ZStack identity 的方式,使视图强制重新加载:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct AsyncImageView: View {
  var url: URL
  @State private var imageData: Data? = nil

  var body: some View {
    ZStack {
      if
        let data = imageData,
        let image = UIImage(data: data)
      {
        Image(uiImage: image)
      } else {
        ProgressView()
      }
    }
    // .id(url) doesn’t work
    .task {
      mageData = try? await URLSession.shared.data(from: url).0
    }
    .id(url)
  }
}

此时,View Tree 和应用之后的 Render Tree 都发生了变化:

此时这个 identity 现在保存在 Render Tree 中。此时如果 url 发生了变化,这意味着 id 以下的结点均已失效。

接着 SwiftUI 使用新的 id 构造一个新的 View Tree。比较 View Tree 和 Render Tree 我们可以发现,id 发生了变化,随后 SwiftUI 会删除 Render Tree 中 id 及以下的所有结点。实际上我们可以将其视为删除 UIKit 中的所有子视图以及它们关联的任何状态,所有相关的对象都被销毁了。

在删除它们后,SwiftUI 会根据 View Tree 添加相应的结点,结构类似,但是这些结点都是新实例,这意味着 onAppear / task 会被执行。

此时更改 url 时,图像会正常发生变化。

那么是否有更加节省资源的解决方案呢?我们可以使用 .onChange 或者包含 id 的 .task 修饰符,当它们相关联的值发生变化时,它们的代码会再次被执行,但不必销毁 Render Tree。


参考资料

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus
Built with Hugo
主题 StackJimmy 设计