Макрос #Preview
в языке Swift предоставляет удобный способ создания и предварительного просмотра компонентов пользовательского интерфейса. Он позволяет разработчикам быстро и легко создавать превью для своих View, чтобы визуально оценить, как они выглядят и взаимодействуют.
Механизм макросов доступен на любой версии iOS, главное использовать Xcode 15.
Сейчас доступно много информации о том, как писать макросы, много примеров и на удивление хорошая документация. Сегодня мы будем не создавать свой макрос, а подробно рассмотрим приватные макросы, предоставляемые Apple, а именно #Preview
.
Как #Preview работает?
В целом это совершенно обычный макрос, но мы не знаем что у него внутри. Пока давайте добавим превью, используя новый макрос:
#Preview {
Text("Simple text")
}
Превью уже работает, и мы можем увидеть наше представление. Однако нас интересует совсем другое — результат работы нового макроса #Preview
, а именно генерируемый код.
Что генерирует макрос #Preview?
Для получения полного результата выполнения макроса, включая новый сгенерированный код, требуется выполнить компиляцию исходного кода. После успешной компиляции будет создан расширенный код, который фактически и будет выполняться после этапа сборки приложения.
Для просмотра сгенерированного кода можно использовать Xcode, нажав на Expand Macro:
А для понимания, где генерируемый код располагается, мы можем воспользоваться командой swiftc:
swiftc TextPreviewView.swift
Не мог не отметить, что есть ещё вариант просмотра результата работы макроса с помощью тестов
При запуске команды мы получим сгенерированный файл @__swiftmacro_04TestA4View33_DC5l7PreviewfMf0
по пути /var/folders/zd/gc5dlw40000gq/T/swift-generated-sources/
.
Открыв файл, мы увидим следующий код и сможем изучить результаты работы макроса (Xcode 15 Beta 2):
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
struct $s04TestA4View33_DC5l7PreviewfMf0_15PreviewRegistryfMu_: PreviewRegistry {
static var fileID: String {
"TextPreviewView.swift"
}
static var line: Int {
17
}
static var column: Int {
1
}
static var preview: Preview {
Preview {
Text("Simple text")
}
}
}
#if os(xrOS)
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
@objc final class $s04TestA4View33_DC5l7PreviewfMf0_17UVPreviewRegistryfMu_: UVPreviewRegistry {
override var fileID: String {
"TextPreviewView.swift"
}
override var line: Int {
17
}
override var column: Int {
1
}
override var preview: Preview {
Preview {
Text("Simple text")
}
}
}
#endif
// original-source-range: TextPreviewView.swift:17:1-19:2
Немного подробнее о сгенерированном коде
Код получился довольно не большим, но давайте добавим разъяснения того, что в нём и зачем.
Обратим внимание на два, практически одинаковых, объекта:
struct $s04TestA4View3...
и class $s04TestA4View3…
Интересно что class
доступен только для xrOS(VisionOS). Тяжело предположить зачем Apple сделала структуру и класс с одинаковыми параметрами, а не использовала просто структуру. Может быть class
с пометкой @objc
используется для поиска Preview в runtime, но зачем тогда пракически идентичная структура - вопрос открытый.
Начнем с протокола, которому соответствует struct $s04TestA4View3...
.
PreviewRegistry
Это протокол PreviewRegistry
, и мы можем просто посмотреть его документацию:
/// Registry protocol used to locate previews at runtime. Types conforming to this protocol are
/// generated for you by the expansion of the `#Preview` macros.
///
/// - Note: Previews should always be created using the `#Preview` macro syntax.
/// Behavior for preview registries defined directly is undefined.
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public protocol PreviewRegistry {
static var fileID: String { get }
static var line: Int { get }
static var column: Int { get }
static var preview: Preview { get }
}
Из нее мы узнаем, что этот протокол необходим для поиска превью во время выполнения. Назначение полей fileID, line и column достаточно сложно предположить. Возможно, они нужны для определения конкретного View для превью, если их несколько в одном файле.
Preview
Интересно, что в PreviewRegistry
у нас есть static var preview: Preview
и в частности тип Preview
. Информации о нём достаточно мало:
/// Base type for creating previews.
///
/// Extensions in SwiftUI, UIKit, AppKit, and WidgetKit provide
/// subject-matter-specific initializers.
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public struct Preview { }
Так как понять, что из себя представляет эта структура, мы пока не можем, то попробуем извлечь немного больше информации, используя инструмент для просмотра деталей объекта Mirror:
// Получение наименования переменных внутри стркутуры `Preview`
Mirror(reflecting: previewRegistry.preview).children.map(\.label)
Сопоставив данные из Mirror
, я смог получить следующую структуру:
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public struct Preview {
var displayName: String?
var source: ViewPreviewSource
var fileID: String
var line: Int
var traits: [PreviewTrait]
}
Теперь с этим можно работать. Давайте рассмортим части Preview
подробнее.
ViewPreviewSource
Еще из интересного: в Preview
есть переменная source: ViewPreviewSource
, которая на самом деле является просто обёрткой для нашего представления. ViewPreviewSource
используется как контейнер, в котором может лежать как SwiftUI View
, так и UIKit UIView
или UIViewController
. Благодаря этому механизму достигается поддержка работы Preview
с UIKit, чего раньше превью не поддерживали без использования дополнительных обёрток. Таким образом, приложение может эксплуатировать возможности обоих фреймворков и позволяет без проблем создавать превью интерфейсов без необходимости вводить дополнительные абстракции или преобразования. Снова обратившись к Mirror
, получаем примерный вид ViewPreviewSource
структуры:
struct ViewPreviewSource<T> {
var makeView: () -> T
}
Зачем вообще углубляться?
Понимание того, как Apple использует свои же нововведения, очень сильно помогает в работе над собственными решениями. Например, этот разбор я проводил для проекта:
Он берёт реализацию превью от Apple и генерирует на их основе snapshot-тесты и демо приложение. Подробнее о Prefire можно узнать из доклада:
Что с этим можно сделать?
Исходя из того что я нашёл выше, можно использовать генерируемый макросом код, а именно протокол PreviewRegistry
, для поиска превью во всём приложении — и при нахождении превью, которое реализует протокол, генерировать тесты.
Например, с помощью Sourcery мы можем найти все PreviewRegistry
и получить нужные нам превью. Для Sourcery нужно будет создать специальный Stencil-шаблон (как это делать, я рассказал в видео выше). Вот так это может выглядеть (псевдокод):
extension Preview {
/// Получение `View`, из `Preview`
func makeView() -> AnyView {
// Получение `ViewPreviewSource` c помощью `Mirror`
let source = Mirror(reflecting: self).children.first(where: { $0.label == "source" })?.value
// Получение `View` c помощью `Mirror`
let viewBuilder = source.flatMap {
Mirror(reflecting: $0).children.first?.value as? () -> (any View)
}
return viewBuilder.flatMap(AnyView.init)
}
}
// Массив всех `Preview`, заполненный с помощью Sourcery
var allPreviews: [Preview] = []
for preview in allPreviews {
// Вызов библиотеки для snapshot- тестов
snapshotTestsing(preview.makeView())
}
Что в итоге?
Таким образом, мы узнали немного больше о том, что происходит при работе макроса #Preview
.
Можно сделать вывод о том, что макросы существенно упрощают жизнь разработчика и Apple уже предлагает нам свои решения на основе макросов.
В результате вместо громоздкого решения мы получаем более компактное и простое:
Старое решение:
struct Text_Preview: PreviewProvider {
static var previews: some View {
Text("Simple text")
}
}
Новое решение:
#Preview {
Text("Simple text")
}