Хабрахабр

iOS Storyboards: анализ плюсов и минусов, best practices

Не всем понравился этот инструмент, и на то есть разумные причины. Apple создала Storyboards, чтобы разработчики могли визуализировать экраны iOS-приложений и связи между ними. В итоге я решил написать такую статью сам.
Я постараюсь подробно разобрать недостатки и преимущества использования Storyboards. Я встречал много статей с критикой Storyboards, однако так и не нашел подробного и непредвзятого анализа всех плюсов и минусов с учетом best practices. Это решение не обязательно должно быть радикальным. Взвесив их, вы сможете принять осмысленное решение, нужны они в проекте или нет. Если в одних ситуациях Storyboards создают проблемы, в других — их использование оправдано: помогает эффективно решать поставленные задачи и писать простой, легко поддерживаемый код.

Итак, начнем с недостатков.

Недостатки

1. В Storyboards тяжело править конфликты при слиянии изменений

Storyboard – это XML-файл. Он хуже поддается чтению, чем код, поэтому разрешать конфликты в нем сложнее. Но эта сложность также зависит и от того, как мы работаем со Storyboard. Можно значительно упростить себе задачу, если следовать приведенным ниже правилам:

  • Не помещать весь UI в один единственный Storyboard, разделить его на несколько более мелких. Это позволит распределить работу над Storyboards между разработчиками без риска возникновения конфликтов, а в случае их неизбежности – упростит задачу по их разрешению.
  • Если нужно использовать один и то же View в нескольких местах – выделить его в отдельный подкласс с собственным Xib-файлом.
  • Делать коммиты чаще, так как гораздо проще работать с изменениями, поступающими небольшими кусками.

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

2. Storyboards мешают повторному использованию кода

Если речь идет об использовании в проекте только Storyboards без Xibs, то проблемы обязательно возникнут. Однако Xibs, на мой взгляд, – необходимые элементы при работе со Storyboards. Благодаря им можно легко создавать переиспользуемые Views, с которыми также удобно работать и в коде.

Для начала, создадим базовый класс XibView, который отвечает за отрисовку UIView, созданного в Xib, в Storyboard:

@IBDesignable
class XibView: UIView { var contentView: UIView?
}

XibView будет загружать UIView из Xib в contentView и добавлять его как свой subview. Сделаем это в методе setup():

private func setup() view.frame = bounds view.autoresizingMask = [.flexibleWidth, .flexibleHeight] addSubview(view) contentView = view
}

Метод loadViewFromNib() выглядит так:

private func loadViewFromNib() -> UIView? { let nibName = String(describing: type(of: self)) let nib = UINib(nibName: nibName, bundle: Bundle(for: XibView.self)) return nib.instantiate(withOwner: self, options: nil).first as? UIView
}

Метод setup() должен вызываться в инициализаторах:

override init(frame: CGRect) { super.init(frame: frame) setup()
} required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) setup()
}

Класс XibView готов. Переиспользуемые Views, внешний вид которых отрисован в Xib-файле, будут наследоваться от XibView:

final class RedView: XibView {
}


Если теперь добавить новый UIView в Storyboard и установить его класс в RedView, то все успешно отобразится:

Создание экземпляра RedView в коде происходит обычным образом:

let redView = RedView()

Еще одной полезной деталью, о которой могут не все знать, является возможность добавлять цвета в каталог .xcassets. Это позволяет менять их глобально во всех Storyboards и Xibs, где они используются.

Чтобы добавить цвет, нажимаем «+» внизу слева и выбираем «New Color Set»:

Указываем нужное имя и цвет:

Созданный цвет появится в разделе «Named Colors»:

Кроме того, его можно получить и в коде:

innerView.backgroundColor = UIColor(named: "BackgroundColor")

3. Нельзя использовать кастомные инициализаторы для UIViewControllers, созданных в Storyboard

В случае со Storyboard мы не можем передать зависимости в инициализаторах UIViewControllers. Обычно все выглядит так:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) { guard segue.identifier == "detail", let detailVC = segue.destination as? DetailViewController else { return } let object = Object() detailVC.object = object
}

Этот код можно сделать лучше с помощью каких-нибудь констант для представления идентификаторов или таких инструментов, как SwiftGen и R.swift, а может даже Perform. Но так мы лишь избавляемся от строковых литералов и добавляем синтаксический сахар, а не решаем проблемы, которые возникают:

  • Как узнать, каким образом в примере выше настраивается DetailViewController? Если вы новый на проекте и не обладаете этими знаниями, вам придется открыть файл с описанием этого контроллера и изучить его.
  • Свойства DetailViewController устанавливаются уже после инициализации, значит они должны быть опциональными. Нужно обработать случаи, когда какое-либо свойство равно nil, иначе приложение может упасть в самый неподходящий момент. Можно пометить свойства как неявно развернутые опциональные (var object: Object!), но суть не изменится.
  • Свойства должны быть помечены как var, не let. А значит возможна ситуация, когда кто-то извне захочет их изменить. DetailViewController должен обрабатывать и такие ситуации.

Один из вариантов решения описан в этой статье.

4. По мере роста Storyboard навигация в нем становится все сложнее

Как мы уже отмечали ранее, не нужно помещать все в один Storyboard, лучше разбить его на несколько более мелких. С появлением Storyboard Reference это стало очень просто.
Добавляем Storyboard Reference из библиотеки объектов в Storyboard:

Выставляем необходимые значения полей в Attributes Inspector – это имя Storyboard-файла и по необходимости Referenced ID, что соответствует Storyboard ID нужного экрана. По умолчанию будет загружен Initial View Controller:

Если указать неверное имя в поле Storyboard или сослаться на несуществующий Storyboard ID, Xcode предупредит об этом на этапе компиляции.

5. Xcode тормозит при загрузке Storyboards

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

6. Storyboards хрупкие, ошибка может привести к падению приложения на этапе выполнения

Основные слабые места:

  • Ошибки в идентификаторах UITableViewCell и UICollectionViewCell.
  • Ошибки в идентификаторах segues.
  • Использование подкласса UIView, которого уже не существует.
  • Синхронизация IBActions и IBOutlets с кодом.

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

Для этого идентификаторам UITableViewCell и UICollectionViewCell можно присваивать названия самих классов ячеек: например, идентификатором ItemTableViewCell будет строка «ItemTableViewCell». Можно снизить вероятность ошибки, если избавиться от строковых литералов в коде. В коде достаем ячейку так:

let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ItemTableViewCell.self)) as! ItemTableViewCell

Можно добавить в UITableView соответствующую generic-функцию:

extension UITableView { open func dequeueReusableCell<T>() -> T where T: UITableViewCell { return dequeueReusableCell(withIdentifier: String(describing: T.self)) as! T }
}

И тогда получить ячейку становится проще:

let cell: ItemTableViewCell = tableView.dequeueReusableCell()

Если вдруг вы забудете указать значение идентификатора ячейки в Storyboard, Xcode выдаст предупреждение, поэтому не стоит их игнорировать.

Создадим специальный протокол: Что касается идентификаторов segues, то для них можно использовать перечисления.

protocol SegueHandler { associatedtype SegueIdentifier: RawRepresentable
}

UIViewController, поддерживающий этот протокол, должен будет определить вложенный тип с таким же именем. В нем перечисляются все идентификаторы segues, которые этот UIViewController может обработать:

extension StartViewController: SegueHandler { enum SegueIdentifier: String { case signIn, signUp }
}

Кроме того, в расширении протокола SegueHandler определим две функции: одна принимает UIStoryboardSegue и возвращает соответствующее ей значение SegueIdentifier, а другая просто вызывает performSegue, принимая на вход SegueIdentifier:

extension SegueHandler where Self: UIViewController, SegueIdentifier.RawValue == String { func performSegue(withIdentifier segueIdentifier: SegueIdentifier, sender: AnyObject?) { performSegue(withIdentifier: segueIdentifier.rawValue, sender: sender) } func segueIdentifier(for segue: UIStoryboardSegue) -> SegueIdentifier { guard let identifier = segue.identifier, let identifierCase = SegueIdentifier(rawValue: identifier) else { fatalError("Invalid segue identifier \(String(describing: segue.identifier)).") } return identifierCase }
}

И теперь в UIViewController, поддерживающем новый протокол, с prepare(for:sender:) можно работать следующим образом:

extension StartViewController: SegueHandler { enum SegueIdentifier: String { case signIn, signUp } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { switch segueIdentifier(for: segue) { case .signIn: print("signIn") case .signUp: print("signUp") } }
}

А запускать segue так:

performSegue(withIdentifier: .signIn, sender: nil)

Если добавить новый идентификатор в SegueIdentifier, то Xcode обязательно заставит его обработать в switch/case.

Еще один вариант избавиться от строковых литералов типа идентификаторов segues и других – использовать инструменты кодогенерации наподобие R.swift.

7. Storyboards менее гибкие, в отличие от кода

Да, это действительно так. Если стоит задача создать сложный экран с анимацией и эффектами, с которыми Storyboard не справится, то нужно использовать код!

8. Storyboards не позволяют сменить тип специальных UIViewControllers

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

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

Это Interface Builder и парсер Storyboards. Такие случаи – редкость, и зачастую их можно обойти другими решениями.

10. Сложный code review

Нужно принять во внимание, что code review – это не совсем поиск багов. Да, их находят в процессе просмотра кода, но главной целью является выявление слабых мест, способных создать проблемы в долгосрочной перспективе. Для Storyboards это, в первую очередь, работа Auto Layout. Не должно быть никаких ambiguous и misplaced. Чтобы их найти, достаточно воспользоваться поиском в Storyboard XML по строкам «ambiguous=«YES»» и «misplaced=«YES»» или просто открыть Storyboard в Interface Builder и искать красные и желтые точки:

Однако этого может быть недостаточно. Конфликты между constraints могут выявляться и во время работы приложения. Если подобная ситуация имеет место, информация об этом выводится в консоли. Такие случаи – не редкость, поэтому к их поиску тоже нужно отнестись серьезно.

Все остальное – соответствие положения и размеров элементов с дизайном, корректная привязка IBOutlets и IBActions – не для code review.

Он сможет лучше вникнуть в детали, ничего не упустив. Кроме того, важно делать коммиты чаще, тогда рецензенту будет проще просматривать изменения небольшими кусками. Это, в свою очередь, положительно скажется на качестве ревью кода.

Итог

В списке недостатков Storyboards я оставил 4 пункта (в порядке убывания их значения):

  1. В Storyboards тяжело править конфликты при слиянии изменений.
  2. Storyboards менее гибкие, в отличие от кода.
  3. Storyboards хрупкие, ошибка может привести к падению приложения на этапе выполнения.
  4. Нельзя использовать кастомные инициализаторы для UIViewControllers, созданных в Storyboard.

Преимущества

1. Визуализация пользовательского интерфейса и constraints

Даже если вы новичок и только взялись за незнакомый проект, то легко найдете точку входа в приложение и как из нее перейти к нужному экрану. Вы знаете, как будет выглядеть каждая кнопка, метка или текстовое поле, какую позицию они будут занимать, как на них влияют constraints, как они взаимодействуют с другими элементами. С помощью нескольких щелчков мыши вы можете легко создать новый UIView, настроить его внешний вид и поведение. Auto Layout позволяет нам работать с UIView естественно, как если бы мы сказали: «Вот эта кнопка должна быть слева от той метки и иметь одинаковую с ней высоту». Такая работа с пользовательским интерфейсом интуитивно понятна и эффективна. Можно попытаться привести примеры, когда грамотно написанный код экономит больше времени при создании каких-то элементов UI, но глобально это мало что меняет. Storyboard хорошо справляется со своей задачей.

Это очень мощный и полезный инструмент, без которого трудно было бы создать приложение, поддерживающее все множество различных размеров экрана. Отдельно отметим Auto Layout. Конечно, существуют случаи, когда Interface Builder не способен обеспечить нужное поведение какого-то очень динамичного и сложного интерфейса, тогда приходится полагаться на код. Interface Builder позволяет увидеть результат работы с Auto Layout без запуска приложения, и если какие-то constraints не вписываются в общую схему, Xcode сразу же предупредит об этом. Но даже в таких ситуациях можно сделать большую часть в Interface Builder и дополнить это лишь парой строчек кода.

Рассмотрим несколько примеров, демонстрирующих полезные возможности Interface Builder.

Динамичные таблицы на основе UIStackView

Создаем новый UIViewController, добавляем UIScrollView на весь экран:

В UIScrollView добавляем вертикальный UIStackView, привязываем его к краям и устанавливаем высоту и ширину, равную UIScrollView. При этом высоте присвоим priority = Low (250):

Далее создаем все необходимые ячейки и добавляем их в UIStackView. Может это будут обычные UIView в единственном экземпляре, а может и переиспользуемые UIView, для которых мы создали свой Xib-файл. В любом случае, весь UI этого экрана – в Storyboard, а благодаря правильно настроенному Auto Layout прокрутка будет работать идеально, подстраиваясь под содержимое:

Добавим в каждую ячейку по UILabel, привяжем их к краям:

Уже понятно, как это все будет выглядеть на этапе выполнения. Мы также можем сделать, чтобы ячейки адаптировались под размеры своего контента. И все это без единой строчки кода.
Более того, если установить hidden = true для UIView из UIStackView, то оно не только скроется, но еще и не будет занимать пространства. К ячейкам можно привязать любые действия, например, переход на другой экран. UIStackView автоматически пересчитает свои размеры:

Self-sizing ячейки

В Size inspector таблицы устанавливаем Row Height = Automatic, а Estimate – в какое-нибудь среднее значение:

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

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

Self-sizing таблица

Нужно реализовать такое поведение таблицы:

В отличие от UILabel, UIButton и других подклассов UIView, с таблицей это сделать немного сложнее, так как Intrinsic Content Size не зависит от размеров ячеек внутри нее. Как добиться подобного динамического изменения высоты? Она не может вычислить свою высоту на основе содержимого, но есть возможность ей в этом помочь.

Этого можно добиться, установив у таблицы height constraint со значением Relation = Less Than Or Equal:

На данном этапе Interface Builder еще не знает, какой высоты будет таблица, ему лишь известно ее максимальное значение, равное 200 (из height constraint). Заметим, что на видео в какой-то момент высота таблицы перестает меняться, достигая определенного максимального значения. Однако у нас есть возможность установить placeholder в поле Intrinsic Size:

Это значение действительно только на время работы с Interface Builder. Как было отмечено ранее, Intrinsic Content Size не равен содержимому таблицы. Мы лишь сказали Interface Builder, что все под контролем. Безусловно, Intrinsic Content Size не обязан быть равен этому значению во время выполнения.

Далее, создаем новый подкласс таблицы CustomTableView:

final class CustomTableView: UITableView { override var contentSize: CGSize { didSet { invalidateIntrinsicContentSize() } } override var intrinsicContentSize: CGSize { return contentSize }
}

Один из тех случаев, когда код необходим. Здесь мы вызываем invalidateIntrinsicContentSize всегда, когда меняется contentSize таблицы. Это позволит системе принять новое значение Intrinsic Content Size. Оно, в свою очередь, возвращает contentSize, заставляя таблицу динамически регулировать свою высоту и отображать определенное количество ячеек без прокрутки. Прокрутка появляется в тот момент, когда мы достигаем предела height constraint.

Они добавляют больше гибкости в вариантах организации содержимого без необходимости дополнительной настройки constraints или каких-либо UIView. Все эти три возможности Interface Builder можно комбинировать друг с другом.

2. Возможность мгновенно увидеть результат своих действий

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

Добавим к RedView два UILabel и два свойства: Использование @IBInspectable раскрывает это преимущество еще интереснее.

final class RedView: XibView { @IBOutlet weak var titleLabel: UILabel! @IBOutlet weak var subtitleLabel: UILabel! @IBInspectable var title: String = "" { didSet { titleLabel.text = title } } @IBInspectable var subtitle: String = "" { didSet { subtitleLabel.text = subtitle } }
}

В Attributes Inspector для RedView появится два новых поля – Title и Subtitle, которые мы пометили как @IBInspectable:

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

Например, расширим базовый класс UIView: Можно управлять чем угодно: cornerRadius, borderWidth, borderColor.

extension UIView { @IBInspectable var cornerRadius: CGFloat { set { layer.cornerRadius = newValue } get { return layer.cornerRadius } } @IBInspectable var borderWidth: CGFloat { set { layer.borderWidth = newValue } get { return layer.borderWidth } } @IBInspectable var borderColor: UIColor? { set { layer.borderColor = newValue?.cgColor } get { return layer.borderColor != nil ? UIColor(cgColor: layer.borderColor!) : nil } } @IBInspectable var rotate: CGFloat { set { transform = CGAffineTransform(rotationAngle: newValue * .pi/180) } get { return 0 } }
}

Видим, что Attributes Inspector объекта RedView обзавелся еще 4-мя новыми полями, с которыми теперь тоже можно поиграться:

3. Предварительный просмотр всех размеров экрана одновременно

Вот мы закинули необходимые элементы на экран, настроили их вид и добавили нужные constraints. Как нам выяснить, будет ли содержимое корректно отображаться на разных размерах экрана? Конечно, можно запустить приложение на каждом симуляторе, но это займет немало времени. Существует вариант получше: у Xcode есть режим предварительного просмотра, он позволяет увидеть сразу несколько размеров экрана одновременно без запуска приложения.

Вызываем Assistant editor, в нем нажимаем на первый сегмент панели переходов, выбираем Preview –> Settings.storyboard (как пример):

Сначала мы видим лишь один экран, но можем добавить столько, сколько нужно, нажав «+» в левом нижнем углу и выбрав необходимые устройства из списка:

Кроме того, если Storyboard поддерживает несколько языков, можно посмотреть, как будет выглядеть выбранный экран с каждым из них:

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

4. Удаление шаблонного UI кода

Создание пользовательского интерфейса без Interface Builder сопровождается либо большим количеством шаблонного кода, либо суперклассами и расширениями, которые влекут за собой дополнительную работу по обслуживанию. Этот код может проникать в другие части приложения, затрудняя чтение и поиск. Использование Storyboards и Xibs позволяет разгрузить код, благодаря чему он становится более сфокусированным на логике.

5. Size classes

Каждый год появляются новые устройства, под которые нужно адаптировать пользовательский интерфейс. В этом помогают концепции trait variations и, в частности, size classes, которые позволяют создавать UI под любые варианты размеров и ориентации экрана.

Например, iPhone 8 имеет size class (wC hR) в портретной ориентации и (wC hC) – в альбомной, а iPhone 8 Plus – (wC hR) и (wR hC) соответственно. Size classes классифицируют высоту (h) и ширину (w) экранов устройств в терминах compact и regular (C и R). По остальным девайсам можно узнать здесь.

Если какие-то параметры макета одинаковы для всех size classes, то их можно настроить в категории «Any», которые уже выбрана по умолчанию. В одном Storyboard или Xib для каждого из size classes можно хранить свой набор данных, а приложение уже на этапе выполнения будет использовать подходящий в зависимости от устройства и ориентации экрана, идентифицируя таким образом текущий size class.

Выберем для просмотра в Storyboard устройство iPhone 8 Plus в портретной ориентации и добавим новое условие для font: если width – Regular (все остальное устанавливаем в «Any»), то размер шрифта должен быть равен 37:

Теперь, если мы поменяем ориентацию экрана, размер шрифта увеличится – сработает новое условие, так как в альбомной ориентации iPhone 8 Plus имеет size class (wR hC). Например, настроим размер шрифта в зависимости от size class. Подробнее о том, как все это делать, можно прочитать здесь. В Storyboard в зависимости от size class можно также скрывать Views, включать/отключать constraints, менять их значение constant и многое другое.

Она позволяет быстро проверить адаптивность UI на любом устройстве и при любой ориентации экрана, а также показывает size class текущей конфигурации (рядом с названием устройства). На скриншоте выше стоит еще отметить нижнюю панель с выбором устройства для отображения макета. Ее цель в том, чтобы включить trait variations только для определенной категории ширины, высоты или ширины и высоты одновременно. Помимо прочего, справа есть кнопка «Vary for Traits». Теперь все последующие изменения макета будут применяться только к устройствам с (wR hR), пока мы не нажмем «Done Varying». Например, выбрав iPad с size class (wR hR), нажимаем «Vary for Traits» и ставим галочки напротив width и height.

Заключение

Мы увидели, что Storyboards имеют свои сильные и слабые стороны. Мое мнение – не стоит полностью отказываться от их использования. При правильном применении они приносят огромную пользу и помогают эффективно решать поставленные задачи. Нужно лишь научиться расставлять приоритеты и забыть аргументы типа «мне не нравятся Storyboards» или «я привык так делать».

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть