Макрос #Preview в языке Swift предоставляет удобный способ создания и предварительного просмотра компонентов пользовательского интерфейса. Он позволяет разработчикам быстро и легко создавать превью для своих View, чтобы визуально оценить, как они выглядят и взаимодействуют.

Сейчас доступно много информации о том, как писать макросы, много примеров и на удивление хорошая документация. Сегодня мы будем не создавать свой макрос, а подробно рассмотрим приватные макросы, предоставляемые 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 }
}

Из нее мы узнаем, что этот протокол необходим для поиска превью во время выполнения. Назначение полей fileIDline и 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")
}

Комментарии (3)


  1. gnatyuk_sergey
    27.06.2023 17:36
    +2

    Спасибо большое за статью!

    Очень интересная и круто, что актуальная для проектов со SwiftUI!

    Подскажи пожалуйста, как я могу использовать макросы в своем приложении, если минимальная таргет версия 14 iOS?


    1. BarredEwe Автор
      27.06.2023 17:36
      +2

      Привет ????

      Я это особо не затрагивал, но макрос #Preview работает и с UIKit:

      #Preview {
          let button = UIButton(type: .system)
          button.setTitle("UIKit", for: .normal)
          
          return button
      }

      На счет iOS 14, сам механизм макросов поддерживается на любой версии оси, главное использовать Xcode 15. Можно создать свой макрос или использовать сторонний. А вот макросы от Apple (SwiftData, Observable, Preview и тд) доступны только от iOS 17.

      Отмечу что ничего не мешает поставить таргет в Xcode - iOS 13 и использовать макрос #Preview. Так как код превью не используется для сборки, туда можно смело добавить #if available(iOS 17, *)


  1. Gargo
    27.06.2023 17:36

    допишите пожалуйста, что это все доступно только в iOS 17 Beta