company_banner

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


    Вы пробовали добавить в 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 (но это лишь домыслы).

    Tinkoff
    IT’s Tinkoff — просто о сложном

    Комментарии 15

      0

      Очень близкий механизм использует typescript для поддержки tsx синтаксиса. Только там кроме билдеров нужно еще и информацию о типах нод объявлять для автокомплита внутри tsx контекста. Странно что этот proposal нигде не упоминает ее. Обычно в swift evaluation везде указывают существующие решения в других языках/платформах.


      На мой вкус реализация в typescript чуть функциональнее для пользователя API. Если кому интересно, то я писал о ней на хабре.

        –2
        В Kotlin это было давно
          +2
          Ностальжи.
          Мне эти танцы с бубнами напомнили внедрение HTML 5 в web.
          и первые пробы WPF и его XAML разметки.
          Всё новое это хорошо забытое старое, не волнуйтесь, года через 3 пройдет и этот UI все детские ошибки и станет юзабелен с точки зрения логики в том числе. А через 5 либо умрет либо станет стандартом.
          Ирония в том что через 3 года появится какой-нибудь новый язык для разметки UI для iOS и тысячи людей будут снова проходить всё это.
          У Flutter от гугла сейчас такие же истории встречаю.
          Я например очень рад что MS вдохнули вторую жизнь в этом году в WPF/UWP и сторонние фреймворки (вроде авалонии) использующие XAML разметку. Уж больно не охота изучать каждые 3-4 года новые сырые способы разметки, когда старый добрый XAML на порядки сильнее, да и ты в нём профи. Но наличие конкуренции это стимул, это важно. Иначе выйдет как с html 5 в котором вроде бы как есть верстка на Grid и всеми поддерживается, но ее никто не использует, «верстаем на div с 2003-го»
            0
            WPF и XAML наверное одни из гибких подходов к реализации интерфейсов
            Чего стоят такие пример как кубы с формами и все это можно встраивать друг в друга
              0
              А что случилось в этом году? Как вдохнули жизнь?
                +1
                — Выкладывают все в OpenSource, та же авалония сможет эти коды использовать, ну и лично мне, например, тоже частенько нужны были возможности залезть в код ScrollViewer какого-нибудь, чтобы чуть-чуть поменять логику.
                — XAML islands — мосты между UWP и WPF/winform проектами позволяющие использовать компоненты одной платформы в другой.
                — анонсированное объединение всех фреймворков в .net 5 который полностью кросплатформ.
                Ну и по мелочи всяческими плюшками присыпали это все сверху.
                  0

                  А, ну про вторую жизнь имхо громко сказано. Это все здорово, конечно, но сомневаюсь что приведёт к росту популярности uwp/wpf/winforms.

              –2

              Я не знаю swift, но такое чувство, что еще чуть-чуть и они заново изобретут С++11 :D

                –2

                Ага, и QML заодно.

                  –1
                  Меньше конкурентов, выше зарплата.
                  0

                  Подождите-ка, а зачем там variadic generics?
                  Билдер же по сути должен принять массив UIView, апкаст же неявен насколько я помню.

                    +1

                    Текущий ViewBuilder принимает на вход generic аргументы, с одним ограничением: каждый из них должен реализовывать протокол View.


                    static func buildBlock<C0, C1, C2>(_ c0: C0, _ c1: C1, _ c2: C2) -> TupleView<(C0, C1, C2)> where C0 : View, C1 : View, C2 : View

                    При этом аргументы могут быть совершенно разного типа. Например в VSack мы можем положить TextField, Toggle и что-нибудь еще вместе. И я думаю под капотом недостаточно знать, что аргумент под протоколом View — важно знать конкретный тип каждого аргумента.

                      0

                      Так TextField и Toogle как раз таки это View в общем-то.
                      Тут разве что есть специфичная обработка для каких-то типов, но это можно и в рантайме сделать тогда(хотя не припомню у стека ничего такого).


                      В общем, для меня такой билдер выглядит странно.

                        0
                        Дело не в ViewBuilder, а в протоколе View, который имеет один ассоциированный тип для body, а значит его нельзя положить в коллекцию без заворачивания в AnyView. Вместо коллекции используется кортеж завернутый в TupleView. Кортежи не поддерживают переменное количество аргументов, отсюда необходимость набора методов buildBlock() с определенным количеством дженерик аргументов.
                        Остается вопрос — зачем вообще в View ассоциированный тип? Официального ответа от Apple пока нет, но вот тут обсуждались некоторые возможные причины, среди которых главная — оптимизация производительности.
                    0

                    ---мимо---

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое