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

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