Чтобы создать обобщённый компонент SwiftUI, задайте его инициализатор как требование протокола или передайте замыкание, которое возвращает этот компонент. В этом руководстве оба подхода используются для отображения одного и того же синтетического элемента в окне macOS с явно указанными границами компилятора и SDK.
Что должен гарантировать обобщённый компонент?
Представьте, что приложение macOS отображает небольшую карточку элемента. Вам нужна повторно используемая панель, которая создаёт карточку на основе элемента, позволяя вызывающему коду выбирать способ представления. Главный вопрос — кто отвечает за создание: объявленный интерфейс компонента или вызывающий код, передающий компонент.
Обобщённое ограничение даёт реализации доступ к возможностям, указанным в этом ограничении. Запись Component: View устанавливает, что компонент является представлением SwiftUI, но не гарантирует наличие у него инициализатора с именем init(item:). В главе «Обобщения» руководства Swift объясняются ограничения протоколов, а в документации Apple по View описано обязательное свойство представления body.
Поэтому следующий фрагмент намеренно некорректен:
// Invalid fragment: View does not require init(item:).
@MainActor
func construct<Component: View>(item: Item) -> Component {
Component(item: item)
}
Добавление этого инициализатора в один конкретный компонент не делает его доступным для каждого обобщённого параметра, ограниченного только протоколом View. Повторно используемому API нужен явный контракт создания. Прежде чем менять компонент, воспользуйтесь этой таблицей решений:
| Требование к созданию | Подходящий интерфейс | Причина |
|---|---|---|
| Каждый участвующий компонент принимает один и тот же элемент | Протокол с требованием init(item:) |
Создание относится к интерфейсу семейства компонентов. |
| Вызывающий код выбирает дополнительные настройки или модификаторы | Фабричное замыкание, возвращающее обобщённый компонент | Создание остаётся на месте вызова. |
| У вызывающего кода уже есть готовый компонент | Хранить значение обобщённого компонента | Создание уже выполнено. |
| Представление переключается между конечным набором макетов | Объединить варианты внутри представления SwiftUI | Внешний API может сохранить конкретную структуру представления. |
Эта таблица — вспомогательный инструмент для проектирования, основанный на документированных правилах языка. Ниже вместе реализованы первые два варианта, чтобы можно было напрямую сравнить их поведение.
Создайте полноценный пример для macOS
Используйте целевой объект приложения macOS в Xcode со SwiftUI и целевой версией macOS 11 или новее. Пример написан для компилятора Swift 6 с соответствующим SDK Apple. Явные аннотации @MainActor требуют Swift 5.5 или новее, поэтому этот пример не предназначен для более ранних компиляторов. В документации Apple указано, что WindowGroup доступен начиная с macOS 11 и служит контейнером для иерархии представлений приложения. См. WindowGroup.
Замените созданный файл точки входа приложения следующим полноценным кодом. Убедитесь, что целевой объект содержит только одно объявление приложения с @main. Созданный файл ContentView можно оставить неиспользуемым. Пакеты, внешние ресурсы и загруженные примеры данных не нужны.
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()
}
}
}
Запустите целевой объект приложения macOS. В результате в одном окне должны отображаться два варианта карточки и обычное текстовое представление. У второй карточки есть дополнительные отступы слева, переданные вызывающим кодом. Это синтетические примеры работы API, а не результаты измерений. В исследовательской среде компилятор Swift не установлен, поэтому этот код был проверен визуально, но не компилировался и не запускался.
Аннотации главного актора явно задают границу создания интерфейса. Текущее объявление View от Apple снабжено аннотациями @MainActor и @preconcurrency; в документации также объясняется, как наследуется изоляция, если соответствие объявлено в исходном объявлении типа. См. View.
Как работает контракт инициализатора?
ItemComponent расширяет View и добавляет ровно одно требование к созданию: init(item:). В Swift можно напрямую задавать требования к инициализаторам в протоколах. Соответствующий тип предоставляет реализацию, а протокол задаёт сигнатуру без тела инициализатора. См. «Протоколы: требования к инициализаторам».
Благодаря этому требованию выражение Component(item: item) допустимо внутри ContractPanel. Аргумент component: Component.Type передаёт конкретный тип, позволяя вывести обобщённый тип: передача ItemCard.self определяет ItemCard как тип компонента для этой панели. Панели не нужно хранить метатип, поскольку её body создаёт экземпляр через обобщённый параметр типа.
Этот контракт полезен, когда каждый компонент должен создаваться из одинакового входного значения. Например, семейство принадлежащих приложению компактных и расширенных вариантов представления элемента может использовать одно и то же требование. Тогда вызывающий код может менять выбранный тип компонента, не переписывая выражение создания в панели.
Недостаток проявляется, когда реализации нужны дополнительные настройки. Требование init(item:) означает, что каждый соответствующий компонент должен предоставлять такой способ создания, даже если его предпочтительный инициализатор дополнительно принимает подпись или настройку макета. Если эти дополнительные данные принадлежат вызывающему коду, фабричный подход ниже обычно точнее выражает это требование.
Для реализаций на основе классов инициализатор, удовлетворяющий требованию протокола, должен быть required, если только класс не является final. Подклассу, который также переопределяет соответствующий назначенный инициализатор суперкласса, могут понадобиться и required, и override. Для структур, таких как ItemCard, required не используется. Эти различия описаны в разделе «Протоколы».
Когда создание следует перенести в фабрику?
FactoryPanel принимает функцию типа @MainActor () -> Component. Она просто гарантирует, что вызов функции создаст выбранный тип компонента. Замыкание может вызвать инициализатор, захватить настройки или вернуть представление с применёнными модификаторами.
Программа показывает практическое различие: фабрика может вернуть элемент ItemCard с отступами или Text, не требуя, чтобы полученный тип соответствовал ItemComponent. Apple объясняет, что модификаторы представлений могут оборачивать переданное им представление в другое представление, поэтому здесь полезно позволить компилятору вывести результирующий тип фабрики. См. View.
Инициализатор помечает замыкание атрибутом @escaping, потому что панель сохраняет его для последующего вызова. В главе «Замыкания» руководства Swift описываются выходящие за пределы функции замыкания и их способность захватывать окружающие значения. В этом примере замыкание захватывает синтетический элемент, используемый окружающим представлением.
Продуманно выбирайте параметры фабрики. Фабрика без аргументов означает, что вызывающий код захватывает всё необходимое для создания. Если элемент выбирает сама панель, фабрика с параметром может яснее выразить эту ответственность. Ниже приведено альтернативное определение компонента, которое следует использовать вместе с существующим типом Item; это не второе полноценное приложение:
@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)
}
}
С таким определением панель передаёт элемент, а вызывающий код — вариант представления. Ограничьте работу фабрики созданием значения представления: контракт создания в примере не гарантирует однократного выполнения, постоянного хранения или кэширования.
Нужен ли фабрикам построитель результата?
В полноценной программе используются обычные замыкания. Каждое возвращает одно выражение, поэтому пользовательская аннотация построителя не нужна. Если требуется несколько дочерних представлений, для этого API достаточно вернуть один VStack, содержащий эти представления.
Построитель результата полезен, когда сам параметр-фабрика должен принимать декларативную последовательность выражений или условное содержимое. В описании Swift Evolution указано, что стандартная возможность построителей результата была реализована в Swift 5.4. См. SE-0289: «Построители результата».
При использовании синтаксиса построителя учитывайте границы SDK. В текущей документации Apple сказано, что в Xcode 26 и более ранних версиях для параметров, создающих представления, используется @ViewBuilder. Для Xcode 27 и новее описано создание без привязки к типу и рекомендуется псевдоним ContentBuilder, указывающий на такое поведение. См. ViewBuilder и ContentBuilder. Обычное замыкание, используемое здесь, позволяет не требовать этот более новый псевдоним.
Почему непрозрачные и экзистенциальные типы не заменяют контракт?
some View скрывает конкретный базовый тип результата, сохраняя его идентичность. В этой программе он хорошо подходит для свойств body. Однако он не добавляет init(item:) в интерфейс View. Значение в обёртке, записанное как any View, скрывает базовый тип иначе и не сохраняет ту же статическую идентичность типа. См. «Непрозрачные и упакованные типы протоколов».
Не превращайте это в общее утверждение, будто экзистенциальные значения никогда нельзя передавать обобщённым функциям. В Swift 5.7 в SE-0352 были введены неявно открываемые экзистенциальные типы, благодаря которым подходящие вызовы могут связать обобщённый параметр с базовым типом экзистенциального значения. В документе также описаны ограничения, включая случаи, когда результат содержит открытый тип внутри обобщённой обёртки и не может быть представлен после стирания типа. См. «Неявно открываемые экзистенциальные типы».
Для задачи с этой панелью достаточно сохранить конкретный тип компонента через обобщённый параметр. Вводить экзистенциальное хранилище только для того, чтобы вызывающий код мог выбирать способ создания представления, не нужно.
Как диагностировать ошибку при создании?
Проследите за выражением, которое вызывает ошибку, и проверьте, какой контракт доступен в этом месте:
- Если ошибка возникает в
Component(item:), проверьте, что обобщённое ограничение включает протокол с требованием именно такой сигнатуры инициализатора. - Если компилятор не может вывести
Component, передайте конкретный метатип, напримерItemCard.self, или фабрику с выражением, возвращающим конкретный тип. - Если компоненту нужны особые настройки вызывающего кода, перенесите создание в фабрику, а не добавляйте в протокол несвязанные требования.
- Если при хранении замыкания возникает ошибка о выходящем за пределы функции замыкании, проверьте наличие аннотации
@escapingу инициализатора. - Если при создании возникает ошибка изоляции актора, проверьте, что и место вызова, и тип функции фабрики явно задают предполагаемую границу главного актора.
- Если экзистенциальный тип приводит к ошибке обобщённого результата, изучите ограничения типа результата в SE-0352, прежде чем считать, что обновление компилятора устранит ограничение.
Для общего семейства компонентов macOS требуйте инициализатор, если единообразное создание входит в интерфейс. Используйте внедрённую фабрику, если вызывающий код должен собирать и настраивать представление. Оба подхода явно задают недостающее требование к созданию и сохраняют конкретный тип компонента, необходимый панели.
Источники
- Протоколы — «Язык программирования Swift», Swift.org.
- Обобщения — «Язык программирования Swift», Swift.org.
- Замыкания — «Язык программирования Swift», Swift.org.
- Непрозрачные и упакованные типы протоколов — «Язык программирования Swift», Swift.org.
- View — документация для разработчиков Apple.
- WindowGroup — документация для разработчиков Apple.
- ViewBuilder — документация для разработчиков Apple.
- ContentBuilder — документация для разработчиков Apple.
- Построители результата — Swift Evolution.
- Неявно открываемые экзистенциальные типы — Swift Evolution.