【译】探寻 SwiftUI 的渲染机制 一

2,408 阅读20分钟

这是一篇来自 Rens Breur 的文章。文中向我们详细阐述了 SwiftUI 中的 render loop 是如何工作的,以及探索的过程。render loop 是驱动 SwiftUI 进行渲染更新的重要机制,通过了解它的原理和策略,我们可以明白 SwiftUI 高性能背后的秘密,以及避免一些不必要的坑。虽然探索 render loop 背后的机制使用到了 run loop 等比较高阶的知识,但是作者对相关知识也进行了较为详尽的解释,因此本篇文章也适合对底层知识不太熟悉的同学阅读。

本篇文章中反复出现了多个专业名次,为了便于阅读,我提前罗列出了这部分专业名词,进行解释。

  • event loop:事件循环,基于消息事件的循环,例如触摸被系统包装成一个事件一层一层传递给 UI 组件并最终触发 UI 组件渲染。
  • render loop:渲染循环,是一个更小的概念,更多关注在消息处理和屏幕渲染上
  • invalidated:无效、失效,类似于 Flutter 的 dirty 。当一个 View 的关联属性改变了,或者其他原因导致 View 需要刷新,View 就会被标记为 invalidated,此时框架会对 View 的body 进行 evaluate 。
  • evaluate:直译是评估,我更倾向于翻译成计算,也就是当框架发现一个 View 被标记为 invalidated 后,框架会尝试比对改变前和改变后的 body 内容。如果框架认为 body 内容改变了,就会重新渲染。注意,evaluation 并不一定会导致重新渲染,这取决于框架对 body 的评估结果。评估虽然不会必然导致渲染,但框架仍需读取 body 数据并进行(可能复杂的)计算以确定内容是否改变。关于这部分内容,本文并没有着重展开,感兴趣的朋友可以阅读《Understanding how and when SwiftUI decides to redraw views》

就像 UIKit 一样,SwiftUI 也是在一个 event loop (事件循环)之上实现的。event loop 会向你的 UI 代码分发消息,进而会触发屏幕的一部分重新渲染。消息的处理和屏幕上图形的渲染构成了一个应用程序的 render loop (渲染循环) 。所有的 UI 框架都是基于 render loop 的,在 SwiftUI 中,它被隐藏得特别好。大多数时候,它都在引擎下工作,我们不需要知道任何底层细节。我们甚至不需要了解什么是 event loop 就可以写出 UI 代码,也不需要关心屏幕多久渲染一次内容。虽然如此,但在某些情况下,了解幕后发生的事情还是很有用的。

我们将首先研究一些例子,了解 SwiftUI render loop 的工作原理。然后,我们将更详细地探讨 render loop ,并提出一些问题。比如:SwiftUI 究竟是在什么时候进行 evaluate(评估)。视图是否在评估完成后立即绘制到屏幕上?这种评估和屏幕渲染有什么关系?我们有时会把评估等价成"渲染",这是正确的吗?

例子:onAppear

在 SwiftUI 中,我们没法得到像 UIKit 中那么丰富的视图生命周期。如果我们想在一个视图出现时执行一个动作,我们只能使用一个函数:onAppear 。但是它到底是什么时候被调用的呢?是不是像 viewWillAppear 那样,在视图被渲染并在屏幕上可见之前调用?如果是的话,我们可以信赖它吗?

以下面的 View 和 ViewModel 为例:

class ViewModel: ObservableObject {
    @Published var statusText: String = "invalid"

    func fetch() {
        self.statusText = "loading"
        // ...
    }
}

struct ContentView: View {
    @StateObject var model = ViewModel()

    var body: some View {
        Text(model.statusText)
            .padding()
            .onAppear { model.fetch() }
    }
}

上面这个 View 在初始化的时候,Text 展示的是 “invalid”,在 onAppear 回调后修改为了 “loading”。我们尝试跑一下代码,发现应用在启动时就直接显示为 "loading"。我们从未看到 "invalid",甚至一瞬间都没有。所以 onAppear 真的如它的名称所言,会在 View 出现的一瞬间调用吗?这个时机是完全可靠的吗?比如在速度较慢的 iPhone 上,或者在有高刷的新款 iPhone 上,会发生什么?会不会因为显示器的刷新率不够而导致 Text 文字闪烁?如果我们给 Text 增加过渡动画,这是否会导致问题?还有,上面这种代码会导致渲染效率降低吗?我们可以看到,body 的关联值 statusText 改变了两次,即 body 被评估了两次,那么内容也会被渲染两次吗?

例子:自定义布局 & preference keys

SwiftUI 提供了一些基本布局工具,比如 Stacks、Alignment guides、Frames 等。不过有一些布局是不可能用基本布局工具来实现的,比如对于需要知道其子视图尺寸的布局,这些工具可能不够用。

举个例子,假设我们想要一个类似 HStack 的容器视图。当它的子视图要超过屏幕时,需要将多余的子视图排布到第二行去。

image.png 下面是这种布局问题的一种解决方案(不是最好方案,如果感兴趣可以参考 Flexible layouts in SwiftUI):

struct Flow<Content: View>: View {
  let content: [Content]

  @State private var sizes: [CGSize] = []

  var body: some View {
    ZStack(alignment: .topLeading) {
      ForEach(0 ..< content.count, id: .self) { i in
        content[i]
          .background(GeometryReader {
            Color.clear
              .preference(key: SizesPreferenceKey.self, value: [$0.size])
          })
          .offset(self.calculateOffset(i)) // 使用其前面所有子视图的尺寸计算出x和y的偏移量
        }
      }
    .onPreferenceChange(SizesPreferenceKey.self) {
      sizes = $0
    }
  }

  // ...
}

首先需要创建一个 @State 变量,用来保存所有子视图的尺寸。然后在子视图的 background 上使用 GeometryReader 来读取子视图的尺寸。回到父视图中,可以在 onPreferenceChange 回调中获取所有子视图的尺寸赋值给 sizes 并最终更新状态。有了所有子视图的尺寸,现在可以正确计算每个子视图的偏移量了。

这个技巧是有效的,但是父视图的 body 需要被评估两次。当 body 第一次被计算时,sizes 还是空的,所以它还不能正确地布局子视图。当所有子视图的尺寸都计算出来后,sizes 被更新。然后,父视图的 body 被第二次评估,它才可以正确地布局其内容。

第一次评估 ZStack 的 body 时,它还没有准备好被显示,所以我们遇到了第一个例子中一样的问题。当视图初始化的时候,它可能经历了两次甚至是多次评估。这些无效的评估会触发渲染并最终影响性能吗?我们该如何避免他们?

好的,我现在就可以给你们问题的最终答案:它们永远不会闪烁(视图不会被突然渲染多次),性能几乎不受影响。如果一个视图的 body 需要被评估两次,那么第一次 body 永远不会被渲染到屏幕上。并且 body 评估并不等同于渲染。很多时候,对视图 body 的评估一定会导致视图被重新渲染。但情况并不总是如此,而且也不是立即如此。为了了解这一点,我们现在将研究一个 SwiftUI 应用程序如何运行,以及它如何渲染其内容。

从硬件开始讲起

熟悉屏幕渲染和触摸机制的同学可以跳过这一段

首先,视图是如何显示在屏幕上的?iPhone 有一个具有特定刷新率的屏幕。对于大多数 iPhone 来说,这是 60 赫兹。这意味着显示屏每秒刷新 60 次,而每一帧都持续 1/60 秒。最高端的 iPhone 有一个动态刷新率,最大刷新率为 120 赫兹。GPU 需要保证只在两次显示刷新之间改变视频帧。如果不这样做,屏幕就会一次合并两个帧的视频,这可能会导致图形伪影,如撕裂。

除了使用 GPU,一个应用程序的部分内容也可能使用 CPU 来渲染内容。在这种情况下,图像首先被生成为位图,然后被发送到 GPU 。GPU 对图形进行转换和组合。如果一个特定的视图或一块图形的渲染成本很高,它可以由 GPU 存储到内存中。

在屏幕上显示数据只是故事的一半,我们还需要接收用户的输入。触摸输入通常以一个特定的频率进行采样。这个频率可能高于显示屏的刷新率。即使触摸的采样频率与显示器刷新率相同,触摸采样率和显示器刷新率也可能不完全同步。对于最新的 iPhone ,触摸采样率是 120 赫兹,是显示器刷新率的两倍。虽然我们不能以注册触摸的速度来更新屏幕,但我们可以利用这些额外的触摸数据在屏幕上显示更详细的图形。在一个绘图应用程序中,我们可以根据更多的触摸来显示绘制的笔触。

游戏大多基于 update loop (更新循环),试图生成尽可能多的帧,以满足甚至超过显示器的硬件刷新率。相反,应用程序只会在数据发生变化、响应触控等事件后才驱动系统执行绘图操作。当应用程序需要处理此类事件时,操作系统会将其唤醒,然后应用程序利用 UI 框架再次渲染屏幕的部分内容。

注册输入事件并使用这些事件在屏幕上渲染图像,需要精确地进行协调。当编写一个应用程序时,你一般不需要担心这个问题。你只需要使用手势或控制事件,然后改变视图内容。但操作系统会仔细地将事件传递给你的应用程序,使你得到的事件不会多于或少于你所需要的,以便在每次刷新显示器时准确地提供一帧,同时也提供尽可能低的延迟。

run loop

熟悉 run loop 的同学可以跳过这一段

在苹果平台上,每个应用程序的核心 event loop(事件循环)背后都是 CFRunLoop 实现的。这个核心基础对象是随 Mac OS X 10.0 发布的 Carbon API 的一部分,并在许多不同的 UI 框架和迭代中存活至今。在被 Carbon 应用程序使用后,它还被 UIKit 使用,如今仍被 SwiftUI 使用。main dispatch queue (主队列)也是在 CFRunLoop 之上实现的,Swift Concurrency 的 MainActor 也是如此。

想要看到 CFRunLoop 是如何工作的,最好的方法是我们创建一个自己的 run loop。假设我们正在编写一个简单的命令行程序,等待用户输入,然后对其采取行动。

while let input = readLine() {
  print(input)
}

我们在一个循环中读取用户的输入,如果我们接收到了什么,就对它执行一个方法,打印出来。这就是一个 run loop 。该程序可以处于两种状态。在第一种状态下,它是空闲的,等待用户输入。线程将被置入睡眠状态,而 CPU 时间被用于其他进程。当有用户输入时,操作系统会唤醒我们的线程来处理它。

如果我们还想在同一个线程中监听传入的网络事件呢?现在我们不能再使用 readLine 方法了,因为那会阻塞线程,直到有用户输入文本。有很多方法可以实现同时等待多个操作系统事件。但无论何种方式,它都需要内核支持。对于一个命令行程序,通常会使用 select 或 Dispatch sources。而在系统内部,CFRunLoop 使用 mach 端口。

下面是 CFRunLoop 的示意图,将其与我们的命令行应用程序进行比较。

CFRunLoop.svg

如果你在 Xcode 调试器中暂停一个 iOS 应用程序,主线程的堆栈信息中便会出现下面的调用栈。

* frame #0: libsystem_kernel.dylib`mach_msg_trap + 10
  frame #1: libsystem_kernel.dylib`mach_msg + 59
  frame #2: CoreFoundation`__CFRunLoopServiceMachPort + 319
  frame #3: CoreFoundation`__CFRunLoopRun + 1249

mach_msg 是系统调用,CFRunLoop 用它来等待多个可能的事件中的任何一个。在这期间,我们的应用程序没有使用 CPU ,或者至少主线程没有使用。

一个 CFRunLoop 被配置为一组传递事件的输入源。当一个应用程序被启动时,它在主线程上启动一个 run loop ,用一个 input sources(输入源)来传递触摸事件。其他的输入源之后也可以被添加到其中。你也可以在辅助线程上启动新的 run loop 。我们可以用一个带有两个输入源的 CFRunLoop 实现一个处理用户输入和网络事件的命令行程序。

来自输入源的事件会按照特定的顺序进行处理。run loop 一共有 4 种类型的输入源:

  • input sources 0。这是自定义的输入源,它们手动调用 CFRunLoop 函数来传递事件。iOS 应用程序中的触摸事件在一个辅助线程上处理,然后通过 input sources 0 送到主线程的 run loop 中。
  • input sources 1。这是基于机器端口的输入源。例如 CADisplayLink,可用于将绘图代码与显示器刷新率同步。异步网络代码也可以使用 input sources 1。(然而,请注意,许多网络库在内部调度队列上使用阻塞的 I/O 调用来代替网络调用,然后通过主调度队列将代码调度到主线程)。
  • Timer sources。计时器,如Timer,使用这种特殊的输入源。
  • The main dispatch queue。调度到主队列的代码,以及与主队列相关的调度源也构成了一个输入源。这允许旧代码和基于调度的代码之间的沟通。(其他队列没有基于 CFRunLoop 实现)

除了添加输入源,我们还可以向 CFRunLoop 添加观察者,当 run loop 到达特定周期时会发送通知。run loop 的周期是由 CFRunLoopActivity 定义的,观察者可以选择对其中的一个或几个周期进行监听。run loop 观察者在苹果自己的框架中被广泛使用,我们很快就会看到。

当一个应用程序的 run loop 工作时,它可能正在处理来自输入源的事件或通知观察者。为了便于我们在调试应用程序时看到 run loop 在做什么,CFRunLoop 会通过以下5个标记函数告知它现在的状态。

__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__
__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__
__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__
__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__
__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__

这些函数不做任何事情,他们的唯一用处是,当我们打印堆栈时,我们可以看到我们当前在 run loop 中的位置。打开一个 Xcode 项目,在你代码中的任何地方放一个断点。堆栈将包含上面5种标记函数中的一个。

通过添加 run loop 观察者,以及在函数中添加断点,我们可以得到很多关于 SwiftUI render loop 的工作信息。

Core Animation & render server

你是否有过这样的经历:当一个应用程序出现卡顿时,你认为它不可能是卡顿,因为还有一些动画在进行中?即使应用程序的主线程被卡住,指示器(菊花)仍在旋转,这总是让我困惑。即使主线程繁忙或暂停,iOS中的动画也可以继续。这不是因为动画发生在另一个线程中,而是因为它们发生在另一个进程中。

操作系统使用合成器来允许多个进程显示图形,然后在同一屏幕上的不同窗口中绘制它们。iOS 也有一个合成器,但它不仅仅是用来在分屏或应用切换器中同时绘制不同的窗口。它还被用来绘制应用程序中的不同 CALayers,并绘制动画。这个过程,即 render server,执行了 Core Animation 的大部分魔法。

Core Animation 与 render server 对话,告诉它要画什么和做什么动画。一般来说,我们会对一个视图进行多次修改,作为对用户操作的反馈。在 UIKit 中,为了响应一个按钮的点击,你可能会同时改变一个视图的大小和背景颜色,或者你可能会调用多个方法来触发 setNeedsDisplay 。如果我们每改变一个参数就渲染一次,很明显效率会非常低,也会导致一些奇怪的问题。为了告诉系统该把哪几个参数打包一起渲染,Core Animation 框架暴露了 CATransactions

CATransaction 包含了 begincommit 两个方法。你可以手动 begin (启动)和 commit (提交)一个 CATransaction 事务。如果你不主动调用 CATransaction API,CATransaction 也会在引擎下被隐式调用。试试 CATransactions,看看它们的效果如何,是一件很有趣的事情。让我们创建一个 UIKit 应用程序,其中有一个 UIButton ,它有以下动作。

@IBAction func buttonPress() {
  self.view.backgroundColor = .red
  sleep(2)
  self.view.backgroundColor = .white
}

在按下按钮后,应用程序被卡住 2 秒,但它所处的视图的背景颜色保持为白色。视图层的变化在睡眠前没有被渲染。这是因为设置背景颜色启动(begin)了一个隐式渲染事务,而这个事务在睡眠前没有提交(commit)。

现在,让我们在置视图的背景色之前和之后添加两行。

@IBAction func buttonPress() {
  CATransaction.begin()
  self.view.backgroundColor = .red
  CATransaction.commit()
  sleep(2)
  self.view.backgroundColor = .white
}

我们现在按下这个按钮,它所在的视图的背景颜色就会变成红色,然后在应用程序卡住的时候保持红色两秒钟,然后变成白色。我们主动提交(commit)了一个事务,因此视图在睡眠之前改变了颜色。由此可以推断,仅仅改变一个视图的背景颜色(不主动调用 CATransaction ),只会隐式地创建渲染事务,并不会去提交这个事务。

那么隐式的事务究竟何时提交?答案是:每当一个隐式事务被启动,就会在当前 run loop 周期结束时被安排提交。它的底层是用一个 run loop 观察者来完成的,这个观察者是由 Core Animation 添加到主 CFRunLoop 中的,观察的周期是 CFRunLoopActivity.beforeWaiting

CATransaction 是可嵌套的。你可以在一个 CATransaction 里面启动另一个 CATransaction但是只有外部事务会被用来渲染和改变屏幕内容。外层事务可以是一个被隐式地启动的事务。举个例子:有些控件可能在调用它们的 action handlers 之前就已经调用了动画代码,动画代码启动了一个隐式事务。然后当你在 action handlers 内使用显式事务(手动调用 CATransaction.commit() )对一个图层进行修改时,提交它不会立即产生任何效果(需要等外层的隐式事务提交时才会改变)。

虽然你在 SwiftUI 应用程序中不直接使用 CATransactions,但 SwiftUI 框架在内部仍然使用 Core Animation 和 CATransactions 进行绘制和动画。与 render server 一起,Core Animation 对 iOS 来说是非常基础的。

触摸事件和显示器刷新率

对 CADisplayLink 、触摸和响应链比较熟悉的同学可以跳过

需要自定义动画或使用物理引擎的应用程序可以使用 CADisplayLink 来使绘图代码与显示器的刷新率同步。在这个 API 可用之前,特别是游戏开发者很难做到这一点,他们不得不使用 NSTimer 并想办法绕过很多限制。

应用程序从操作系统接收触摸事件的频率与显示屏刷新的频率相同。这是合理的,因为我们使用触摸来更新视图,如果比显示的频率更高,那就是一种浪费。但是,如果我们将收到这些触摸事件的时间与 CADisplayLink 启动的时间进行比较,我们会看到它们并不完全同步。

在具有高触摸刷新率的 iPhone 上,一个显示刷新周期内会发生多个触摸事件,但我们不会单独接收它们。在 UIKit 中,我们可以从 UITouch 对象中获得那些中间的触摸事件。

所有的 run loop 输入源,包括用于实现 CADisplayLink 和接收触摸的输入源,都以不同的方式应对系统繁忙的情况。如果多个触摸事件发生时,应用程序仍在忙于响应前一个触摸,它们将不会被单独传递,但仍可从最近的触摸事件中恢复触摸。相反,如果在下一次显示刷新即将发生时系统仍在忙碌, CADisplayLink 根本不会通知我们。

全貌

有了这些关于 iOS 中用于处理事件(如触摸)和在屏幕上渲染内容的底层技术的背景知识,我们现在可以看一下完整的 SwiftUI 渲染循环。我在这里用图形画出了它。

SwiftUIRunLoop.svg

当 APP 不做任何事情时,一个 SwiftUI 应用程序将有一个空闲的 CFRunLoop 。CFRunLoop 将等待来自输入源的事件,如触摸、网络事件、定时器或显示器刷新。为了响应触摸,SwiftUI 可能会调用一个 Button 的 action handler。如果我们在 action handler 中设置一个断点,我们会在堆栈跟踪中看到 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ 。这是因为触摸事件是由 input sources 0 输入源传递的。

为了响应来自输入源的事件,我们可能会更新视图中的一些 @State 变量,或者在 @ObservedObject 上调用一个函数,进而触发 objectWillChange 。在这种情况下,SwiftUI 视图就会被标记为 invalidated(无效) 。这意味着它的 body 需要被重新评估,但如果立即这样做,效率就会很低。有可能改变了一个 @State 变量的同一个函数会改变另一个 @State 变量。因此,评估 body 会被安排在之后执行。

这个“之后”具体是什么时间节点?如果我们把断点放在视图 body 的任何一点上,我们可以在堆栈跟踪中看到 __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ 。就像隐式地提交 CATransaction 一样,被标记为 invalidated(无效)的视图,其 body 评估也被安排在当前 run loop 周期结束时执行。这也是通过一个 run loop 观察者实现的,该观察者同样观察 CFRunLoopActivity.beforeWaiting 阶段。如果一个视图在同一个 run loop 中被两次标记为 invalidated(无效),它将只会被评估一次。

在所有 invalidated(无效)的视图被重新评估后,SwiftUI 不会立即将控制权返回给 run loop。一些 View 的回调,如 onChangeonPreferenceChange ,以及 onAppear 首先被调用,这些回调可能再次使视图 invalidated(无效)。对于视图第二次评估,SwiftUI 没有使用 run loop 观察器。

而如果这第二次评估导致再次调用回调,并导致再一次视图 invalidated(无效),SwiftUI 将暂时禁用视图 invalidated(无效),以防止无限循环。它还会打印一个类似这样的警告。

onChange(of: _) action tried to update multiple times per frame

以上是 SwiftUI 如何使用 run loop 减少视图评估的情况。接下来我们看看渲染部分。

在重新评估视图的时候,我们仍然会同时对多个视图、多个属性进行修改。正如我们所看到的,这些变化不会立即在屏幕上绘制。它们也会启动一个隐式 CATransaction 。因此,SwiftUI 利用了 UIKit 应用程序中的相同优化。

只有当隐式 CATransaction 被提交时,视图的内容才会被渲染到屏幕上。这也是 CPU 真正调用渲染代码的时刻。不过这带来一个问题:如果 SwiftUI 在 render loop 的这一部分崩溃了,就很难弄清楚如何解决,因为很难看到是哪个视图的哪一部分导致的。

总结

在 render loop 中,为了优化代码,有一个常见的模式:确保只在需要的时候调用。当调用一个函数或改变一个变量触发了一个更新时,这个更新不会立即执行。相反,它被安排在以后进行。当视图因其状态改变而失效时,例如 onChangeonAppear 这样的处理程序被调用时,以及当 Core Animation 需要绘制图形时,就会发生这种优化。这些优化在框架内部处理,主要使用了 CFRunLoop 观察者。

通过对 render loop 的了解,我们知道了为什么开头例子中的代码是安全的。在 body 最后一次评估之前的变化都没有被渲染,因为它们所属的隐式事务还没有提交。在调试或试图提高性能时,知道 SwiftUI 在做什么很有用。

SwiftUI 中的渲染循环可能隐藏得很好,它所使用的技术与我们在 UIKit 应用程序中使用的技术相同,并且有很好的文档。如果我们能更好地了解它的工作原理,我们就能更好地理解我们所写的代码的副作用,并做出更好的决定。有时,我们可能会把“渲染”等价成“evaluate 评估”。但有时,理解其中的区别会很有帮助。

解释一下,无论苹果的文档还是 Xcode instrument 提供的 SwiftUI debug 工具,似乎都把关注点主要集中在 evaluation ,因此会出现上文“很多人会把 evaluate 和渲染等价”的描述。主要原因是 evaluate -> 真正渲染的这个判定过程(即计算前后 body 是否一致)是隐藏在框架内部的,我们优化的渠道不多。感兴趣的同学可以参考下《Understanding how and when SwiftUI decides to redraw views》

翻译自 The SwiftUI render loop