CGLayout — новая система автоматического layout'а в iOS

    Привет Хабр!
    Хочу представить мою последнюю open-source разработку — CGLayout — вторая система разметки в iOS после Autolayout, основанная на ограничениях.



    "Очередная система автолайаута… Зачем? Для чего?" — наверняка подумали вы.
    Действительно iOS сообществом создано уже немало layout-библиотек, но ни одна так и не стала по-настоящему массовой альтернативой ручному layout`у, не говоря уже про Autolayout.


    CGLayout работает с абстрактными сущностями, что позволяет одновременно использовать UIView, CALayer и not rendered объекты для построения разметки. Также имеет единое координатное пространство, что позволяет строить зависимости между элементами, находящимися на разных уровнях иерархии. Умеет работать в background потоке, легко кешируется, легко расширяется и многое-многое другое.


    CGLayout функциональный продукт, у которого есть хорошие перспективы развиться в большой проект.


    Но изначально цель была банальна, как и обычно, просто упростить себе жизнь.
    Всем иногда приходится писать ручной layout, либо из-за плохой производительности Autolayout, либо из-за сложной логики. Поэтому постоянно писались какие-то расширения (а-ля setFrameThatFits и т.д.)


    В процессе реализации таких расширений возникают идеи более сложного порядка, но из-за отсутствия времени, как обычно, это все остается в рамках записи в Trello и висит там вечность.
    Но когда ты наконец добрался до реализации и не видно горизонта, тебя затягивает, и остановиться уже нереально. Все-таки я надеюсь время было потрачено не зря и мое решение облегчит кому-то жизнь, если не функциональностью фреймворка, то примером кода.


    Помимо рассказа о своем решении, я попробую проанализировать и сравнить другие фреймворки, поэтому думаю скучно не будет.


    Сравнение


    Функциональность


    Требования FlexLayout ASDK (Texture) LayoutKit Autolayout CGLayout
    Производительность + + + - +
    Кешируемость + + + +- +
    Мультипоточность - + + - +
    Cross-hierarchy layout - - - + +
    Поддержка CALayer и 'not rendered' вью - + - - +
    Расширяемость - + + - +
    Тестируемость + + + + +
    Декларативный + + + + +

    Какие-то показатели могут быть субъективны, т.к. я не использовал эти фреймворки в production. Если я ошибся, поправьте меня пожалуйста.


    Производительность


    Для тестирования использовался LayoutFrameworkBenchmark.



    AsyncDisplayKit не добавлен в график в силу того, что он не был включен разработчиком бенчмарка, да и ASDK осуществляет layout в бэкграунде, что не совсем честно для измерений производительности. В качестве альтернативы можно посмотреть приложение Pinterest. Производительность там действительно впечатляющая.


    Анализ


    Про многие фреймворки уже есть много информации, я лишь поделюсь своим мнением. Я буду говорить в основном о негативных моментах, потому что они бросаются в глаза сразу, а плюсы описаны на страницах фреймворков.


    LayoutKit


    Github 25 issues


    Не очень гибкий, требует написания большого количества кода, и достаточно большого включения программиста в реализацию. Не нашел информации об использовании LayoutKit'а в каких-то других приложениях, кроме самого LinkedIn.
    И все-таки есть мнение, что LayoutKit не достиг своей цели, лента в приложении LinkedIn все равно тормозит.


    Особенности:


    • Под каждую нестандартную разметку необходимо создавать субкласс с реализацией этого layout'а.
    • Ядро системы занимается созданием вью и их переиспользованием. Все вью крутятся в кеше, что может сломать ссылочную целостность при использовании внешней ссылки на вью. Да и в принципе вью недоступны через публичный интерфейс.
    • Layout блоки (такие как LabelLayout) дублируют много информации о представлении. При их инициализации нужно задавать дефолтные значения (текста и т.д)
    • Нет гибкости в создании относительных зависимостей между элементами.

    FlexLayout (aka YogaKit)


    Github 65 issues
    AppSight


    Предоставляет возможность заниматься только layout'ом. Никаких других плюшек, фишек нет.
    Особенности:


    • Нет возможности закешировать layout. Можно получить только размер.
    • Не нативная реализация. API больше подходящее для кроссплатформерных разработчиков, веб-разработчиков.
    • Проблемы с изменением размера экрана — приходится вручную изменять размер layout-объекта и делать пересчет.
    • Требует создания лишних вью при организации layout-блоков.

    AsyncDisplayKit (Texture)


    Github 200 issues
    AppSight


    В Facebook пошли путем создания потокобезопасной абстракции более высокого уровня. Что вызвало необходимость реализовывать весь стек инструментов UIKit. Тяжелая библиотека, если вы решили ее использовать, отказаться потом от нее будет невозможно. Но все-таки это пока самое грамотное и развитое open-source решение.
    Особенности:


    • Не совместим с другими layout-фреймворками, кроме встроенного Yoga.
    • Требует использования ASDisplayNode субклассы. По сути исключая работу с UIKit. Решив работать с ASDK можно забыть про другие средства, привыкайте писать вместо UI…, AS… .
    • Собственная реализация большого количества UIKit механизмов, что может привести к устареванию кода и больших расхождений с реализацией от Apple, багам не связанным с релизами Apple.
    • Дорогая поддержка.
    • Мало информации связанных с решением проблем. Поиск на StackOverflow со строкой "AsyncDisplayKit" находит 181 совпадений (для сравнения “UIKit” — 66,550).

    CGLayout


    • Использование как на уровне UIView, так и на уровне CALayer. Можно комбинировать и ограничивать вью по положению layer'а и наоборот. Возможность использовать not rendered объекты.
    • Переиспользование layout-спецификации для разных объектов.
    • Возможность создания cross-hierarchy зависимостей.
    • Строгая типизация.
    • Кеширование разметки через получение snapshot'ов.
    • Создание кастомных ограничений независимых от окружения. Например ограничение по размеру строки для лейбла.
    • Поддержка layout guides и легкое создание плейсхолдеров для вью.
    • Поддерживает любой доступный layout (прямой, background, кэшированный).
    • Простая интеграция с UIKit.
    • Легко расширяемая.

    Текущие ограничения:


    • Программисту необходимо думать о последовательности при определении layout-схемы и применении ограничений.
    • Расчет разметки UIView, используя layer свойство ведет к неопределенному поведению, так как frame у UIView изменяется неявно и побочные действия (такие как drawRect, layoutSubviews) не вызываются. При этом layer UIView спокойно можно использовать как ограничение для другого layer'а.
    • В случае получения snapshot для фрейма, отличающегося от текущего значения bounds в супервью и наличии ограничений, основанных на супервью, может приводить к неожидаемому результату.
    • Пока не очень приспособлен к сложному layout'у с вероятностными ограничениями.
    • Расчет разметки с ограничениями между UIView и CALayer медленный из-за необходимости конвертировать координаты с моей реализацией.

    Что пока не реализовано:


    1. Поддержка RTL.
    2. Поведение при удалении вью из иерархии.
    3. Поддержка macOS, tvOS.
    4. Поддержка trait-коллекций.
    5. Нет удобной конструкций для выполнения разметки переиспользуемых вью.
    6. Динамическое изменение текущей layout-конфигурации.

    Реализация CGLayout


    CGLayout построен на современных принципах языка Swift.
    Реализация управления разметкой в CGLayout базируется на трех базовых протоколах: RectBasedLayout, RectBasedConstraint, LayoutItem.


    Термины

    Все сущности имплементирующие LayoutItem я буду называть layout-элементами, все остальные сущности просто layout-сущностями.


    Условные обозначения

    Основная разметка


    public protocol RectBasedLayout {
        func layout(rect: inout CGRect, in source: CGRect)
    }

    RectBasedLayout — декларирует поведение для изменения разметки и определяет для этого один метод, с возможностью ориентирования относительно доступного пространства.


    Структура Layout, имплементирующая протокол RectBasedLayout, определяет полную и достаточную разметку для layout-элемента, т.е. позиционирование и размеры.
    Соответственно, Layout разделяется на два элемента выравнивание Layout.Alignment и заполнение Layout.Filling. Они в свою очередь состоят из горизонтального и вертикального лайаута. Все составные элементы реализуют RectBasedLayout. Что позволяет использовать элементы лайаута разного уровня сложности для реализации разметки. Все лайаут сущности легко могут быть расширены вашими имплементациями.


    Составная схема структуры Layout:

    Ограничения


    Все ограничения реализуют RectBasedConstraint. Если сущности RectBasedLayout определяют разметку в доступном пространстве, то сущности RectBasedConstraint это доступное пространство определяют.


    public protocol RectBasedConstraint {
        func constrain(sourceRect: inout CGRect, by rect: CGRect)
    }

    LayoutAnchor содержит конкретные ограничители (сторона, размер и т.д.), имеющие абстрагированное от окружения поведение.
    В данный момент реализованы основные ограничители.


    Составная схема структуры LayoutAnchor:

    Layout constraints


    public protocol LayoutConstraintProtocol: RectBasedConstraint {
        var isIndependent: Bool { get }
        func layoutItem(is object: AnyObject) -> Bool
        func constrainRect(for currentSpace: CGRect, in coordinateSpace: LayoutItem) -> CGRect
    }

    Определяют зависимость от layout-элемента или контента (текст, картинка и т.д.). Являются самодостаточными ограничениями, которые содержат всю информацию об источнике ограничения и применяемых ограничителях.
    LayoutConstraint — ограничение, связанное с layout-элементом с определенным набором ограничителей.
    AdjustLayoutConstraint — ограничение, связанное с layout-элементом, содержит size-based ограничители. Доступен для layout-элементов, поддерживающих AdjustableLayoutItem протокол.


    Layout-элементы


    public protocol LayoutItem: class, LayoutCoordinateSpace {
        var frame: CGRect { get set }
        var bounds: CGRect { get set }
        weak var superItem: LayoutItem? { get }
    }

    Его реализуют такие классы как UIView, CALayer, а также not rendered классы. Также вы можете реализовать другие классы, например stack view.


    Во фреймворке есть реализация LayoutGuide. Это аналог UILayoutGuide из UIKit, но с возможностью фабрики layout-элементов. Что позволяет использовать LayoutGuide в качестве плейсхолдера, что довольно актуально в свете последних дизайн решений. В частности для этих целей создан класс ViewPlaceholder. Он реализует такой же паттерн загрузки вью как и UIViewController. Поэтому работа с ним будет очень знакомой.


    Для элементов, которые могут рассчитать свой размер задекларирован протокол:


    public protocol AdjustableLayoutItem: LayoutItem {
        func sizeThatFits(_ size: CGSize) -> CGSize
    }

    По умолчанию его реализуют только UIView.


    Layout coordinate space


    public protocol LayoutCoordinateSpace {
        func convert(point: CGPoint, to item: LayoutItem) -> CGPoint
        func convert(point: CGPoint, from item: LayoutItem) -> CGPoint
        func convert(rect: CGRect, to item: LayoutItem) -> CGRect
        func convert(rect: CGRect, from item: LayoutItem) -> CGRect
    
        var bounds: CGRect { get }
        var frame: CGRect { get }
    }

    Система лайаута имеет объединенную координатную систему представленную в виде протокола LayoutCoordinateSpace.


    Она создаёт единый интерфейс для всех layout-элементов, при этом используя основные реализации каждого из типов (UIView, CALayer, UICoordinateSpace + собственная реализация для кросс-конвертации).


    Layout-блоки


    public protocol LayoutBlockProtocol {
        var currentSnapshot: LayoutSnapshotProtocol { get }
        func layout()
        func snapshot(for sourceRect: CGRect) -> LayoutSnapshotProtocol
        func apply(snapshot: LayoutSnapshotProtocol)
    }

    Layout-блок является законченной и самостоятельной единицей макета. Он определяет методы для выполнения разметки, получения/применения snapshot`а.


    LayoutBlock инкапсулирует layout-элемент, его основной лайаут и ограничения, реализующие LayoutConstraintProtocol.


    Процесс актуализации разметки начинается с определения доступного пространства с помощью ограничений. Следует учитывать, что система пока никак решает проблем с конфликтными ограничениями и никак их не приоритезирует, поэтому следует внимательно подходить к применению ограничений. Так в общем случае, ограничения основанные на размере (AdjustLayoutConstraint) следует ставить после ограничений, основанных на позиционировании. В качестве исходного пространства берется пространство супервью (bounds). Каждое ограничение изменяет доступное пространство (обрезает, смещает, растягивает и т.д.). После того как ограничения отработали, полученное пространство передается в Layout, где и рассчитывается актуальная разметка для элемента.


    LayoutScheme — блок, который объединяет другие лайаут блоки и определяет корректную последовательность для выполнения разметки.


    Layout snapshot


    public protocol LayoutSnapshotProtocol {
        var snapshotFrame: CGRect { get }
        var childSnapshots: [LayoutSnapshotProtocol] { get }
    }

    LayoutSnapshot — снимок представленный в виде набора фреймов, сохраняя иерархию layout-элементов.


    Extended


    Все расширяемые элементы реализуют протокол Extended.


    public protocol Extended {
        associatedtype Conformed
        static func build(_ base: Conformed) -> Self
    }

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


    Пример использования


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


    let leftLimit = LayoutAnchor.Left.limit(on: .outer)
    let topLimit = LayoutAnchor.Top.limit(on: .inner)
    let heightEqual = LayoutAnchor.Size.height()
    ...
    let layoutScheme = LayoutScheme(blocks: [
       distanceLabel.layoutBlock(with: Layout(x: .center(), y: .bottom(50), 
                                         width: .fixed(70), height: .fixed(30))),
       separator1Layer.layoutBlock(with: Layout(alignment: separator1Align, filling: separatorSize), 
                                        constraints: [distanceLabel.layoutConstraint(for: [leftLimit, topLimit, heightEqual])])
    ...
    ])
    
    ...
    
    override public func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        layoutScheme.layout()
    }

    В этом примере сепаратор использует ограничения по distanceLabel, после того, как определено положение этого лейбла.


    Итоги


    Autolayout пока остаётся основным инструментом лайаута в силу своей стабильности, хорошего API и мощной поддержкой. Но сторонние решения могут помочь решить частные проблемы, в силу своей узкой направленности или гибкости.


    CGLayout имеет не совсем привычную логику описания процесса layout'а, поэтому требует привыкания.
    Тут еще много работы, но это вопрос времени, при этом уже сейчас видно, что он имеет ряд преимуществ, которые должны ему позволить занять свою нишу в области подобных систем. Работа фреймворка ещё не тестировалось в production, и у вас есть возможность попробовать это сделать. Фреймворк покрыт тестами, поэтому больших проблем возникнуть не должно.


    Надеюсь на ваше активное участие в дальнейшей разработке фреймворка.


    Github репозиторий


    И в конце хотелось бы поинтересоваться у хабра-юзеров:
    Какие требования предъявляете вы к layout-системам?
    Что вам больше всего не нравится делать при построении верстки?

    • +12
    • 4,4k
    • 8
    Поделиться публикацией
    Похожие публикации
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 8
    • 0
      Masonry не смотрели? Я применяю его сам и часто встречаю его в компаниях, где приходилось работать. Полностью устраивает в том, что касается autolayout. Можете сравнить вашу разработку в том числе и с ним? Для понимания. С остальными движками из вашего примера я не работал, думаю, что у них более широкие возможности, но все же полезно было бы узнать об их преимуществах относительно Masonry (он же SnapKit).
      • 0

        Masonry и Snapkit это всего лишь обертки Autolayout с более удобным API. Поэтому сравнивать их особого смысла нет. Я все-таки старался рассматривать фреймворки, которые готовы заменить autolayout полностью.

      • +2
        Не наговаривайте на ASDK, все у нас там хорошо!!!
        • 0
          Я вот иногда использую Framezilla, он вроде без Autolayout. Попадался ли вам на глаза этот фреймворк?
          • 0

            Не попадался, спасибо, на досуге посмотрю.

          • 0
            Редко использую подобные библиотеки. AutoLayout вполне справляется с задачами.
            Если конечно нужно создать какой-то кастомный элемент, то расставить constraints в коде не составляет труда, тем более с VFL это можно в 2 строки(но не все constraints).
            • 0
              Но как видно из графика он очень медленный. Если на простых интерфейсах это незаметно глазу, то в случае большого количества вьюх, производительность падает экспоненциально.
            • 0
              YogaKit не стоило пихать в сравнение. Это просто Obj-C обертка над C библиотекой Yoga/FlexLayout.

              Сама Yoga ничего не знает про UIView, оперирует нодами и вообще офигенна.
              Кстати, Yoga прекрасно оборачивают на Swift все кому не лень. Чего и вам советую.

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