SwiftUI was first announced in WWDC 2019, and SwiftUI Previews was shipped with Xcode since version 11. It’s intriguing to me that how Swift in Xcode can act like Javascript in a browser, so I did some investigations. I certainly don’t know it inside out, but I’d like to document some observations.
In this article, the module of interest is called SamplePreview
and its build target is SamplePreview.framework
. The file that contains SwiftUI code is MySwiftUIView.swift
.
Xcode includes quite a few apps and frameworks to support SwiftUI Previews.
There are two apps that play essential roles, Xcode Previews
and XCPreviewAgent
. They are built to run on simulators.
Xcode Previews.app
- The frontend app that shows the UI. This app can actually be installed in a simulator.
XCPreviewAgent.app
- A deamon app that loads our code and talks to
Xcode Previews
through XPC.
- A deamon app that loads our code and talks to
Besides the two apps, there are a ton of frameworks to support Previews. Some of them are used by Xcode itself, some of them are used on a simulator, and some of them are for both. I found them through the LC_LOAD_DYLIB
of these Mach-O files.
PreviewsXcode.framework
PreviewsDeveloperTools.framework
PreviewsFoundation.framework
PreviewsMessaging.framework
PreviewsModel.framework
PreviewsPipeline.framework
PreviewsPlatforms.framework
PreviewsScenes.framework
PreviewsUI.framework
PreviewsXcodeUI.framework
PreviewsInjection.framework
PreviewsOSSupport.framework
PreviewsOSSupportUI.framework
PreviewsServices.framework
PreviewsServicesUI.framework
After running the preview, if we examine the Derived Data, we will find some interesting files generated by Xcode for the preview purpose.
${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Intermediates.noindex/SamplePreview.build/Debug-iphonesimulator/SamplePreview.build
├── DerivedSources
│ └── SamplePreview_vers.c
└── Objects-normal
└── arm64
├── MySwiftUIView.1.preview-thunk.swift
├── MySwiftUIView.1.preview-thunk.dylib
└── (other intermediates files)
MySwiftUIView.swift (original source)
import SwiftUI
struct MySwiftUIView: View {
var body: some View {
VStack(spacing: 8) {
Text("Hello")
Text("World")
}
}
}
struct MySwiftUIView_Previews: PreviewProvider {
static var previews: some View {
MySwiftUIView()
}
}
MySwiftUIView.1.preview-thunk.swift
@_private(sourceFile: "MySwiftUIView.swift") import SamplePreview
import SwiftUI
import SwiftUI
extension MySwiftUIView_Previews {
@_dynamicReplacement(for: previews) private static var __preview__previews: some View {
#sourceLocation(file: "/Users/qyang/Projects/SamplePreview/SamplePreview/MySwiftUIView.swift", line: 17)
MySwiftUIView()
#sourceLocation()
}
}
extension MySwiftUIView {
@_dynamicReplacement(for: body) private var __preview__body: some View {
#sourceLocation(file: "/Users/qyang/Projects/SamplePreview/SamplePreview/MySwiftUIView.swift", line: 8)
VStack(spacing: __designTimeInteger("#6017.[1].[0].property.[0].[0].arg[0].value", fallback: 8)) {
Text(__designTimeString("#6017.[1].[0].property.[0].[0].arg[1].value.[0].arg[0].value", fallback: "Hello"))
Text(__designTimeString("#6017.[1].[0].property.[0].[0].arg[1].value.[1].arg[0].value", fallback: "World"))
}
#sourceLocation()
}
}
import struct SamplePreview.MySwiftUIView
import struct SamplePreview.MySwiftUIView_Previews
SamplePreview_vers.c
extern const unsigned char SamplePreviewVersionString[];
extern const double SamplePreviewVersionNumber;
const unsigned char SamplePreviewVersionString[] __attribute__ ((used)) = "@(#)PROGRAM:SamplePreview PROJECT:SampleProject-1" "\n";
const double SamplePreviewVersionNumber __attribute__ ((used)) = (double)1.;
(Before Xcode 14, there is another file MySwiftUIView.5.preview-thunk.swift
is generated.)
From the generated files, we can see some Swift features are used.
@_dynamicReplacement
- This is the native Swift swizzling. There are plenty of articles online talking about this. The TL;DR is that the
previews
and other properties will be replaced by the generated code at runtime.
- This is the native Swift swizzling. There are plenty of articles online talking about this. The TL;DR is that the
__designTime*
(__designTimeString
,__designTimeInteger
,__designTimeSelection
, etc)- I don’t know how those work exactly, especially for the mysterious strings, like
#6017.[3].[0].property.[0].[0].arg[1].value.[3].modifier[1].arg[0].value
. My guess is that it’s sort of a key used in the XPC payload, so that we can get values without rebuilding.
- I don’t know how those work exactly, especially for the mysterious strings, like
#sourceLocation
- This lets debugger know where the original code is. We certainly don’t want to debug the generated code.
From the Xcode build panel, we can see that the preview build is separate from the regular build.
When building for previews, the environment variable ENABLE_PREVIEWS
is set to YES
. By comparing the build logs, I found that building for previews has some extra compiler flags to swiftc
. Basically they enable the dynamic replacement feature.
-Xfrontend -enable-implicit-dynamic
-Xfrontend -enable-private-imports
-Xfrontend -enable-dynamic-replacement-chaining
Also, the generated code needs to be compiled too, but the command didn't show up in the build logs. We can set SWIFT_EXEC
and LD
to stub the default tool and output the flags into a log file.
Compile MySwiftUIView.1.preview-thunk.swift
swiftc
-F /Applications/Xcode.app/Contents/SharedFrameworks-iphonesimulator
-enforce-exclusivity=checked
-DDEBUG
-sdk /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator15.4.sdk
-target arm64-apple-ios15.4-simulator
-module-cache-path /Users/qyang/Library/Developer/Xcode/DerivedData/ModuleCache.noindex
-Xfrontend -serialize-debugging-options
-enable-testing
-swift-version 5
-I ${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Products/Debug-iphonesimulator
-F ${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Products/Debug-iphonesimulator
-c
-j10
-serialize-diagnostics
-Xcc -I${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Intermediates.noindex/SamplePreview.build/Debug-iphonesimulator/SamplePreview.build/swift-overrides.hmap
-Xcc -iquote
-Xcc ${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Intermediates.noindex/SamplePreview.build/Debug-iphonesimulator/SamplePreview.build/SamplePreview-generated-files.hmap
-Xcc -I${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Intermediates.noindex/SamplePreview.build/Debug-iphonesimulator/SamplePreview.build/SamplePreview-own-target-headers.hmap
-Xcc -I${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Intermediates.noindex/SamplePreview.build/Debug-iphonesimulator/SamplePreview.build/SamplePreview-all-non-framework-target-headers.hmap
-Xcc -ivfsoverlay
-Xcc ${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Intermediates.noindex/SamplePreview.build/Debug-iphonesimulator/SamplePreview.build/all-product-headers.yaml
-Xcc -iquote
-Xcc ${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Intermediates.noindex/SamplePreview.build/Debug-iphonesimulator/SamplePreview.build/SamplePreview-project-headers.hmap
-Xcc -I${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Products/Debug-iphonesimulator/include
-Xcc -I${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Intermediates.noindex/SamplePreview.build/Debug-iphonesimulator/SamplePreview.build/DerivedSources-normal/arm64
-Xcc -I${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Intermediates.noindex/SamplePreview.build/Debug-iphonesimulator/SamplePreview.build/DerivedSources/arm64
-Xcc -I${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Intermediates.noindex/SamplePreview.build/Debug-iphonesimulator/SamplePreview.build/DerivedSources
-Xcc -DDEBUG=1
-Xcc -ivfsoverlay
-Xcc ${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Intermediates.noindex/SamplePreview.build/Debug-iphonesimulator/SamplePreview.build/unextended-module-overlay.yaml
-Xcc -working-directory/Users/qyang/Projects/SamplePreview/SamplePreview
${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Intermediates.noindex/SamplePreview.build/Debug-iphonesimulator/SamplePreview.build/Objects-normal/arm64/MySwiftUIView.1.preview-thunk.swift
-o ${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Intermediates.noindex/SamplePreview.build/Debug-iphonesimulator/SamplePreview.build/Objects-normal/arm64/MySwiftUIView.1.preview-thunk.o
-module-name SamplePreview_PreviewReplacement_MySwiftUIView_1
-Onone
-Xfrontend -disable-modules-validate-system-headers
-gline-tables-only
Link MySwiftUIView.1.preview-thunk.dylib
clang
-F /Applications/Xcode.app/Contents/SharedFrameworks-iphonesimulator
-target arm64-apple-ios15.4-simulator
-isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator15.4.sdk
-L${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Products/Debug-iphonesimulator
-F${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Products/Debug-iphonesimulator
-Xlinker -rpath
-Xlinker @executable_path/Frameworks
-Xlinker -rpath
-Xlinker @loader_path/Frameworks
-Xlinker -no_deduplicate
-Xlinker -objc_abi_version
-Xlinker 2
-fobjc-link-runtime
-L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphonesimulator
-L/usr/lib/swift
-lAStaticLib
-compatibility_version 1
-current_version 1
-dynamiclib
${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Products/Debug-iphonesimulator/SamplePreview.framework/SamplePreview ${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Intermediates.noindex/SamplePreview.build/Debug-iphonesimulator/SamplePreview.build/Objects-normal/arm64/MySwiftUIView.1.preview-thunk.o
-o ${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Intermediates.noindex/SamplePreview.build/Debug-iphonesimulator/SamplePreview.build/Objects-normal/arm64/MySwiftUIView.1.preview-thunk.dylib
-framework SwiftUI
These are just regular compiling and linking, no special flags. Two things are worth mentioning.
- The module name is
SamplePreview_PreviewReplacement_MySwiftUIView_1
. It just looks funny. - The
MySwiftUIView.1.preview-thunk.dylib
links allSamplePreview
’s dependent static libraries, but notSamplePreview
itself. Notice the-lAStaticLib
? That's a static library depended bySamplePreview
.
The first time to run SwiftUI Previews involves multiple steps. They don't necessarily follow the order and can be executed at the same time.
- Build the
SamplePreview.framework
. - Generate
MySwiftUIView.1.preview-thunk.swift
and other source files. - Build generated sources file into
MySwiftUIView.1.preview-thunk.dylib
. - Launch
Xcode Previews.app
.- We can see "Xcode Previews" process shows up in the Activity Monitor.
- Launch
XCPreviewAgent.app
which loads theSamplePreview.framework
andMySwiftUIView.1.preview-thunk.dylib
. The relevant implementation inSamplePreview.framework
is replaced by the implementation in*.preview-thunk.dylib
.XCPreviewAgent
process shows up in the Activity Monitor.- If we attach lldb to
XCPreviewAgent
process (with SIP disabled) and runimage list
, we can make sure it loadsSamplePreview.framework
and*.preview-thunk.dylib
.
Xcode Previews
andXCPreviewAgent
communicate through XPC.- We can use xpcspy (with SIP disabled) to snoop some communications, although the actual content is kind of a cipher.
- No code regeneration. No rebuild whatsoever.
XCPreviewAgent
can just updateXcode Previews
through XPC.- I believe those
__designTime*
functions made this possible.
- I believe those
SamplePreview.framework
is not rebuilt*.preview-thunk.swift
is re-generated and rebuilt into the*.preview-thunk.dylib
.- we can verify this by checking the timestamp of those files.
XCPreviewAgent
is killed and relaunched, loading the new dylibs.- We can tell because the PID of
XCPreviewAgent
is changed.
- We can tell because the PID of
- The preview needs to be manually refreshed and everything needs to be rebuilt.
There is no way to load a static library at runtime. The module is required to be built as a dynamic framework, so that XCPreviewAgent
can load it, probably through dlopen
. All of our modules are actually statically linked to the app binary. To make SwiftUI Previews work, we generated artificial dynamic framework targets for relevant modules.
Although XCPreviewAgent
is a fat binary (containing arm64 and x86_64 slices), it’s always launched natively (arm64 version) on M1, no matter whether Xcode is running on Rosetta or not. Because arm64 binary can only load arm64 dylibs, the SamplePreview.framework
have to be built for arm64. Otherwise, we will see the error "mach-o file, but is an incompatible architecture (have 'x86_64', need 'arm64')".
This is an interesting problem. Despite being a dylib, SamplePreview
can depend on any number of static libraries. If those static libraries reference some assets, e.g. imageset, they won't show up in the preview. This is because of the bundle paths.
- The main bundle is
.../Library/Developer/Xcode/UserData/Previews/Simulator Devices/.../XCPreviewAgent.app
- The bundle of
SamplePreview
code is${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Products/Debug-iphonesimulator/SamplePreview.framework
. - The bundle of static libraries code is
${DERIVED_DATA}/Build/Intermediates.noindex/Previews/SamplePreview/Intermediates.noindex/SamplePreview.build/Debug-iphonesimulator/SamplePreview.build/Objects-normal/arm64
.
The first two make sense, but the third one is surprising. It's actually because of the dynamic replacement. That location is where *.preview-thunk.dylib
is. As mentioned above, both of the SamplePreview
and *.preview-thunk.dylib
link with the dependent static libraries. When XCPreviewAgent
loads the dylibs, the previews
implementation is replaced, calling into *.preview-thunk.dylib
instead. Then different code paths will end up in the different dynamic libraries.
Here is more explanation with some code sample.
// AStaticLib.swift, will be compile into a static lib
import SwiftUI
private class BundleToken {}
public class AStaticLib {
public static let logo = Image("logo", bundle: Bundle(for: BundleToken.self))
}
// The image shows up.
// Although `previews` is replaced, the `logo` variable is not.
// The replaced `previews` calls `logo`, who calls AStaticLib, which ends up in the SamplePreview.
import SwiftUI
import AStaticLib
let logo = AStaticLib.logo
struct MySwiftUIView_Previews: PreviewProvider {
static var previews: some View {
logo
}
}
// The image won't show up.
// The replaced `previews` calls `AStaticLib` directly, which is in `*.preview-thunk.dylib`.
import SwiftUI
import AStaticLib
struct MySwiftUIView_Previews: PreviewProvider {
static var previews: some View {
AStaticLib.logo
}
}
The more I learned, the more questions I have. There are still a lot of questions that I don’t have an answer yet.
- How Xcode detect which part of code is changed? Thus it can act differently based on the code change.
- How does those
__designTime*
functions work exactly? - What information are communicated through XPC?
- ...