Сделайте компонент SwiftUI обобщённым по типу, реализующему протокол, а затем объявите хранимое представление как Provider.Content. Так связь между провайдером и его связанным типом представления сохранится во всём компоненте.

В этом руководстве создаётся небольшое окно macOS с двумя искусственными плитками. Задача — хранить провайдер и создаваемое им представление, не заменяя связанный тип на any View или AnyView. Этот же подход применим, когда переиспользуемый компонент для настольного приложения принимает пользовательскую метку, дополнительный элемент или провайдер содержимого.

Какую связь должны сохранять хранимые свойства?

Связанный тип задаёт тип, который выбирает каждая реализация протокола. Если протокол объявляет associatedtype Content: View, реализующий его провайдер должен предоставить конкретный тип содержимого. Обобщённый код может обращаться к этому типу как Provider.Content, даже если его реализация скрыта за непрозрачным результатом. Swift описывает связанные типы и обобщённые ограничения в главе «Обобщения».

Поэтому компоненту нужны два взаимосвязанных объявления: обобщённый параметр с ограничением на протокол провайдера и свойство хранимого содержимого, тип которого является связанным типом этого параметра. Два независимых свойства, каждое из которых лишь соответствует протоколу, не выражают такую связь.

В самом SwiftUI используется похожий контракт: View объявляет associatedtype Body: View, а Swift выводит этот связанный тип из реализации body пользовательского представления. Content провайдера и Body компонента — разные связанные типы; они не обязаны совпадать. См. документацию Apple по Body.

Создание полноценного примера для macOS

Для этого примера используйте проект приложения macOS в Xcode со SwiftUI и целевой версией macOS 13 или новее. Замените сгенерированные объявления приложения и представления содержимого одним файлом ниже, сохранив в целевом модуле ровно одно объявление @main. Apple описывает такую структуру точки входа приложения и использование WindowGroup в документации по App.

В примере намеренно используется давно существующий синтаксис связанных типов и именованных обобщений. Непрозрачные результаты появились в Swift 5.1; необязательный синтаксис первичных связанных типов, рассмотренный далее, требует Swift 5.7. Файл целиком рассчитан на актуальную цепочку инструментов Swift 6. Его проверили по полученной документации, но здесь не компилировали, поскольку в этой среде нет компилятора Swift. Выбранная целевая версия macOS — предварительное условие примера, а не утверждение о самой ранней версии ОС, поддерживающей все базовые возможности. См. SE-0244 и 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()
        }
    }
}

Соберите и запустите целевой модуль macOS. В результате должно появиться окно с текстовой плиткой и плиткой, где рядом с подписью расположен синий круг. Обе используют один и тот же обобщённый компонент, при этом тип провайдера выводится независимо в каждой точке вызова.

Явные аннотации @MainActor помещают эти объявления, создающие элементы интерфейса, в один и тот же контекст актора. В актуальной документации Apple по View протокол объявлен с изоляцией главного актора. Эти аннотации также явно задают предполагаемый контекст пользовательского протокола провайдера.

Почему обобщённый компонент сохраняет тип?

Для первой карточки Provider — это TextTile, поэтому Provider.Content — это Text. Для второй карточки Provider — это CircleTile, а связанный тип содержимого — непрозрачный результат CircleTile.makeContent(). На этот тип можно ссылаться через CircleTile.Content, не указывая вложенный тип макета SwiftUI.

Ключевое присваивание — self.content = provider.makeContent(). Протокол гарантирует, что этот метод возвращает именно Provider.Content, то есть ровно тот тип, который объявлен для хранимого свойства. Это связь на этапе компиляции, выведенная из обобщённого ограничения, а не сравнение типов во время выполнения. В главе Swift об обобщениях объясняется доступ к связанным типам через ограниченные обобщённые параметры.

Использование some View в реализации протокола не разрушает эту связь. Непрозрачный результат скрывает конкретную реализацию, сохраняя идентичность её типа. Поэтому он подходит на роль свидетеля связанного типа. Различие объясняется в разделе Swift «Непрозрачные и упакованные типы протоколов».

У этих карточек разные специализации: TileCard<TextTile> и TileCard<CircleTile>. Повторное использование объявления не требует, чтобы у каждой карточки был один универсальный тип хранимого содержимого.

Почему не подходят независимые непрозрачные типы или свойства-экзистенциалы?

Ниже приведены фрагменты для диагностики, а не замена полной программы:

// 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

Для хранимого свойства с непрозрачным типом нужен инициализатор в объявлении, задающий его базовый тип. Запись some View не создаёт выбираемый вызывающей стороной обобщённый параметр, который произвольный инициализатор компонента сможет заполнить позже. Например, let content: some View = Text("Sample") задаёт тип для этого объявления, но не связывает свойство со связанным типом другого свойства. Это правило для хранимых свойств описано в SE-0244: «Непрозрачные типы результата».

Экзистенциал, например any TileProvider, позволяет хранить значения разных типов, реализующих протокол. Он скрывает конкретный тип реализации, поэтому объявление больше не показывает связь в виде Provider.Content. Аналогично, any View — это контейнер-экзистенциал; его контракт не совпадает с контрактом именованного обобщённого типа, реализующего View. См. объяснение Swift для упакованных типов протоколов.

В некоторых обобщённых вызовах Swift может неявно открывать аргументы-экзистенциалы. Это не означает, что любой экзистенциал можно использовать в любом обобщённом инициализаторе. В частности, SE-0352 ограничивает открытие, когда неизвестный базовый тип окажется внутри обобщённого результата, например X<T>. Обобщённая специализация компонента может столкнуться именно с таким ограничением. Сохраняйте обобщённый тип провайдера до создания карточки, а не исходите из того, что экзистенциал всегда можно преобразовать обратно в устойчивую специализацию. См. SE-0352: «Неявно открываемые экзистенциалы».

Выбор способа хранения в зависимости от момента создания содержимого

В примере makeContent() вызывается при каждой инициализации TileCard, а возвращённое значение представления сохраняется. Это не создаёт постоянный кэш между последующими инициализациями компонента. Хранение обоих свойств демонстрирует требуемую связь типов, но компоненту, которому нужно только результирующее представление, можно не хранить провайдер.

Если компонент должен запрашивать содержимое при каждом вычислении body, оставьте только провайдер. Ниже приведено заменяющее объявление компонента для использования с протоколом и приложением выше:

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

Оба варианта сохраняют связанный тип. Их различие напрямую определяется местом вызова метода: инициализация в первом варианте, вычисление body во втором. Выбирайте этот момент осознанно и не добавляйте в создание содержимого работу, которая зависит от того, что вызов произойдёт строго один раз.

Используйте эту памятку при адаптации примера:

Требование компонента Стратегия объявления Связь типов
Хранить провайдер и созданное им представление Обобщённый провайдер и Provider.Content Сохраняются идентичность провайдера и тип его содержимого
Создавать содержимое при вычислении body Хранить обобщённый провайдер Связанный тип сохраняется без отдельного хранимого значения содержимого
Хранить провайдеры с заведомо общим типом содержимого Рассмотреть ограниченный экзистенциал Заданное равенство типов содержимого сохраняется, а идентичность провайдера скрывается
Намеренно стирать различия между реализациями представлений Использовать AnyView на выбранной границе Хранится тип обёртки вместо исходного типа представления

Устраняют ли первичные связанные типы потребность в обобщениях?

Первичные связанные типы позволяют короче задавать выбранные ограничения на равенство типов. Если вы владеете протоколом, этот альтернативный заголовок делает Content первичным; остальные требования протокола остаются прежними:

// Alternative protocol header, requiring Swift 5.7 or later.
@MainActor
protocol TileProvider<Content> {
    associatedtype Content: View

    var title: String { get }
    func makeContent() -> Content
}

С таким объявлением any TileProvider<Text> обозначает экзистенциал, у которого связанный тип содержимого заведомо равен Text. Разные реализации провайдера могут использовать это хранилище лишь в том случае, если их реализации протокола обеспечивают точное равенство типов содержимого. Этот синтаксис ограничивает связанный тип, но не превращает протокол в обобщённую структуру. См. SE-0346 и SE-0353.

Это не объединяет произвольные неизвестные типы представлений в один сохраняемый конкретный тип. В частности, CircleTile предоставляет непрозрачный результат для содержимого, поэтому вызывающий код не может просто считать этот результат типом Text. Используйте исходный обобщённый компонент, если связанный тип содержимого провайдера должен оставаться неограниченным.

Ограниченные экзистенциалы реализованы в Swift 5.7, а SE-0353 прямо указывает на ограничения среды выполнения и обратной совместимости при развёртывании. Поэтому одной поддержки компилятора недостаточно, чтобы установить совместимость необязательного варианта с целевой платформой. Перед его внедрением проверьте диагностику доступности для целевой цепочки инструментов. В основном примере ограниченное хранение экзистенциалов не используется. См. обсуждение ABI в SE-0353.

Что проверить, если компиляция завершилась ошибкой?

Прежде чем менять макет, проследите путь типа от требования протокола до хранимого свойства:

  1. Убедитесь, что протокол объявляет связанный тип с ограничением View.
  2. Убедитесь, что у компонента есть именованный обобщённый параметр провайдера с ограничением на этот протокол.
  3. Объявите хранимое содержимое как Provider.Content, чтобы его тип совпадал с типом результата метода.
  4. Проверьте, не преобразовал ли родительский компонент провайдера в any TileProvider.
  5. Если перед сохранением добавляется модификатор, проверьте тип результата: изменённое представление может уже не быть Provider.Content.
  6. Отдельно проверьте сообщения об изоляции акторов, не смешивая их с сообщениями о связанных типах.

Apple указывает, что модификаторы представлений оборачивают представление в другое представление. Следовательно, присваивание provider.makeContent().padding() свойству, объявленному как Provider.Content, меняет контракт, которому должно соответствовать присваивание. Сохраняйте исходное содержимое и применяйте модификаторы оформления в body либо измените объявленный тип хранимого свойства так, чтобы он описывал результат с модификатором. См. документацию по View.

У построителей тоже есть граница версий. В актуальной документации Apple описано поведение ViewBuilder для Xcode 26 и более ранних версий, а начиная с Xcode 27 — другое поведение построения содержимого. В примере нет требования к пользовательскому построителю: каждый провайдер возвращает одно явное выражение. Если вы дополните его API провайдера с построителем, следуйте документации для фактически используемой версии Xcode. См. ViewBuilder.

Если вы намеренно выбираете AnyView, учитывайте описанное поведение: при изменении типа обёрнутого представления старая иерархия удаляется и создаётся новая. Это отдельное проектное решение, не связанное с сохранением связанного типа при хранении. Apple описывает это в документации по AnyView.

Для этого компонента сохраняйте связь на границе хранения: обобщённый Provider, результат типа Provider.Content и хранимое свойство того же типа. Тогда вы сможете менять внутренний макет провайдера, а компилятор будет сохранять его связанный тип представления во всём компоненте.

Источники