Построители результатов в Swift: описание и примеры кода

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру 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: изображения, текст, звук»

Источник: https://habr.com/ru/company/otus/blog/555848/


Интересные статьи

Интересные статьи

Вместе с моим коллегой Евгением мы потратили много времени. Приложение обрабатывает тысячи запросов в асинхронном конвейере, полном async/await. Во время нашего исследова...
Мы уверены, что открытый код — одна из основ быстрого развития технологий. Иногда такие решения становятся бизнесом, но важно, что труд энтузиастов и код, который находится в их основе, могут исп...
Компании переполнили рынок товаров и услуг предложениями. Разнообразие наблюдается не только в офлайне, но и в интернете. Достаточно вбить в поисковик любой запрос, чтобы получить подтверждение насыще...
Есть статьи о недостатках Битрикса, которые написаны программистами. Недостатки, описанные в них рядовому пользователю безразличны, ведь он не собирается ничего программировать.
Сегодня мы публикуем вторую часть материала о написании чистого кода при разработке React-приложений. Вот ещё несколько полезных советов. → Читать первую часть