[转载] 探究 SwiftUI Preview 的工作原理

原文地址

如果你爱 SwiftUI😍,那你很可能也恨 SwiftUI Preview😡。

之所以这么说,是因为大部分使用 SwiftUI 的开发者,都或多或少遇到过这样的一个界面:

除了崩溃,Xcode Preview 还经常会莫名其妙地卡死无法展示预览效果。

一般遇到这种情况,由于我们不了解 Preview 的工作原理,所以除了处理明显的项目编译错误外,对于其他疑难杂症,似乎只能通过清除缓存、重启 Xcode 这些方法来解决。

为了更好地了解这些问题的根因,这篇文章将探究 SwiftUI Preview 的工作原理。尽管无法完全杜绝 SwiftUI Preview 产生的问题,但至少能够帮助你看懂异常日志,希望能给你的日常开发过程带来一些启发。

TLDR:先说结论


  1. Xcode 16 中,Preview 的工作原理进行了较大调整。如果你对 Xcode 16 之前的 Preview 工作原理感兴趣,可以阅读构建稳定的预览视图 —— SwiftUI 预览的工作原理
  2. 从 Xcode 16 开始,正常的 Build and Run 流程与 Preview 共享构建产物,只是产物的执行流程不同,Preview 会使用 JIT 的方式运行产物
  3. Preview 有三种不同层次的重新构建操作,适用于源代码文件不同程度的修改。如果我们用 Small、Middle、Large 来指代这三种操作,它们的区别可以用下面的表格来表示:
重新构建程度 典型场景 重新构建范围 Preview 应用刷新方式
Small 修改方法中的字符串字面量 不重新构建 保留原应用进程,重新执行 Preview 宏中定义的方法
Middle 修改方法中的其他内容 只重新构建修改了方法的源代码文件 关闭原应用进程,重新开启一个新的应用实例,再执行 Preview 宏中定义的方法
Large 修改类或者结构体属性、修改全局变量 整个工程重新构建,等同于重新执行一次带缓存的 build and run 关闭原应用进程,重新开启一个新的应用实例,再执行 Preview 宏中定义的方法

接下来我们可以通过下面的内容来进一步了解这些细节。

怎么研究:从构建产物来一探究竟


为了研究 Preview 的工作原理,让我们先做一个假设:Preview 在工作过程中,一定会在 Xcode 的 DerivedData 文件夹中留下蛛丝马迹。因此,我们不妨将 DerivedData 加入 Git 管理,观察每一次操作会给 DerivedData 文件夹带来什么变化。

为了方便研究,我们创建了一个名为 SwiftUIPreviewSample 的工程,并将项目的 DerivedData 文件夹放到 .xcproject 同级目录以方便查看。你也可以通过查看每一个 commit diff 来了解不同修改对 DerivedData 的影响。

复习一下:一次 Build and Run 的应用是怎么运行起来的?


从 Xcode 16 开始,SwiftUI Preview 的工作机制就发生了一些变化,其中最主要的变化就是:Build and Run 和 Preview 共享了同样的构建产物,这是为了让 Preview 和 Build and Run 的编译产物可以复用,进而提高 Preview 本身的构建效率。

当我们点击 Play 之后,Xcode 就会构建整个项目,整个过程的中间产物和最终产物都存在于 ~/Library/Developer/Xcode/DerivedData/xxx/Build 文件夹下的 Build/Intermediates.noindexBuild/Products 文件夹下。

在最终的 .app 中,我们一般可以看到这样的内容:

1
2
3
4
XXX.app
|__ XXX
|__ __preview.dylib
|__ XXX.debug.dylib

根据 Apple 的这个 官方文档,我们可以得知,为了让 Preview 和 Build and Run 共享构建产物,Xcode 在项目中开启 ENABLE_DEBUG_DYLIB 的情况下,会将原本都放在 XXX.app/XXX 中的主要内容都拆分到 XXX.debug.dylib 这个动态库中,而原本的二进制文件就成为了一个仅仅起到跳板作用的 “空壳” 可执行文件。

为了验证这一点,你可以打开看看你的任意一个完整项目的二进制文件,仅仅从大小你就可以看出,随着代码的增多,增大的只有 XXX.debug.dylib 这个动态库。

而当我们将二进制启动之后,也可以通过 lsof -p $(pgrep -f "") 命令查看到,二进制在整个运行过程中,也确实读取了 debug.dylib 这个动态库。

1
2
3
4
5
lsof -p $(pgrep -f SwiftUIPreviewSample)
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
SwiftUIPr 77422 onee cwd DIR 1,18 416 315720871 /Users/onee/Library/Containers/spatial.onee.SwiftUIPreviewSample/Data
SwiftUIPr 77422 onee txt REG 1,18 57552 316066805 /Users/onee/Code/Playground/SwiftUIPreviewSample/Build/Products/Debug/SwiftUIPreviewSample.app/Contents/MacOS/SwiftUIPreviewSample
SwiftUIPr 77422 onee txt REG 1,18 290816 264469085 /Applications/Xcode.app/Contents/Developer/usr/lib/libBacktraceRecording.dylib

所以,在一次正常的 Build and Run 流程中,整个二进制的构建和执行结果会是下图所示的流程:

探究一下:一次 Preview 的应用是怎么运行起来的?


而当我们启用了 Preview 之后,整个应用的构建流程就会开始有一些变化。首先,在整个构建过程中,Xcode 将会针对使用了 Preview 宏的 Swift 代码源文件,生成一些特别的 .preview-thunk.swift 文件,在这个文件中会对原始的 Swift 文件做一些文件上的预处理。

Note

在计算机领域中,thunk 一般指的是一种用于解决不同代码段之间的接口问题的技术,一个典型例子就是将回调风格的异步函数转换为 async/await 风格的函数,我们就可以将这个过程称之为 thunkify。

例如,假如我们的源文件是是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import SwiftUI
let myText = "Hello, world!"
struct ContentView: View {
@State var item = Item(name: "Hello!")
@State var count = 0

var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("\(item.name) Foo, Bar, Baz")
}
.padding()
}
}
#Preview {
ContentView()
}

那么对应的 .preview-thunk.swift 文件就会是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import func SwiftUI.__designTimeFloat
import func SwiftUI.__designTimeString
import func SwiftUI.__designTimeInteger
import func SwiftUI.__designTimeBoolean
#sourceLocation(file: "/Users/onee/Code/Playground/SwiftUIPreviewSample/SwiftUIPreviewSample/ContentView.swift", line: 1)
// 1. 这里标记了源文件的位置
//
// SwiftUIPreviewSample
// Created by: onee on 2025/1/10
//
import SwiftUI
let myText = "Hello, world!"
struct ContentView: View {
@State var item = Item(name: "Hello!")
@State var count = 0

var body: some View {
VStack {
Image(systemName: __designTimeString("#2282_0", fallback: "globe"))
// 2. 这里使用了开头引入的私有函数
.imageScale(.large)
.foregroundStyle(.tint)
Text("\(item.name) Foo, Bar, Baz")
}
.padding()
}
}
#Preview {
ContentView()
}

Xcode 对原始的 Swift 文件主要做了两个地方的处理,首先是使用 #sourceLocation 标记了源文件的位置,方便做一些异常报错的优化;另外,在一些文字字面量的位置,使用 __designTimeString 替换原本的文字内容,方便在文字变化时直接修改。

除了额外生成这些 .preview-thunk.swift 文件,其他的构建流程和普通的 Build and Run 的构建流程基本一致。Xcode 会将整个项目完整构建,最终也会生成和普通的 Build and Run 一样的 .app 文件。并且,我们也可以在 Xcode 中的 Report Navigator 中查看到整个构建过程的详细信息。

Note

是的,这一点相当反直觉。我相信大部分人看到 Preview 宏的写法时,第一反应都会以为 Preview 只会编译单个文件。

和正常的 Build and Run 不同的是,Preview 运行起来的应用会使用 PreviewInjection.framework 和 XOJITExecutor.framework 这两个系统私有库(macOS 在 /System/Library/PrivateFrameworks 目录下就能找到)来 JIT 执行整个应用。这一点,我们可以通过故意在 Preview 中写一个会产生异常的代码来验证:

1
2
3
4
5
6
7
8
struct ContentView: View {
var body: some View {
// 在这里故意写一个数组越界的代码
let a = [1]
let b = a[2]
// other code
}
}

然后我们就可以从 Preview 的异常日志中看到这样的信息:

1
2
3
4
5
6
7
8
5   ???                        0x3400f0aa4 _$s20SwiftUIPreviewSample11ContentViewV4bodyQrvg$14$body
6 ??? 0x3400f1d30 _$s20SwiftUIPreviewSample11ContentViewV0A2UI0E0AadEP4body4BodyQzvgTW$14$body
# ...
70 XOJITExecutor 0x000007adc __xojit_executor_run_program_wrapper + 1832
71 XOJITExecutor 0x0000037cc ???
72 PreviewsInjection 0x000038098 ???
73 SwiftUIPreviewSample 0x000001958 __debug_blank_executor_main + 1056
74 dyld 0x000006274 start + 2840

其中 ”???” 就是 Xcode Preview 采用 JIT 方式执行的代码。由于没有调试信息,所以在最终的日志中我们也看不到具体名称。

相同的异常在 Build and Run 运行起来的应用中,会是这个样子:

1
2
3
4
5
6
7
5   HotReloadInspector.debug.dylib	       0x10467656c ContentView.body.getter + 644 (ContentView.swift:74)
6 HotReloadInspector.debug.dylib 0x10467940c protocol witness for View.body.getter in conformance ContentView + 28
# ...
75 SwiftUI 0x1c1016f38 static App.main() + 224
76 HotReloadInspector.debug.dylib 0x10467d128 static HotReloadInspectorApp.$main() + 40
77 HotReloadInspector.debug.dylib 0x10467d2d8 __debug_main_executable_dylib_entry_point + 12 (HotReloadInspectorApp.swift:9)
78 dyld 0x191770274 start + 2840

与 Build and Run 不同,Preview 模式下运行的应用完全不会读取 .debug.dylib 这个动态库,而是读取 __preview.dylib 来完成后续所有的 JIT 执行。这一点也可以从 lsof -p $(pgrep -f "XXX") 命令的结果中看到:

1
2
3
4
5
lsof -p $(pgrep -f SwiftUIPreviewSample)
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
SwiftUIPr 42252 onee cwd DIR 1,18 704 2 /
SwiftUIPr 42252 onee txt REG 1,18 57040 316025086 /Users/onee/Library/Developer/Xcode/UserData/Previews/Simulator Devices/EA49B734-414B-4A25-B2F4-9D72D059EF9E/data/Containers/Bundle/Application/571D3078-50BF-4273-90CC-A5AF8D652500/SwiftUIPreviewSample.app/SwiftUIPreviewSample
SwiftUIPr 42252 onee txt REG 1,18 34896 316025089 /Users/onee/Library/Developer/Xcode/UserData/Previews/Simulator Devices/EA49B734-414B-4A25-B2F4-9D72D059EF9E/data/Containers/Bundle/Application/571D3078-50BF-4273-90CC-A5AF8D652500/SwiftUIPreviewSample.app/__preview.dylib #

另外,如果是 Preview SPM Package 中的代码,整体的执行流程会和 Preview 主工程中的代码有些许的不同,Xcode 会使用 XCPreviewAgent.app 这个应用来运行整个应用。这个应用就藏在 Xcode 中,你可以通过 find /Applications/Xcode.app/ -name "*Agent" 来看看他具体在哪里:

1
2
3
find /Applications/Xcode.app/ -name "*Agent"
/Applications/Xcode.app//Contents/Developer/Platforms/AppleTVOS.platform/Developer/Library/Xcode/Agents/XCPreviewAgent.app/XCPreviewAgent
# others...

这样,我们就可以用下面的流程图来表示 Preview 的整个运行流程:

Warning

尽管通过框架名称我们可以推断出来 Preview 使用了 JIT 来执行代码,不过在此次的探究中,我们并没有找到具体的 JIT 执行细节,例如异常日志中 ??? 所代表的二进制内容的具体位置,因此在目前版本的图中,??? 代表的二进制前面就先放了一个问号。

如果你对这方面有更深入的了解,欢迎在评论区留言或者直接 与我交流

到目前为止,我们已经知道一个 Preview 执行首次执行起来是什么样子了,如果我们修改了代码,Preview 会如何重新构建呢?

Preview 的三层重构建策略


正如我们文章一开始所说,Preivew 有三种不同的策略来重新构建应用。这里我们分别用具体的例子来进行说明。

Small

首先是改动最小的,修改方法中的字面量,例如 这个修改,我们将 ContentView 中的 Text 中的字面量从 Hello, world! 改为 Hello, world!, Foo, Bar, Baz

而在此之前,ContentView 对应的 thunk.swift 文件中,Text 对应的代码是这个样子的:

1
Text(__designTimeString("#25104_1", fallback: "Hello, world!")

从 commit 记录可以看出,在这种修改下,DerivedData 文件夹下的内容并没有发生任何变化。同时,通过对比 pgrep -f SwiftUIPreviewSample 的 PID 也可以得知,原有的 SwiftUIPreviewSample 进程并没有被销毁。

并且,如果我们使用 @State 存储了一些变量,Small 程度下的修改并不能保留这些变量的状态,例如我们将 count 从 0 改为 1,修改字面量后, count 的值会恢复为 0。

那我们就就可以合理推断,在这种策略下,Xcode 应该是通过直接读取源代码中的字面量内容,然后将新的字面量内容更新到已有的 SwiftUIPreviewSample 进程中,进一步通过重新执行 Preview 宏创建出来的一系列方法来实现整个视图的更新的。

当 Preview 重新执行时,__designTimeString() 会返回更新后的字符字面量的最新值,从而实现了视图上文字的更新。

Middle

接下来,如果我们修改了方法中其他非字面量的内容,例如将一个变量修改为带插值的字符串,如 这个修改

从 commit 记录可以看出,在这种情况下,Xcode 会重新生成 .preview-thunk.swift 文件以及对应的 .o 文件,但重新生成的范围仅限于做了修改的文件,.app 下的二进制文件和动态库并不会重新生成。

同时,通过 pgrep -f SwiftUIPreviewSample 的 PID 也可以得知,原有的 SwiftUIPreviewSample 进程已经被关闭,Xcode 重新生成了一个新的 SwiftUIPreviewSample 进程。

因此我们可以合理推断,在这种策略下,Xcode 只会重新编译做了方法内容修改的源文件,然后通过重新运行一次 SwiftUIPreviewSample 进程来实现整个视图的更新。

Large

最后,如果我们修改了类或结构体属性、修改全局变量、增加 @State 等,例如 这个修改

从 commit 记录可以看出,类似这样的修改也会更新 .app 下的二进制文件和动态库。当这样的修改出现时,我们可以从 Xcode 的 Report Navigator 中看到整个项目的重新编译日志:

同样,通过 pgrep -f SwiftUIPreviewSample 的 PID 也可以得知,原有的 SwiftUIPreviewSample 进程已经被关闭,Xcode 重新生成了一个新的 SwiftUIPreviewSample 进程。

因此我们可以合理推断,在这种策略下,Xcode 会重新编译整个项目,然后通过重新运行一次 SwiftUIPreviewSample 进程来实现整个视图的更新。

他山之石:对比一下 Flutter 的 Hot Reload


尽管在 Xcode 16 中 Preview 已经做出了一些优化,但如果对比其他框架的 Hot Reload 功能,例如 Flutter 的 Hot Reload,目前版本的 Preview 还有不少提升空间:

  1. 不支持断点调试。在这一点上,Flutter 的 Hot Reload 不但支持断点调试,当代码修改之后断点的位置也不会漂移,可以说体验非常好
  2. 视图上的状态会被重置。相比之下,Flutter 在大部分情况下的 Hot Reload 会保留视图的状态,这样在调试需要状态的 UI 时会方便很多
  3. 整体实现更加黑盒。Flutter 的文档详细探讨了 DartVM 支持 Hot Reload 的过程和原理,而 Apple 在这一点上相对欠缺

不过,也许 Xcode 16 的 Preview 机制改进只是一个开始,希望后续版本的 Xcode 能针对 Preview 有更大的优化。

参考

-------------本文结束感谢您的阅读-------------

欢迎关注我的其它发布渠道