使用 Xcode Previews 快速构建 UI

本文基于 Session 10252 整理创作。

自 WWDC2019 发布 SwiftUI 发布以来,Xcode Previews 和声明式 UI 编写方式成为 SwiftUI 吸引开发者尝试的两大特性。但是 Previews 自从早期发布之后,开发者陆续遇到很多奇怪的奔溃和不能正常工作的情况,其中最大的问题是只可以预览 SwiftUI 代码和在已有的大型仓库里无法正常使用,大部分原因是每次出发 preview 需要全量编译工程,但是因为第三方 Pod 库数量众多,配置复杂导致无法在 preview 模式下编译通过;即使是那些能通过编译的,每次小的修改也会触发整个工程的编译,过程耗时甚至大于直接在 simulator 里直接运行的时间。

今年 Xcode 15 的发布带来了 Previews 的诸多更新,解决了部分问题,下面让我们一起来了解下,包括;

  • 新的 Preview 语法
  • 支持原生预览 UIKit、AppKit、Widget 等
  • 支持预览 framework
  • Canvas 和 Console 的新特性

最后简单探讨下 Previews 的工作原理。

image-20230627111717444

何为 Xcode Previews

自 WWDC 2019 推出 SwiftUI 时,随之提供的可以实时查看 SwiftUI 代码修改后效果的功能,而不需要重新编译和重新运行,类似 HTML 开发领域中早期 Dreamware 软件提供的实时查看和现代浏览器中利用 websocket 实现的 HotReload/Live Preview。相同开发体验的还存在 Scratch 编程里——左侧拖动代码,右侧查看游戏步骤。实时 preview 的需求在各个语言中都存在,在这一点上,客户端开发的体验一直落后于 Web 开发体验,直到 Apple 官方推出了 SwiftUI 库。

在 objective-c 社区,有 injectionforxcode, 这样工具实现实时预览,但是作为二等公民,这些库不可避免的污染原工程文件。

对 Xcode Previews 使用的详细介绍的例子如下,via

添加元素

用 inspector 反向修改代码

**特别的:**和 canvas 配套使用的 attribute inspector,具有反过来修改代码的能力。这是 Xcode Previwes 特有的能力,HTML 的 HotReload/Preview 可不曾拥有(Chrome 有类似修改本地 CSS 反过来修改 scss 源码的实验特性)。

Xcode 15 Previews 的新特性

新的 Preview 语法

在 Canvas 启用情况下,在任意 swift 文件书写下面代码,即可预览任意层级 UI 元素,包括按钮、组件、模块、页面。

1
2
3
4
// Xcode 15
#Preview("Section") {
    ContentView()
}

上述代码使用了 Swift5.9 版本的 macro 特性,相比之前的 Struct 更简洁。

1
2
3
4
5
6
7
8
9
// Xcode 14
// 1
struct ContentView_Previews: PreviewProvider {
    // 2
    static var previews: some View {
        // 3
        ContentView()
    }
}

如果展开 #Preview 宏,可以看到底层同样也是用 struct 对象实现,但是使用 DeveloperToolsSupport 代替了之前的PreviewProvider。相比旧版本,新版的 DeveloperToolsSupport 明显属于开发工具(for dev) ,这样 IDE 工具链可以更确定只在 dev 阶段引入而排除在 production 的包外——语义更明确。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
struct $s12GifExtractor33_92246833A23D9370671866AF251B18F6Ll7PreviewfMf2_15PreviewRegistryfMu_: DeveloperToolsSupport.PreviewRegistry {
    static var fileID: String {
        "GifExtractor/ContentView.swift"
    }
    static var line: Int {
        110
    }
    static var column: Int {
        1
    }

    static var preview: DeveloperToolsSupport.Preview {
        Preview("Section") {
            ContentView()
        }
    }
}

新的宏也支持多个宏并列的模式,则同时在 Canvas 里同时打开多个 tab。

Tutorial 里的 样例代码还没有修改为 #Preview 宏。

支持原生预览 UIKit、AppKit、Widget

不像旧版暴露 PreviewProvider 对象给开发者,macro 在内部优雅或意大利面式的兼容多种情况。新版 #Preview支持 UIKit。从 SwiftUI 问世之初,并不是说 Preview 不支持预览 UIKit,只是比较麻烦,例如作者就曾经想把 preview 能力引入严选的 iOS 工程。经过修修补补,虽说可以运行,只是在实际工作中可用性很差,无法实用,链接见这里。新工程、小型工程里是可运行的,用法和 #Preview 是类似的,但如果新工程何不直接用 SwiftUI 呢?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import UIKit
#if canImport(SwiftUI)

import SwiftUI
@available(iOS 13.0, *)
struct UIView_Preview: PreviewProvider {
    static var previews: some View {
        UIViewPreview {
            let name = NameFlag()
            name.configure(withImageName: "duck", name: "小鸭子", count: 2)
            return name
        }.padding()
    }
}
#endif

实现原理是用了 UIViewControllerRepresentable,UIViewRepresentable 将 UIKit 元素 host 在 hostingView 里。感兴趣的人可查看源码。

好在 Xcode15 原生支持 UIKit,合理猜测也是类似原理的语法糖,把 UIKit 引入 SwiftUI 世界。目前作者没有找到 #Preview 宏定义的实现。

1
2
3
4
5
6
7
8
9

/// Creates a preview of a SwiftUI view.
///
/// - Parameters:
///   - name: Optional display name for the preview, which appears in the canvas.
///   - body: A closure producing a SwiftUI view.
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@freestanding(declaration, names: arbitrary) public macro Preview(_ name: String? = nil, body: @escaping () -> View) -> () = #externalMacro(module: "PreviewsMacros", type: "SwiftUIView")

用法也很简单,需要注意一定要返回一个 UIView

1
2
3
4
5
6
7
8
#Preview("SwiftUI") {
    Text("hite")// without return
}
#Preview("UIKit") {
    let name = UILabel()
    name.text = "hite"
    return name// return required
}

AppKit 同理,对于 PreviewProvider 而已无所谓里面的内容是什么,也不关心是属于哪个功能,所以在旧版本里,Widget 里的 UI 组件当然也可以使用 Canvas 来预览。限于之前 WidgetKit 对刷新频率和刷新时机的限制,我们是无法直接指定刷新动作只能指定 entry 的时间,所以对于有些类似有时间间隔连续发生的事件,测试时很不直接,需要在刷新界面傻等。例如之前作者做的 widget 需求里指定每一小时刷新一次,作者测试时,先把代码里的 timeinterval 修改为 10s 来测试。虽说原理上是一样,但并不符合实际场景。

而 Xcode 15,特意指出支持 Widget 刷新,原因在于,原生提供了 mock 机制,用连续事件序列模拟了有时间间隔的事件,而且不需要修改代码做测试,使用预览方式语法一样,只不过多一个 function 传入 timeline event sequence,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#Preview("list",as: .systemSmall) {
    SlowMotion()
} timeline: {
    SimpleEntry(date: .now, emoji: "😀")
    SimpleEntry(date: .now, emoji: "🤩")
}
// 代码见 demo 工程
#Preview("sequence",as: .systemMedium) {
    SlowMotion()
} timelineProvider: {
    Provider() // 会忽略 date 的数值
}

image-20230628100151629

相比之前改代码测试的方式确实优雅的多。

特别的,timeline 的 closure 接收多个 entry 实例,类似 ViewBuilder 可接收多个参数形式。

预览 Live Ativities 流程一样的,差别在于参数不同,略过。

支持预览 framework

预览某 SwiftUI/UIKit 视图对象时,Previews 需要一个可执行文件来运行目标视图,并把此视图作为 app 首页。类比理解下,就是 UIKit 里 RootViewController.loadView、SwiftUI 里 hostingView。这个可执行文件可以是 app、 widget,通常都是当前开发的 app。

如何确认当前可执行文件?Xcode 使用以下步骤确认;

  1. 当前编辑文件相关联有哪些?
  2. 这些文件所属的 targets 和依赖 targets
  3. 当前选中的 scheme 依赖的 target 和 上述 targets 取交集

如果第三步里 targets 没有交集,则 Canvas 会报错,image-20230628144723295在有交集的情况下,Previews 只会选中当前活动 scheme 关联的 app,如果此 scheme 包含了当前文件所属 target,则预览时如果引用到外部资源,且该外部资源在多个 target 存在且不同,则只有当前 scheme 关联的 target 的资源有效。

甚至于,你打开了一个 framework 工程,并不存在可执行文件,此时在 Swift 文件内 编写代码 #Preview 预览视图,Xcode 会自动创建 XCPreviewAgent 依赖加载 framework。如果遇到和 XCPreviewAgent 相关的奔溃,那么就知道是它是干什么的。

XCPreviewAgent 在缺少可执行文件时才会创建;已经有 App Target 的情况下没有。

在一个普通的 SwiftUI 页面引用第三方 framework 里的方法,和引用所有官方内置库一样,并没有区别。假设你不想在工程里编写测试代码,避免污染业务代码。或者对当前工程模块化后,有多个 framework,如何测试 framework 里的功能?

一个方法是,可以创建新的 app target,该 target 依赖 framework, 选择新创建的 app target scheme,在 framework 源码里预览——坦白的说,这个方式并没有多优雅,即使是之前 Xcode 13 也可实施。

image-20230628121451166

除了可以在这个 app target 指定和 info.plist 这类 app 基本的设置外,作者没看出来和在 framework 里直接预览的区别。

Preview Content

本 Session 还特意提了下 Development Assets ,其实不是什么新东西,至少 2019 年作者写自有 TCP 协议解包工具时候,整个工具的代码就放在 Development Assets 下。本质上,Preview 只属于开发阶段的资源,理所当然不应该被打包到线上。

Canvas 和 Console 的新特性

1. Canvas 工具条

本 Session 强调的各类 Variants, Canvas Devices Settings、Devices Selectors、真机预览,在旧版 Xcode Canvas 都可以实现,不过新版听取了社区的意见做了整理,变成内置功能。

image-20230628150540673

这些不同 Color Scheme、 Orientation、 Dynamic Type 的预览,之前都可以使用 PreviewProvider 提供的接口设置。例如,不同的 Variants 同时展示的能力,只需要 PreviewProvider 里对相同 Content 应用不同配置即可实现。新版 Xcode 把这些测试常用的代码,抽出来做成 Canvas 上的按钮。

作为对比,之前作者写为了测试不同屏幕上 Widget 的背景图片对其问题,编写的代码;

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
struct ContentView_Previews: PreviewProvider {
    // previews 支持返回多个 View
    static var previews: some View {
        // 多个 content,表示同时查看不同机型上的表现
        ContentView()
            .previewInterfaceOrientation(.landscapeLeft)
            .preferredColorScheme(.dark)
            .previewDevice(PreviewDevice(rawValue: "iPhone 14"))
            .previewDisplayName("iPhone 14")

        ContentView()
            .previewDevice(PreviewDevice(rawValue: "iPhone 14 Pro Max"))
            .previewDisplayName("iPhone 14 Pro Max")
    }
}

上述代码运行后,在 Xcode 13 上是这样的。

Xcode showing Previews on an iPhone 12 and an iPhone 12 Pro Max.

在新版 Xcode 15 里多个 View 会渲染为多个 Tab。

Anyway,新版的 Canvas 还算有些改进,起码可以作为 Tests 流程的支持手段,给 QA 使用也是不错。不过 Xcode 15 中默认选择 Live ,Xcode 14 默认是 Selectable 模式。

2. Console 里日志

Xcode 14 开始, Previews 运行时产生的日志可以在 Console 里查看了,之前想做相同的事情,需要到 Console.app 或者把日志输出到界面之上来实现。Xcode 15 座了些微调,注意右下角的日志,包含 ExecutablePreviews,在旧版 Xcode 14 里两个 tab 顺序是相反的。

image-20230628144941746

Previews 的工作原理

很多开发者会遇到可以直接 run 代码,但是 preview 失败的情况,这是有很多原因造成的,但本质上,为了避免两种模式下的生成的产物干扰做的隔离。 run 模式和 preview 模式运行了不同的模拟器。在 preview 模式 和 run 模式下,编译和启动 app 的环境变量本身都是不同的。

以下是证据,我们分别找到模拟器和 Previews 的是 GifExtractor 的进程。在 Activities monitor 里找到打开的同名 app 的 PID,执行 PS,可以看到 Previews 里执行的 app 路径和正常的模拟器路径运行的 app 路径不同,环境变量也不同。

image-20230628163040117

1
2
3
4
5
// Previews
> ps eww 35666
  PID   TT  STAT      TIME COMMAND
35666   ??  Ss     0:00.59 /Users/hite/Library/Developer/Xcode/UserData/Previews/Simulator Devices/63498DD1-5849-408D-9194-FD52F7791230/data/Containers/Bundle/Application/C77FBD44-7490-4BA1-BDA8-AF9A9ADA6D0B/GifExtractor.app/GifExtractor
... 省略
1
2
3
4
5
// simulator
> ps eww 36302
  PID   TT  STAT      TIME COMMAND
36302   ??  SXs    0:00.54 /Users/hite/Library/Developer/CoreSimulator/Devices/F32B0F72-D04F-47E3-8ABE-E98DD7E868E7/data/Containers/Bundle/Application/EE2DCF92-2A24-4DED-9A1A-D5D8907ADAC9/GifExtractor.app/GifExtractor 
... 省略

这个独立的 Preview 运行后的界面通过 XPC 和 XCPreviewKit 通讯,展示在 Canvas 上面。

为什么可以热更新代码? 大概原理和之前提到的 InjectionIII 原理类似,有一个专门运行预览补丁的动态库 PreviewsInjection 位于(/System/Library/PrivateFrameworks/PreviewsInjection.framework/PreviewsInjection ),通过 dylib loader 将 Xcode 生成的 xxx.preview-thunk.dylib 补丁文件加载到 app ,供 PreviewsInjection 做替换使用。

1
2
3
4
5
extension UIView_Preview {
    @_dynamicReplacement(for: previews) private static var __preview__previews: some View {
    AnyView(...)
    }
}

核心是 @_dynamicReplacement(for:) 方法,至于此方法是不是通过修改 vTable 来达到 method swizzling 没有深究,了解的读者可以留言。

详细的解释查看文末的几个链接,比作者挖的深入。

总结

总的来说,本次 Previews 的更新缺乏突破性,都是一些对已有功能的语法糖包装、整理提炼,但也算是改进。作者最大的关心——是否能够在大型仓库里跑起来,本次调研没有涉及,因为手边已经没有这么大的仓库做测试了。希望不要一个小改动也要编译整个工程,本 session 里推荐的尽量的模块化,可以局部化解这个问题。

对本 Session 标题“Build programmatic UI with Xcode Previews“ 里的 “programmatic” 较疑惑——本 session 并没有体现出可编程 UI 特性。目前,UI 都是通过代码驱动的呀!

参考