Dmitriy Azarov

Storyboard или интерфейс из кода?

Довольно продолжительное время я был адептом исключительно Storyboard и презирал использование кода в качестве основного способа построения интерфейса. Все изменилось. Для чего может понадобиться построение интерфейса из кода, как основной способ?

Путь с UIStoryboard

Начать нужно с того, как был выстроен процесс работы со Storyboard. Все экране делились на секции (Регистрация, Профиль, Новости и пр) все это относится к разным секциям. Каждая секция реализуется в своем Storyboard, чтобы не пихать все контроллеры в один. Все связанные с секцией классы располагаются в одной папке секции. Все, что относится к UI расположено в отдельной папке. Таким образом весь интерфейс выделен в отдельный слой

Все директории разделены

Для безопасности работы с контроллерами и Storyboard были приняты следующие соглашения:

  • Контроллер инициализируется только одним способом
  • Всей навигацией управляет роутер

Controller Init

Все контроллеры наследуются от базового который имеет следующие важные функции

/// Init controller with data. Should return false if data is incorrect or controller could not be initialized
///
/// - Parameter data: dictionary with data
open func initController(_ data: [String:Any]?) -> Bool {
    return true
}

public class func getController(_ data: [String:Any]? = nil) -> OXViewController? {
   let controller = getViewController()
   if !controller.initController(data) {
       return nil
   }
   return controller
}

Таким образом создаются все экземпляры контроллеров. Внутрь getController передаются необходимые параметры. initController проверяет входящие параметры, и, если его все устраивает создается контроллер. Это позволяет сделать типобезопасную инициализацию. За то, чтобы правильно достать контроллер из Storyboard есть следующий метод. Для всех контроллеров необходимо переопределить storyboardName: String и этот метод создаст контроллер с именем равным имени класса. На мой взгляд это сильно упрощает конвенцию имен и позволяет абстрагироваться от задачи создания экземпляра контроллера.

Router

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

func presentLogs(parent: UIViewController) {
   if let controller = DebugLogsViewController.getController() {
       parent.navigationController?.pushViewController(controller, animated: true  )
   }
}

func presentMatchDetails(parent: UIViewController,
                                         match: Match) {
   if let controller = MatchDetailsViewController.getController(["match": match]) {
       parent.navigationController?.pushViewController(controller, animated: true)
   }
}

Используя эти 2 соглашения я добился высокого процента CrashFree в зоне ответственности контроллеров и окружения. Почему же возник вопрос, заданный в самом начале?

Code

Рассмотрим основные моменты, с которыми я столкнулся, чтобы начать перевод построения интерфейсов со Storyboard в код. Я не до конца уверен, что это единственный правильный путь, но единственный известный мне, который позволяет получить меньше проблем, чем используя Storyboard.

1. Изменения дизайна

Хорошо, если при небольшом изменении дизайна меняется один контроллер. Без вопросов проще и быстрее все обновить в Storyboard. Но, когда дело касается 3, 5, 10 а то и почти всех контроллеров это конец света. На деле такое бывает достаточно часто. Меняется общий стиль для всех таблиц, шрифт, цвета кнопок. Большинство описанных проблем решается каким-либо классом Style в котором у каждого уважающего себя iOS разработчика будет основной цвет, шрифты и другие атрибуты для построения интерфейсов.

Как только создание нового экрана начинается с того, что весь он или часть копируется из другого - повод задуматься. Гораздо дешевле уже тут использовать и переиспользовать View. Вот мы подошли ко второму пункту

2. Переиспользование

Нет никакой возможности переиспользовать Storyboard. На помощь приходят Xib, комбинации и макароны код. Эти подходы позволяют переиспользовать код. Но теряется визуальная составляющая в Storyboard. Необходимо запоминать где лежат те или иные части интерфейса.

Все!

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

Если после прочтения статьи вам показалось, что я рекомендую избавиться от Storyboard и начать все дизайнить в коде - это не так. Из кода стоит делать верстку только в двух случаях. А именно: точно известно, что дизайн будет меняться или множественное переиспользование. Есть еще возможность скомбинировать Storyboard и код в случае динамического разнопланового контента.

Update

Как указали в комментариях getController и initController не панацея. Вот уже пару недель я использую другой подход, который пока полностью не опробовал не публиковал тут.

convenience init(player: UserModel?) {
    self.init()
    
    self.player = player
}

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

Router по прежнему нужен, он делает работу по созданию объекта контроллера. Но больше он отвечает за переходы, анимацию.

func presentScheduleEdit(parent: ViewControllerBase,
                         event: YouthEvent?,
                         delegate: UpdatableViewController) {
    let controller = ScheduleEditEventViewController(event: event, delegate: delegate)
    let masterNC = MasterNavigationController.makeMasterNC(rootViewController: controller)
    parent.present(masterNC, animated: true, completion: nil)
}
  • 13 авг 2017
  • iOS
10 комментариев
Kek, 21 августа 2017 г.

Внутрь getController передаются необходимые параметры. initController проверяет входящие параметры, и, если его все устраивает создается контроллер.

А в каком случае может быть такое, что контроллер что-то не устроит?

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

Если для перехода к контроллеру (и для его создания) используется только Router, то зачем нужны методы getController и прочие в базовом классе контроллера? Не должно ли оно быть в роутере, так как задача делегирована ему.

На помощь приходят Xib... Эти подходы позволяют переиспользовать код.

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

Так все таки, чем это лучше (использовать код вместо ксибов и сторибордов)?

В общем не ясна причина такого строгого отказа от сторибордов. У всего есть свои плюсы и минусы. Для общей верстки экранов – сториборды, для переиспользуемых элементов – ксиби и кастомные вьюшки, в зависимости от их толщиныю

ответить
Oxozle, 22 августа 2017 г.

А в каком случае может быть такое, что контроллер что-то не устроит?

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

Если для перехода к контроллеру (и для его создания) используется только Router, то зачем нужны методы getController и прочие в базовом классе контроллера? Не должно ли оно быть в роутере, так как задача делегирована ему.

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

По большому счету статья о том, что сейчас хоть и развиты Storyboard, от дизайна из кода не отказаться. И есть случаи, где он гораздо удобнее.

ответить
Kek, 22 августа 2017 г.

Так как getController принимает не строго типизированные данные, а словарь

Я кстати просто определяю свойства в контроллере и устанавливаю их при его создании.

let controller = MyController.instantiate()
controller.property1 = "blabla"
controller.property2 = ["test", ...]
...
ответить
Oxozle, 22 августа 2017 г.

Такое возможно, но лучше все же использовать конструктор и передавать обязательные параметры для инициализации там. Это позволит быть уверенным, что в контроллере будут все данные, которые необходимы. Также это позволит проще подойти к тестированию. Мне кажется, сейчас у вас нет тестов (правда и у меня их не особо много).

Это также заставляет помнить какие объекты нужно обязательно инициализировать в контроллере. А еще это открывает внутреннее строение.

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

ответить
Kek, 22 августа 2017 г.

Это также заставляет помнить какие объекты нужно обязательно инициализировать в контроллере.

Это и нужно. Я написал упрощенно, и только для случая создания контроллера из сториборда, для ручной инициализации конечно лучше использовать convinience init. Не нужно создавать контроллеры напрямую, нужно использовать роутер.

// Router
func openSomething(parent: ..., property1: String, property2: String? = nil) -> Bool {
    // validation
    ...

    let controller = MyController.instantiate()
    controller.property1 = property1
    controller.property2 = property2
    controller.dataSource = myDataSource
    ...
}

Получаем проверку типов на этапе компиляции, валидацию данных вне контроллера (тем более валидацию бизнес-логики в контроллер не засунешь). Другое дело, что поле может быть забыто, но в юнит-тестах это выяснится. Если не выяснится, мы получим крэш, который найдем. Либо будет дефолтное поведение контроллера в случае таких ситуаций ("валидация presentation-логики", которую нужно засунуть в контроллер). Не будет такого, что пользователь нажал на кнопку и ничего не произошло.

А еще это открывает внутреннее строение.

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

И насчет валидации у меня такое мнение: если мы не дошли до показа контроллера в роутере (провалили валидацию) – значит косяк в бизнес-логике. Если же мы показали контроллер, то косяки могут остаться только в presentation-логике, и контроллер сам решит, как это отобразить.

Не понимаю, как я дошел до валидации :) Это как начать читать на википедии о самолетах, а закончить эзотерикой :)

ответить
Oxozle, 22 августа 2017 г.

Я добавил в статью пример из реального проекта, очень похож на ваш пример.

func presentScheduleEdit(parent: ViewControllerBase,
                         event: YouthEvent?,
                         delegate: UpdatableViewController) {
    let controller = ScheduleEditEventViewController(event: event, delegate: delegate)
    let masterNC = MasterNavigationController.makeMasterNC(rootViewController: controller)
    parent.present(masterNC, animated: true, completion: nil)
}

Сейчас пришел к тому, что всей presentation логикой управляет Router (готовит контроллер и анимации). Router принимает строготипизированные данные.

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

Это могут быть сеттеры, инкапсуляция не нарушается, так как имплементация все равно не известна вызывающему коду. Сеттеры тоже не помогут. Чем плох обычный convinience init?

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

ответить
Kek, 21 августа 2017 г.

Коммент превратился в кашу. Надо конвертировать "\n" в "
".

ответить
Oxozle, 22 августа 2017 г.

Спасибо, поправил, теперь поддерживается Markdown.

ответить
Kek, 22 августа 2017 г.

Основные минусы сторибордов: 1. тяжкий резолв конфликтов 2. не знаешь что происходит under the hood 3. уменьшение скорости компиляции.

ответить
Oxozle, 22 августа 2017 г.
  1. Это решается разделением сторибордов на более мелкие
  2. Под капотом достаточно понятно из документации Apple, еще ниже нет смысла спускаться
  3. Да, это согласен. И более того, сильно уменьшает продуктивность лагучий дизайнер, который при каждом переходе в дизайнер / код / дизайнер заново пытается докомпилить код, чтобы отобразить его.

В последнем случае я уже предпочитаю не использовать Xib, а для переиспользования кода использовать SnapKit

ответить
Ваш комментарий
адрес не будет опубликован
Текст