iOS-разработка со SnapKit: спасаем консоль от простыни ошибок и ускоряем отрисовку UI

Привет, я Даша, занимаюсь iOS‑разработкой в Сравни. Мы в мобильной команде пользуемся SnapKit — помогает нам ревьюить изменения в общих компонентах быстрее и проще. Инструмент прекрасный, но я заметила тенденцию: стоит в работе появиться сложным вариантам вёрстки, как сразу в разы растёт вероятность, что UI может выглядеть ок, а в консоли будет отображаться множество ошибок LayoutConstraints, логи засоряются, найти действительно полезную информацию становится сложнее.

26e0ea4d28fc0dfefaed6bdbac24b2ab.jpeg

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

Тот самый SnapKit

Давайте сверимся, что говорим об одном и том же инструменте. SnapKit — это библиотека, содержащая синтаксический сахар и обертки для более удобной работы с классом NSLayoutConstraint, позволяющим настроить взаимное расположение объектов интерфейса.

Первый релиз SnapKit случился ещё в далёком 2016 и создавался под Swift 2.3; последний релиз 5.6.0 вышел в апреле 2022 и умеет работать с Swift 5.6.

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

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

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

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

При использовании SnapKit у меня часто возникали вопросы, как это всё работает, и можно ли достичь такого понимания работы библиотеки, чтобы даже без запуска приложения сказать: вот здесь будет работать правильно, а здесь верстка поплывёт. Вдобавок ситуация с портянкой ошибок LayoutConstraints в консоли приносит ощущение неаккуратности. Всё это подтолкнуло меня к исследованию краевых случаев в работе SnapKit; о результатах хочу вам рассказать в этой статье.

Давайте наводить порядок со SnapKit.

Больше атрибутов  

Первым делом предлагаю расширить список используемых атрибутов, с помощью которых создаются ограничения Layout. Стандартные варианты я собрала в таблице 1; еще несколько, которые можно назвать объединяющими — в таблице 2.

Таблица 1

ViewAttribute

NSLayoutAttribute

view.snp.left

NSLayoutConstraint.Attribute.left

view.snp.right

NSLayoutConstraint.Attribute.right

view.snp.top

NSLayoutConstraint.Attribute.top

view.snp.bottom

NSLayoutConstraint.Attribute.bottom

view.snp.leading

NSLayoutConstraint.Attribute.leading

view.snp.trailing

NSLayoutConstraint.Attribute.trailing

view.snp.width

NSLayoutConstraint.Attribute.width

view.snp.height

NSLayoutConstraint.Attribute.height

view.snp.centerX

NSLayoutConstraint.Attribute.centerX

view.snp.centerY

NSLayoutConstraint.Attribute.centerY

view.snp.lastBaseline

NSLayoutConstraint.Attribute.lastBaseline

Таблица 2

ViewAttribute

Объединяемые ViewAttribute

view.snp.edge

view.snp.horizontalEdges, view.snp.verticalEdges

view.snp.horizontalEdges

view.snp.left, view.snp.right

view.snp.verticalEdges

view.snp.top, view.snp.bottom

view.snp.directionalEdges

view.snp.directionalHorizontalEdges, view.snp.directionalVerticalEdges

view.snp.directionalHorizontalEdges

view.snp.leading, view.snp.trailing

view.snp.directionalVerticalEdges

view.snp.top, view.snp.bottom

view.snp.size

view.snp.width, view.snp.height

view.snp.center

view.snp.centerX, view.snp.centerY

view.snp.margins

view.snp.leftMargin, view.snp.rightMargin,   view.snp.topMargin, view.snp.bottomMargin

view.snp.directionalMargins

view.snp.leadingMargin, view.snp.trailingMargin, view.snp.topMargin, view.snp.bottomMargin

view.snp.centerWithinMargins

view.snp.centerXWithinMargins, view.snp.centerYWithinMargins

Объединяющие атрибуты нужны, чтобы сократить количество кода при описании ограничений, а также для упрощения понимания, что мы хотели всем этим сказать.

Что под капотом SnapKit, на примере

Дальше давайте заглянем вглубь SnapKit. Если хотите сразу перейти к разбору причин ошибок для ограничений (главное, ради чего мы здесь!), можете смело пропустить эту часть статьи. Если же хочется освежить в памяти особенности библиотеки, тогда поехали.

В SnapKit существуют три публичных метода, позволяющих управлять списком ограничений (LayoutConstraints):

  • makeConstraints(_ closure:);

  • remakeConstraints(_ closure:);

  • updateConstraints(_ closure:).

Ещё есть четвёртый метод — removeConstraints(); позволяет очистить список ограничений у выбранного вью.

Предположим, мы хотим поместить кнопку button по центру ее superView. Тогда нам нужно сделать следующую запись:

button.snp.makeConstraints {
  $0.center.equalToSuperview()
}

Если углубиться в реализацию, то в методе makeConstraints(_ closure:) происходит вызов метода makeConstraints(item:, closure:) класса ConstraintMaker (подобие использования паттерна фасад), в котором мы увидим следующие действия:

1. Из нашей кнопки создаётся экземпляр ConstraintMaker, в ходе инициализации которого нашей кнопки выставляется translatesAutoresizingMaskIntoConstraints = false, для того, чтобы далее иметь возможность настраивать NSLayoutConstraint.

2. Далее обратно в замыкание передаётся ConstraintMaker, где мы его обогащаем различными ConstraintMakerExtendable: center, size, edges и прочим.

Каждая строка внутри замыкания приводит в вызову метода makeExtendableWithAttributes(_:) (в нашем примере для атрибута center), который добавляет в список descriptions экземпляра ConstraintMaker объект ConstraintDescription (класс с описанием параметров ограничения) с атрибутом, соответствующим указанному ограничению (либо с набором атрибутов, если ограничение является объединяющим), а дальше возвращает объект ConstraintMakerExtendable, наследника ConstraintMakerRelatable.

В нашем случае, center является объединяющим атрибутом, поэтому в ConstraintDescription будет передан OptionSet [.centerX, .centerY].

3. После того, как будет обработан атрибут center, для него вызывается метод equalToSuperview(), который приводит к созданию экземпляра ConstraintMakerEditable.

ConstraintMakerEditable аккумулирует в себе информацию о том, с какой вью будет взаимодействовать наша button при позиционировании и какой тип взаимосвязи будет использоваться. В нашем случае center нашей кнопки будет равен центру её superView. А если superView не будет найден, то процесс остановится с fatalError("Expected superview but found nil when attempting make constraint `equalToSuperview`.").

Также у ConstraintMakerEditable есть параметр sourceLocation, в который сохраняется наименование файла и номер строки, на которой произошёл вызов метода equalToSuperview(), которые используются при выводе ошибок.

4. Компилятор проходится по всем строкам в замыкании; с помощью цикла for перебираются descriptions; из них достаются параметры constraint и добавляются в массив.

5. Для каждого constraint из этого массива вызывается метод activateIfNeeded с флагом updatingExisting в состоянии false, что приводит к вызову метода NSLayoutConstraint.activate для активации ограничения.

Флаг updatingExisting нужен, чтобы различать два метода: makeConstraints и updateConstraints. Первый создает список ограничений «с нуля», второй позволяет обновить уже созданные ограничения. Примечательно, что при вызове третьего метода (remakeConstraints) сперва вызывается removeConstraints, в котором для всех constraints вызывается метод deactivateIfNeeded (NSLayoutConstraint.deactivate(_:)), каждое ограничение удаляется из массива constraints, а уже потом вызывается makeConstraints.

При использовании обновления (updateConstraints) флаг updatingExisting в состоянии true запускает сбор всех существующих ограничений layoutConstraints для вью в массив, после чего в цикле идёт обращение к каждому экземпляру LayoutConstraint. Если такое ограничение существует в массиве, то оно обновляется, если нет — вызывается fatalError("Updated constraint could not find existing matching constraint to update: \(layoutConstraint)").

Есть ли разница в написании ограничений? Если использовать объединяющий атрибут center вместо centerX и centerY, то количество изначальных вызовов всех функций для построения ограничения уменьшится, что позволит системе потратить операционное время на выполнение других задач.

Ошибки при выставлении ограничений

Давайте теперь разбираться, откуда могут взяться те самые ошибки в консоли — что может пойти не так при выставлении ограничений. Тут у меня получился список из 6 ловушек, угодить в которые, увы, проще, чем хотелось бы.

Ловушка 1: не указать все необходимые ограничения

По моему опыту список ограничений зачастую сводится к такому:

button.snp.makeConstraints {
  $0.center.equalToSuperview()
}

Список ограничений не полный — указанная кнопка может выйти за пределы своего superView и система не скажет, что что‑то пошло не так (и даже ошибок Layout не будет).

Кнопка с коротким текстом и кнопка с длинным текстом до исправления ограниченийКнопка с коротким текстом и кнопка с длинным текстом до исправления ограничений

Решение: добавить по одному ограничению по вертикали и по горизонтали:

button.snp.makeConstraints {
  $0.center.equalToSuperview()
  $0.top.left.greaterThanOrEqualToSuperview()
}

Кнопка с коротким текстом и кнопка с длинным текстом после исправления ограниченийКнопка с коротким текстом и кнопка с длинным текстом после исправления ограничений

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

Ловушка 2: не различать offset и inset

Когда мы используем .offset(50), под капотом константа остается константой — это эквивалентно указанию параметра constant равного 50 для NSLayoutConstraint.

Ограничение будет таким:

button.snp.makeConstraints {
  0.right.equalToSuperview().offset(50)
}

Это приведёт к тому, что правая граница нашего вью будет на 50 точек правее границы своей родительской вью. Пока приравняем левый край к краю superView.

Кнопка с коротким текстом и кнопка с длинным текстом до исправления ограниченийКнопка с коротким текстом и кнопка с длинным текстом до исправления ограничений

Когда мы используем .inset(50), под капотом константа заменяется на ConstraintInsets(top: CGFloat(amount), left: CGFloat(amount), bottom: CGFloat(amount), right: CGFloat(amount)), где далее amount — наш отступ в 50 точек. При пересчёте параметров для NSLayoutConstraint отступ в 50 точек для указанного нами атрибута right становится отступом в -50 точек (если было бы left.inset(50), то отступ так и остался бы 50 точек). И теперь мы имеем корректное значение отступа от правой границы нашего вью до правой границы superView.

Кнопка с коротким текстом и кнопка с длинным текстом после исправления ограниченийКнопка с коротким текстом и кнопка с длинным текстом после исправления ограничений

Обработка ограничения:  

button.snp.makeConstraints {
  $0.edges.equalToSuperview().inset(50)
}

Атрибут edges — это один из объединяющих атрибутов, который состоит из [.horizontalEdges, .verticalEdges]. Для нашего ограничения так же создастся ConstraintInsets(top: CGFloat(amount), left: CGFloat(amount), bottom: CGFloat(amount), right: CGFloat(amount)), где amount — наш отступ 50, который далее преобразуется в массив из четырех ограничений (top, bottom, left, right), которые активируются через NSLayoutConstraint.activate.

Давайте запишем ограничение иначе:

button.snp.makeConstraints {
  $0.edges.equalToSuperview().offset(50)
}

Тут ситуация аналогична с записью $0.right.equalToSuperview().offset(50): значение offset передаётся без изменений.

Было:

▿ Optional>
  ▿ some : 4 elements
    ▿ 0 : 

    ▿ 1 : 

    ▿ 2 : 

    ▿ 3 : 

Кнопка с коротким текстом и кнопка с длинным текстом с установленным insetКнопка с коротким текстом и кнопка с длинным текстом с установленным inset

Стало:

▿ Optional>
  ▿ some : 4 elements
    ▿ 0 : 

    ▿ 1 : 

    ▿ 2 : 

    ▿ 3 : 

Кнопка с коротким текстом и кнопка с длинным текстом с установленным offsetКнопка с коротким текстом и кнопка с длинным текстом с установленным offset

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

Ловушка 3: добавлять лишние ограничения

Когда хочешь, чтобы твой UI выглядел максимально хорошо, иногда случается перестраховаться.

Давайте выставим ограничения для нашей кнопки:

button.snp.makeConstraints {
    $0.center.equalToSuperview()
    $0.top.left.right.bottom.equalToSuperview().inset(50)
}

На экране выглядит хорошо, при этом общее количество NSLayoutConstraint, которые потом будет активировать система, будет равно шести: первые два для centerY и centerX + по одному на каждую из сторон.

Если же мы ограничимся только достаточными ограничениями, то запись можно сократить до двух строк:

button.snp.makeConstraints {
    $0.center.equalToSuperview()
    $0.top.left.equalToSuperview().inset(50)
}

Количество NSLayoutConstraint для активации сократится до четырех: первые два для centerY и centerX + ограничение по горизонтали + ограничение по вертикали.

Ещё один пример про перестраховку — добавление ограничений для расположения дочерних вью внутри UIStackView. Стек довольно удобный инструмент для создания адаптивного UI, который позволяет минимизировать количество кода для правильного размещения компонентов на экране. Он умеет сам распределять дочерние вью внутри себя. При этом у UIStackView есть ряд настроек, которые помогут правильно распределить компоненты.

Добавим верхнему дочернему вью вертикального стека ограничения:

buttonInStack.snp.makeConstraints {
    $0.top.left.right.equalToSuperview()
}

Никаких странностей мы не заметим. Да, эти ограничения излишни, но ни ошибок LayoutConstraints, ни неправильной вёрстки это не вызовет.

Вертикальный стек с кнопкамиВертикальный стек с кнопками

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

Горизонтальный стек с кнопками и ошибки в консолиГоризонтальный стек с кнопками и ошибки в консоли

Есть такая поговорка: лучший код — ненаписанный. Если поступить в соответствии с этим правилом в нашем примере, то ничего переделывать не пришлось бы.

Однако бывают случаи, когда без некоторых ограничений, даже внутри UIStackView — не обойтись. Например, нужно чтобы была картинка справа и текст слева. Текст может быть какой угодно длины, но картинка должна быть строго 50×50 точек.

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

Ловушка 4: путать left и leading, right и trailing

Зачастую над разницей действительно можно не задумываться, ведь для большинства стран left и leading будут описывать левый край вью, а right и trailing — правый.

Но для ряда стран, где параметр UIUserInterfaceLayoutDirection (направление пользовательского интерфейса) должен быть rightToLeft, поведение будет иное: leading будет описывать правый край, trailing — левый.

Благодаря заложенным в SnapKit преобразованиям, разница не будет видна, однако, полезная практика: помнить об этом при создании приложения и везде сохранять единую логику написания ограничений.

Ловушка 5: не продумывать взаимосвязь ограничений

Предположим, нам необходимо сделать компонент, внутри которого будут размещены три UILabel с текстом. Отступы между ними разные, поэтому мы решили не использовать стек, посмотрели на дизайн и сделали вывод, что первый текст должен быть прижат к верху контейнера, второй должен иметь отступ 50 точек от верха, а третий можно прижать к низу контейнера, указав для контейнера отступы сверху и снизу экрана как на макете.

Набор ограничений может выглядеть так:

label1.snp.makeConstraints {
    $0.top.left.right.equalToSuperview()
}

label2.snp.makeConstraints {
    $0.top.equalToSuperview().offset(50)
    $0.left.right.equalToSuperview()
}

label3.snp.makeConstraints {
    $0.left.right.bottom.equalToSuperview()
}

При запуске всё может выглядеть хорошо, в консоли не будет никаких ошибок.

Некорректные зависимости лейблов, но корректная версткаНекорректные зависимости лейблов, но корректная верстка

Но к чему может привести такая вёрстка? Если label1 будет достаточно длинный, то он может занять больше 50 точек на экране, и тогда он перекроет label2. Такая же ситуация и с label2 по отношению к label3. При этом всё ещё не будет никаких ошибок LayoutConstraints.

Некорректные зависимости и некорректная версткаНекорректные зависимости и некорректная верстка

Причина проблемы — неправильная взаимосвязь ограничений между лейблами.

Правильный вариант будет выглядеть так:

label1.snp.makeConstraints {
    $0.top.horizontalEdges.equalToSuperview()
}

label2.snp.makeConstraints {
    $0.top.equalTo(label1.snp.bottom).offset(16)
    $0.horizontalEdges.equalToSuperview()
}

label3.snp.makeConstraints {
    $0.top.equalTo(label2.snp.bottom).offset(16)
    $0.horizontalEdges.bottom.equalToSuperview()
}

Корректные зависимости и корректная версткаКорректные зависимости и корректная верстка

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

Ловушка 6: непонимание разницы между left, right, top, bottom и leftMargin, rightMargin, topMargin, bottomMargin

Давайте разместим два квадратных вью согласно ограничениям:

view.addSubview(squareView1)

squareView1.snp.makeConstraints {
  $0.centerY.equalToSuperview().offset(-100)
  $0.size.equalTo(100)
  $0.left.equalToSuperview()
}

view.addSubview(squareView2)

squareView2.snp.makeConstraints {
  $0.centerY.equalToSuperview().offset(100)
  $0.size.equalTo(100)
  $0.leadingMargin.equalToSuperview()
}

На экране получим следующее:

Разница расположения в зависимости от left / leadingMarginРазница расположения в зависимости от left / leadingMargin

Разница в том, что для красного квадрата мы указали привязку левого края (left) нашего вью к левому краю его superView, а для серого мы завязались на leftMargin для вью и для его superView.

Дело в том, что margin имеют отступ от края вью и если посмотреть в дебагере, то мы увидим следующее:

LayoutMargins экрана и серого UIViewLayoutMargins экрана и серого UIView

У серого вью и у основного вью нашего контроллера есть LayoutMargins. Для серого UIView это квадрат, уменьшенный на 8 точек с каждой стороны, для основного UIView контроллера — прямоугольник с отступами 16 точек слева и справа, 47 точек сверху и 34 точки снизу. В ограничении $0.leadingMargin.equalToSuperview() выравнивание идёт относительно leadingMargin обоих вью, поэтому отступ от края серого квадрата до края его superView будет всего 8 точек (16 точек — 8 точек).

Если необходимо, чтобы отступ серого квадрата зависел от leadingMargin его superView, но не уменьшал его, то необходимо немного откорректировать запись:

squareView2.snp.makeConstraints {
    $0.centerY.equalToSuperview().offset(100)
    $0.size.equalTo(100)
    $0.leading.equalTo(view.snp.leadingMargin)
}

Тут мы задаём, что для squareView2 будет учитываться его leading параметр, а для его superView — leadingMargin.

Левый край дочерней UIView зависиот от левого LayoutMargin экранаЛевый край дочерней UIView зависиот от левого LayoutMargin экрана

Ещё раз про LayoutConstraints

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

Для поиска источника ошибок посоветую пользоваться методом labeled(:_) при написании ограничений он позволяет добавить identifier для NSLayoutConstraint, который потом будет отображаться в консоли в случае возникновения ошибок расположение компонентов.

Спасаем консоль от простыни ошибок:, а точно ли оно нужно?   

Главный совет в борьбе с ошибками ограничений макета в SnapKit я сформулировала для себя так: всегда задавать себе два проверочных вопроса: «А точно оно будет работать правильно?» и «А точно ли оно нужно?». Желание оформлять код правильно и использовать библиотеку по максимуму — прекрасное стремление. Но если делаешь не особенно насыщенный UI или твоё приложение не рассчитано на использование людей с другим направлением письма — многое из описанного выше может привести к оверинжинирингу, улучшениям ради улучшений. А точно ли оно нужно?

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

Какой у вас опыт со SnapKit? Расскажите в комментариях!

© Habrahabr.ru