Привет!
Хочу представить мою последнюю 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)
Предоставляет возможность заниматься только layout'ом. Никаких других плюшек, фишек нет.
Особенности:
- Нет возможности закешировать layout. Можно получить только размер.
- Не нативная реализация. API больше подходящее для кроссплатформерных разработчиков, веб-разработчиков.
- Проблемы с изменением размера экрана — приходится вручную изменять размер layout-объекта и делать пересчет.
- Требует создания лишних вью при организации layout-блоков.
AsyncDisplayKit (Texture)
В 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 медленный из-за необходимости конвертировать координаты с моей реализацией.
Что пока не реализовано:
- Поддержка RTL.
- Поведение при удалении вью из иерархии.
- Поддержка macOS, tvOS.
- Поддержка trait-коллекций.
- Нет удобной конструкций для выполнения разметки переиспользуемых вью.
- Динамическое изменение текущей 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
. Что позволяет использовать элементы лайаута разного уровня сложности для реализации разметки. Все лайаут сущности легко могут быть расширены вашими имплементациями.
Ограничения
Все ограничения реализуют RectBasedConstraint
. Если сущности RectBasedLayout
определяют разметку в доступном пространстве, то сущности RectBasedConstraint
это доступное пространство определяют.
public protocol RectBasedConstraint {
func constrain(sourceRect: inout CGRect, by rect: CGRect)
}
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, и у вас есть возможность попробовать это сделать. Фреймворк покрыт тестами, поэтому больших проблем возникнуть не должно.
Надеюсь на ваше активное участие в дальнейшей разработке фреймворка.
И в конце хотелось бы поинтересоваться у хабра-юзеров:
Какие требования предъявляете вы к layout-системам?
Что вам больше всего не нравится делать при построении верстки?
Автор: Denis Koryttsev