Адаптируем существующее бизнес-решение под SwiftUI. Часть 3. Работаем с архитектурой
Сегодня поговорим уже об особенностях архитектуры, и как перенести и встроить в приложение на 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 и сделать чистую конфигурацию, мы поговорим в следующей части.
Исходники примера вы можете найти по ссылке.