Магия SwiftUI или о Function builders

в 17:38, , рубрики: dsl, ios development, iOS разработка, swift, swiftUI, Блог компании Tinkoff.ru, разработка мобильных приложений, разработка под iOS

Магия SwiftUI или о Function builders - 1

Вы пробовали добавить в VStack больше 10 вьюх?

var body: some View {
        VStack {
            Text("Placeholder1")
            Text("Placeholder2")
            // ... тут вьюшки с 3 по 10 . . .
            Text("Placeholder11")
        }
    }

Я попробовал — это не компилируется. Да, я тоже сначала был удивлен и погрузился в изучение форума Swift и гитхаба. Результатом моего изучения стало — "все равно не компилируется ¯_(ツ)_/¯ ". Но подождите, давайте разберемся почему.

Function Builder

Для начала стоит понять, как такой синтаксис стал вообще доступен. В основе столь непривычного нам декларативного создания элементов лежит механизм Function Builder.
На гитхабе в swift-evolution есть proposal от John McCall и Doug Gregor — Function builders (Proposal: SE-XXXX), в котором они подробно описывают о том, какая проблема перед ними стояла, почему было решено использовать именно Functions Builder и что это вообще такое.

Итак, что это?

Сложно описать это в двух словах, но если коротко — это механизм, который позволяет в теле кложуры перечислить аргументы, некое содержимое, и выдать из всего этого общий результат.
Цитата из Proposal: SE-XXXX:

Основная идея в том, что мы берем результат выражения, включая вложенные выражения вроде if и switch, и формируем их в один результат, который становится возвращаемым значением текущей функции. Эта "сборка" контролируется билдером функции, который является кастомным атрибутом.

Оригинал

the basic idea is that we take the «ignored» expression results of a block of statements — including in nested positions like the bodies ofifandswitchstatements — and build them into a single result value that becomes the return value of the current function. The way this collection is performed is controlled by afunction builder, which is just a new kind of custom-attribute type;

Такой механизм позволяет писать декларативный древовидный код, без лишних символов пунктуации:

let myBody = body {
  let chapter = spellOutChapter ? "Chapter" : ""
  div {
    if useChapterTitles {
      h1(chapter + "1. Loomings.")
    }
    p {
      "Call me Ishmael. Some years ago"
    }
    p {
      "There is now your insular city"
    }
  }
}

Доступно это благодаря новому атрибуту @_functionBuilder. Этим атрибутом помечается некоторый билдер, он может быть структурой. У этого билдера реализуется ряд конкретных методов. Далее этот билдер используется сам, в качестве пользовательского атрибута в различных ситуациях.
Чуть ниже я покажу как это работает и как организовать такой код.

Зачем это?

Таким образом Apple хотят сделать поддержу встроенного domain-specific language DSL.
John McCall и Doug Gregor главными аргументами приводят то, что такой код намного легче читать и писать — это упрощает синтаксис, делает его более лаконичным и, как следствие, код становится более поддерживаемым. При этом они отмечают, что их решение — это не универсальный DSL.
Это решение нацеленно на конкретный ряд проблем, в числе которых описывать линейные и древовидные структуры, такие как XML, JSON, иерархии View и т.д.

Как с этим работать?

Вы можете создать свой function builder, мне было легче понять принцип его работы именно так. Рассмотрим примитивный пример билдера, который конкатенирует строки.

// 1. Создаем Builder
@_functionBuilder struct MyBuilder {
    static func buildBlock(_ atrs: String...) -> String {
        return atrs.reduce("", + )
    }
}

// 2. Добавляем его атрибутом перед кложурой в каком либо методе
func stringsReduce(@MyBuilder block: () -> String) -> String {
    return block()
}

// 3. Используем в клиентском коде
let result = stringsReduce {
        "1"
        "2"
}

print(result) // "12"

Под капотом это будет отрабатывать так:

let result = stringsReduce {
      return MyBuilder.build("1", "2")
}

Важно, что в реализации билдера методы должны быть именно статическими, с конкретными именами и с конкретным видом параметров из этого списка. Можно лишь изменять тип и имя входного параметра.

static func buildBlock(_ <*atrs*>: <*String*>...) -> <*String*>

Именно конкретные имена методов будут искаться в билдере и подставляться на этапе компиляции. И если метод не будет найден — случится ошибка компиляции.
И это магия. Когда вы будете реализовывать билдер, компилятор не подскажет вам совершенно ничего. Не скажет о доступных методах, не поможет автокомплитом. Лишь когда вы напишете клиентский код, который не сможет обработаться этим билдером, вы получите невнятную ошибку.
Пока единственное решение, которое я нашел, это руководствоваться списком методов.
Так зачем нужны другие методы? Ну например, чтобы поддержать такой код c проверками

stringsReduce {
    if .random() { // рандомное значение Bool
       "one string"
    }
    else {
       "another one"
    }
   "fixed string"
}

Для поддержки такого синтаксиса в билдере нужно реализовать методы buildEither(first:/second:)

static func buildEither(first: String) -> String {
    return first
}

static func buildEither(second: String) -> String {
    return second
}

Реакция сообщества

Забавно то, что этого еще нет в Swift 5.1, то есть пулл-реквест c этой фичей еще не влит, но тем не менее Apple уже добавили ее в XCode 11 beta. А на Function builders → Pitches → Swift Forums можно посмотреть реакцию комьюнити на этот proposal.

ViewBuilder

Теперь вернемся к VStack и посмотрим документацию его инициализатора init(alignment:spacing:content:).
Выглядит он следующим образом:

init(alignment: HorizontalAlignment = .center, spacing: ? = nil, @ViewBuilder content: () -> Content)

И перед кложурой контент стоит пользовательский атрибут @ViewBuilder
Объявлен он следующим образом:

@_functionBuilder public struct ViewBuilder {

    /// Builds an empty view from an block containing no statements, `{ }`.
    public static func buildBlock() -> EmptyView

    /// Passes a single view written as a child view (e..g, `{ Text("Hello") }`) through
    /// unmodified.
    public static func buildBlock<Content>(_ content: Content) -> Content where Content : View
}

Пользовательским атрибутом его делает @_functionBuilder, прописанный в начале его объявления.

А если полистать документацию еще ниже, то там видно множество статических методов buildBlock, отличающихся количеством аргументов.

Это значит, что код вида

var body: some View {
      VStack {
            Text("Placeholder1")
            Text("Placeholder2")
            Text("Placeholder3")
        }
}

под капотом преобразуется в такой

  var body: some View {
    VStack(alignment: .leading) {
      ViewBuilder.buildBlock(Text("Placeholder1"), Text("Placeholder2"), Text("Placeholder3"))
    }
  }

Т.е. отрабатывает метод билдера buildBlock(::_:).

Из всего этого списка метод с максимальным количеством аргументов — это этот парень buildBlock(::::::::::) (10 аргументов):

extension ViewBuilder {
    public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View, C7 : View, C8 : View, C9 : View
}

И соответственно, возвращаясь к изначальному примеру, когда вы пытаетесь поднять VStack и одиннадцать вьюшек внутри, компилятор пытается найти метод ViewBuilder'a buildBlock, у которого 11 аргументов на входе. Но такого метода нет: отсюда и ошибка компиляции.
Это актуально для всех коллекций, использующих в инициализаторе кложуру с атрибутом @ViewBuilder: V|H|Z-Stack, List, Group и прочие, внутри которых вы можете объявить больше одной вьюшки перечислением.
И это грустно.

MEM (простите, так и не нашел достойного мема)

Как быть?

Мы можем обходить это ограничение используя ForEach

struct TestView : View {
    var body: some View {
        VStack {
            ForEach(texts) { i in
                Text(«(i)»)
            }
        }
    }

    var texts: [Int] {
        var result: [Int] = []
        for i in 0...150 {
            result.append(i)
        }
        return result
    }
}

Или же вложенностью коллекций:

var body: some View {
        VStack {
            VStack {
                Text("Placeholder_1")
                Text("Placeholder_2")
                // И Еще 8
            }
            Group {
                Text("11")
                Text("12")
                // И Еще 8
            }
        }
 }

Но такие решения выглядят как костыли и остается лишь надежда на светлое будущее. Но какое оно это будущее?
В Swift уже есть Variadic parameters. Это возможность метода принимать на вход аргументы перечислением. Например известный каждому метод print позволяет написать как print(1, 2), так и print(1, 2, 3, 4) и это без излишних перегрузок метода.

print(items: Any...)

Но этой фичи языка недостаточно, так как метод buildBlock принимает на вход разные generic аргументы.
Добавление Variadic generics решило бы эту проблему. Variadic generics позволяют абстрагироваться от множества generic типов, например как-то так:

 static func buildBlock<…Component>(Component...) -> TupleView<(Component...)> where Component: View

И Apple просто обязаны добавить это. В этот механизм сейчас все упирается. И мне кажется, что его просто не успели допилить к WWDC 2019 (но это лишь домыслы).

Автор: Алексей Зверев

Источник


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js