这是一篇来自 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 的容器视图。当它的子视图要超过屏幕时,需求将剩余的子视图排布到第二行去。

【译】探寻 SwiftUI 的渲染机制 一
下面是这种布局问题的一种解决方案(不是最好方案,假如感兴趣能够参阅 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 被第2次评价,它才能够正确地布局其内容。

第一次评价 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 的示意图,将其与咱们的命令行应用程序进行比较。

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

假如你在 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 烘托循环。我在这里用图形画出了它。

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

当 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(无效)。关于视图第2次评价,SwiftUI 没有运用 run loop 调查器。

而假如这第2次评价导致再次调用回调,并导致再一次视图 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