Архитектура SwiftUI + VIPER: модульный подход к разработке iOS‑приложений

Дисклеймер: я не претендую на то, что данный подход является идеальном, просто хочу поделиться тем, что у нас отлично работает и дать возможность другим, пользоваться своими наработками, про TCA и MVVM знаю, но нам они не подошли из-за недостаточной гибкости.

Часть 1. Теория

SwiftUI + VIPER — это доработанный архитектурный паттерн разработки приложений, предоставляющий чистую, модульную и легко тестируемую структуру. В этой статье мы рассмотрим, как работает архитектура SwiftUI + VIPER и как создавать приложения, модули и сервисы на ее основе.

Основные принципы архитектуры SwiftUI VIPER

VIPER — это аббревиатура, которая означает View, Interactor, Presenter, Entity и Router. Каждая из этих частей выполняет определенные функции в структуре приложения. Небольшая сложность здесь заключается в том, что для использования этого вместе со SwiftUI нам необходимо добавить такую штуку как — ViewState и немного видоизменить существующие понятия VIPER.

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

SwiftUI + Viper

SwiftUI + Viper

Далее я буду пояснять, какую роль на себя берет каждая часть модуля, сразу показывая исходные коды для лучшего понимания. Для знающих людей здесь главное уловить — зачем нам ViewState и View и как они нам помогают, остальное можно пропустить.

View — отвечает за отображение данных пользователю и взаимодействие с ним. Обрабатывает пользовательский ввод и передает его на обработку презентеру. При написании кода для SUI вы можете думать, что это Storyboard и больше ничего. Здесь мы настраиваем Layout, красим элементы и связываем наш View с данными для отображения.

struct MainView: View {
  var viewState: MainViewState
  
  var body: some View {
        Text("Hello iOS")
  }
}

ViewState — это абстракция, представляющая состояние для View. Она содержит данные, необходимые для отображения текущего состояния интерфейса и обработку события от пользователя. Для простоты можно провести аналогию с ViewController. Например, здесь мы можем реагировать на изменение полей ввода, или задавать анимации. Так же здесь содержится presenter, чтобы передавать и получать какие-то изменения данных.

final class MainViewState: ObservableObject, MainViewStateProtocol{
  private let id = UUID()
  private var presenter: MainPresenterProtocol?
}

Presenter — отвечает за обработку данных от интерактора и их подготовку к отображению на экране. Также управляет взаимодействием между интерактором и видом.

final class MainPresenter: MainPresenterProtocol {
  private let router: MainRouterProtocol
  private let viewState: MainViewStateProtocol
  private let interactor: MainInteractorProtocol

  init(router: MainRouterProtocol, interactor: MainInteractorProtocol, viewState: MainViewStateProtocol) {
      self.router = router
      self.interactor = interactor
      self.viewState = viewState
  }
}

Entity — представляет объекты данных, используемые в приложении. Обычно это простые структуры данных без методов, содержащие только свойства, здесь обойдемся без исходников, так как я полагаю, что Viper — это та архитектура к которой приходят не совсем новички.

Router — отвечает за навигацию между экранами приложения. Обратите внимание, что в инициализатор мы передаем сервис, который и будет отвечать за глобальное состояние навигации.

final class MainRouter: MainRouterProtocol { 
  let navigation: any NavigationServiceType

  init(navigation: any NavigationServiceType){
      self.navigation = navigation
  }
}

Assembly — это сборщик модуля, ответственный за создание всех необходимых зависимостей и инициализацию самого модуля.

final class MainAssembly: Assembly {
  func build() -> some View {
      // Создаем необходимые компоненты модуля
      let navigation = container.resolve(NavigationAssembly.self).build()
      let router = MainRouter(navigation: navigation)
      let interactor = MainInteractor()
      let viewState = MainViewState()
      let presenter = MainPresenter(router: router, interactor: interactor, viewState: viewState)
      viewState.set(with: presenter)
      let view = MainView(viewState: viewState)
      return view
}

Отлично, мы рассмотрели сам модуль Viper, но он не заведется без навигации, в нашем решении она реализована через сервис и здесь будет представлена в упрощенном, но работающем виде.

Сервисы (SOA), на примере NavigationService

Сервисы представляют собой независимые компоненты, которые могут использоваться разными модулями приложения. Они упрощают организацию кода и обеспечивают повторное использование функциональности.

Здесь, как и в прошлой части я буду давать описание каждой части сервиса и сразу давать пример кода.

Views — это перечисление, которое мы будем использовать в нашем сервисе навигации, чтобы понимать, какой именно модуль сейчас находится в стеке. Чтобы SwiftUI мог наблюдать за состоянием навигации мы должны пометить его как Hashable.

enum Views: Equatable, Hashable {
  case main
}

Protocol — описывает контракт для сервиса навигации. Иначе говоря мы будем видеть общаться с нашим сервисом используя только описанные в протоколе свойства и методы. Чуть выше мы говорили, что сервис навигации будет отвечать за глобальное состояние навигации, именно поэтому мы должны подписать его на ObservableObject. Так мы будем сообщать SUI, что в нем произошло изменение.

protocol NavigationServiceType: ObservableObject, Identifiable {
    var items:[Views] { get set }
    var modalView: Views? { get set }
    var alert: CustomAlert? { get set }
}

Service — реализация самого сервиса, здесь мы должны

import SwiftUI

public class NavigationService: NavigationServiceType  {
    
    public let id = UUID()
    
    @Published var modalView: Views?
    @Published var items: [Views] = []
    @Published var alert: CustomAlert?
}

Assembly — это сборщик сервиса, ответственный за создание всех необходимых зависимостей и инициализацию самого сервиса.

final class NavigationAssembly: Assembly {
    //Only one navigation allowed in one app
    static let navigation: any NavigationServiceType = NavigationService()
    
    func build() -> any NavigationServiceType {
        return NavigationAssembly.navigation
    }
}

Отлично, теперь мы умеем делать модули и сервисы, осталось сложить это воедино и у нас получится готовое приложение. Для того, чтобы связать всю эту логику, мы написали несколько вспомогательных классов.

Соединяй и властвуй — все остальное, что нам необходимо, чтобы SUI подружился с Viper и Services

RootApp — Корневой класс приложения представляет точку входа в ваше приложение. Так же он инициализирует все необходимые зависимости и задает ViewBuilder, который нам поможет управлять созданием View. Обратите внимание, что здесь мы связываем наш сервис навигации со SwiftUI передавая его как параметр в RootView.

@main
class RootApp: App {
    
    var appViewBuilder: ApplicationViewBuilder
    @ObservedObject var navigationService: NavigationService
    
    let container: DependencyContainer = {
        let factory = AssemblyFactory()
        let container = DependencyContainer(assemblyFactory: factory)

        // Services
        container.apply(NavigationAssembly.self)
    
        // Modules
        container.apply(MainAssembly.self)

        return container
    }()

    required init() {
        navigationService = container.resolve(NavigationAssembly.self).build() as! NavigationService
        appViewBuilder = ApplicationViewBuilder(container: container)
    }
    
    var body: some Scene {
        WindowGroup {
            RootView(navigationService: navigationService,
                     appViewBuilder: appViewBuilder)
        }
    }
    
}

RootView — корневое представление приложения. Оно использует NavigationStack для управления навигацией между нашими будущими страницами, которые мы создаем используя наш билдер модулей appViewBuilder.

struct RootView: View {
    @ObservedObject var navigationService: NavigationService
    var appViewBuilder: ApplicationViewBuilder

    var body: some View {
        NavigationStack(path: $navigationService.items) {
            appViewBuilder.build(view: .main)
                .navigationDestination(for: Views.self) { path in
                    switch path {
                    default:
                        fatalError()
                }
              }
        }
    }
}

ApplicationViewBuilder — отвечает за создание View в приложении. Проще говоря, в методе build мы должны вернуть метод, который расскажет нам, как должен создаваться Viper модуль для определенной страницы.

@MainActor
final class ApplicationViewBuilder: Assembly {
    
    required init(container: Container) {
        super.init(container: container)
    }
   
    @ViewBuilder
    func build(view: Views) -> some View {
        switch view {
        case .main:
            buildMain()
        }
    }
    
    @ViewBuilder
    fileprivate func buildMain() -> some View {
        container.resolve(MainAssembly.self).build()
    }
}

Неожиданная находка — Подход с ViewBuilder дает нам возможность отображать preview сразу с инициализированными зависимостями. Для этого нам необходимо реализовать логику по внедрению зависимостей в статический ViewBuilder, который мы будем использовать для Preview. То, как здесь берется container — может выглядеть немного страшно, но мы будем использовать этот код только для превью, поэтому, не паримся или делаем свой статичный контейнер.

extension ApplicationViewBuilder {
    
    static var stub: ApplicationViewBuilder {
        return ApplicationViewBuilder(
            container: RootApp().container
        )
    }
}

Используя эту логику мы можем показывать preview таким образом: (бонусом у вас будет работать вся навигация по приложению сразу в превью)

struct MainPreviews: PreviewProvider {
    static var previews: some View {
        ApplicationViewBuilder.stub.build(view: .main)
    }
}

Ну вот, собственно говоря, и все, что нам необходимо знать, чтобы начать писать приложение на SwiftUI используя Viper.

Часть 2. Создаем свое приложение используя готовый шаблон для XCode

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

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

Установка шаблона

  1. Вам нужно скачать локальную копию проекта.

  2. Открыть терминал и перейти в директорию с репозиторием шаблона.

  3. Выполнить в консоли команду:

swift install.swift

После всего проделанного у вас должен появиться шаблон проекта и шаблоны модулей/ сервисов в окне создания новых проектов (файлов) XCode

Создание Проекта

Чтобы начать использовать архитектуру SwiftUI VIPER, вам нужно создать новый проект:

  1. Откройте Xcode.

  2. Выберите «File» > «New» > «Project» или используйте сочетание клавиш »⇧⌘N».

  3. Выберите «VIPER Architecture» в качестве шаблона проекта.

  4. Нажмите «Next» и укажите имя проекта.

  5. Нажмите «Create» и ваш проект будет создан с базовой структурой архитектуры VIPER.

Создание Модуля

Для создания нового модуля в проекте, выполните следующие шаги:

  1. Откройте проект в Xcode.

  2. В навигаторе проекта выберите папку «Modules».

  3. Создайте новый файл, выбрав «File» > «New» > «File…» или используя сочетание клавиш »⌘N».

  4. Выберите «Module» в качестве шаблона и укажите имя модуля.

  5. После создания модуля, удалите ссылку на папку модуля в навигаторе проекта, а затем верните ее обратно, перетащив папку из Finder в Xcode.

  6. Добавьте свой модуль в DI Container в файле RootApp.swift

  7. Теперь вы можете доставать свой модуль из контейнера в ApplicationViewBuilder container.resolve (YOUR_NAME_MODULE_Assembly.self).build ()

Создание Сервисов

Для создания сервисов модуля, выполните следующие шаги:

  1. Откройте проект в Xcode.

  2. В навигаторе проекта выберите папку «Services».

  3. Создайте новый файл, выбрав «File» > «New» > «File…» или используя сочетание клавиш »⌘N».

  4. Выберите «Service» в качестве шаблона и укажите имя сервиса, например, «NetworkService» или «SettingsService».

  5. После создания сервиса, удалите ссылку на папку сервиса в навигаторе проекта, а затем верните ее обратно, перетащив папку из Finder в Xcode.

  6. Добавьте свой сервис в DI Container в файле RootApp.swift

  7. Теперь вы можете доставать свой модуль из контейнера в любом Assembly написав container.resolve (YOUR_NAME_SERVICE_Assembly.self).build ()

Часть 3. Итоги

Теперь вы знаете, что для того, чтобы использовать Viper и SwiftUI, не обязательно прибегать к UIKit. Вы рассмотрели все части приложения и как они связаны, это позволит вам продолжить развивать это направление имея отличную точку для старта. Так же у вас появился шаблон для быстрого создания проектов, модулей и сервисов. Еще у вас появилась возможность создавать Preview с необходимыми вам зависимостями и возможность переходить между экранами в окне превью.

Чего добились мы:

Используя этот подход, мы добились, ухода от прослоек UIKit в нашем Viper. В продакшене мы можем тестировать не только логику приложения, но и состояние страниц после различных бизнес-сценариев. Появилась возможность от отказа от неудобной MVVM и громоздкой и непонятной TCA в сторону более понятную разработчикам. И самое главное — Больше половины кодовой базы теперь шарится между проектами.

P.S. — Если вам понравилась статья, поддержите меня, поставив звезду в репозитории шаблона.

© Habrahabr.ru