WWDC Quick Look 💓 By SwiftGGTeam
Structure your app for SwiftUI previews

Structure your app for SwiftUI previews

观看原视频

Highlight

Apple 演示了用 pinned previews、@StateObject、Development Assets、简单输入与协议抽象重构 SwiftUI 视图,让 Xcode Previews 跳过沉重启动逻辑并覆盖真实界面状态。

核心内容

SwiftUI Previews 失效时,常见根因是视图把太多运行期依赖拖进了 canvas;modifier 错误反而比较容易定位。一个缩略图预览需要启动网络模型,一个编辑器预览必须构造 CloudKit 记录,一个状态条预览缺少 EnvironmentObject 就无法渲染。这样的 view 在 app 里能跑,在 preview 里却很难快速试错。

这场 session 用一个照片拼贴 app 贯穿演示。Kevin 先从最轻的工作流讲起:把某个预览 pin 住,再切回正在编辑的文件。这样改缩略图颜色、Asset Catalog 的深色模式颜色、动态字体尺寸时,开发者不用离开真实使用场景。

接着,session 把问题推进到 app 结构。SwiftUI app life cycle 让 app 的启动入口也变成 SwiftUI 类型,但 preview 不应该为了一个叶子视图去初始化完整网络层。@StateObject 在这里的价值,是把昂贵模型的创建时机交给 SwiftUI 管理,并让 preview 跳过当前画面不需要的初始化工作。

最后,Apple 把 previewability 落到 view 输入设计上。能传简单不可变值,就不要传整个 CloudKit-backed model;需要修改值时,用 Binding;必须把复杂数据传下去时,用协议抽象和 design-time model;确实适合跨层传递的状态,再用 environment。这样做之后,canvas 里可以运行完整 app 流程,而不用启动真实 CloudKit 数据模型。

详细内容

Pin 预览,保留上下文

02:31)第一段演示解决的是一个具体问题:单独看 layout thumbnail 时灰色矩形正常,放到有照片内容滑过的 selector 上就消失了。Xcode 允许把 selector 的 preview pin 住,再切回 thumbnail 文件继续编辑。

说明:本 session 的 Resources 和 Code snippets 均为 0。下面所有 Swift 代码块都是根据 transcript 中的演示步骤整理出的最小示例,用来表达结构与 API 用法,并非 Apple Code tab 原始 snippet。

struct LayoutThumbnail_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            LayoutThumbnail()
                .previewDisplayName("Default")

            LayoutThumbnail()
                .environment(\.colorScheme, .dark)
                .previewDisplayName("Dark")
        }
    }
}

关键点:

  • Group 对应 transcript 中复制多个 preview 的操作,便于同时看默认外观和深色外观。
  • environment(\.colorScheme, .dark) 对应 inspector 中切换 dark appearance 的目的,用代码固定状态后可以反复回归。
  • previewDisplayName 让多个配置在 canvas 中可区分,适合与 pinned preview 一起检查上下文。

05:15)动态字体的例子里,thumbnail 从 environment 读取 size category,再用 app 自己定义的 scale 调整尺寸和间距。固定某个数值解决不了这类问题,同一个小 view 要在多种 accessibility size 下同时接受检查。

struct LayoutThumbnail: View {
    @Environment(\.sizeCategory) private var sizeCategory

    var body: some View {
        RoundedRectangle(cornerRadius: 6)
            .frame(
                width: 44 * sizeCategory.previewScale,
                height: 44 * sizeCategory.previewScale
            )
    }
}

关键点:

  • @Environment(\.sizeCategory) 来自 transcript 中“通过 environment 传入 size category”的做法。
  • previewScale 是 app 为自身布局定义的扩展,避免在 view 里散落多套 magic number。
  • 把 size 和 spacing 都接到同一个 scale 后,pinned 的 accessibility previews 可以立刻暴露布局问题。

@StateObject 避免 preview 启动重活

06:11)app 启动时常见的初始化包括读文档、连接 CloudKit、访问设备或拉网络数据。preview 的优势是直接跳到某个小 view,这个场景不该承担完整启动成本。

@main
struct MosaicApp: App {
    @StateObject private var networkModel = NetworkModel()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environmentObject(networkModel)
        }
    }
}

关键点:

  • transcript 中先把 network model 放在 app type 属性上,再改成 @StateObject
  • @StateObject 让 SwiftUI 负责模型生命周期,并让变化驱动界面更新。
  • session 明确展示了 Debug Preview 和 debug gauges:改造前 preview 触发 CPU 与网络工作,改造后不再做当前预览不需要的启动工作。

把 sample data 放进 Development Assets

09:22)照片 app 需要大量示例图片来测试 layout 和 effects,但这些图片不应进入发布包。Xcode target 的 Development Assets 支持把文件夹路径标记为仅开发版本使用,Asset Catalog 和 Swift 文件都可以放进去。

protocol CollageSlot {
    associatedtype ImagePublisher

    var imagePublisher: ImagePublisher { get }
    var effects: SlotEffects { get set }
}

struct DesignTimeSlot<ImagePublisher>: CollageSlot {
    var imagePublisher: ImagePublisher
    var effects: SlotEffects
}

struct DesignTimeCollage<ImagePublisher> {
    var name: String
    var layout: CollageLayout
    var slots: [DesignTimeSlot<ImagePublisher>]
}

关键点:

  • transcript 中的 preview content folder 包含预览用图片和两个 Swift 文件。
  • DesignTimeCollageDesignTimeSlot 对应演示里的 design-time model,用来替代 CloudKit-backed model。
  • Development Assets 负责打包边界,view 仍然通过普通输入消费这些样本数据。

让 view 输入变简单

13:08)Collaborator cell 原本接收一个 ObservedObject collaborator,而 collaborator 大部分内容来自 CKUserIdentity。为了一个 cell preview 去构造 CloudKit 查询没有必要。Apple 的处理方式是先看 UI 真正需要什么,再把输入缩到 name、image、connection status。

struct CollaboratorCell: View {
    let name: PersonNameComponents
    let image: Image
    let status: ConnectionStatus

    private static let nameFormatter = PersonNameComponentsFormatter()

    var body: some View {
        HStack {
            image
            Text(Self.nameFormatter.string(from: name))
            StatusBadge(status: status)
        }
    }
}

关键点:

  • name、image、status 都来自 transcript 中列出的 cell 最小输入。
  • session 先用 String 展示简单输入,再改成 PersonNameComponents,让系统按 locale 处理姓名格式。
  • preview 因为不依赖 CloudKit,可以快速创建在线、离线、长姓名、带头像等配置。

17:47)如果 view 需要修改值,就传入简单类型的 Binding。inspector 示例只需要编辑 SlotEffects,不需要拿到完整 image slot record。

struct EffectsInspector: View {
    @Binding var effects: SlotEffects
    var replacePhotoHandler: () -> Void

    var body: some View {
        VStack {
            Slider(value: $effects.saturation)
            Button("Replace Photo", action: replacePhotoHandler)
        }
    }
}

struct EffectsInspectorPreviewHost: View {
    @State private var effects = SlotEffects()

    var body: some View {
        EffectsInspector(effects: $effects) {
            effects.saturation -= 0.1
        }
    }
}

关键点:

  • @Binding 对应 transcript 中把 SlotEffects 传入 inspector 的做法。
  • EffectsInspectorPreviewHost 对应“中间 view 存储 state,再把 binding 传给被测 view”。
  • replacePhotoHandler 对应 session 中把按钮行为交给调用方的 closure,preview 可以用它验证按钮确实触发。

用协议和 environment 处理复杂边界

22:41)collage editor 需要 name、layout、slot data。slot image 可能来自云端、磁盘或错误状态,所以 Apple 定义了 CollageProtocol 和 slot 抽象,再让 editor 对 collage type 与 image picker type 做泛型。

protocol CollageProtocol {
    associatedtype Slot

    var name: String { get set }
    var layout: CollageLayout { get set }
    var slots: [Slot] { get set }
}

struct CollageEditor<Collage: CollageProtocol, ImagePicker: View>: View {
    var collage: Collage
    var makeImagePicker: () -> ImagePicker

    var body: some View {
        EditorCanvas(collage: collage, makeImagePicker: makeImagePicker)
    }
}

关键点:

  • CollageProtocol 对应 transcript 中“只描述 UI 编辑需要的数据”的抽象。
  • ImagePicker 泛型对应 design-time photo picker,preview 不必打开完整照片库。
  • 真实 app model 和 design-time model 可以同时符合协议,editor 代码不需要知道数据来自 CloudKit 还是预览资源。

27:34)environment 适合跨层共享状态。Cloud sync status view 使用 EnvironmentObject,所以每个 preview 都要显式提供一个 status。这样能覆盖在线、离线、最近同步等状态。

struct CloudSyncStatusView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            CloudSyncStatusView()
                .environmentObject(CloudSyncStatus.online)

            CloudSyncStatusView()
                .environmentObject(CloudSyncStatus.offline)

            CloudSyncStatusView()
                .environmentObject(CloudSyncStatus.lastSynced(hoursAgo: 2))
        }
    }
}

关键点:

  • environmentObject 对应 transcript 中给每个 preview 附加 CloudSyncStatus 的操作。
  • 多个 preview 覆盖不同 sync state,避免只验证正常路径。
  • session 也提醒:能把依赖定义成 view input 时优先使用显式输入;environment 用在确实需要跨层传递的状态。

核心启发

  • 做一个 preview matrix:为核心 cell 建立 light、dark、accessibility size、长文本、空数据等配置。为什么值得做:pinned previews 可以把小 view 放回真实上下文。怎么开始:把常见状态写进 PreviewProviderGroup,并给每个状态加 previewDisplayName

  • 拆出 design-time model:为 CloudKit、Core Data 或网络模型做一套只服务 preview 的轻量类型。为什么值得做:session 中的 collage editor 可以完整交互,却没有创建 CloudKit-backed model。怎么开始:先定义 view 真正需要的协议,再让真实模型和预览模型分别 conform。

  • 把预览素材移进 Development Assets:准备一组足够丰富的图片、JSON 或 Swift 样本数据。为什么值得做:Development Assets 只进入开发版本,不增加发布包体积。怎么开始:在 target 的 Development Assets 中添加 Preview Content 文件夹,并把预览图片与 design-time Swift 文件放进去。

  • 给可交互 preview 建 host view:当被测 view 需要 Binding 或 callback 时,用中间 view 持有 @State。为什么值得做:effects inspector 的 slider 和 replace button 可以在 canvas 与 on-device preview 中直接操作。怎么开始:写一个 PreviewHost,把 $state 和测试 closure 传给目标 view。

  • 审计每个 view 的输入边界:把 rich model 尽早翻译成简单类型。为什么值得做:Collaborator cell 不需要知道 CKUserIdentity 的获取方式,只需要能渲染姓名、头像和连接状态。怎么开始:从一个难以预览的 view 入手,列出 UI 真实读取的字段,再删除 preview 中用不到的 model 依赖。

关联 Session

评论

GitHub Issues · utterances