若要让一个委托处理多种 SwiftUI 组件类型,可将回调方法设为泛型,并让组件独立于委托的具体类型。本指南将构建一个小型 macOS 瓷砖选择器,在不引入递归 Self 要求的情况下保留发送方的内容类型。

先确定回调契约,再选择语法

读者的任务很明确:构建一个可复用的 SwiftUI 组件,其内容类型可以变化,同时由一个委托接收所有受支持内容特化的选择回调。示例使用两个模拟瓷砖——文本和圆形,并显示所选瓷砖的标识符。

Swift 将委托定义为一种基于协议的方式,让类或结构体把职责交给另一个实例。委托可以接收一个操作及其发送方,如语言指南中的委托示例所示。协议

对于此组件,预期契约是:“对于每一种遵循 View 的内容类型,委托都可以接收一个包含该内容的瓷砖。”将类型参数放在回调方法上。Swift 的泛型函数会在每次调用时确定具体类型实参,而关联类型则由协议一致性确定。这是两种不同的约定。泛型

要求 合适的接口形式 结果
一个委托处理所有受支持的内容类型 泛型回调方法 每次回调都可以使用不同的内容类型。
每个委托处理一种固定的内容类型 关联类型 每个一致性实现提供一种内容类型。
回调只需要瓷砖标识符 接收标识符的回调 回调不依赖渲染类型。

此表是根据这些语言规则整理出的设计参考。如果保留类型化发送方是 API 的一部分,就选择第一行。如果发送方标识符已包含接收方所需的全部信息,就选择第三行;此时传递整个组件会引入不必要的依赖。

移除对委托自身类型的依赖

一种紧耦合的设计可能会让组件以委托为泛型参数,然后在委托协议中将该组件写作 Component<Self>。这里的 Self 指最终遵循该协议的委托类型。关联类型同样属于该遵循类型。声明

这种结构会使回调的类型检查依赖于某个组件特化,而该特化本身又依赖于委托。某个具体的递归声明是否会被接受,取决于其完整约束和编译器诊断。笼统地说 Swift 会拒绝所有递归协议关系并不准确:SE-0157 在 Swift 4.1 中加入了递归关联类型约束。支持关联类型上的递归约束

对于这里选定的任务,组件类型完全不需要包含委托的具体类型。下面的设计为 Tile 指定一个泛型参数 Content。它存储的委托是 any TileDelegate,而协议要求在方法级别引入自己的 Content 参数。回调保留发送方的内容类型,无需通过委托的 Self 来表达。

any 这种写法明确表示一个存在类型值:该值可以容纳遵循协议的委托类型实例。SE-0335 在 Swift 5.6 中引入了这种写法。将委托存储为存在类型并不意味着必须把瓷砖的内容擦除为 AnyView。引入存在类型 any

构建完整的 macOS 瓷砖选择器

使用 macOS SwiftUI App target,并配备 Swift 6 编译器,例如 Xcode 16 或更高版本附带的编译器。将 macOS 部署目标设为 11.0 或更高版本,以使用本示例中的 @StateObject 存储。Apple 文档说明,StateObject 从 macOS 11.0 起可用。StateObject

用下面这个单一源文件替换生成的 App 声明和初始视图。确保 target 中只有一个 @main App 声明;Swift 只允许一种程序入口点形式。声明

import SwiftUI
import Combine

@MainActor
protocol TileDelegate: AnyObject {
    func didChoose<Content: View>(_ tile: Tile<Content>)
}

@MainActor
struct Tile<Content: View>: View {
    let id: String
    let delegate: any TileDelegate
    let content: Content

    init(
        id: String,
        delegate: any TileDelegate,
        @ViewBuilder content: () -> Content
    ) {
        self.id = id
        self.delegate = delegate
        self.content = content()
    }

    var body: some View {
        Button(action: {
            delegate.didChoose(self)
        }) {
            VStack(spacing: 8) {
                content
                    .frame(width: 100, height: 50)
                Text("Choose \(id)")
            }
            .padding(8)
        }
    }
}

@MainActor
final class SelectionModel: ObservableObject, TileDelegate {
    @Published private(set) var selection = "No tile selected"

    func didChoose<Content: View>(_ tile: Tile<Content>) {
        selection = "Selected: \(tile.id)"
    }
}

@MainActor
struct ChooserView: View {
    @StateObject private var model = SelectionModel()

    var body: some View {
        VStack(spacing: 16) {
            HStack(spacing: 16) {
                Tile(id: "text", delegate: model) {
                    Text("Sample")
                }

                Tile(id: "circle", delegate: model) {
                    Circle()
                }
            }

            Text(model.selection)
        }
        .padding(24)
        .frame(minWidth: 340, minHeight: 180)
    }
}

@main
@MainActor
struct DelegateDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ChooserView()
        }
    }
}

这是一个完整的 App 示例,包括导入、委托、组件、模型和入口点。此处只进行了检查,未编译或运行,因为当前环境没有 Swift 编译器或 macOS SDK。

Apple 的 Button API 接受一个操作和一个生成视图的标签。在此示例中,点击瓷砖会在按钮操作中调用委托;计算 body 会构建界面,但不会发送选择事件。Button

在 Mac 上构建并运行 App。初始状态应显示 No tile selected。点击 Choose text 应显示 Selected: text;点击 Choose circle 应显示 Selected: circle。这些是示例预期产生的结果,并非已报告的实际运行结果。

理解示例保留了什么

第一个瓷砖的类型是 Tile<Text>,第二个是 Tile<Circle>。两者使用同一个 SelectionModel 实例。它的泛型回调满足任一内容类型的协议要求,同时读取共有的 id 属性。这正是将泛型参数放在方法上的实际好处。

接收方也可以通过 tile.content 访问 Content。不过,在泛型实现内部,声明的约束只保证 View 提供的能力。若代码需要文本专属操作,就需要不同的契约或额外信息;回调不能假定每个内容值都是 Text。这符合 Swift 基于约束的泛型模型。泛型

组件按照 Apple 文档中的自定义视图模式,以 some View 实现 body。当前的 View 声明也带有主执行器隔离。示例将面向 UI 的协议和类型明确标记为 @MainActor,使回调与模型更新处于同一隔离域。View

@StateObject 会在包含视图的标识生命周期内提供稳定的模型存储。模型已发布的选择状态发生变化时,依赖该状态的状态视图会更新。若将模型构造移入普通计算表达式,就无法再通过 StateObject 表达这种所有权关系。StateObject

示例会强引用必需的委托。它的模型只存储选择字符串,因此图示对象图中没有指回瓷砖的引用。如果之后让模型持有发送方,就应重新检查所有权关系。Apple 的语言指南演示了在这类关系需要时,使用类约束委托和弱引用存储。协议

何时关联类型是更合适的契约

假设接收方只能接受文本瓷砖。另一种协议可以声明 associatedtype Content: View 并要求 func didChoose(_ tile: Tile<Content>)。这表示每种一致性实现对应一种内容类型。将 Content 声明为主关联类型可以简化约束语法,但不会把该要求变成一个能接受所有内容类型的方法。SE-0346 在 Swift 5.7 中引入了主关联类型。主关联类型的轻量级同类型要求

这种替代方案会形成不同版本的 API。你还需要将组件的委托属性和初始化器参数改为相应的受约束存在类型,例如 any FixedTileDelegate<Content>,并让每个接收方提供其固定关联类型和对应的非泛型回调。文本专用的一致性实现无法再通过同一个一致性实现处理圆形瓷砖。

SE-0353 在 Swift 5.7 中加入了受约束存在类型,使存在类型的主关联类型可以绑定到指定类型。绑定一个关联类型仍表示固定关系,并不会为任意内容类型提供一组回调实现。受约束的存在类型

请将编译器支持与部署支持分开考虑。SE-0353 明确指出,其中的运行时更改无法向后部署到现有操作系统版本。在较旧的部署目标上采用替代方案之前,请检查实际 target 中的可用性诊断。上面的完整示例使用普通委托存在类型,不需要存储受约束存在类型。受约束的存在类型

按有效顺序排查适配失败

  1. 检查回调的预期范围。 如果一个委托必须处理文本和圆形瓷砖,其见证实现必须保留泛型方法签名。只接受 Tile<Text> 的方法无法满足该要求。
  2. 检查组件的泛型参数。 对于此设计,Tile 依赖于 Content;它的类型不包含委托的具体类型。
  3. 检查执行器隔离。 让 UI 回调和示例中的可观察模型运行在主执行器上。从其他隔离上下文发起的调用必须遵守这一边界。
  4. 检查事件发生位置。 在按钮操作中发送选择事件,而不是在计算 body 或初始化视图时发送。
  5. 检查模型所有权。 在包含视图中保留 @StateObject,并避免为回调创建第二个模型实例。

如果适配后仍然失败,可先精简为一个瓷砖和一个委托实现,再恢复第二种内容类型。这个过程可以区分协议见证问题和视图组合问题。示例中的两种不同内容类型可用于有效检查契约:它们都必须无需强制转换或内容擦除,就到达同一个泛型回调。

关键的设计决策在于类型变化应放在哪里。对于要处理每种组件特化的委托,应将这种变化放在回调方法上,并让组件类型独立于委托的具体类型标识。由此得到的 API 既保留了类型化发送方,也让 macOS 选择流程简洁明了,易于检查和扩展。

来源