Behalte bei einem macOS-Fenster, das heterogene SwiftUI-Komponenten anzeigt, die Ausgabe jedes Providers stark typisiert und lösche den Typ, wenn die gemeinsame Rendering-Schnittstelle einen konkreten View-Typ benötigt. Lösche den Provider-Typ früher, wenn die Collection selbst einen einheitlichen Wrapper benötigt.

Dieser Artikel erstellt eine kleine Komponentengalerie mit synthetischem Text und einem blauen Farbfeld. Ziel ist es, verschiedene Provider-Implementierungen gemeinsam zu speichern und sie über eine einzige SwiftUI-Schnittstelle darzustellen. Die Entwurfsentscheidung betrifft die Frage, an welcher Stelle die Beziehung zwischen einem Provider und seiner Ausgabe nicht mehr sichtbar sein soll.

Was muss der Provider-Vertrag bewahren?

Ein Provider mit einem zugehörigen Ausgabetyp besagt, dass jeder konforme Typ seinen eigenen View-Typ auswählt. Eine konkrete Implementierung kann diese Auswahl hinter some View verbergen, während generische Verbraucher die Beziehung zwischen dem Provider und seiner Ausgabe beibehalten. Opaque Types bewahren die Typidentität; geboxte Protokolltypen ermöglichen verschiedene zugrunde liegende Typen hinter einer gemeinsamen Schnittstelle. Siehe Opaque and Boxed Protocol Types.

Das Folgende ist ein Vertragsfragment, keine vollständige Anwendung:

import SwiftUI

@MainActor
protocol ViewProvider {
    associatedtype Content: View
    var id: String { get }
    func makeView() -> Content
}

@MainActor
struct TitleProvider: ViewProvider {
    let id: String
    let title: String

    func makeView() -> some View {
        Text(title).font(.headline)
    }
}

Hier wird TitleProvider.Content aus dem opaken Rückgabetyp von makeView() abgeleitet. Das Protokoll verlangt einen zugehörigen Typ, und die Implementierung stellt ihn durch Inferenz bereit. SE-0244 dokumentiert dieses unterstützte Muster ausdrücklich sowie die Einschränkung, einen opaken Rückgabetyp nicht direkt als Protokollanforderung zu deklarieren. Daher ist es kein von diesem Vorschlag unterstützter Vertrag, die Anforderung durch func makeView() -> some View zu ersetzen. Siehe Opaque Result Types.

Ein Array von any ViewProvider ermöglicht die heterogene Speicherung. SE-0309 aus Swift 5.7 erlaubt die existentiale Verwendung von Protokollen mit zugehörigen Typen, wobei Einschränkungen für einzelne Member-Operationen bestehen bleiben. Beim Lesen einer Ausgabe über dieses Existential kann ihr zugehöriger Typ auf die obere Schranke des Protokolls gelöscht werden – in diesem Fall auf any View. Siehe Unlock existentials for all protocols.

Damit ergeben sich zwei getrennte Entscheidungen: wie gemischte Provider gespeichert werden und welchen konkreten Typ der Renderer zurückgibt. Apple dokumentiert AnyView als konkreten, tyngelöschten View, der View entspricht, und führt ihn damit als ausdrückliche Option für die zweite Entscheidung auf. Siehe AnyView.

Die Grenze anhand der Anforderungen des Consumers wählen

Verwende diese Entscheidungstabelle, bevor du das Protokoll änderst. Die Empfehlungen wenden die dokumentierten Typverträge auf die Galerieaufgabe an; sie sind Entwurfshinweise und keine gemessenen Leistungsergebnisse.

Anforderung des Consumers Geeigneter Entwurf Wo Typinformationen verborgen werden
Einen konkreten Provider durch wiederverwendbaren Code darstellen Ein generischer Consumer, eingeschränkt auf ViewProvider Die Beziehung zwischen Provider und Ausgabe bleibt verfügbar
Verschiedene Provider speichern und ihre gemeinsamen Metadaten beibehalten [any ViewProvider], mit einem Rendering-Helper, der AnyView zurückgibt Provider-Typ beim Speichern; Ausgabetyp beim Rendering
Einen konkreten Provider-Wrapper für nachgelagerten Code bereitstellen [AnyViewProvider] Beim Erstellen des Wrappers
Von jeder Implementierung verlangen, bereits einen tyngelöschten View zurückzugeben Eine Protokollanforderung mit Rückgabe von AnyView Innerhalb jeder Implementierung
Eine feste, kleine Menge von Komponentenvarianten anzeigen Ein Enum und ein Builder-basiertes switch Die Varianten bleiben im Enum explizit

Für diese Galerie solltest du existenzielle Speicherung mit dem Löschen des Ausgabetyps kombinieren. Die Provider behalten ihre eigenen Ausgabetypen, die Collection stellt eine stabile Zeichenfolgenkennung bereit, und ein einzelner Helper übernimmt die Umwandlung in AnyView. Wenn sich das interne Layout einer Komponente ändert, muss diese Komponente daher keinen neuen Code zur Typlöschung enthalten.

Ein generischer Collection-Parameter wäre für eine andere Aufgabe geeignet. Ein Array, dessen Elementtyp ein einzelner generischer Providertyp ist, kann nicht automatisch mehrere voneinander unabhängige Providertypen darstellen. Swifts implizites Öffnen von Existentials funktioniert bei einem einzelnen existenziellen Argument; es gibt einem heterogenen Array keinen gemeinsamen zugrunde liegenden konformen Typ. Siehe Implicitly Opened Existentials.

Eine vollständige macOS-Komponentengalerie erstellen

Das Beispiel erfordert einen Compiler ab Swift 5.7 und ein macOS-SwiftUI-Anwendungsziel. Lege das Deployment Target des Beispiels auf macOS 12 oder höher fest. Ersetze in einem Xcode-Projekt für eine macOS-App mit SwiftUI die generierte App-Deklaration durch das vollständige Programm unten und behalte genau eine @main-Deklaration bei.

Die expliziten @MainActor-Annotationen sorgen dafür, dass die Erstellung und das Rendering der Provider auf demselben Actor stattfinden. Apples aktuelle View-Deklaration ist auf dem Main Actor isoliert. Die Dokumentation erklärt außerdem, dass benutzerdefinierte Views ihren Inhalt über body bereitstellen. Siehe View.

import SwiftUI

@MainActor
protocol ViewProvider {
    associatedtype Content: View
    var id: String { get }
    func makeView() -> Content
}

@MainActor
struct TitleProvider: ViewProvider {
    let id: String
    let title: String

    func makeView() -> some View {
        Text(title)
            .font(.headline)
    }
}

@MainActor
struct SwatchProvider: ViewProvider {
    let id: String

    func makeView() -> some View {
        RoundedRectangle(cornerRadius: 8)
            .fill(Color.blue)
            .frame(width: 96, height: 48)
    }
}

@MainActor
func erasedView<P: ViewProvider>(for provider: P) -> AnyView {
    AnyView(provider.makeView())
}

@MainActor
struct GalleryView: View {
    let providers: [any ViewProvider]

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            ForEach(providers, id: \.id) { provider in
                erasedView(for: provider)
            }
        }
        .padding(24)
        .frame(minWidth: 280, minHeight: 180)
    }
}

@main
@MainActor
struct ProviderGalleryApp: App {
    private let providers: [any ViewProvider] = [
        TitleProvider(id: "title", title: "Component gallery"),
        SwatchProvider(id: "swatch")
    ]

    var body: some Scene {
        WindowGroup {
            GalleryView(providers: providers)
        }
    }
}

Das beabsichtigte Ergebnis ist ein Fenster, in dem Component gallery über einem blauen, abgerundeten Rechteck angezeigt wird. Alle Eingaben sind lokal erzeugte synthetische Werte. Das Beispiel wurde geprüft, aber hier nicht kompiliert, da die verfügbare Umgebung weder einen Swift-Compiler noch ein Apple-SDK enthält.

Das Ergebnis des Helpers ist unabhängig vom zugehörigen Ausgabetyp des Providers immer AnyView. Innerhalb des Helpers ist der Provider generisch, und seine Content-Beziehung bleibt verfügbar. Apple dokumentiert den hier verwendeten Initializer als Annahme eines generischen, auf View eingeschränkten Werts, den er tyngelöscht zurückgibt. Siehe AnyView.init(_:).

Die Collection verwendet id: \.id, weil das Protokoll id auf String festlegt; ein anderer unbekannter zugehöriger Typ muss nicht ermittelt werden. Apples Dokumentation zu ForEach erlaubt entweder Elemente, die Identifiable entsprechen, oder einen expliziten Bezeichner-Key-Path. Halte Bezeichner innerhalb dieser Collection eindeutig und für die logischen Komponenten über Aktualisierungen hinweg stabil. Siehe ForEach.

Warum funktioniert ein generischer Helper, wenn ein generischer View-Wrapper scheitert?

Die entscheidende Eigenschaft von erasedView ist sein konkreter Rückgabetyp. Swift 5.7 führte das implizite Öffnen existenzieller Argumente ein. Dadurch kann der Compiler bei einem Aufruf einen generischen Parameter an den zugrunde liegenden Typ eines existenziellen Werts binden. Weiterhin eingeschränkt sind Ergebnisse, die diesen geöffneten Typ über einen invarianten generischen Container offenlegen würden. SE-0352 dokumentiert außerdem ein weiter gefasstes Öffnungsverhalten, das im Sprachmodus Swift 6 aktiviert ist. Siehe Implicitly Opened Existentials.

Das folgende Diagnosefragment ergänzt das obige Programm um einen generischen Wrapper. Der auskommentierte Aufruf veranschaulicht die problematische Grenze; er ist kein zusätzlicher Einstiegspunkt der Anwendung:

@MainActor
struct ProviderRow<P: ViewProvider>: View {
    let provider: P

    var body: some View {
        provider.makeView()
    }
}

@MainActor
func inspectBoundary(_ provider: any ViewProvider) {
    // Problematic: the constructed result would retain the opened P.
    // let row = ProviderRow(provider: provider)

    // Supported boundary: the result is the concrete type AnyView.
    let row = erasedView(for: provider)
    _ = row
}

Das erklärt, warum das Hinzufügen einer generischen Ebene den Fehler allein möglicherweise nicht behebt. Prüfe sowohl den Parameter als auch das Ergebnis. Ein Helper, der einen konkreten tyngelöschten View zurückgibt, schließt die Umwandlung innerhalb des Aufrufs ab; ein Ergebnis wie ProviderRow<P> behält den Providertyp in seiner Struktur bei.

Ebenso ist some View keine Schreibweise für einen Rückgabewert, dessen zugrunde liegender Typ zur Laufzeit beliebig variieren darf. Die Implementierung wählt einen konsistenten zugrunde liegenden Typ, der möglicherweise von generischen Argumenten abhängt. Ein Builder kann Verzweigungen in einem zusammengesetzten Ergebnis kodieren, aber eine opake Ergebnisdeklaration allein vereinheitlicht keine voneinander unabhängigen Rückgabetypen. Siehe Opaque and Boxed Protocol Types.

Wann sollte der Provider selbst tyngelöscht werden?

Wähle einen konkreten Wrapper, wenn nachgelagerte Schnittstellen einen einzelnen Providertyp erhalten sollen oder wenn Aufrufer Provider vor dem Speichern vereinheitlichen sollen. Die folgende zusätzliche Deklaration funktioniert mit dem bestehenden Protokoll und den Providern des Beispiels. Sie speichert den Provider und erstellt seinen View beim Aufruf von makeView().

@MainActor
struct AnyViewProvider: ViewProvider {
    let id: String
    private let build: @MainActor () -> AnyView

    init<P: ViewProvider>(_ provider: P) {
        id = provider.id
        build = { AnyView(provider.makeView()) }
    }

    func makeView() -> AnyView {
        build()
    }
}

Um diese Alternative zu verwenden, ändere beide gespeicherten Eigenschaften providers im vollständigen Programm zu [AnyViewProvider]. Ersetze das anfängliche Array der App durch folgenden Ausdruck:

[
    AnyViewProvider(
        TitleProvider(id: "title", title: "Component gallery")
    ),
    AnyViewProvider(SwatchProvider(id: "swatch"))
]

Ersetze dann erasedView(for: provider) im ForEach-Closure der Galerie durch provider.makeView(). Der Wrapper gibt bereits AnyView zurück, daher ist für diesen Aufruf keine zusätzliche Umwandlung erforderlich. Diese Implementierung kopiert die Kennung und speichert den übergebenen Provider-Wert als Capture; sie bietet keine Möglichkeit, diesen gespeicherten Wert später zu ersetzen.

Wenn jeder Consumer eine tyngelöschte Ausgabe benötigt, kann ein einfacheres Protokoll direkt makeView() -> AnyView verlangen. Das reduziert den Adaptercode, macht aber jeden Conformer für die Typlöschung verantwortlich. Verwende es, wenn die tyngelöschte Ausgabe eine bewusste Zusage der Schnittstelle ist. Bevorzuge den Vertrag mit zugehörigem Typ, wenn generische Consumer weiterhin die Beziehung zwischen jedem Provider und seiner Ausgabe nutzen können sollen.

Compilergrenzen und View-Identität bewahren

Halte Sprachfeatures und das Verhalten von Buildern auseinander. Das Beispiel stützt sich auf die existenziellen Features von Swift 5.7 und ein explizites AnyView-Ergebnis. Apples aktuelle Dokumentation unterscheidet zwischen Xcode 26 und früheren Versionen sowie Xcode 27 und späteren Versionen: Letztere erstellen typunabhängigen Builder-Inhalt und empfehlen für dieses Verhalten ContentBuilder. Die Dokumentation beschreibt die Typsicherheit weiterhin mithilfe bedingter Conformances. Interpretiere gelockerte Prüfungen der Builder-Eingaben nicht als allgemeine Zusage, dass ein Existential selbst jede generische View-Einschränkung erfüllt. Siehe ViewBuilder und ContentBuilder.

Für eine feste Komponentenmenge bietet sich ein Enum an, dessen Fälle Komponentendaten enthalten und dessen Rendering-Property über diese Fälle verzweigt. Dadurch werden die unterstützten Varianten explizit. Dieser Entwurf eignet sich, wenn beim Hinzufügen einer neuen Komponente ein zentraler Renderer aktualisiert werden soll; der Provider-Ansatz passt zu Schnittstellen, bei denen konforme Implementierungen ihr Rendering selbst bereitstellen sollen.

Die Typlöschung hat auch eine dokumentierte Auswirkung auf die Identität. Apple erklärt, dass beim Ändern des von einem AnyView umschlossenen Typs die vorherige Hierarchie zerstört und eine neue erstellt wird. Eine stabile Kennung für die Collection setzt dieses Verhalten nicht außer Kraft. Wenn eine Komponente zwischen verschiedenen umschlossenen View-Typen wechseln kann, prüfe ihr Zustands- und Übergangsverhalten in der tatsächlichen App. Siehe AnyView.

Behalte für diese Galerie den Provider-Vertrag mit zugehörigem Typ bei, speichere die gemischten Werte als Existentials und gib AnyView aus einem einzelnen Rendering-Helper zurück. Verschiebe die Typlöschung in einen konkreten Provider-Wrapper, wenn nachgelagerte Schnittstellen diesen einheitlichen Typ benötigen. So bleibt eine klare Grenze, die sich bei Änderungen an der Collection, dem Rendering-Vertrag oder der Compilerkonfiguration überprüfen lässt.

Quellen