XcodePreviews は SwiftUI で記述された UI を Xcode 上でプレビューするための新しい拡張機能です。 SwiftUI と UIKit には互換性があるため、 UIKit で構築された UI を XcodePreviews でプレビューさせることも可能です。
これによって、アプリケーションの再コンパイル・再実行なしに UI の変更を即時プレビューすることを可能にします。

今回は、この XcodePreviews がどのような仕組みで実現されているのかを解説します。

Build artefacts

XcodePreviews を実現するために新しく導入された Build artefacts を調べるために PreviewsSample という Single View App を作成しました。このプロジェクをビルドし、プレビュー可能な状態にしたときの Build artefacts が以下です。新しい中間ディレクトリとして Previews ディレクトリが作成されていることがわかります。

$ tree --filelimit 40 ~/Library/Developer/Xcode/DerivedData/PreviewsSample-bwzdqecwyvbvpofgqyzocvibrlbd
~/Library/Developer/Xcode/DerivedData/PreviewsSample-bwzdqecwyvbvpofgqyzocvibrlbd
├── Build
│   ├── Intermediates.noindex
│   │   ├── Previews
│   │   ├── PreviewsSample.build
│   │   └── XCBuildData
│   └── Products
│       └── Debug-iphonesimulator
├── Index
├── Logs
├── OpenQuickly-ReferencedFrameworks.index-v1
├── SourcePackages
├── TextIndex
├── info.plist
└── scm.plist

83 directories, 215 files

もう少し詳細に見ていきましょう。 Previews ディレクトリの中には { ProjectName }.build と似通った構造が存在することがわかります。ここで、 Previews ディレクトリ内の Objects-normal には swift ファイルが存在しており、我々が書いたソースコードの派生ソースコードが存在することがわかります。

$ tree --filelimit 40 ~/Library/Developer/Xcode/DerivedData/PreviewsSample-bwzdqecwyvbvpofgqyzocvibrlbd
~/Library/Developer/Xcode/DerivedData/PreviewsSample-bwzdqecwyvbvpofgqyzocvibrlbd
├── Build
│   ├── Intermediates.noindex
│   │   ├── Previews
│   │   │   └── PreviewsSample
│   │   │       ├── Intermediates.noindex
│   │   │       │   ├── PreviewsSample.build
│   │   │       │   │   └── Debug-iphonesimulator
│   │   │       │   │       ├── PreviewsSample.build
│   │   │       │   │       │   ├── Base.lproj
│   │   │       │   │       │   ├── DerivedSources
│   │   │       │   │       │   ├── Objects-normal
│   │   │       │   │       │   │   └── x86_64
│   │   │       │   │       │   │       ├── AppDelegate.d
│   │   │       │   │       │   │       ├── AppDelegate.dia
│   │   │       │   │       │   │       ├── AppDelegate.o
│   │   │       │   │       │   │       ├── AppDelegate.swiftdeps
│   │   │       │   │       │   │       ├── AppDelegate~partial.swiftdoc
│   │   │       │   │       │   │       ├── AppDelegate~partial.swiftmodule
│   │   │       │   │       │   │       ├── ContentView.2.preview-thunk.dia
│   │   │       │   │       │   │       ├── ContentView.2.preview-thunk.dylib
│   │   │       │   │       │   │       ├── ContentView.2.preview-thunk.o
│   │   │       │   │       │   │       ├── ContentView.2.preview-thunk.swift
│   │   │       │   │       │   │       ├── ContentView.3.preview-thunk.dia
│   │   │       │   │       │   │       ├── ContentView.3.preview-thunk.dylib
│   │   │       │   │       │   │       ├── ContentView.3.preview-thunk.o
│   │   │       │   │       │   │       ├── ContentView.3.preview-thunk.swift
│   │   │       │   │       │   │       ├── ContentView.d
│   │   │       │   │       │   │       ├── ContentView.dia
│   │   │       │   │       │   │       ├── ContentView.o
│   │   │       │   │       │   │       ├── ContentView.swiftdeps
│   │   │       │   │       │   │       ├── ContentView~partial.swiftdoc
│   │   │       │   │       │   │       ├── ContentView~partial.swiftmodule
│   │   │       │   │       │   │       ├── PreviewsSample-OutputFileMap.json
│   │   │       │   │       │   │       ├── PreviewsSample-Swift.h
│   │   │       │   │       │   │       ├── PreviewsSample-master.swiftdeps
│   │   │       │   │       │   │       ├── PreviewsSample-master.swiftdeps~moduleonly
│   │   │       │   │       │   │       ├── PreviewsSample.LinkFileList
│   │   │       │   │       │   │       ├── PreviewsSample.SwiftFileList
│   │   │       │   │       │   │       ├── PreviewsSample.swiftdoc
│   │   │       │   │       │   │       ├── PreviewsSample.swiftmodule
│   │   │       │   │       │   │       ├── PreviewsSample_dependency_info.dat
│   │   │       │   │       │   │       ├── SceneDelegate.d
│   │   │       │   │       │   │       ├── SceneDelegate.dia
│   │   │       │   │       │   │       ├── SceneDelegate.o
│   │   │       │   │       │   │       ├── SceneDelegate.swiftdeps
│   │   │       │   │       │   │       ├── SceneDelegate~partial.swiftdoc
│   │   │       │   │       │   │       └── SceneDelegate~partial.swiftmodule
│   │   │       │   │       │   └── ...
│   │   │       │   │       ├── PreviewsSampleTests.build
│   │   │       │   │       └── PreviewsSampleUITests.build
│   │   │       │   └── XCBuildData
│   │   │       └── Products
│   │   │           └── Debug-iphonesimulator
│   │   ├── PreviewsSample.build
│   │   │   └── Debug-iphonesimulator
│   │   │       └── PreviewsSample.build
│   │   │           ├── Objects-normal
│   │   │           │   └── x86_64
│   │   │           │       ├── AppDelegate.d
│   │   │           │       ├── AppDelegate.swiftdeps
│   │   │           │       ├── AppDelegate~partial.swiftdoc
│   │   │           │       ├── AppDelegate~partial.swiftmodule
│   │   │           │       ├── ContentView.d
│   │   │           │       ├── ContentView.swiftdeps
│   │   │           │       ├── ContentView~partial.swiftdoc
│   │   │           │       ├── ContentView~partial.swiftmodule
│   │   │           │       ├── PreviewsSample-OutputFileMap.json
│   │   │           │       ├── PreviewsSample.SwiftFileList
│   │   │           │       ├── SceneDelegate.d
│   │   │           │       ├── SceneDelegate.swiftdeps
│   │   │           │       ├── SceneDelegate~partial.swiftdoc
│   │   │           │       └── SceneDelegate~partial.swiftmodule
│   │   │           └── ...
│   │   └── XCBuildData
│   └── Products
│       └── Debug-iphonesimulator
└── ...

83 directories, 215 files

プレビューを実現する派生ソースコード

今回は、プロジェクトを生成したときに自動生成されるこちらのソースコードを用いてビルドを行いました。

import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Hello, World!")
    }
}

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

こちらが、 { ClassName }.3.preview-thunk.swift として出力されるファイルの中身です。 __designTimeSelection__designTimeString のように __designTime{ Name } の形式で定義されたプライベートなラッパーを利用していることがわかります。これらの関数がプレビューのために作成されるバイナリと Xcode のプレビュー機能を連携させる役割をしていると思われます。

他にも見慣れない記述があります。これらについて次に解説していきます。

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

extension ContentView {
    @_dynamicReplacement(for: body) private var __preview__body: some View {
        #sourceLocation(file: "/Users/.../ContentView.swift", line: 13)
        AnyView(__designTimeSelection(Text(__designTimeString("#7452.[1].[0].property.[0].[0].arg[0].value.[0].value", fallback: "Hello, World!")), "#7452.[1].[0].property.[0].[0]"))
#sourceLocation()
    }
}

extension ContentView_Previews {
    @_dynamicReplacement(for: previews) private static var __preview__previews: some View {
        #sourceLocation(file: "/Users/.../ContentView.swift", line: 19)
        AnyView(__designTimeSelection(ContentView(), "#7452.[2].[0].property.[0].[0]"))
#sourceLocation()
    }
}

typealias ContentView = PreviewsSample.ContentView
typealias ContentView_Previews = PreviewsSample.ContentView_Previews

言語機能

1. Private imports

@_private(sourceFile: )
この属性を追加して import を行うと通常外部からアクセスが不可能な変数や関数にアクセスが可能になります。これにより、我々はこの派生コードの存在を意識することなく開発を行うことが可能になっています。ただし、ソースファイル単位で明示的に指定する必要があり、この機能による影響は最小限に抑えられるようになっています。
また、この属性を有効化するには -enable-private-imports フラグを付けてモジュールをコンパイルする必要があります。

上記の例では ContentView が internal で宣言されているにも関わらず、 ContentView の extension を定義することに成功していることがわかります。また、 @_dynamicReplacement の引数に渡している参照先も、 Private imports によってアクセス可能となっているものです。

2. Source Location

#sourceLocation(file: ,line: )
この指定は、派生コードで発生したクラッシュなどのデバッグ情報を我々が書いたコード上に反映させる役割を担っています。引数に指定されている数値は、元のコードを派生コードで置き換えている箇所の行数を示しています。処理の置換については次に解説します。

3. Dynamic Replacement

@_dynamicReplacement(for: )
この処理は我々開発者にも公開されている機能である method swizzling のような役割を果たしています。
また、すべての関数が method swizzling によて置換される可能性があることをコンパイラに伝えるためには -enable-implicit-dynamic フラグを用いてコンパイルする必要があります。

上記の例では static var previews: some View を置き換えているということがわかります。引数には置き換え元の参照先を指定しています。

UIKit と SwiftUI の相互互換環境下

次に、 UIKit で記述された UI をプレビューする例をご紹介します。 UIKit に SwiftUI との互換性を持たせるには { UIView / UIViewController}Representable に準拠させます。
以下の例は AutoPreviewable のサンプルとなりますので、導入の際は参考にしてみてください。

import UIKit
#if canImport(SwiftUI) && DEBUG
import SwiftUI

@available(iOS 13, *)
struct AkkeyViewPreviews: PreviewProvider {
    static var previews: some View {
        Group {
            AkkeyView()
                .previewLayout(.fixed(width: 414, height: 100))
                .previewDevice(PreviewDevice(rawValue: "iPhone XS Max"))
        }
    }

    static var platform: PreviewPlatform? = .iOS
}

@available(iOS 13, *)
extension AkkeyView: UIViewRepresentable {
    typealias UIViewType = AkkeyView

    func makeUIView(context: Context) -> AkkeyView {
        return .init()
    }

    func updateUIView(_ uiView: AkkeyView, context: Context) {
        // Make parameter change for preview
    }
}
#endif

以下が、上記ソースコードから生成された派生ソースコードです。 method swizzling によって処理が置換される部分に着目すると、元のソースコードで previews: some View, makeUIView(context: ), updateUIView(_ uiView: , context: ) と定義された箇所の内部が置換されることがわかります。つまり、これらの内部のみを編集した場合に関しては再コンパイル・再実行なしに変更がプレビューに反映されるということになります。

@_private(sourceFile: "AutoPreviewable.swift") import AutoPreviewable
#if canImport(SwiftUI) && DEBUG
import SwiftUI
#endif
import SwiftUI
import UIKit

#if canImport(SwiftUI) && DEBUG
extension AkkeyView {
    @_dynamicReplacement(for: updateUIView(_:context:)) private func __preview__updateUIView(_ uiView: AkkeyView, context: Context) {
        #sourceLocation(file: "/Users/.../AutoPreviewable.swift", line: 38)
#sourceLocation()
    }
}
#endif

#if canImport(SwiftUI) && DEBUG
extension AkkeyView {
    @_dynamicReplacement(for: makeUIView(context:)) private func __preview__makeUIView(context: Context) -> AkkeyView {
        #sourceLocation(file: "/Users/.../AutoPreviewable.swift", line: 33)
        return .init()
#sourceLocation()
    }
}
#endif

#if canImport(SwiftUI) && DEBUG
@available(iOS 13, *) extension AkkeyViewPreviews {
    @_dynamicReplacement(for: previews) private static var __preview__previews: some View {
        #sourceLocation(file: "/Users/.../AutoPreviewable.swift", line: 12)
        AnyView(__designTimeSelection(Group {
            __designTimeSelection(AkkeyView()
                .previewLayout(.fixed(width: __designTimeInteger("#8417.[1].[0].[1].[0].property.[0].[0].arg[0].value.[2].modifier[0].arg[0].value.arg[0].value", fallback: 414), height: __designTimeInteger("#8417.[1].[0].[1].[0].property.[0].[0].arg[0].value.[2].modifier[0].arg[0].value.arg[1].value", fallback: 100)))
                .previewDevice(__designTimeSelection(PreviewDevice(rawValue: __designTimeString("#8417.[1].[0].[1].[0].property.[0].[0].arg[0].value.[2].modifier[1].arg[0].value.arg[0].value.[0].value", fallback: "iPhone XS Max")), "#8417.[1].[0].[1].[0].property.[0].[0].arg[0].value.[2].modifier[1].arg[0].value")), "#8417.[1].[0].[1].[0].property.[0].[0].arg[0].value.[2]")
        }, "#8417.[1].[0].[1].[0].property.[0].[0]"))
#sourceLocation()
    }
}
#endif

参考文献

  1. Behind SwiftUI Previews
  2. AutoPreviewable