Адаптация бизнес-решения под SwiftUI: архитектура
В одной из предыдущих статей мы начали разговор о том, как адаптировать уже существующее бизнес-приложение под SwiftUI, а также рассмотрели работу с готовыми библиотеками под UIKit. Продолжим разбирать тонкости SwiftUI и поговорим об особенностях архитектуры и о том, как перенести и встроить в приложение на SwiftUI существующую бизнес-логику..
Стандартный поток данных в SwiftUI построен на взаимодействии View и некой модели, содержащей свойства и переменные состояния, или самой являющейся такой переменной состояния. Поэтому логично, что рекомендуемым архитектурным партерном для приложений на SwiftUI является MVVM. Apple предлагает использовать его совместно с фреймворком Combine, который представляет декларативный Api SwiftUI для обработки значений во времени. ViewModel реализует протокол ObservableObject и подключается как ObservedObject к конкретному View.
Изменяемые свойства модели декларируются как @Published.
class NewsItemModel: ObservableObject { @Published var title: String = "" @Published var description: String = "" @Published var image: String = "" @Published var dateFormatted: String = "" }
Как и в классическом MVVM, ViewModel общается с моделью данных (т.е бизнес-логикой) и передает данные в том или ином виде View.
struct NewsItemContentView: View { @ObservedObject var moder: NewsItemModel init(model: NewsItemModel) { self.model = model } //... какой-то код }
MVVM, как и практически любой другой паттерн, имеет тенденцию к перегруженности и избыточности. Загруженность ViewModel всегда зависит от того, насколько хорошо выделена и абстрагирована бизнес-логика. Загруженность View определяется сложностью зависимостью элементов от переменных состояния и переходов на другие View.
В SwiftUI к этому добавляется то, что View является структурой, а не классом, и, следовательно, не поддерживает наследование, вынуждая дублировать код. Если в небольших приложениях это не критично, то с ростом функционала и усложнения логики перегруз становится критическим, а большое количество копипаста угнетает.
Попробуем воспользоваться подходом чистого кода и чистой архитектуры в данном случае. Совсем отказаться от MVVM мы не можем, все-таки на нем построен DataFlow SwiftUI, но немного перестроить вполне.
Предупреждение!
Если у вас аллергия на статьи про архитектуру, а от словосочетания Clean code выворачивает наизнанку, пролистните пару абзацев вниз. Это не совсем Clean code от дядюшки Боба!
Да, мы не будем брать Clean Code дядюшки Боба в чистом виде. Как по мне, в нем присутствует оверинженеринг. Мы возьмем только идею.
Основная идея чистого кода – это создание максимально читабельного кода, который можно потом безболезненно расширять и модифицировать.
Существует довольно много принципов разработки ПО, которых рекомендуется придерживаться.
Многие их знают, но не все любят и не все используют. Это отдельная тема для холивара.
Для обеспечения чистоты кода как минимум следует разделить код на функциональные слои и модули, использовать решение задач в общем виде и реализовать абстракцию взаимодействия между компонентами. И, по крайней мере, нужно отделить код UI от так называемой бизнес-логики.
Независимо от выбранного архитектурного паттерна логика работы с БД и сетью, обработки и хранения данных отделяется от UI и модулей самого приложения. При этом модули работают с реализациями сервисов или хранилищ, которые в свою очередь обращаются к общему сервису сетевых запросов или общему хранилищу данных. Инициализация переменных, по которым можно обратиться к тому или иному сервису, производится в неком общем контейнере, к которому в итоге модуль приложения (бизнес- логика модуля) и обращается.
Если у нас выделена и абстрагирована бизнес-логика, то мы можем устраивать взаимодействие между компонентами модулей так, как нам нравится.
В принципе все существующие паттерны IOS-приложений функционируют по одному и тому же принципу.
Всегда есть бизнес-логика, есть данные. Также есть некий диспетчер вызовов, то, что отвечает за представление и преобразование данных для вывода и то, куда выводятся преобразованные данные. Разница лишь в том, как распределяются роли между компонентами.
Т.к. мы стремимся сделать приложение читабельным, упростить текущие и будущие изменения, то логично все эти роли разделить. Бизнес-логика у нас уже выделена, данные всегда отделены. Остаются диспетчер, презентер и view. В итоге мы получаем архитектуру, состоящую из View-Interactor-Presenter, в которой интерактор взаимодействует с сервисами бизнес-логики, презентер преобразует данные и отдает их в виде некой ViewModel нашему View. По-хорошему навигация и конфигурация также выносятся из View в отдельные компоненты.
Получаем архитектуру VIP + R с разделением спорных ролей по разным компонентам.
Попробуем посмотреть на примере. У нас есть небольшое приложение агрегатор новостей, написанное на SwiftUI и MVVM.
В приложении 3 отдельных экрана со своей логикой, т.е 3 модуля:
- модуль списка новостей;
- модуль экрана новости;
- модуль поиска по новостям.
Каждый из модулей состоит из ViewModel, которая взаимодействует с выделенной бизнес- логикой, и View, который отображает то, что ему транслирует ViewModel.
Мы стремимся к тому, чтобы ViewModel занимался только хранением готовых для отображения данных. Сейчас же он занимается как обращением к сервисам, так и обработкой полученных результатов.
Эти роли мы переносим на презентер и интерактор, которые заводим для каждого модуля.
Полученные от сервиса данные интерактор передает презентеру, который наполняет подготовленными данными существующую ViewModel, привязанную к View. В принципе в том, что касается разделения бизнес-логики модуля, все несложно.
Теперь переходим к View. Попробуем разобраться с вынужденным дублированием кода. Если мы имеем дело с каким-нибудь контролом, то это могут быть его стили или настройки. Если же речь идет об экранном View, то это:
- стили экрана;
- общие UI-элементы (LoadingView);
- информационные алерты;
- некие общие методы.
Наследование мы использовать не можем, но вполне можем использовать композицию. Именно по этому принципу создаются все кастомные View в SwiftUI.
Итак, мы создаем View-контейнер, в который перенесем всю одинаковую логику, а наш экранный View передадим в инициализатор контейнера и затем используем как контентный View внутри body.
struct ContainerView<Content>: IContainer, View where Content: View { @ObservedObject var containerModel = ContainerModel() private var content: Content public init(content: Content) { self.content = content } var body : some View { ZStack { content if (self.containerModel.isLoading) { LoaderView() } }.alert(isPresented: $containerModel.hasError){ Alert(title: Text(""), message: Text(containerModel.errorText), dismissButton: .default(Text("OK")){ self.containerModel.errorShown() }) } }
Экранный View встраивается в ZStack внутри body ContainerView, куда также вынесен код по отображению LoadingView и код для отображения информационного алерта.
Также нам нужно, чтобы наш ContainerView получал сигнал от ViewModel внутреннего View и обновлял свое состояние. Мы не можем подписаться через @Observed на ту же модель, что и внутренний View, потому что перетянем ее сигналы.
Поэтому мы налаживаем связь с ней через паттерн делегат, а для актуального состояния контейнера используем его собственную ContainerModel.
class ContainerModel:ObservableObject { @Published var hasError: Bool = false @Published var errorText: String = "" @Published var isLoading: Bool = false func setupError(error: String){ //.... } func errorShown() { //... } func showLoading() { self.isLoading = true } func hideLoading() { self.isLoading = false } }
ContainerView реализует протокол IContainer, ссылка на экземпляр присваивается модели встраиваемого View.
protocol IContainer { func showError(error: String) func showLoading() func hideLoading() } struct ContainerView<Content>: IContainer, View where Content: View&IModelView { @ObservedObject var containerModel = ContainerModel() private var content: Content public init(content: Content) { self.content = content self.content.viewModel?.listener = self } //какой-то код }
View реализует протокол IModelView для инкапсуляции доступа к модели и унификации некоторой логики. Модели для тех же целей реализуют протокол IModel:
protocol IModelView { var viewModel: IModel? {get} } protocol IModel:class { //.... var listener:IContainer? {get set} }
Затем уже в этой модели при необходимости вызывается метод делегата, например, для отображения алерта с ошибкой, в котором происходит изменение переменной состояния модели контейнера.
struct ContainerView<Content>: IContainer, View where Content: View&IModelView { @ObservedObject var containerModel = ContainerModel() private var content: Content //какой-то код func showError(error: String) { self.containerModel.setupError(error: error) } func showLoading() { self.containerModel.showLoading() } func hideLoading() { self.containerModel.hideLoading() } }
Теперь мы можем унифицировать работу View, переключившись на работу через ContainerView. Это очень облегчит нам жизнь при работе с конфигурацией следующих модулей и навигацией. Как настроить навигацию в SwiftUI и сделать чистую конфигурацию, мы поговорим в следующей статье.
Исходники примера вы можете найти по ссылке.