要构造泛型 SwiftUI 组件,可以在协议中要求其初始化器,也可以注入一个返回该组件的闭包。本教程通过这两种方式,在 macOS 窗口中展示同一个合成条目,并明确说明编译器和 SDK 的边界。

泛型组件必须承诺什么?

假设 macOS 应用要显示一张小型条目卡片。你希望创建一个可复用面板,根据某个条目构造卡片,同时允许调用方选择呈现方式。核心问题在于由谁负责构造:组件声明的接口,还是提供组件的调用方。

泛型约束让实现能够使用该约束所声明的功能。写上 Component: View,只能说明该组件是 SwiftUI 视图;这并不意味着该组件有名为 init(item:) 的初始化器。Swift 的泛型章节介绍了协议约束,而 Apple 的View 文档说明了视图必须实现的 body 属性。

因此,下面这段代码有意设为无效:

// Invalid fragment: View does not require init(item:).
@MainActor
func construct<Component: View>(item: Item) -> Component {
    Component(item: item)
}

即使把这个初始化器添加到某个具体组件中,也不会让它自动成为所有仅受 View 约束的泛型参数都能使用的功能。可复用 API 需要明确的构造契约。在修改组件之前,可先参考以下决策表:

构造需求 适用接口 原因
所有参与的组件都接受同一个条目 要求 init(item:) 的协议 构造职责属于组件系列的接口。
调用方需要选择额外配置或修饰符 返回泛型组件的工厂闭包 构造留在调用位置。
调用方已经持有一个构造完成的组件 存储泛型组件值 构造已经完成。
呈现方式只在有限的几种布局间切换 在 SwiftUI 视图内部组合这些选项 外围 API 可以保留具体的视图结构。

这张表依据文档所述的语言规则整理而成,可作为设计参考。下文会一并实现前两种方案,方便直接比较它们的行为。

构建完整的 macOS 示例

使用带有 SwiftUI 的 Xcode macOS App target,并将 macOS 部署目标设为 11 或更高版本。示例针对 Swift 6 编译器及相匹配的 Apple SDK 编写。显式的 @MainActor 注解要求 Swift 5.5 或更高版本,因此此代码不适用于更早的编译器。Apple 文档说明,WindowGroup 从 macOS 11 起可用,并且可作为应用视图层级的容器。参见 WindowGroup。

将生成的应用入口文件替换为以下完整程序。确保 target 中只有一个 @main 应用声明。生成的 ContentView 文件可以保留,但不会使用。不需要任何软件包、外部资源或下载的示例数据。

import SwiftUI

struct Item {
    let title: String
    let detail: String
}

@MainActor
protocol ItemComponent: View {
    init(item: Item)
}

@MainActor
struct ItemCard: ItemComponent {
    let item: Item

    init(item: Item) {
        self.item = item
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text(item.title)
                .font(.headline)
            Text(item.detail)
        }
        .padding()
        .background(Color.gray.opacity(0.12))
        .cornerRadius(8)
    }
}

@MainActor
struct ContractPanel<Component: ItemComponent>: View {
    let item: Item

    init(item: Item, component: Component.Type) {
        self.item = item
    }

    var body: some View {
        Component(item: item)
    }
}

@MainActor
struct FactoryPanel<Component: View>: View {
    private let makeComponent: @MainActor () -> Component

    init(make: @escaping @MainActor () -> Component) {
        self.makeComponent = make
    }

    var body: some View {
        makeComponent()
    }
}

@MainActor
struct DemoView: View {
    private let item = Item(
        title: "Sample tile",
        detail: "Blue circle on a neutral background"
    )

    var body: some View {
        VStack(alignment: .leading, spacing: 18) {
            Text("Initializer contract")
            ContractPanel(item: item, component: ItemCard.self)

            Text("Injected factory")
            FactoryPanel {
                ItemCard(item: item)
                    .padding(.leading, 12)
            }

            Text("Alternative factory output")
            FactoryPanel {
                Text(item.title + ": " + item.detail)
            }
        }
        .padding(24)
        .frame(width: 420)
    }
}

@main
@MainActor
struct ComponentDemoApp: App {
    var body: some Scene {
        WindowGroup {
            DemoView()
        }
    }
}

运行 macOS App target。预期结果是出现一个窗口,其中包含两种卡片呈现方式和一种纯文本呈现方式。第二张卡片由调用方额外添加了前置内边距。这些合成示例用于展示 API 行为,并非测量结果。研究环境中没有安装 Swift 编译器,因此这里只检查了这段代码,未进行编译或运行。

主 actor 注解明确标示了 UI 构造的边界。Apple 当前的 View 声明带有 @MainActor 和 @preconcurrency 注解;其文档还说明了当符合性声明位于类型的原始声明中时,隔离属性如何继承。参见 View。

初始化器契约如何工作?

ItemComponent 扩展了 View,并且只增加了一项构造要求:init(item:)。Swift 支持直接在协议中要求初始化器。遵循该协议的类型负责提供实现;协议只声明签名,不包含初始化器主体。参见协议:初始化器要求。

有了这项要求,在 ContractPanel 中使用 Component(item: item) 就是有效的。component: Component.Type 参数为泛型推断提供了具体类型:传入 ItemCard.self 后,面板的组件类型便确定为 ItemCard。面板不必存储元类型,因为它的 body 会通过泛型类型参数进行构造。

当所有组件都应使用相同输入来构造时,这种契约很有用。例如,应用自有的一组紧凑型和展开型条目呈现组件可以共享这项要求。这样,调用方就可以更改所选组件类型,而不必重写面板的构造表达式。

当实现需要额外配置时,这种方式的取舍就会显现出来。要求 init(item:) 意味着每个遵循协议的组件都必须提供这条构造路径,即使它更适合使用还接受标题或布局设置的初始化器。如果这些额外信息应由调用方负责,下面的工厂方案通常能更直接地表达需求。

对于基于类的实现,除非类是 final,否则满足协议要求的初始化器必须标记为 required。若子类还覆盖了签名相同的父类指定初始化器,可能需要同时使用 required 和 override。ItemCard 这样的结构体不使用 required。这些区别记录在协议文档中。

何时应将构造移入工厂?

FactoryPanel 接受一个类型为 @MainActor () -> Component 的函数。它只承诺调用该函数会生成所选组件类型。闭包可以调用初始化器、捕获配置,或返回已经应用修饰符的视图。

程序展示了实际差异:工厂可以返回添加了内边距的 ItemCard,也可以返回 Text,而这两种结果类型都不必遵循 ItemComponent。Apple 说明,视图修饰符可能会将输入包装在另一个视图中,因此让编译器推断工厂的结果类型在这里很有用。参见 View。

初始化器将闭包标记为 @escaping,因为面板会存储闭包以供稍后调用。Swift 的闭包章节介绍了逃逸闭包及其捕获周围值的能力。在此示例中,闭包捕获了外围视图使用的合成条目。

应仔细选择工厂的输入。零参数工厂意味着调用方会捕获构造所需的一切。如果面板本身负责选择条目,带参数的工厂可以更清楚地表达这一职责。以下代码用于替换组件定义,并与现有的 Item 一同使用;它不是第二个完整应用:

@MainActor
struct ItemFactoryPanel<Component: View>: View {
    let item: Item
    private let makeComponent: @MainActor (Item) -> Component

    init(
        item: Item,
        make: @escaping @MainActor (Item) -> Component
    ) {
        self.item = item
        self.makeComponent = make
    }

    var body: some View {
        makeComponent(item)
    }
}

采用此定义后,面板提供条目,调用方提供呈现方式。工厂只负责生成视图值:示例中的构造契约不承诺只执行一次、持久存储或缓存。

工厂需要结果构建器吗?

完整程序中的工厂是普通闭包。每个闭包都返回一个表达式,因此无需自定义构建器注解。如果需要多个子视图,为此 API 返回一个 VStack 就足够了;堆栈本身包含这些子视图。

如果工厂参数本身需要接受由多个表达式或条件内容组成的声明式序列,结果构建器就很有用。Swift Evolution 记录了标准化的结果构建器功能,该功能已在 Swift 5.4 中实现。参见 SE-0289:结果构建器。

采用构建器语法时,应注意 SDK 版本边界。Apple 当前文档说明,Xcode 26 及更早版本中的视图生成参数可以使用 @ViewBuilder。对于 Xcode 27 及更高版本,文档介绍了与类型无关的构造方式,并建议使用 ContentBuilder 类型别名来表明这种行为。参见 ViewBuilder 和 ContentBuilder。这里使用的普通闭包无需依赖这个较新的类型别名。

为什么不使用不透明类型或存在类型来替代契约?

some View 会隐藏一个特定的底层结果类型,同时保留其类型身份。它适用于本程序中的 body 属性,但不会向 View 接口添加 init(item:)。写作 any View 的装箱值以不同方式隐藏底层类型,也不会保留相同的静态类型身份。参见不透明协议类型与装箱协议类型。

不要据此笼统断言存在类型值永远不能传给泛型函数。SE-0352 在 Swift 5.7 中引入了隐式打开的存在类型,使符合条件的调用能够将泛型参数绑定到存在类型的底层类型。该提案也记录了一些限制,包括结果类型将已打开的类型嵌入泛型包装器,而类型擦除后无法表示该结果的情况。参见隐式打开的存在类型。

对于此面板任务,通过泛型参数保留具体组件类型就足够了。无需仅仅为了让调用方选择如何构造视图而引入存在类型存储。

如何诊断构造错误?

沿着出错的表达式逐步检查,并查看该位置可用的契约:

  1. 如果 Component(item:) 报错,请检查泛型约束是否包含要求该准确初始化器签名的协议。
  2. 如果编译器无法推断 Component,请提供 ItemCard.self 这样的具体元类型,或提供具有具体返回表达式的工厂。
  3. 如果组件需要调用方特定的配置,请将构造移入工厂,而不是向协议添加无关要求。
  4. 如果存储闭包时出现逃逸闭包诊断,请检查初始化器是否标记了 @escaping。
  5. 如果构造触发 actor 隔离诊断,请检查调用位置和工厂的函数类型是否都明确表达了预期的主 actor 边界。
  6. 如果存在类型导致泛型结果错误,请先查看 SE-0352 对结果类型的限制,再判断升级编译器是否能消除这一限制。

对于共享的 macOS 组件系列,如果统一构造属于接口的一部分,就要求初始化器。如果调用方应负责组装和配置呈现方式,就使用注入的工厂。这两种方式都能明确表达缺失的构造承诺,同时保留面板所需的具体组件类型。

来源