Um eine generische SwiftUI-Komponente zu erstellen, verlangen Sie ihren Initializer in einem Protokoll oder injizieren Sie eine Closure, die sie zurückgibt. Dieses Tutorial verwendet beide Ansätze, um dasselbe synthetische Element in einem macOS-Fenster anzuzeigen, und benennt Compiler- und SDK-Grenzen ausdrücklich.

Was muss eine generische Komponente zusichern?

Angenommen, eine macOS-App zeigt eine kleine Elementkarte an. Sie möchten ein wiederverwendbares Panel, das die Karte aus einem Element erstellt, und den Aufrufenden zugleich die Darstellung wählen lässt. Die zentrale Frage ist, wer für die Erstellung zuständig ist: die deklarierte Schnittstelle der Komponente oder der Aufrufende, der die Komponente bereitstellt.

Eine generische Einschränkung gibt der Implementierung Zugriff auf die durch diese Einschränkung festgelegten Fähigkeiten. Mit Component: View wird festgelegt, dass die Komponente eine SwiftUI-View ist; dadurch wird nicht festgelegt, dass die Komponente einen Initializer namens init(item:) besitzt. Swifts Kapitel zu Generics erläutert Protokolleinschränkungen, während Apples Dokumentation zu View die erforderliche Eigenschaft body der View beschreibt.

Daher ist dieses Fragment absichtlich ungültig:

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

Diesen Initializer zu einer konkreten Komponente hinzuzufügen, macht ihn nicht über jeden generischen Typparameter verfügbar, der lediglich auf View eingeschränkt ist. Die wiederverwendbare API benötigt einen expliziten Erstellungsvertrag. Verwenden Sie diese Entscheidungstabelle, bevor Sie die Komponente ändern:

Anforderung an die Erstellung Geeignete Schnittstelle Begründung
Jede beteiligte Komponente akzeptiert dasselbe Element Protokoll mit init(item:) als Anforderung Die Erstellung gehört zur Schnittstelle der Komponentenfamilie.
Aufrufende wählen zusätzliche Konfiguration oder Modifikatoren Factory-Closure, die die generische Komponente zurückgibt Die Erstellung bleibt am Aufrufort.
Der Aufrufende hat bereits eine fertige Komponente Den Wert der generischen Komponente speichern Die Erstellung ist bereits erfolgt.
Die Darstellung wechselt zwischen einer begrenzten Zahl von Layouts Die Alternativen innerhalb einer SwiftUI-View zusammensetzen Die umgebende API kann eine konkrete View-Struktur beibehalten.

Diese Tabelle ist eine aus den dokumentierten Sprachregeln abgeleitete Designhilfe. Die ersten beiden Optionen werden unten gemeinsam implementiert, damit sich ihr Verhalten direkt vergleichen lässt.

Ein vollständiges macOS-Beispiel erstellen

Verwenden Sie ein Xcode-macOS-App-Target mit SwiftUI und einem macOS-Bereitstellungsziel ab Version 11. Das Beispiel ist für einen Swift-6-Compiler mit dem passenden Apple-SDK geschrieben. Die expliziten @MainActor-Annotationen erfordern Swift 5.5 oder neuer; für frühere Compiler ist der Code daher nicht vorgesehen. Apple dokumentiert WindowGroup als ab macOS 11 verfügbar und als Container für die View-Hierarchie einer App. Siehe WindowGroup.

Ersetzen Sie die generierte App-Einstiegsdatei durch das folgende vollständige Programm. Stellen Sie sicher, dass das Target nur eine @main-App-Deklaration enthält. Die generierte Datei ContentView kann unbenutzt bleiben. Es sind keine Pakete, externen Assets oder heruntergeladenen Beispieldaten erforderlich.

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()
        }
    }
}

Führen Sie das macOS-App-Target aus. Das erwartete Ergebnis ist ein Fenster mit zwei Kartendarstellungen und einer einfachen Textdarstellung. Die zweite Karte hat zusätzlichen führenden Innenabstand, der von ihrem Aufrufenden vorgegeben wird. Dies sind synthetische Beispiele für das API-Verhalten, keine gemessenen Ergebnisse. In der Forschungsumgebung ist kein Swift-Compiler installiert; der Code wurde daher geprüft, hier aber weder kompiliert noch ausgeführt.

Die Main-Actor-Annotationen machen die UI-Erstellungsgrenze ausdrücklich. Apples aktuelle View-Deklaration ist mit @MainActor und @preconcurrency annotiert; die Dokumentation erläutert außerdem, wie die Isolation vererbt wird, wenn die Konformität in der ursprünglichen Deklaration eines Typs angegeben wird. Siehe View.

Wie funktioniert der Initializer-Vertrag?

ItemComponent erweitert View und fügt genau eine Erstellungsanforderung hinzu: init(item:). Swift unterstützt Initializer-Anforderungen direkt in Protokollen. Ein konformer Typ stellt die Implementierung bereit; das Protokoll legt die Signatur ohne Initializer-Body fest. Siehe Protokolle: Initializer-Anforderungen.

Durch diese Anforderung ist Component(item: item) innerhalb von ContractPanel zulässig. Das Argument component: Component.Type liefert einen konkreten Typ für die generische Typinferenz: Durch die Übergabe von ItemCard.self wird festgelegt, dass der Komponententyp dieses Panels ItemCard ist. Das Panel muss den Metatyp nicht speichern, da sein body über den generischen Typparameter erstellt.

Dieser Vertrag ist nützlich, wenn jede Komponente aus derselben Eingabe erstellt werden können soll. Beispielsweise könnte eine app-eigene Familie kompakter und erweiterter Elementdarstellungen diese Anforderung gemeinsam nutzen. Aufrufende können dann den ausgewählten Komponententyp ändern, ohne den Erstellungsaufruf des Panels neu schreiben zu müssen.

Der Nachteil zeigt sich, wenn eine Implementierung zusätzliche Konfiguration benötigt. init(item:) zu verlangen bedeutet, dass jede konforme Komponente diesen Erstellungsweg anbieten muss, selbst wenn ihr bevorzugter Initializer zusätzlich eine Beschriftung oder eine Layouteinstellung akzeptiert. Gehört diese Zusatzinformation dem Aufrufenden, drückt der folgende Factory-Ansatz die Anforderung meist direkter aus.

Bei klassenbasierten Implementierungen muss ein Initializer, der eine Protokollanforderung erfüllt, required sein, sofern die Klasse nicht final ist. Eine Unterklasse, die auch einen passenden designierten Initializer der Oberklasse überschreibt, benötigt möglicherweise sowohl required als auch override. Structs wie ItemCard verwenden required nicht. Diese Unterschiede sind unter Protokolle dokumentiert.

Wann sollte die Erstellung in eine Factory verlagert werden?

FactoryPanel akzeptiert eine Funktion des Typs @MainActor () -> Component. Sie verspricht lediglich, dass der Aufruf der Funktion den ausgewählten Komponententyp erzeugt. Die Closure kann einen Initializer aufrufen, Konfiguration erfassen oder eine View mit angewendeten Modifikatoren zurückgeben.

Das Programm zeigt den praktischen Unterschied: Die Factory kann eine mit Innenabstand versehene ItemCard zurückgeben oder Text, ohne dass einer der resultierenden Typen ItemComponent übernehmen muss. Apple erklärt, dass View-Modifikatoren ihre Eingabe in eine andere View einhüllen können; deshalb ist es hier nützlich, den Compiler den resultierenden Factory-Typ ableiten zu lassen. Siehe View.

Der Initializer markiert die Closure mit @escaping, da das Panel sie zur späteren Ausführung speichert. Swifts Kapitel zu Closures dokumentiert escapende Closures und ihre Fähigkeit, Werte aus dem umgebenden Kontext zu erfassen. In diesem Beispiel erfasst die Closure das synthetische Element, das von der umgebenden View verwendet wird.

Wählen Sie die Eingaben der Factory bewusst. Eine Factory ohne Argumente bedeutet, dass der Aufrufende alles für die Erstellung Erforderliche erfasst. Wenn das Panel selbst ein Element auswählt, kann eine Factory mit Parameter diese Zuständigkeit klarer ausdrücken. Die folgende Definition ersetzt die Komponente und wird zusammen mit dem vorhandenen Item verwendet; sie ist keine zweite vollständige App:

@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)
    }
}

Mit dieser Definition stellt das Panel das Element bereit und der Aufrufende die Darstellung. Beschränken Sie die Arbeit der Factory auf die Erzeugung des View-Werts: Der Erstellungsvertrag des Beispiels verspricht weder einmalige Ausführung noch dauerhafte Speicherung oder Caching.

Benötigen Factorys einen Result Builder?

Die Factorys im vollständigen Programm sind gewöhnliche Closures. Jede gibt einen einzelnen Ausdruck zurück, daher ist keine benutzerdefinierte Builder-Annotation erforderlich. Wenn mehrere untergeordnete Views benötigt werden, genügt es für diese API, ein einzelnes VStack zurückzugeben; der Stack enthält die untergeordneten Views.

Ein Result Builder ist nützlich, wenn der Factory-Parameter selbst eine deklarative Folge von Ausdrücken oder bedingte Inhalte akzeptieren soll. Swift Evolution hält fest, dass das standardisierte Result-Builder-Feature in Swift 5.4 implementiert wurde. Siehe SE-0289: Result builders.

Beachten Sie beim Einsatz der Builder-Syntax die SDK-Grenze. Apples aktuelle Dokumentation beschreibt @ViewBuilder für Parameter, die Views erzeugen, in Xcode 26 und früher. Für Xcode 27 und neuer beschreibt sie eine typsunabhängige Erstellung und empfiehlt den Alias ContentBuilder, um dieses Verhalten kenntlich zu machen. Siehe ViewBuilder und ContentBuilder. Die hier verwendete gewöhnliche Closure vermeidet die Anforderung dieses neueren Alias.

Warum ersetzen opake oder existentiale Typen den Vertrag nicht?

some View verbirgt einen bestimmten zugrunde liegenden Ergebnistyp und bewahrt dessen Identität. Das eignet sich gut für die Eigenschaften body in diesem Programm. Es fügt der View-Schnittstelle nicht init(item:) hinzu. Ein gekapselter Wert vom Typ any View verbirgt seinen zugrunde liegenden Typ auf andere Weise und bewahrt nicht dieselbe statische Typidentität. Siehe Opake und gekapselte Protokolltypen.

Machen Sie daraus nicht die pauschale Behauptung, dass existentiale Werte niemals an generische Funktionen übergeben werden können. SE-0352 führte in Swift 5.7 implizit geöffnete Existenziale ein. Damit können geeignete Aufrufe einen generischen Typparameter an den zugrunde liegenden Typ eines Existenzials binden. Der Vorschlag dokumentiert auch Einschränkungen, etwa Ergebnisse, die den geöffneten Typ in einem generischen Wrapper einbetten, wenn sich das Ergebnis nach der Typlöschung nicht darstellen lässt. Siehe Implicitly Opened Existentials.

Für diese Panel-Aufgabe genügt es, den konkreten Komponententyp über einen generischen Parameter beizubehalten. Es ist nicht nötig, existentiale Speicherung einzuführen, nur damit Aufrufende wählen können, wie sie eine View erstellen.

Wie lässt sich ein Erstellungsfehler diagnostizieren?

Verfolgen Sie den fehlschlagenden Ausdruck und prüfen Sie den an dieser Stelle verfügbaren Vertrag:

  1. Wenn Component(item:) fehlschlägt, prüfen Sie, ob die generische Einschränkung ein Protokoll enthält, das genau diese Initializer-Signatur verlangt.
  2. Wenn der Compiler Component nicht ableiten kann, geben Sie einen konkreten Metatyp wie ItemCard.self oder eine Factory mit einem konkreten Rückgabeausdruck an.
  3. Wenn eine Komponente aufruferspezifische Konfiguration benötigt, verlagern Sie die Erstellung in die Factory, statt dem Protokoll sachfremde Anforderungen hinzuzufügen.
  4. Wenn das Speichern einer Closure eine Diagnose zu escapenden Closures auslöst, prüfen Sie die @escaping-Annotation des Initializers.
  5. Wenn die Erstellung eine Diagnose zur Actor-Isolation auslöst, prüfen Sie, ob sowohl der Aufrufort als auch der Funktionstyp der Factory die beabsichtigte Main-Actor-Grenze ausdrücken.
  6. Wenn ein Existenzial einen Fehler zum generischen Ergebnis verursacht, prüfen Sie die Einschränkungen für Ergebnistypen in SE-0352, bevor Sie annehmen, dass ein Compiler-Upgrade die Einschränkung aufhebt.

Verlangen Sie für eine gemeinsame macOS-Komponentenfamilie den Initializer, wenn eine einheitliche Erstellung Teil der Schnittstelle ist. Verwenden Sie eine injizierte Factory, wenn der Aufrufende die Darstellung zusammenstellen und konfigurieren soll. Beide Wege machen die fehlende Erstellungszusage ausdrücklich und bewahren den konkreten Komponententyp, den das Panel benötigt.

Quellen