Pull to refresh

SwiftUI: архитектура State-Model-View

Level of difficultyMedium
Reading time9 min
Views4.4K

В документации по UIKit компании Apple можно найти объяснение, что структура приложений основана на шаблоне проектирования Model-View-Controller (MVC).

В материалах Apple по SwiftUI объяснений и даже просто ссылок на паттерны проектирования, похоже, нет. Попробуем сначала разобраться почему. Далее рассмотрим логичные и простые решения для построения как отдельных компонентов, так и уровень приложения с использованием состояний и property wrappers; подход, который логично обозначить как State-Model-View.

Почему тема паттернов сложна

Тема использования шаблонов (паттернов) для архитектуры приложений считается сложной. Начинающие разработчики с ней мучаются много и долго. Сложна она скорее не тем, что непонятны сами идеи паттернов проектирования с акронимами, возникшими чаще задолго до мобильных приложений, а тем что изобретатели придумывают некоторые новые абстрактные сущности вместе с изощренными методами как их связать друг с другом. Та-да, Сoupling здесь передает нам большой привет! :)

Представьте уважаемого джуна, который не может ухватить практическую целесообразность применения тех или иных паттернов только потому что именно об этом  обычно не говорится с примерами кода, позволяющими понять почему с паттерном жить лучше, чем без него. Дело ограничивается абстрактными тезисами которые читателю нужно принять на веру, например, что данный [подставьте свой любимый] шаблон “решает проблему сборки”, “облегчает процесс разработки сложных приложений”, “позволяет отделить логические части проекта на разные объекты” и даже страшилки, что “упрощенный подход к архитектуре непременно сыграет злую шутку”.

Не будем спорить с уважаемыми авторами и примем эти тезисы на веру. Поищем, что говорит сама Apple про архитектуру для SwiftUI - приложений. Хм, похоже, почти ничего. 

Впрочем, на форуме Swift-разработчиков легко найти юмористическую презентацию Stop using MVVM for SwiftUI. В ней говорится, что не нужно заниматься овер-инжинирингом, а лучше использовать простые решения в логике SwiftUI вместо искусственно усложненных. Всё это со сравнениями фрагментов кода. Попутно отметим, что архитектура там в шутку названа как MV без С, то есть Model-View без Controller. Очень ценны и обширные комментарии к посту с различными точками зрения и примерами.

Людям не интересно обсуждать простые и понятные вещи, тема использования паттернов для проектирования кода действительно сложна.

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

О декларативном синтаксисе

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

SwiftUI uses a declarative syntax, so you can simply state (state подчеркнуто мной) what your user interface should do.

Что здесь означает декларативный синтаксис?

Рассмотрим простой пример. 

Сниппеты этого примера и большинства последующих для SwiftUI взяты отсюда

Здесь разработчик разместил в горизонтальном контейнере HStack два видимых элемента (картинку и текст) и один невидимый экземпляр Spacer, который толкает другие компоненты влево. Если этот Spacer разместить выше post.image, то элементы сместятся к правому краю. Если перевернуть экран симулятора или физического устройства  с portrait на landscape, то все компоненты корректно переместятся. Если включить в iOS dark mode, то цвета фона и текста автоматически переключатся. На разных устройствах, в том числе  iPad визуал будет масштабирован по другому, с учетом другого разрешения и соотношения сторон экрана.

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

Замечательно, если SwiftUI такой весь из себя декларативный, то …

UIKit - императивный фреймворк?

Эх, маркетологи - это не инженеры. Свою работу знают, поэтому их нарративы легко приживаются в умах разработчиков. Можно ли назвать “императивными” методы из UIKit, когда программисты используют, например, вот такие описания:

private func setupConstraints() {
  NSLayoutConstraint.activate([
    aView.topAnchor.constraint(equalTo: self.view.topAnchor),
    aView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
    aView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
    aView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor)          
    // etc.
  ])
}

Здесь по сути задается описание ограничений системы линейных уравнений для их решения внутри движка без участия программиста.  Этот пример - сама квинтэссенция декларативности.

Впрочем, если посмотреть первоисточники из WWDC 2019, где много говорилось именно про декларативность, то всё же дилемма “императивный UIKit vs декларативный SwiftUI” скорее предложена разными последующими комментаторами. Таковы паттерны мышления людей - везде искать и находить  дихотомии, которые маркетологи используют в своих целях.

А тогда какую концепцию разработки реализует UIKit? Для разработчика из 90, недавно освоившим ООП ответ был бы вполне очевидным - это архитектура, управляемая событиями (event-driven), тесно связанная с объектно-ориентированным подходом . Да, действительно, вот довольно старое, но актуальное описание от Apple.

Интересно, что хотя использование иерархии наследования в ООП и создание своих кастомных классов в принципе несложно, но, похоже, разработчики нередко “упрощают” себе жизнь ловкими трюками, как, например, создание экземпляров классов из оберток с замыканиями:

var aButton: UIButton = {
  let button = UIButton()
  button.translatesAutoresizingMaskIntoConstraints = false
  button.setTitle("Login", for: .normal)
  button.setTitleColor(.white, for: .normal)
  button.addTarget(nil, action: #selector(touchTheButton), for: .touchUpInside)
  button.layer.cornerRadius = 12
  button.clipsToBounds = true
  return button
}()

вместо объектно-ориентированного аналога с отдельным классом:

let aButton = MyButton(title: "Login",
                       titleColor: .white,
                       action: touchTheButton)

Не отсюда ли в числе прочего следует скорее надуманная, чем реальная проблема “massive view controllers”? Есть простые методы решения этой проблемы.

Но вернемся к SwiftUI.

Архитектура State-Model-View

Концепция логичной архитектуры для SwiftUI лежит на поверхности. Для визуализации интерфейса через views особое значение имеют переменные, которые имеют смысл состояний и в режиме run time имеют функцию триггеров.

Вот типичный пример, когда в зависимости от состояния булевской переменной, завернутой в property wrapper @State отображается один View, представляющий здесь актуальный вопрос квиза по фреймворку SwiftUI или совсем другой, показывающий уже конечный результат квиза:

Для views количеством более двух можно использовать перечисление enum и конструкцию switch. Пример кода - в конце статьи.

Вопрос: как в примере выше бинарная переменная меняет своё состояние? Ответ: через связывание (binding) и не в родном QuizView, а во вложенном QuizQuestionView, куда она передается через инициализатор структуры с префиксом $ 

Вот сниппет с QuizQuestionView:

Обратим здесь внимание на принципиальную особенность метода передачи данных через binding в SwiftUI: мы никак не извещаем корневой view об изменении состояния переменной quizzing, показывающей находимся ли мы в состоянии квиза и не принимаем никаких действий по скрытию экрана, когда тест уже завершён. Мы в нужный момент просто меняем state-переменную. Далее движок автоматически передает изменение состояния в корневой view и таким образом изменение состояния триггера полностью меняет вид экрана с квиза на его результат без каких либо иных императивных предписаний и условий.

Похожая концепция используется для отображения модальных экранов sheets и алертов:

Здесь код содержит две переменных, от состояния которых зависит, отображается ли алерт на экране, а после ответа “Да” в иерархию отображения добавляется и новый экземпляр SheetView.

Модификаторы .alert и .sheet не обязательно подстраивать к кнопке, они могут быть размещены при любых views в локальном scope свойства body.

То есть архитектура диалога пользователь - экран крутится примерно по такой схеме:

Ключевым элементом здесь является движок под капотом механизма связывания переменных состояния с обёртками, в имени которых присутствует state (@State @StateObject)

По сути эта архитектура крутится вокруг State, дата Model (в этих примерах с использованием SwiftData) и композиции Views.

А кстати, про композицию Views: 

Конструктор лего для иерархии Views

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

В SwiftUI легко сделать маленький компонентный View из двух - трех элементов (как на первом сниппете), далее поместить его в один или несколько более сложных узлов и так далее, пока в умелых руках конструктора из элементарных кирпичиков не соберется приложение в целом. Конечно не всё так просто, но все же принцип лучше использовать такой.

Вот пример одного экрана с тремя различными графиками Swift Charts:

Каждая диаграмма сделана отдельным компонентом, все вместе помещены в вертикальный контейнер VStack и каждому компоненту передана одна версия правды - модели данных из SwiftData. Отметим ясность и простоту компонента StatisticsView на экране выше - вся сложность декомпозирована и скрыта в невидимых здесь деталях.

Модификатор .frame(height: 260) для верхней диаграммы круговой диаграммы вносит точное указание по высоте. Без него бублик сверху будет визуально казаться непропорционально мелким. Если бы диаграмм было только две, а не три, то это ограничение тогда было бы излишним. Поэтому не следует frame включать внутрь компонента SectorMarkView. Посмотрим, кстати на этот компонент детальнее:

Ничего особенного, SwiftUI в отсутствие других соперников за место на экране растянул диаграмму на весь экран. Отметим модификатор .groupBoxed - его нет в стандартной библиотеке модификаторов, это кастомный элемент. Вот он:

GroupBoxWrapper не делает ничего супер-сложного, просто упаковывает наш content view-источник внутрь контейнера GroupBox, немного упрощая визуальное представление кода, но здесь важен еще один принцип конструирования визуальных компонентов SwiftUI: мы использовали протокол ViewModifier и extension для протокола View, чтобы добавить еще один кастомный компонент лего в библиотечку стандартных узлов.

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

В завершение, может быть, самое интересное:

Приложение, управляемое состояниями

В SwiftUI на удивление легко сделать приложение, напрямую управляемое состояниями.

Для примера: [бесплатное] приложение Swift-Way в App Store - симулятор набора разработчиков Swift в A Dream Company. Приложение имеет простой интерфейс, но сложную логику и сценарии, включает в себя специальный скриптовый язык с интерпретатором для генерации диалогов интервью на “живом” экране, модель компетенции программистов.

В любой момент времени это приложение может находиться всего в одном из четырех состояний:

enum AppState: String {
    case intro      // "вы получите приглашение" - сеттинг игры
    case interview  // диалоги: скрининг, HR, тех. интервью
    case menu       // главный экран с TabView
    case game       // встроенная игра, расслабиться в ожидании интервью
}

Как приложение переходит в каждое из них? Посмотрим на ContentView - корневой view в иерархии:

Первое, мы видим, что в зависимости от текущего значения в переменной appState приложение с помощью конструкции switch отображает один из трех case с Views. Самый простой - это MainView с таб-баром, который сейчас виден на темном экране симулятора справа. Это SwiftUI компонент, поэтому переменная состояния передана туда через обыкновенный binding.

Два других - экран интервью и экран casual-игры - это другой фреймворк эпохи UIKit (SpriteKit), поэтому изменение состояния здесь производится через извещение центра нотификаций в замыкании.

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

Это простая и удобная схема для разработки общей архитектуры приложения. Настолько простая, что даже сложно это назвать “паттерном проектирования”.

Впрочем, здесь нужно добавить, что если приложение имеет сложную бизнес-логику с этапами, то нужно различать состояние интерфейса от состояния истории (story). В этом примере имеется еще дополнительное состояние (переменная situation) которая отслеживает общий статус воронки набора нового программиста в команду разработки. Статус может иметь одно начальное состояние (получено приглашение от компании к серии интервью) и несколько конечных, в том числе приятный оффер и между ними различные переходные статусы. 

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

Подведем итог

Не нужно усложнять вещи паттернами с непроверенной ценностью - практично использовать SwiftUI так, “как оно есть”.

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

Tags:
Hubs:
Total votes 7: ↑7 and ↓0+7
Comments3

Articles