Uczyń komponent SwiftUI typem generycznym względem typu zgodnego z protokołem, a następnie zadeklaruj przechowywany widok jako Provider.Content. Dzięki temu relacja między dostawcą a jego powiązanym typem widoku zostaje zachowana w całym komponencie.
W tym samouczku zbudujemy małe okno macOS zawierające dwa przykładowe kafelki. Celem jest przechowanie dostawcy oraz tworzonego przez niego widoku bez zastępowania powiązanego typu przez any View lub AnyView. Ten sam wzorzec sprawdza się, gdy komponent desktopowy wielokrotnego użytku przyjmuje własną etykietę, element dodatkowy lub dostawcę zawartości.
Jaką relację muszą zachowywać przechowywane właściwości?
Typ powiązany określa typ wybierany przez każdą konformancję protokołu. Jeśli protokół deklaruje associatedtype Content: View, dostawca zgodny z tym protokołem musi dostarczyć jeden konkretny typ zawartości. Kod generyczny może odwołać się do tego typu jako Provider.Content, nawet gdy jego implementacja jest ukryta za nieprzezroczystym wynikiem. Swift opisuje typy powiązane i ograniczenia generyczne w rozdziale Generics.
Komponent potrzebuje zatem dwóch powiązanych deklaracji: parametru generycznego ograniczonego do protokołu dostawcy oraz właściwości przechowującej zawartość, której typem jest typ powiązany tego parametru. Dwie niezależne właściwości, które jedynie są zgodne z protokołami, nie wyrażają tej relacji.
SwiftUI korzysta z podobnego kontraktu: View deklaruje associatedtype Body: View, a Swift wywnioskuje ten typ powiązany z implementacji body niestandardowego widoku. Content dostawcy i Body komponentu to odrębne typy powiązane; nie muszą być identyczne. Zobacz dokumentację Apple dotyczącą Body.
Zbuduj kompletny przykład dla macOS
W tym przykładzie użyj projektu aplikacji macOS w Xcode z SwiftUI i docelową wersją macOS 13 lub nowszą. Zastąp wygenerowane deklaracje aplikacji i widoku zawartości poniższym pojedynczym plikiem, pozostawiając w celu dokładnie jedną deklarację @main. Apple opisuje strukturę punktu wejścia aplikacji oraz użycie WindowGroup w dokumentacji App.
Przykład celowo używa klasycznej składni typów powiązanych i nazwanych typów generycznych. Wyniki nieprzezroczyste wprowadzono w Swift 5.1; opcjonalna składnia głównych typów powiązanych, omówiona dalej, wymaga Swift 5.7. Kompletny plik jest przeznaczony dla aktualnego łańcucha narzędzi Swift 6. Został sprawdzony względem pobranej dokumentacji, ale nie został tutaj skompilowany, ponieważ w tym środowisku nie ma kompilatora Swift. Wybrana docelowa wersja macOS jest wymaganiem przykładu, a nie stwierdzeniem, że jest to najwcześniejsza wersja systemu obsługująca każdą używaną funkcję. Zobacz SE-0244 i SE-0346.
import SwiftUI
@MainActor
protocol TileProvider {
associatedtype Content: View
var title: String { get }
func makeContent() -> Content
}
@MainActor
struct TextTile: TileProvider {
let title: String
let message: String
func makeContent() -> Text {
Text(message)
}
}
@MainActor
struct CircleTile: TileProvider {
let title: String
let caption: String
func makeContent() -> some View {
HStack(spacing: 8) {
Circle()
.fill(Color.blue)
.frame(width: 16, height: 16)
Text(caption)
}
}
}
@MainActor
struct TileCard<Provider: TileProvider>: View {
private let provider: Provider
private let content: Provider.Content
init(provider: Provider) {
self.provider = provider
self.content = provider.makeContent()
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(provider.title)
.font(.headline)
content
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray.opacity(0.12))
}
}
@MainActor
struct ContentView: View {
var body: some View {
VStack(spacing: 12) {
TileCard(provider: TextTile(
title: "Text tile",
message: "Blue square"
))
TileCard(provider: CircleTile(
title: "Shape tile",
caption: "Blue circle"
))
}
.padding()
.frame(width: 360)
}
}
@main
@MainActor
struct AssociatedViewDemoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Zbuduj i uruchom cel macOS. Oczekiwanym rezultatem jest okno zawierające kafelek z tekstem oraz kafelek z niebieskim okręgiem obok podpisu. Oba korzystają z tego samego komponentu generycznego, a typ dostawcy jest niezależnie wywnioskowywany w każdym miejscu wywołania.
Jawne adnotacje @MainActor utrzymują te deklaracje tworzące interfejs użytkownika w tym samym kontekście aktora. Aktualna dokumentacja Apple dotycząca View deklaruje ten protokół z izolacją do głównego aktora. Zastosowane tu adnotacje jawnie określają również zamierzony kontekst niestandardowego protokołu dostawcy.
Dlaczego komponent generyczny zachowuje typ?
W przypadku pierwszej karty Provider to TextTile, a więc Provider.Content to Text. W przypadku drugiej karty Provider to CircleTile, a jej typem zawartości jest nieprzezroczysty wynik CircleTile.makeContent(). Możesz nazwać ten typ przez CircleTile.Content, nie podając zagnieżdżonego typu układu SwiftUI.
Kluczowe przypisanie to self.content = provider.makeContent(). Protokół gwarantuje, że ta metoda zwraca dokładnie Provider.Content, czyli dokładnie typ zadeklarowany dla przechowywanej właściwości. Jest to relacja sprawdzana w czasie kompilacji i wynikająca z ograniczenia generycznego, a nie porównanie typów w czasie działania. Rozdział Generics w dokumentacji Swift wyjaśnia odwoływanie się do typów powiązanych przez ograniczone parametry generyczne.
Użycie some View w implementacji zgodnej z protokołem nie usuwa tej relacji. Wynik nieprzezroczysty ukrywa konkretną implementację, zachowując tożsamość jej typu. Dzięki temu może służyć jako typ spełniający wymagania typu powiązanego. Swift wyjaśnia tę różnicę w artykule Opaque and Boxed Protocol Types.
Te karty mają różne specjalizacje: TileCard<TextTile> i TileCard<CircleTile>. Ponowne użycie deklaracji źródłowej nie wymaga, aby każda karta miała jeden uniwersalny typ przechowywanej zawartości.
Dlaczego niezależne właściwości nieprzezroczyste ani egzystencjalne nie rozwiązują problemu?
Poniższe fragmenty służą do pokazania błędów diagnostycznych, a nie zastępują kompletnego programu:
// Diagnostic fragment: no initializer establishes the opaque type.
let content: some View
// Diagnostic fragment: hides which conforming provider is stored.
let provider: any TileProvider
// Diagnostic fragment: stores an existential view value.
let content: any View
Właściwość przechowywana o nieprzezroczystym typie wymaga inicjalizatora deklaracji, który ustala jej typ bazowy. Zapis some View nie tworzy parametru generycznego wybieranego przez wywołującego, który można później wypełnić w dowolnym inicjalizatorze komponentu. Na przykład let content: some View = Text("Sample") ustala typ dla tej deklaracji; nie wiąże właściwości z typem powiązanym innej właściwości. Tę regułę dotyczącą właściwości przechowywanych opisuje SE-0244: Opaque Result Types.
Typ egzystencjalny, taki jak any TileProvider, pozwala przechowywać różne typy zgodne z protokołem. Ukrywa konkretny typ zgodny z protokołem, więc deklaracja nie ujawnia już relacji jako Provider.Content. Podobnie any View jest kontenerem egzystencjalnym; nie stanowi takiego samego kontraktu jak nazwany typ generyczny zgodny z View. Zobacz wyjaśnienie Swift dotyczące typów protokołów opakowanych w kontener.
Swift może niejawnie otwierać argumenty egzystencjalne w przypadku niektórych wywołań generycznych. Nie oznacza to, że każdy typ egzystencjalny można użyć w dowolnym inicjalizatorze generycznym. W szczególności SE-0352 ogranicza otwieranie, gdy nieznany typ bazowy miałby wydostać się na zewnątrz w wyniku generycznym, takim jak X<T>. Specjalizacja komponentu generycznego może napotkać dokładnie tę granicę. Zanim utworzysz kartę, zachowaj typ dostawcy jako generyczny, zamiast zakładać, że typ egzystencjalny zawsze da się ponownie przekształcić w trwałą specjalizację. Zobacz SE-0352: Implicitly Opened Existentials.
Dobierz sposób przechowywania do momentu tworzenia zawartości
Przykład wywołuje makeContent() podczas każdej inicjalizacji TileCard i przechowuje zwróconą wartość widoku. Nie tworzy trwałej pamięci podręcznej dla kolejnych inicjalizacji komponentu. Zachowanie obu właściwości pokazuje wymaganą relację typów, ale komponent, który potrzebuje wyłącznie wynikowego widoku, może pominąć przechowywanie dostawcy.
Jeśli komponent ma pobierać zawartość przy każdym obliczeniu jego body, zachowaj tylko dostawcę. Poniższa deklaracja komponentu zastępuje wcześniejszą i współpracuje z podanym wyżej protokołem oraz aplikacją:
@MainActor
struct TileCard<Provider: TileProvider>: View {
private let provider: Provider
init(provider: Provider) {
self.provider = provider
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(provider.title)
.font(.headline)
provider.makeContent()
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray.opacity(0.12))
}
}
Oba rozwiązania zachowują typ powiązany. Różnica wynika bezpośrednio z miejsca wywołania metody: w pierwszym rozwiązaniu jest to inicjalizacja, a w drugim obliczanie zawartości. Świadomie wybierz ten moment i zadbaj, aby tworzenie zawartości nie wymagało wywołania dokładnie raz.
Skorzystaj z poniższej tabeli, dostosowując przykład:
| Wymaganie komponentu | Strategia deklaracji | Relacja typów |
|---|---|---|
| Przechowywanie dostawcy i utworzonego przez niego widoku | Dostawca generyczny oraz Provider.Content |
Zachowuje tożsamość dostawcy i jego typ zawartości |
| Tworzenie zawartości podczas obliczania zawartości widoku | Przechowywanie dostawcy generycznego | Zachowuje typ powiązany bez przechowywania osobnej wartości zawartości |
| Przechowywanie dostawców o znanym wspólnym typie zawartości | Rozważ typ egzystencjalny z ograniczeniem | Zachowuje określoną równość typów zawartości, ukrywając tożsamość dostawcy |
| Celowe zatarcie różnic między implementacjami widoków | Użycie AnyView na wybranej granicy |
Przechowuje typ opakowania zamiast ujawniać pierwotny typ widoku |
Czy główne typy powiązane eliminują potrzebę użycia typów generycznych?
Główne typy powiązane oferują krótszy zapis wybranych ograniczeń równości typów. Jeśli jesteś właścicielem protokołu, poniższy alternatywny nagłówek sprawia, że Content staje się głównym typem; pozostałe wymagania protokołu pozostają bez zmian:
// Alternative protocol header, requiring Swift 5.7 or later.
@MainActor
protocol TileProvider<Content> {
associatedtype Content: View
var title: String { get }
func makeContent() -> Content
}
Przy takiej deklaracji any TileProvider<Text> opisuje typ egzystencjalny, którego typ powiązanej zawartości jest znany i równy Text. Różne implementacje dostawcy mogą współdzielić ten typ przechowywania tylko wtedy, gdy ich konformancje ujawniają dokładnie taką równość typu zawartości. Ta składnia ogranicza typ powiązany; nie przekształca protokołu w generyczną strukturę. Zobacz SE-0346 i SE-0353.
Nie łączy to dowolnych, nieznanych typów widoków w jeden zachowany typ konkretny. W szczególności CircleTile udostępnia nieprzezroczysty wynik zawartości, więc wywołujący nie może po prostu potraktować tego wyniku jako Text. Użyj pierwotnego komponentu generycznego, gdy powiązany typ zawartości dostawcy ma pozostać nieograniczony.
Typy egzystencjalne z ograniczeniami wprowadzono w Swift 5.7, a SE-0353 wyraźnie opisuje ograniczenia środowiska wykonawczego i wdrażania wstecznego. Sama obsługa przez kompilator nie wystarcza więc do potwierdzenia zgodności z docelową wersją systemu dla tego opcjonalnego rozwiązania. Przed jego przyjęciem sprawdź diagnostykę dostępności w docelowym łańcuchu narzędzi. Główny przykład nie zależy od przechowywania typów egzystencjalnych z ograniczeniami. Zobacz omówienie ABI w SE-0353.
Co sprawdzić, gdy kompilacja się nie powiedzie?
Zanim zmienisz układ widoków, prześledź typ od wymagania protokołu do przechowywanej właściwości:
- Upewnij się, że protokół deklaruje typ powiązany z ograniczeniem do
View. - Upewnij się, że komponent ma nazwany parametr generyczny dostawcy ograniczony do tego protokołu.
- Zadeklaruj przechowywaną zawartość jako
Provider.Content, zgodnie z typem zwracanym przez metodę. - Sprawdź, czy nadrzędny komponent nie przekształcił już dostawcy w
any TileProvider. - Jeśli przed przechowaniem dodajesz modyfikator, sprawdź typ wyniku: zmodyfikowany widok może już nie być typu
Provider.Content. - Osobno sprawdź komunikaty diagnostyczne dotyczące izolacji aktorów i typów powiązanych.
Apple dokumentuje, że modyfikatory widoku opakowują widok w inny widok. W konsekwencji przypisanie provider.makeContent().padding() do właściwości zadeklarowanej jako Provider.Content zmienia warunek, który musi spełniać przypisywana wartość. Przechowuj oryginalną zawartość i stosuj modyfikatory prezentacji w body albo przeprojektuj typ przechowywanej wartości tak, by opisywał zmodyfikowany wynik. Zobacz View.
Konstruktory mają też ograniczenia zależne od wersji. Aktualna dokumentacja Apple opisuje zachowanie ViewBuilder w Xcode 26 i wcześniejszych wersjach, a następnie inne zachowanie budowania zawartości, począwszy od Xcode 27. Przykład nie wymaga niestandardowego konstruktora: każdy dostawca zwraca jedno jawne wyrażenie. Jeśli rozszerzysz go o interfejsy API dostawców oparte na konstruktorach, stosuj się do dokumentacji wersji Xcode, której faktycznie używasz do kompilacji. Zobacz ViewBuilder.
Jeśli celowo wybierzesz AnyView, uwzględnij jego udokumentowane zachowanie: zmiana typu opakowanego widoku niszczy starą hierarchię i tworzy nową. To odrębna decyzja projektowa od zachowania typu powiązanego podczas przechowywania. Apple opisuje to w dokumentacji AnyView.
W tym komponencie zachowaj widoczną relację na granicy przechowywania: generyczny Provider, wynik typu Provider.Content oraz właściwość przechowywaną o tym samym typie. Dzięki temu możesz zmieniać wewnętrzny układ dostawcy, a kompilator przeniesie jego powiązany typ widoku przez komponent.
Źródła
- Generics — Swift.org
- Opaque and Boxed Protocol Types — Swift.org
- View — Apple
- Body — Apple
- App — Apple
- ViewBuilder — Apple
- AnyView — Apple
- SE-0244: Opaque Result Types — Swift Evolution
- SE-0346: Lightweight same-type requirements for primary associated types — Swift Evolution
- SE-0352: Implicitly Opened Existentials — Swift Evolution
- SE-0353: Constrained Existential Types — Swift Evolution