Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Перевод подготовлен в рамках набора на курс "iOS Developer. Professional".
Всех желающих приглашаем на открытый демо-урок «Machine Learning в iOS с помощью CoreML и CreateML: изображения, текст, звук». На занятии обсудим:
1. Основные архитектуры нейронных сетей и их оптимизированные версии под мобильные устройства;
2. Возможности CoreML 3 и 4, обучение на iOS устройстве;
3. Самостоятельное обучение классификатора изображений с помощью CreateML и использование его с Vision;
4. Использование обученных моделей для работы с текстом и звуком в iOS.
Построители результатов (result builders) в Swift позволяют получать результирующее значение из последовательности компонентов — выставленных друг за другом «строительных блоков». Они появились в Swift 5.4 и доступны в Xcode 12.5 и более поздних версиях. Ранее эти средства были известны как function builders («построители функций»). Вам, вероятно, уже приходилось использовать их при создании стеков представлений в SwiftUI.
Должен признаться: поначалу я думал, что это некая узкоспециализированная возможность Swift, которую я никогда не стану применять для организации своего кода. Однако стоило мне в ней разобраться и написать небольшое решение для создания ограничений представления в UIKit, как я обнаружил, что раньше просто не понимал всю мощь построителей результатов.
Что такое построители результатов?
Построитель результата можно рассматривать как встроенный предметно-ориентированный язык (DSL), описывающий объединение неких частей в окончательный результат. В простых объявлениях представлений SwiftUI за кадром используется атрибут @ViewBuilder
, который представляет собой реализацию построителя результата:
struct ContentView: View {
var body: some View {
// This is inside a result builder
VStack {
Text("Hello World!") // VStack and Text are 'build blocks'
}
}
}
Все дочерние представления (в данном случае VStack, содержащий Text
) будут объединены в одно представление View
. Другими словами, «строительные блоки» View
встраиваются в «результат» View
. Это важно понять, поскольку именно так работают построители результатов.
Если рассмотреть объявление протокола View
в SwiftUI, можно заметить, что переменная body
определяется с использованием атрибута @ViewBuilder
:
@ViewBuilder var body: Self.Body { get }
Именно так можно использовать собственный построитель результата в качестве атрибута функции, переменной или сабскрипта.
Создание собственного построителя результата
Способ определения кастомного построителя результата я покажу на примере, который использовал сам. При разработке авторазметки посредством кода я обычно реализую логику следующего вида:
var constraints: [NSLayoutConstraint] = [
// Single constraint
swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor)
]
// Boolean check
if alignLogoTop {
constraints.append(swiftLeeLogo.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor))
} else {
constraints.append(swiftLeeLogo.centerYAnchor.constraint(equalTo: view.centerYAnchor))
}
// Unwrap an optional
if let fixedLogoSize = fixedLogoSize {
constraints.append(contentsOf: [
swiftLeeLogo.widthAnchor.constraint(equalToConstant: fixedLogoSize.width),
swiftLeeLogo.heightAnchor.constraint(equalToConstant: fixedLogoSize.height)
])
}
// Add a collection of constraints
constraints.append(contentsOf: label.constraintsForAnchoringTo(boundsOf: view)) // Returns an array
// Activate
NSLayoutConstraint.activate(constraints)
Как видите, здесь довольно много условных ограничений. В сложных представлениях прочитать их все может быть весьма непросто.
В этом случае построители результатов — это отличное решение. Они позволяют переписать приведенный выше пример кода следующим образом:
@AutolayoutBuilder var constraints: [NSLayoutConstraint] {
swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor) // Single constraint
if alignLogoTop {
swiftLeeLogo.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
} else {
swiftLeeLogo.centerYAnchor.constraint(equalTo: view.centerYAnchor) // Single constraint
}
if let fixedLogoSize = fixedLogoSize {
swiftLeeLogo.widthAnchor.constraint(equalToConstant: fixedLogoSize.width)
swiftLeeLogo.heightAnchor.constraint(equalToConstant: fixedLogoSize.height)
}
label.constraintsForAnchoringTo(boundsOf: view) // Returns an array
}
Здорово, не правда ли?
Итак, рассмотрим способ создания такого решения.
Определение построителя для авторазметки
Начинаем с определения собственной структуры AutolayoutBuilder
и добавляем атрибут @resultBuilder
, чтобы пометить ее как построитель результата:
@resultBuilder
struct AutolayoutBuilder {
// .. Handle different cases, like unwrapping and collections
}
Чтобы объединить все «строительные блоки» и получить результат, нам нужно настроить обработчики для каждого случая, в частности для обработки опционалов и коллекций. Но для начала реализуем обработку случая с единственным ограничением.
Это делается с помощью следующего метода:
@resultBuilder
struct AutolayoutBuilder {
static func buildBlock(_ components: NSLayoutConstraint...) -> [NSLayoutConstraint] {
return components
}
}
Этот метод принимает на вход вариативный параметр components (то есть параметр с переменным числом возможных значений). Это означает, что может существовать одно или несколько ограничений. Нам нужно вернуть коллекцию ограничений, то есть в этом случае мы можем напрямую вернуть входные компоненты.
Теперь мы можем определить коллекцию ограничений следующим образом:
@AutolayoutBuilder var constraints: [NSLayoutConstraint] {
// Single constraint
swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor)
}
Обработка коллекции «строительных блоков»
Следующим шагом будет обработка коллекции элементов как одного элемента. В первом примере кода мы использовали удобный метод constraintsForAnchoringTo(boundsOf:)
, который возвращает множество ограничений в виде коллекции. Если бы мы применили его в этом случае, мы получили бы следующую ошибку:
Описание ошибки отлично объясняет происходящее:
Cannot pass array of type ‘[NSLayoutConstraint]’ as variadic arguments of type ‘NSLayoutConstraint’ — Невозможно передать массив типа «[NSLayoutConstraint]» как вариативные аргументы типа «NSLayoutConstraint»
Как ни странно, в Swift нельзя передавать массив в качестве вариативных параметров. Вместо этого нужно определить собственный метод для обработки коллекции в качестве входных компонентов. На первый взгляд может показаться, что нам нужен следующий доступный метод:
К сожалению, как указано в описании метода, он обеспечивает поддержку только циклов, которые объединяют несколько результатов в один. Мы здесь используем не итератор, а удобный метод для прямого возврата коллекции, поэтому нам потребуется написать еще немного собственного кода.
Можно решить эту проблему, определив новый протокол, который реализуется как с использованием одного NSLayoutConstraint
, так и с использованием коллекции ограничений:
protocol LayoutGroup {
var constraints: [NSLayoutConstraint] { get }
}
extension NSLayoutConstraint: LayoutGroup {
var constraints: [NSLayoutConstraint] { [self] }
}
extension Array: LayoutGroup where Element == NSLayoutConstraint {
var constraints: [NSLayoutConstraint] { self }
}
Этот протокол позволит нам преобразовывать как отдельные ограничения, так и коллекцию ограничений в массив ограничений. Другими словами, мы можем объединить оба типа в один — [NSLayoutConstraint
].
Теперь мы можем переписать наш построитель результата так, чтобы он принимал наш протокол LayoutGroup
:
@resultBuilder
struct AutolayoutBuilder {
static func buildBlock(_ components: LayoutGroup...) -> [NSLayoutConstraint] {
return components.flatMap { $0.constraints }
}
}
Для получения единой коллекции ограничений здесь используется метод flatMap
. Если вы не знаете, для чего нужен метод flatMap
или почему мы использовали его вместо compactMap
, почитайте мою статью Методы compactMap и flatMap:в чем разница?
Наконец, мы можем обновить наше решение, чтобы задействовать новый обработчик коллекции «строительных блоков»:
@AutolayoutBuilder var constraints: [NSLayoutConstraint] {
// Single constraint
swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor)
label.constraintsForAnchoringTo(boundsOf: view) // Returns an array
}
Разворачивание опционалов
Другой случай, который необходимо рассмотреть, — это разворачивание опционалов. Этот механизм позволит добавлять ограничения, если существует определенное значение.
Добавим метод buildOptional(..)
к нашему построителю результата:
@resultBuilder
struct AutolayoutBuilder {
static func buildBlock(_ components: LayoutGroup...) -> [NSLayoutConstraint] {
return components.flatMap { $0.constraints }
}
static func buildOptional(_ component: [LayoutGroup]?) -> [NSLayoutConstraint] {
return component?.flatMap { $0.constraints } ?? []
}
}
Метод пытается преобразовать результат в коллекцию ограничений или возвращает пустую коллекцию, если данного значения не существует.
Теперь мы можем развернуть опционал в нашем определении «строительных блоков»:
@AutolayoutBuilder var constraints: [NSLayoutConstraint] {
// Single constraint
swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor)
label.constraintsForAnchoringTo(boundsOf: view) // Returns an array
// Unwrapping an optional
if let fixedLogoSize = fixedLogoSize {
swiftLeeLogo.widthAnchor.constraint(equalToConstant: fixedLogoSize.width)
swiftLeeLogo.heightAnchor.constraint(equalToConstant: fixedLogoSize.height)
}
}
Обработка условных операторов
Еще один распространенный случай — условные операторы. В зависимости от логического значения может потребоваться добавить то или иное ограничение. Этот обработчик может обрабатывать первый или второй компонент в проверке условия:
@AutolayoutBuilder var constraints: [NSLayoutConstraint] {
// Single constraint
swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor)
label.constraintsForAnchoringTo(boundsOf: view) // Returns an array
// Unwrapping an optional
if let fixedLogoSize = fixedLogoSize {
swiftLeeLogo.widthAnchor.constraint(equalToConstant: fixedLogoSize.width)
swiftLeeLogo.heightAnchor.constraint(equalToConstant: fixedLogoSize.height)
}
// Conditional check
if alignLogoTop {
// Handle either the first component:
swiftLeeLogo.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
} else {
// Or the second component:
swiftLeeLogo.centerYAnchor.constraint(equalTo: view.centerYAnchor)
}
}
В наш построитель результата надо добавить еще пару обработчиков «строительных блоков»:
@resultBuilder
struct AutolayoutBuilder {
static func buildBlock(_ components: LayoutGroup...) -> [NSLayoutConstraint] {
return components.flatMap { $0.constraints }
}
static func buildOptional(_ component: [LayoutGroup]?) -> [NSLayoutConstraint] {
return component?.flatMap { $0.constraints } ?? []
}
static func buildEither(first component: [LayoutGroup]) -> [NSLayoutConstraint] {
return component.flatMap { $0.constraints }
}
static func buildEither(second component: [LayoutGroup]) -> [NSLayoutConstraint] {
return component.flatMap { $0.constraints }
}
}
В обоих обработчиках buildEither
для получения ограничений и их возвращения в виде плоской структуры используется все тот же протокол LayoutGroup
.
Это были последние два обработчика, необходимые для работы нашего примера. Ура!
Однако мы еще не закончили. Мы можем немного усовершенствовать этот код, используя построители результатов внутри функций.
Использование построителей результатов в качестве параметров функций
Отличный способ использовать построитель результата — определить его как параметр функции. Так мы действительно получим пользу от нашего кастомного AutolayoutBuilder
.
Например, можно добавить такое расширение к NSLayoutConstraint
, чтобы немного упростить активацию ограничений:
extension NSLayoutConstraint {
/// Activate the layouts defined in the result builder parameter `constraints`.
static func activate(@AutolayoutBuilder constraints: () -> [NSLayoutConstraint]) {
activate(constraints())
}
Применяться расширение будет вот так:
NSLayoutConstraint.activate {
// Single constraint
swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor)
label.constraintsForAnchoringTo(boundsOf: view) // Returns an array
// Unwrapping an optional
if let fixedLogoSize = fixedLogoSize {
swiftLeeLogo.widthAnchor.constraint(equalToConstant: fixedLogoSize.width)
swiftLeeLogo.heightAnchor.constraint(equalToConstant: fixedLogoSize.height)
}
// Conditional check
if alignLogoTop {
// Handle either the first component:
swiftLeeLogo.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
} else {
// Or the second component:
swiftLeeLogo.centerYAnchor.constraint(equalTo: view.centerYAnchor)
}
}
Теперь, реализовав данный метод, мы также можем создать удобный метод в UIView
для непосредственного добавления субпредставления с ограничениями:
protocol SubviewContaining { }
extension UIView: SubviewContaining { }
extension SubviewContaining where Self == UIView {
/// Add a child subview and directly activate the given constraints.
func addSubview<View: UIView>(_ view: View, @AutolayoutBuilder constraints: (Self, View) -> [NSLayoutConstraint]) {
addSubview(view)
NSLayoutConstraint.activate(constraints(self, view))
}
}
Это можно использовать следующим образом:
let containerView = UIView()
containerView.addSubview(label) { containerView, label in
if label.numberOfLines == 1 {
// Conditional constraints
}
// Or just use an array:
label.constraintsForAnchoringTo(boundsOf: containerView)
}
Поскольку мы используем дженерики, мы можем выполнять проверку условий на основе входного типа UIView
. В этом случае можно добавить различные ограничения, если метка label
будет содержать только одну строку текста.
Как разработать собственное решение с построителем результата?
Вы, наверняка, думаете: как же определить, будет ли построитель результата полезен в том или ином фрагменте кода?
Каждый раз, когда вам встречается фрагмент кода, состоящий из нескольких условных элементов и преобразуемый в одиночный элемент возвращаемого типа, вы можете задуматься о написании построителя результата. Однако делать это стоит лишь в случае, если вы уверены, что вам придется писать этот фрагмент часто.
Когда мы пишем ограничения для разработки авторазметки посредством кода, мы повторяем одни и те же инструкции по нескольку раз, поэтому в данном случае стоит написать кастомный построитель результата. Сами ограничения тоже состоят из множества «строительных блоков», если рассматривать каждую коллекцию ограничений (одиночную или нет) как отдельный такой блок.
Наконец, я хотел бы сослаться на репозиторий с примерами построителей функций (которые теперь называются построителями результатов).
Заключение
Построители результатов — это очень мощное дополнение к Swift 5.4. Они позволяют писать код на собственном предметно-ориентированном языке, за счет чего можно усовершенствовать свой подход к написанию кода. Я надеюсь, что эта статья немного прояснит для вас понятие кастомных построителей результатов, которые помогают упрощать код на организационном уровне.
Узнать подробнее о курсе "iOS Developer. Professional"
Смотреть вебинар «Machine Learning в iOS с помощью CoreML и CreateML: изображения, текст, звук»