Привет Хабр!
Хочу представить мою последнюю 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-системам?
Что вам больше всего не нравится делать при построении верстки?

Комментарии (8)


  1. GDXRepo
    23.09.2017 04:14

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


    1. K-o-D-e-N Автор
      23.09.2017 10:27

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


  1. Bimawa
    23.09.2017 10:38
    +2

    Не наговаривайте на ASDK, все у нас там хорошо!!!


  1. iWheelBuy
    23.09.2017 20:51

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


    1. K-o-D-e-N Автор
      23.09.2017 21:37

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


  1. serg1k17
    24.09.2017 11:14

    Редко использую подобные библиотеки. AutoLayout вполне справляется с задачами.
    Если конечно нужно создать какой-то кастомный элемент, то расставить constraints в коде не составляет труда, тем более с VFL это можно в 2 строки(но не все constraints).


    1. K-o-D-e-N Автор
      24.09.2017 13:51

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


  1. Fanruten
    25.09.2017 10:03

    YogaKit не стоило пихать в сравнение. Это просто Obj-C обертка над C библиотекой Yoga/FlexLayout.

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