Адаптируем существующее бизнес-решение под iOS. Часть 3. Работаем с архитектурой

—

от автора

Продолжаем разбирать тонкости SwiftUI. С предыдущими частями можно ознакомиться по ссылкам:
часть 1
часть 2

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

Исходники примера вы можете найти по ссылке

ссылка на оригинал статьи https://habr.com/ru/post/500470/


Комментарии

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

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