Building Stable Preview Views: How SwiftUI Previews Work

Published on

Get weekly handpicked updates on Swift and SwiftUI!

As one of the most striking features of SwiftUI, the preview function has attracted many developers to try SwiftUI for the first time. However, as the project grows, more and more developers have found that the preview function is not as easy to use as they originally thought. Due to the increasing number of preview crashes and scenarios, some developers have already regarded preview as one of the shortcomings of SwiftUI and have developed a sense of rejection towards it.

Is the preview function really so unbearable? Are we using the preview in the right way? In two articles, I will share my understanding and perception of the preview function, and explore how to build a stable preview. This article will first analyze the implementation mechanism of the preview function, allowing developers to understand the situations where preview is inevitably unable to handle.

A section of view code that causes preview to crash

Not long ago, Toomas Vahter wrote a blog post called ”Bizarre error in SwiftUI preview”, which mentioned an interesting phenomenon. The code below can run on real devices and simulators, but it will cause the preview to crash.

Swift
import SwiftUI

struct ContentView: View {
    @StateObject var viewModel = ViewModel()
    var body: some View {
        VStack {
            ForEach(viewModel.items) { item in
                Text(verbatim: item.name)
            }
        }
        .padding()
    }
}

extension ContentView {
    final class ViewModel: ObservableObject {
        let items: [Item] = [
            Item(name: "first"),
            Item(name: "second"),
        ]
        func select(_: Item) {
            // implement
        }
    }

    struct Item: Identifiable {
        let name: String
        var id: String { name }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

The solution is to change the code from:

Swift
func select(_: Item) {
            // implement
}

to:

Swift
func select(_: ContentView.Item) {
            // implement
}

After the modification, the preview function is working properly. Unfortunately, Toomas Vahter did not explain the cause of the crash in the article. I will use this code segment to explore with everyone how the preview function works.

Investigating the cause of preview crashing

First, create a new iOS project named “StablePreview”. Copy the above code into it (note: do not launch the view preview at this time), and then compile the project.

https://cdn.fatbobman.com/image-20230522105513088.png

Find the Derived Data directory corresponding to this project.

https://cdn.fatbobman.com/image-20230522105916884.png

Search for files with the suffix .preview-thunk.swift in the Derived Data directory for the project:

https://cdn.fatbobman.com/image-20230522110506987.png

At this point, there should be no files in the Derived Data directory that meet the criteria.

Click the Enable button in the preview to launch the preview.

https://cdn.fatbobman.com/image-20230522110636690.png

You may find that the preview function is not working properly, with an error message:

https://cdn.fatbobman.com/image-20230522110719469.png

Let’s search again for files with the suffix .preview-thunk.swift in the Derived Data directory of the current project.

https://cdn.fatbobman.com/image-20230522110813828.png

At this point, you will see that Xcode has generated a file called ContentView.1.preview-thunk.swift. This file is a derivative code generated by Xcode for the preview feature. Let’s take a look at this file to see what content has been generated.

Swift
@_private(sourceFile: "ContentView.swift") import StablePreview
import SwiftUI
import SwiftUI

extension ContentView_Previews {
    @_dynamicReplacement(for: previews) private static var __preview__previews: some View {
        #sourceLocation(file: "/Users/yangxu/Documents/博客相关/BlogCodes/StablePreview/StablePreview/ContentView.swift", line: 34)
        ContentView()

#sourceLocation()
    }
}

extension ContentView.Item {
typealias Item = ContentView.Item

    @_dynamicReplacement(for: id) private var __preview__id: String {
        #sourceLocation(file: "/Users/yangxu/Documents/博客相关/BlogCodes/StablePreview/StablePreview/ContentView.swift", line: 28)
 name

#sourceLocation()
    }
}

extension ContentView.ViewModel {
typealias ViewModel = ContentView.ViewModel

    @_dynamicReplacement(for: select(_:)) private func __preview__select(_: Item) {
        #sourceLocation(file: "/Users/yangxu/Documents/博客相关/BlogCodes/StablePreview/StablePreview/ContentView.swift", line: 22)

#sourceLocation()
            // implement
    }
}

extension ContentView {
    @_dynamicReplacement(for: body) private var __preview__body: some View {
        #sourceLocation(file: "/Users/yangxu/Documents/博客相关/BlogCodes/StablePreview/StablePreview/ContentView.swift", line: 6)
        VStack {
            ForEach(viewModel.items) { item in
                Text(verbatim: item.name)
            }
        }
        .padding()

#sourceLocation()
    }
}

import struct StablePreview.ContentView
import struct StablePreview.ContentView_Previews

There are several language features to note:

  • @_private(sourceFile:)

Enables access to variables and functions that are not normally accessible from outside code, so we don’t have to increase access permissions in the project code.

  • #sourceLocation(file:, line:)

Reflects debugging information such as crashes from derived code to the code we write, helping developers find the corresponding source code location.

  • @_dynamicReplacement(for:)

@_dynamicReplacement is a key mechanism for implementing preview functionality. It specifies a method as a dynamic replacement method for another method. Xcode uses @_dynamicReplacement to provide replacement methods for multiple functions in the derived code. In previews, the __preview__previews method replaced by the replacement is used as the entry point for previews. See Swift Native method swizzling for more information on @_dynamicReplacement.

  • import struct StablePreview.ContentView

In the derived code, import struct StablePreview.ContentView is used instead of import StablePreview. This means that when the compiler compiles this code, it has very little information to rely on and can only perform type inference within a very small scope to improve efficiency. This is also the main reason why this code cannot run properly in previews.

When the compiler is compiling the following code, it cannot find the definition corresponding to Item, which results in a preview failure.

Swift
extension ContentView.ViewModel { // Cannot perform correct type inference
typealias ViewModel = ContentView.ViewModel

    @_dynamicReplacement(for: select(_:)) private func __preview__select(_: Item) {
        #sourceLocation(file: "/Users/yangxu/Documents/博客相关/BlogCodes/StablePreview/StablePreview/ContentView.swift", line: 22)

#sourceLocation()
            // implement
    }
}

Following the original blog’s method, after changing func select(_: Item) to func select(_: ContentView.Item), the derived code will change to:

Swift
extension ContentView.ViewModel {
typealias ViewModel = ContentView.ViewModel

    @_dynamicReplacement(for: select(_:)) private func __preview__select(_: ContentView.Item) { // Detailed information is available to obtain the definition of Item
        #sourceLocation(file: "/Users/yangxu/Documents/博客相关/BlogCodes/StablePreview/StablePreview/ContentView.swift", line: 22)

#sourceLocation()
            // implement
    }
}

Therefore, during compilation, the definition information of Item can be obtained correctly.

This explains why this piece of code can run on both the simulator and the physical device, but causes crashes in previews. Previews use derived code as an entry point and rely on limited import information to compile derived code, which may result in incomplete information and inability to compile. When running on the simulator or physical device, there is no need to compile the derived code prepared for previews, only the project files need to be compiled. The compiler can correctly infer the Item in the ContentView corresponds to the Item in func select(_: Item) from the complete code.

Now that we understand the problem, we can use two other methods to solve the previous issue of the code not being usable in previews.

  • Method 1:

Move Item out of ContentView and place it at the same level of code as ContentView. This will result in import struct StablePreview.Item appearing in the derived code of the preview. The compiler can then process func select(_: Item) correctly.

  • Method 2:

Add typealias Item = ContentView.Item at the same level of code as ContentView. This will result in typealias Item = StablePreview.Item appearing in the derived code of the preview. After two aliasing guides, the compiler can find the correct Item definition.

Next, let’s continue to see how Xcode loads preview views…

Look for files with the suffix .preview-thunk.dylib in the Derived Data directory of the project.

https://cdn.fatbobman.com/image-20230522131911942.png

This file is a dynamic library generated after compiling the derivative code in preview mode. Execute the following command at the location of this file: nm ./ContentView.1.preview-thunk.dylib | grep ' T '

https://cdn.fatbobman.com/image-20230522132730344.png

It can be seen that after compiling the preview derivative files, Xcode only generates a _main method in the dynamic library. In this method, it is highly likely that environment settings related to previewing, setting the initial state of the preview, and other operations related to previewing are defined. Finally, several processes dedicated to previewing are created. By communicating between the preview process and Xcode through XPC, the goal of previewing specific views in Xcode is ultimately achieved.

https://cdn.fatbobman.com/image-20230522134401399.png

Read the article ”Behind SwiftUI Previews” by Damian Malarczyk to learn more about implementation details.

Preview Workflow

We can roughly summarize the exploration process above into the following workflow:

  • Xcode generates preview derivative code files.
  • Xcode compiles the entire project, parses files, obtains preview view implementations, and prepares other required resources.
  • Xcode compiles the preview derivative code files and creates a dynamic library.
  • Xcode launches the preview process, loads the _XCPreviewKit framework and the dylib generated by the preview derivative code file.
  • The XCPreviewKit framework creates a preview window in the preview process.
  • Xcode sends message instructions via XPC, the _XCPreviewKit framework updates the preview window, and two process interact and synchronize with each other.
  • The user sees the preview effect in the Xcode interface.

Partial conclusions from the implementation of the preview

  • If the project cannot be compiled, the preview cannot run properly either.
  • The preview does not start a complete emulator, therefore some code may not behave as expected in the preview, for example (the preview does not have the application’s lifecycle events).
Swift
struct ContentView: View {

    var body: some View {
        VStack {
            Text("Hello world")
        }
        .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
            print("App will resign active")
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
  • In order to improve efficiency, the generated preview derivative files will minimize unnecessary imports as much as possible. However, this may also result in situations where compilation cannot be performed normally (such as in the example in this article).
  • Previews are based on preview derivative files, and developers must provide sufficient contextual information for preview views in preview code (such as injecting the required environment objects).

Overall, although Xcode’s preview function is extremely convenient in the view development process, it still operates in a functionally limited environment. Developers using previews need to be aware of their limitations and avoid implementing functions in previews that exceed their capabilities.

Next

In this article, we have explored the implementation principle of Xcode preview function and pointed out its limitations. In the next article, we will examine the preview function from the developer’s perspective: its design purpose, the most suitable usage scenarios, and how to build stable and efficient previews.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed