W oknie macOS wyświetlającym heterogeniczne komponenty SwiftUI zachowaj silne typowanie wyniku każdego dostawcy i zastosuj erasure, gdy wspólny interfejs renderowania wymaga jednego konkretnego typu widoku. Zastosuj erasure dostawcy wcześniej, gdy sama kolekcja wymaga jednolitej otoczki.
W tym artykule tworzymy niewielką galerię komponentów zawierającą syntetyczny tekst i niebieską próbkę koloru. Zadanie polega na przechowywaniu różnych implementacji dostawców razem i renderowaniu ich za pomocą jednego interfejsu SwiftUI. Decyzja projektowa dotyczy miejsca, w którym należy przestać ujawniać relację między dostawcą a jego wynikiem.
Co powinien zachowywać kontrakt dostawcy?
Dostawca z typem wyniku określonym przez typ skojarzony deklaruje, że każdy typ zgodny z protokołem wybiera własny typ widoku. Konkretna implementacja może ukryć ten wybór za pomocą some View, podczas gdy klienci generyczni zachowują relację między dostawcą a jego wynikiem. Nieprzezroczyste typy w Swift zachowują tożsamość typu; opakowane typy protokołów pozwalają ukryć różne typy bazowe za wspólnym interfejsem. Zobacz Nieprzezroczyste i opakowane typy protokołów.
Poniższy fragment przedstawia kontrakt, a nie kompletną aplikację:
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)
}
}
Tutaj TitleProvider.Content jest wywnioskowany z nieprzezroczystego typu zwracanego przez makeView(). Protokół wymaga typu skojarzonego, a implementacja dostarcza go przez wnioskowanie. SE-0244 wprost dokumentuje ten obsługiwany wzorzec oraz ograniczenie zabraniające deklarowania nieprzezroczystego typu zwracanego bezpośrednio jako wymagania protokołu. Zatem zastąpienie wymagania przez func makeView() -> some View nie jest kontraktem obsługiwanym i opisanym w tej propozycji. Zobacz Nieprzezroczyste typy wynikowe.
Tablica typu any ViewProvider rozwiązuje problem heterogenicznego przechowywania. SE-0309 wprowadzone w Swift 5.7 dopuszcza używanie protokołów z typami skojarzonymi jako typów egzystencjalnych, zachowując jednak ograniczenia dotyczące wywoływania poszczególnych składowych. Odczytanie wyniku przez taki typ egzystencjalny może wymazać jego typ skojarzony do górnego ograniczenia protokołu — w tym przypadku do any View. Zobacz Odblokowanie typów egzystencjalnych dla wszystkich protokołów.
Masz więc do podjęcia dwie osobne decyzje: jak przechowywać różne dostawców oraz jaki konkretny typ zwraca renderer. Apple opisuje AnyView jako konkretny, wymazujący typ widoku zgodny z View, co czyni go jawną opcją w drugim przypadku. Zobacz AnyView.
Wybierz granicę w zależności od wymagań klienta
Przed zmianą protokołu skorzystaj z poniższej tabeli decyzyjnej. Te zalecenia odnoszą udokumentowane kontrakty typów do zadania z galerią; są wskazówkami projektowymi, a nie wynikami pomiarów wydajności.
| Wymaganie klienta | Odpowiedni projekt | Miejsce ukrycia informacji o typie |
|---|---|---|
| Renderowanie jednego konkretnego dostawcy za pomocą wielokrotnego użytku kodu | Klient generyczny ograniczony do ViewProvider |
Zachowaj dostępną relację między dostawcą a wynikiem |
| Przechowywanie różnych dostawców i zachowanie wspólnych metadanych | [any ViewProvider] z pomocniczą funkcją renderującą, która zwraca AnyView |
Typ dostawcy przy przechowywaniu; typ wyniku przy renderowaniu |
| Udostępnienie dalszemu kodowi jednej konkretnej otoczki dostawcy | [AnyViewProvider] |
Podczas tworzenia otoczki |
| Wymaganie, by każda implementacja zwracała już wymazany widok | Wymaganie protokołu zwracające AnyView |
Wewnątrz każdej implementacji |
| Wyświetlanie ustalonego, niewielkiego zestawu wariantów komponentu | Enum i oparte na builderze switch |
Warianty pozostają jawne w enumie |
W tej galerii wybierz przechowywanie wartości jako typów egzystencjalnych i erasure wyniku. Dostawcy zachowują własne typy wyników, kolekcja udostępnia stabilny identyfikator tekstowy, a jedna funkcja pomocnicza odpowiada za konwersję do AnyView. Zmiana wewnętrznego układu komponentu nie wymaga wtedy dodawania kodu erasure w tym komponencie.
Kolekcja z parametrem generycznym służyłaby innemu zadaniu. Tablica, której typ elementu jest jednym typem dostawcy generycznego, nie może automatycznie reprezentować kilku niezwiązanych ze sobą typów dostawców. Implicit opening w Swift działa dla pojedynczego argumentu typu egzystencjalnego; nie nadaje heterogenicznej tablicy jednego wspólnego zgodnego typu bazowego. Zobacz Niejawnie otwierane typy egzystencjalne.
Zbuduj kompletną galerię komponentów macOS
Przykład wymaga kompilatora Swift 5.7 lub nowszego oraz aplikacji macOS używającej SwiftUI. Ustaw minimalną wersję systemu dla przykładu na macOS 12 lub nowszą. W projekcie aplikacji macOS w Xcode używającym SwiftUI zastąp wygenerowaną deklarację aplikacji poniższym kompletnym programem, zachowując dokładnie jedną deklarację @main.
Jawne adnotacje @MainActor zapewniają, że tworzenie dostawców i renderowanie odbywają się na tym samym aktorze. Obecna deklaracja View firmy Apple jest izolowana do głównego aktora. Dokumentacja wyjaśnia też, że widoki niestandardowe dostarczają swoją zawartość przez body. Zobacz 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)
}
}
}
Oczekiwany rezultat to jedno okno, w którym nad niebieskim zaokrąglonym prostokątem widnieje tekst Component gallery. Wszystkie dane wejściowe są lokalnymi wartościami syntetycznymi. Przykład został sprawdzony, ale nie został tutaj skompilowany, ponieważ dostępne środowisko nie zawiera kompilatora Swift ani SDK Apple.
Wynik funkcji pomocniczej zawsze ma typ AnyView, niezależnie od typu skojarzonego wyniku dostawcy. Wewnątrz tej funkcji dostawca jest generyczny, a relacja z jego Content pozostaje dostępna. Apple opisuje użyty tutaj inicjalizator jako przyjmujący wartość generyczną ograniczoną do View i wymazujący jej typ. Zobacz AnyView.init(_:).
Kolekcja używa id: \.id, ponieważ protokół ustala id jako String; nie wymaga odzyskiwania innego nieznanego typu skojarzonego. Dokumentacja ForEach firmy Apple dopuszcza elementy identyfikowalne albo jawny key path do identyfikatora. Identyfikatory w tej kolekcji powinny być unikatowe i stabilne dla poszczególnych komponentów podczas aktualizacji. Zobacz ForEach.
Dlaczego funkcja generyczna działa, gdy generyczna otoczka widoku zawodzi?
Kluczową cechą erasedView jest konkretny zwracany typ. Swift 5.7 wprowadził niejawne otwieranie argumentów egzystencjalnych, co pozwala kompilatorowi dopasować parametr generyczny do typu bazowego wartości egzystencjalnej na potrzeby wywołania. Nadal ogranicza jednak wyniki, które ujawniałyby ten otwarty typ przez niezmienniczy kontener generyczny. SE-0352 opisuje także szersze zachowanie otwierania dostępne w trybie języka Swift 6. Zobacz Niejawnie otwierane typy egzystencjalne.
Poniższy fragment diagnostyczny dodaje do programu powyżej generyczną otoczkę. Zakomentowane wywołanie ilustruje problematyczną granicę; nie stanowi dodatkowego punktu wejścia aplikacji:
@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
}
To wyjaśnia, dlaczego samo dodanie warstwy generycznej może nie rozwiązać błędu. Sprawdź zarówno jej parametr, jak i wynik. Funkcja pomocnicza zwracająca konkretny wymazany widok kończy konwersję wewnątrz wywołania; wynik taki jak ProviderRow<P> zachowuje typ dostawcy w swojej strukturze.
Podobnie some View nie jest sposobem zapisu wartości zwracanej, której typ bazowy może dowolnie zmieniać się w czasie działania programu. Implementacja wybiera spójny typ bazowy, który może zależeć od argumentów generycznych. Builder potrafi zakodować gałęzie w złożonym wyniku, ale sama deklaracja nieprzezroczystego wyniku nie łączy niezwiązanych typów zwracanych. Zobacz Nieprzezroczyste i opakowane typy protokołów.
Kiedy należy wymazać sam typ dostawcy?
Wybierz konkretną otoczkę, gdy dalsze interfejsy powinny otrzymywać jeden typ dostawcy albo gdy chcesz, by wywołujący ujednolicili dostawców przed ich przechowaniem. Poniższa dodatkowa deklaracja współpracuje z istniejącym protokołem i dostawcami z przykładu. Przechowuje dostawcę i tworzy jego widok po wywołaniu 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()
}
}
Aby użyć tej alternatywy, zmień obie przechowywane właściwości providers w kompletnym programie na [AnyViewProvider]. Zastąp początkową tablicę aplikacji poniższym wyrażeniem:
[
AnyViewProvider(
TitleProvider(id: "title", title: "Component gallery")
),
AnyViewProvider(SwatchProvider(id: "swatch"))
]
Następnie zastąp erasedView(for: provider) w domknięciu ForEach galerii przez provider.makeView(). Otoczka już zwraca AnyView, więc to wywołanie nie wymaga dodatkowej konwersji. Ta implementacja kopiuje identyfikator i przechowuje przekazaną wartość dostawcy; nie służy do późniejszego zastępowania tej przechwyconej wartości.
Jeśli każdy klient potrzebuje wymazanego wyniku, prostszy protokół może bezpośrednio wymagać makeView() -> AnyView. Ogranicza to kod adaptera, ale nakłada na każdą implementację obowiązek erasure. Wybierz to rozwiązanie, gdy wymazany wynik jest zamierzonym zobowiązaniem interfejsu. Wybierz kontrakt z typem skojarzonym, gdy klienci generyczni powinni nadal móc korzystać z relacji między każdym dostawcą a jego wynikiem.
Uwzględnij ograniczenia kompilatora i tożsamość widoku
Nie łącz funkcji języka z zachowaniem buildera. Przykład korzysta z funkcji typów egzystencjalnych wprowadzonych w Swift 5.7 oraz jawnego wyniku AnyView. Aktualna dokumentacja Apple rozróżnia Xcode 26 i starsze wersje od Xcode 27 i nowszych: te nowsze tworzą zawartość buildera niezależną od typu i w tym zastosowaniu zalecają ContentBuilder. W dokumentacji nadal opisano bezpieczeństwo typów za pomocą zgodności warunkowej z protokołem. Nie traktuj złagodzenia kontroli wejściowych buildera jako ogólnej gwarancji, że sam typ egzystencjalny spełnia wszystkie generyczne ograniczenia View. Zobacz ViewBuilder i ContentBuilder.
W przypadku ustalonego zestawu komponentów rozważ enum, którego przypadki przechowują dane komponentów, a właściwość renderująca przełącza się między nimi. W ten sposób obsługiwane warianty są jawne. To przydatne rozwiązanie, gdy dodanie nowego komponentu powinno wymagać aktualizacji jednego centralnego renderera; podejście z dostawcami pasuje do interfejsów, w których zgodne implementacje powinny same dostarczać sposób renderowania.
Erasure typów ma też udokumentowaną konsekwencję dla tożsamości. Apple stwierdza, że zmiana typu opakowanego przez AnyView niszczy poprzednią hierarchię i tworzy nową. Stabilny identyfikator kolekcji nie zmienia tego zachowania. Jeśli komponent może przełączać się między różnymi typami opakowanych widoków, sprawdź stan i zachowanie przejść w rzeczywistej aplikacji. Zobacz AnyView.
W tej galerii zachowaj kontrakt dostawcy z typem skojarzonym, przechowuj różne wartości jako typy egzystencjalne i zwracaj AnyView z jednej funkcji pomocniczej renderującej. Przenieś erasure do konkretnej otoczki dostawcy, gdy dalsze interfejsy potrzebują takiego jednolitego typu. Dzięki temu granica pozostaje wyraźna i łatwa do sprawdzenia, gdy zmienia się kolekcja, kontrakt renderowania lub konfiguracja kompilatora.
Źródła
- Nieprzezroczyste i opakowane typy protokołów — Język programowania Swift
- Nieprzezroczyste typy wynikowe — Swift Evolution
- Odblokowanie typów egzystencjalnych dla wszystkich protokołów — Swift Evolution
- Niejawnie otwierane typy egzystencjalne — Swift Evolution
- View — Dokumentacja Apple dla deweloperów
- AnyView — Dokumentacja Apple dla deweloperów
- init(_:) — Dokumentacja Apple dla deweloperów
- ForEach — Dokumentacja Apple dla deweloperów
- ViewBuilder — Dokumentacja Apple dla deweloperów
- ContentBuilder — Dokumentacja Apple dla deweloperów