作为 SwiftUI 最引人注意图功用之一,预览功用吸引了不少开发者初次触摸 SwiftUI。然而,跟着项目规模的增长,越来越多的开发者发现预览功用并不如开始想象的那么易用。由于预览溃散的次数和场景的增加,一些开发者现已视预览为 SwiftUI 的缺点之一,并对其产生了排斥感。

预览功用真的如此不堪吗?咱们当时运用预览的办法真的稳当吗?我将经过两篇文章来共享我对预览功用的认知和了解,并探讨怎么构建安稳的预览。本文将首先剖析预览功用的完成机制,让开发者了解哪些情况是预览必然无法处理的。

原文发表在我的博客wwww.fatbobman.com

欢迎订阅我的公众号:【肘子的Swift记事本】

让预览溃散的一段视图代码

不久前,Toomas Vahter 写了一篇博客 Bizarre error in SwiftUI preview,其间提到了一个有趣的现象。下面这段代码能够在真机和模拟器上运转,但会导致预览溃散。

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()
    }
}

处理的办法,便是将:

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

修改为:

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

修改后,预览功用能够正常作业了。可惜的是,Toomas Vahter在文章中没有告知读者溃散原因。我借用这段代码来与大家一起探究预览功用是怎么作业的。

探寻预览溃散的原因

首先,创建一个名为 StablePreview 的新 iOS 项目。将上述代码复制到其间( 留意:此刻不要发动视图预览 ),然后编译项目。

构建稳定的预览视图 —— SwiftUI 预览的工作原理

找到该项目对应的 Derived Data 目录。

构建稳定的预览视图 —— SwiftUI 预览的工作原理

在项目对应的 Derived Data 目录中,查找尾缀为 .preview-thunk.swift 的文件:

构建稳定的预览视图 —— SwiftUI 预览的工作原理

此刻 Derived Data 目录中应该没有满足条件的文件。

点击预览的启用按钮,发动预览。

构建稳定的预览视图 —— SwiftUI 预览的工作原理

你会发现预览无法正常运用,错误提示为:

构建稳定的预览视图 —— SwiftUI 预览的工作原理

咱们再次查找当时项目 Derived Data 目录下尾缀为 .preview-thunk.swift 的文件。

构建稳定的预览视图 —— SwiftUI 预览的工作原理

这时分,你会看到 Xcode 协助咱们生成了一个名为 ContentView.1.preview-thunk.swift 的文件。该文件是 Xcode 为预览功用生成的衍生代码,让咱们打看这个文件,看看究竟生成了什么内容。

@_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

其间有这么几个言语特性需求留意:

  • @_private(sourceFile: )

让当时代码能够访问原本外部无法访问的变量和函数,这样咱们就无需在项目代码中进步访问权限。

  • #sourceLocation(file: ,line: )

负责将衍生代码中产生的溃散等调试信息反映在咱们写的代码上,协助开发者找到对应的源代码方位。

  • @_dynamicReplacement(for: )

@_dynamicReplacement 是完成预览功用的要害机制。它用于指定某个办法作为另一个办法的动态代替办法。在衍生代码中,Xcode 运用 @_dynamicReplacement 为多个函数供给了代替办法。在预览时,以代替后的 __preview__previews 办法作为预览进口。请参阅 Swift Native method swizzling 以了解 @_dynamicReplacement 的更多信息。

  • import struct StablePreview.ContentView

在衍生代码中,未运用 import StablePreview,而是运用了 import struct StablePreview.ContentView。这意味着编译器在编译这段代码时,能够依靠的信息很少,只能在很小的范围内进行类型揣度,以进步功率。这也是本段代码无法在预览中正常运转的主要原因。

编译器在编译下面的代码时,无法找到 Item 对应的界说,因而导致预览失败。

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
    }
}

按照原博客的做法,将 func select(_: Item) 特征为 func select(_: ContentView.Item) 后,衍生代码将改变为:

extension ContentView.ViewModel {
typealias ViewModel = ContentView.ViewModel
    @_dynamicReplacement(for: select(_:)) private func __preview__select(_: ContentView.Item) { // 具备了详细的信息,能够获取到 Item 的界说
        #sourceLocation(file: "/Users/yangxu/Documents/博客相关/BlogCodes/StablePreview/StablePreview/ContentView.swift", line: 22)
#sourceLocation()
            // implement
    }
}

因而在编译的时分,也就能够正确的获取 Item 的界说信息了。

这就解说了这段代码为什么在模拟器和真机中能够运转,但会导致预览溃散。由于预览是以衍生代码作为进口,只依靠有限的导入信息对衍生代码进行编译,因而或许会呈现因信息不完好而无法编译的情况。而在模拟器和真机运转时,并不需求编译为预览准备的衍生代码,只需求编译项目文件即可。编译器能够从完好的代码中正确揣度出 ContentView 中的 Item 对应 func select(_: Item) 中的 Item。

了解了问题所在,咱们还能够运用其他两种办法来处理之前的代码无法在预览中运用的问题。

  • 办法一

将 Item 从 ContentView 中移出来,放置到与 ContentView 同级的代码方位。这样,在预览的衍生代码中,将会呈现 import struct StablePreview.Item 这行代码。编译器也就能够正确处理 func select(_: Item) 了。

  • 办法二

在与 ContentView 同级的代码方位添加 typealias Item = ContentView.Item。在预览的衍生代码中,将会呈现 typealias Item = StablePreview.Item 。经过两次别名指引,编译器也能找到正确的 Item 界说。

接下来,让咱们持续查看 Xcode 是怎么加载预览视图的。。

在项意图 Derived Data 目录中查找尾缀为 .preview-thunk.dylib 的文件。

构建稳定的预览视图 —— SwiftUI 预览的工作原理

该文件是预览状态下衍生代码编译后生成的动态库。在该文件所在方位执行以下命令: nm ./ContentView.1.preview-thunk.dylib | grep ' T '

构建稳定的预览视图 —— SwiftUI 预览的工作原理

能够看出,Xcode 在编译了预览的衍生文件后,在动态库中只生成了一个 _main 办法。在该办法中,大概率进行了界说预览相关的环境设置、设置预览初始状态等操作。最终,再创建了几个专门用于预览的进程。经过 XPC 在预览进程与 Xcode 之间进行通信,最终完成了在 Xcode 中预览特定视图的意图。

构建稳定的预览视图 —— SwiftUI 预览的工作原理

阅读 Damian Malarczyk 所写的 Behind SwiftUI Previews 一文,了解更多完成细节。

预览的作业流程

咱们对上面的探索进程进行一个梳理,大致上能够得到如下的作业流程:

  • Xcode 生成预览衍生代码文件
  • Xcode 编译整个项目,解析文件、获取预览视图完成、准备依靠的其他资源
  • Xcode 编译预览衍生代码文件,创建动态库
  • Xcode 发动预览线程,在其间加载 _XCPreviewKit 结构和预览衍生文件生成的 dylib
  • XCPreviewKit 结构在预览线程中创建预览窗口
  • Xcode 经过 XPC 发送音讯指令, _XCPreviewKit 结构更新预览窗口,并在两个线程建进行交互与同步
  • 用户在 Xcode 界面中看到预览效果

从预览的完成中能够得到的部分定论

  • 假如项目无法编译,预览也无法正常运转
  • 预览并没有发动完好的模拟器,因而某些代码无法在预览中完成预期的行为,例如( 预览不存在应用程序的生命周期事件 ):
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()
    }
}
  • 为了进步功率,生成的预览衍生文件会尽或许减少不必要的导入。但是,这也或许导致无法正常编译的情况产生(例如本文中的比如)
  • 预览是以预览衍生文件作为进口的,开发者必须在预览代码中为预览视图供给足够的上下文信息( 例如注入所需的环境目标 )

总的来说,Xcode 预览功用虽然在视图开发流程中极为便利,但它仍处在一个功用受限的环境中。开发者运用预览时需求清醒地认识到其局限性,并避免在预览中完成超出其才能范围的功用。

接下来

在本文中,咱们探讨了 Xcode 预览功用的完成原理,并指出其存在必定局限性。鄙人一篇文章中,咱们将从开发者的角度审视预览功用:它的设计意图、最适宜的运用场景以及怎么构建安稳高效的预览。

欢迎你经过 Twitter、 Discord 频道 或博客的留言板与我进行沟通。

订阅下方的 邮件列表,能够及时获得每周最新文章。

原文发表在我的博客wwww.fatbobman.com

欢迎订阅我的公众号:【肘子的Swift记事本】