Проблема

Один из проектов нашей компании использует архитектуру VIPER. Во времена UIKit проблем с ней не было, но настала новая »темная» эра SwiftUI. В условиях SwiftUI «чистый» VIPER невозможен. Пришлось что-то придумывать, поскольку аналогичное решение в сети не подходило.

Теория

Моя идея состояла в следующем: если VIPER использует делегаты и реактивность в нем смотрится как минимум странно, а SwiftUI как раз таки реактивен - нужно сделать отдельный слой, который будет поддерживать реактивность, и при этом будет работать как делегат Presenter слоя. Почему не использовать реактивные переменные в Presenter? Ответом послужит, что мне не хотелось загромождать этот слой реактивностью. Как по мне, эти слои должны быть разделены и Presenter должен выполнять свои изначальные функции. В таком случае возникает меньше путаницы и идет разграничение: что происходит и на каком именно слое. Название у этого слоя выбрано Model, что следует из тайтла статьи. Он должен работать, как работало бы View со стороны взаимодействия с Presenter. То есть принимать ивенты и отправлять свои на SwiftUIView. А с самим SwiftUI View слой будет связан реактивными переменными.

Реализация

Для начала разберем реализацию самого слоя Model

import Combine

class SceneModel: ObservableObject {
    
    var output: SceneModelOutput! // Реализуем презентер как делегат модели
    
    @Published var articlesState: State = .rest {
        didSet {
            if articlesState == .loaded {
                setSpinnerState(isSpin: false)
            }
        }
    }
        
    @Published var articles: [Articles] = []
        
    @Published var spinnerState: Bool = false
        
    func loadArticles() {
        output.loadArticles()
    }
        
    func setSpinnerState(isSpin: Bool) {
        spinnerState = isSpin
    }
}
    
// Реализуем модель как делегат презентера
extension SceneModel: SceneModelInput {
    func setArticlesState(state: State) {
        articlesState = state
    }
    
    func setArticles(articles: [Articles]) {
        self.articles = articles
    }
}

В SceneModel мы храним различные состояния и данные, которые нам подготавливает Presenter, в нашем случае это реактивные поля.

// Реализуем презентер как делегат модели
extension ScenePresenter: SceneModelOutput {
    func loadArticles() {
        interactor.loadArticles()
    }
}

// Вызываем метод делегата презентера
extension ScenePresenter: SceneModelOutput {
    func articlesLoaded(articles: [Article]) {
        model.setArticles(articles: articles)
        model.setArticlesState(state: .loaded)
    }
}

В Presenter мы реализуем функционал делегата модели и, в нашем случае, получая событие из Interactor выполняем функции на модели.

import SwiftUI

struct SceneView: View {
    
    @ObservedObject var output: SceneModel
    
    var body: some View {
        Group {
            switch output.articlesState {
            case .loaded:
                List {
                    ForEach(output.articles) {
                        Text($0.text)
                    }
                }
            default:
                EmptyView()
            }
        }
        .onAppear(perform: {
            output.loadArticles()
        })
    }
    
}

В SwiftUIView выстраиваем взаимодействие с моделью.

Во всех остальных же модулях все остается работать так же, как и работало раньше.

Таким образом получаем работающий в реактивном пространстве VIPER со SwiftUI. При этом не нарушается суть VIPER в делегировании полномочий между модулями и сохраняется "реактивность" SwiftUI.

Надеюсь, что я ничего не нарушил в процессе написания статьи. Прошу не кидать в меня палками, это только первая моя статья) Буду рад конструктивной критике!

Комментарии (12)


  1. MFilonen2
    29.08.2021 12:58
    +3

    Честно говоря, всегда удивляла эта архитектура. Несмотря на формальную слабую связанность, фактически в большинстве случаев все элементы связаны сильно, потому что протоколы не переиспользуются для других экранов. Вообще ни один компонент не переиспользуется.
    То есть вместо того, чтобы делегировать составляющие контроллера его компонентам и потом их переиспользовать (повторяющиеся анимации, действия, стили, логику работы и отображения), происходит не разделение, а расслоение контроллера, где каждый слой все равно уникален.
    При этом такой подход и такое количество файлов убивает желание заниматься настоящей, разделяющей, декомпозицией.
    Здесь же наблюдаем именно такую ситуацию. В SwiftUI реактивность является одной из основных идей фреймворка. Абстрагирование реактивности в пользу ручного управления состоянием должно иметь веские причины. Здесь же основная причина – противоречие канонам спорной, по моему мнению, архитектуры.


    1. varton86
      30.08.2021 08:56

      Протоколы переиспользуются, например, для тестов и моков. Всё там хорошо, кроме большого объема кода, но это лечится генерацией. У реактивщины есть свои минусы - когда ее много, она становится плохо управляемой.


      1. t-nick
        30.08.2021 13:59
        +1

        Моки никак нельзя назвать переиспользованием, потому что это код тестов, а не приложения.


    1. spiceginger
      02.09.2021 13:21

      У нее действительно слабая связанность. Просто ее часто неправильно готовят. Представим что у вас в приложении есть несколько UITableView, для тейбл вью у вас один вайпер на всех, у каждой ячейки в таблице свой собственный вайпер — позволяет вам переиспользовать ячейку в разных таблицах — ей не нужно знать где она. Допустим одна и таже ячейка может иметь или не иметь кнопку "Like" — извлекаем ее из ячейки и делаем ей собственный вайпер. Вот вам и слабая связанность и полное переиспользование кода и Р. Мартин счастлив. Впрочем это относится не только к вайперу, но и ко всяким вью моделям и тд.


      1. MFilonen2
        02.09.2021 14:16

        А Вам не кажется, что вайпер на одну кнопку – это слишком много? Разве не логичнее делить кнопку не на шаблонные компоненты, а на аспекты кнопки – стиль там, способ нажатия, состояния?


        1. spiceginger
          02.09.2021 14:47

          Я хотел бы вам написать "Вы не понимаете — это другое" :) Но напишу так. Все должно быть сбалансировано конечно. И то что вы пишете имеет смысл если это какая то уникальная кнопка. Понятия слишком много не существует если того требует задача. Но если мы вернемся к кнопке лайк и посмотрим на ее бизнес задачу, допустим что бы поставить лайк пользователь должен быть авторизован, то есть только кликнув на кнопку пользователю может быть показан логин экран, после успешного прохождения авторизации (а может и процесса создания аккаунта) нужно будет потом отправить состояние лайк на бэкэнд и обновить состояние кнопки. Вы же не будете разруливать это в каждой вью модели (или бог знает что там экраном управляет) экрана который поддерживает лайки? Понятно что вы как то вычлените это. Но зачем вью модели экрана в целом знать о том что что то на экране может быть лайкнуто? А я часто вижу код что каждая вью модель знает про то что есть кнопка/ки лайк и редиректит в эту вычлененую чать -то есть фактически бессмысленный копи паст? При этом копка лайк допустим разная если это лист сущностей или экран деталей сущности. А еще с текстом без текста, а еще отправляет разную аналитику но выглядит при этом одинаково? А еще возможно за кнопку лайк отвечает вообще другая команда? Вот вам тут и пригодится и слабая связанность и отдельный вайпер для кнопки. Отделяем мух от котлет и властвуем.


          1. MFilonen2
            02.09.2021 15:26

            Я не считаю, что все эти задачи типа проверки авторизации и прочего должны быть частью кнопки лайк. Все эти задачи вообще не обязаны быть частью интерфейсного элемента. Они могут быть нужны когда-угодно, и как их достать из этого вайпера кнопки потом?
            А экраном никто не должен управлять целиком, особенно если экран сложный. Надо разделять экран на несколько VC, делать делегаты-обработчики, которые не знают про существование экрана, а просто описывают логику, и так далее. В данном случае просто назначьте обработчик делегатом кнопки, но обработчик будет не классом, а тайпалиасом, который будет можно применить и к другим кнопкам с другими дженерик-параметрами.


            1. spiceginger
              02.09.2021 15:35

              Вы фактически сказали тоже самое что и я только назвав тоже самое другими словами. Все должно быть составным. И на этом можно остановиться потому что мыслим мы одинаково. Просто забудьте слово вайпер. И мы тут же окажемся в согласии о том что автор понимает вайпер слишком буквально — как 5 сущностей…
              ЗЫ: я нигде не сказал что авторизация является частью конпки лайк.


              1. t-nick
                03.09.2021 00:51

                Если VIPER не понимать буквально, он просто перестанет существовать :).

                Кстати т.н. Clean Architecture - по сути всего лишь про абстракции и создание границ между слоями, что есть ни что иное как DI из SOLID. То есть про возможность менять части системы по отдельности. Ни о каком фреймворке там речи не шло.

                VIPER же похож на какой-то карго культ, особенно в попытке натянуть его на SwiftUI.


                1. spiceginger
                  03.09.2021 01:11

                  А собственно никто и не сказал что вайпер это фреймворк. Фреймворк это что то что можно загрузить как библиотеку. Вайпер всего лишь позволяет не ломать голову над названиями разных сущностей из разных слоев. Остальное от лукавого.


                  1. t-nick
                    03.09.2021 13:02

                    "Фреймворк" не в смысле подключаемой библиотеки, а в смысле организации кода.

                    Вайпер всего лишь позволяет не ломать голову над названиями разных сущностей из разных слоев.

                    Тут вообще спорно, что VIPER про разные слои. По идее так должно быть, но по факту, все, кроме Entity относиться к UI слою (из тех примеров что я видел), так как Interactor обычно тесно связан с логикой UI.

                    Кстати, почему-то авторы VIPER совершенно забыли про Controller, в CA именно он должен вызывать Interactor, Presenter работает в другую сторону. И Presenter и Controller являются адаптерами между UI и Interactor.

                    VIPER есть ни что иное, как криво преподнесенный MVP, который Боб Мартин прямо приводит в качестве примера, внезапно, Clean Architecture. А что такое MVC в iOS? Да то же самое! Так как там View так же ничего не знает о Model. Model в MVP - делает то же самое, что и Interactor в Viper.
                    Все что добавляет VIPER - интерфейсы, которые и так должны быть, если вы следуете DI принципу. Clean Architecture, чистая в первую очередь от фреймворков.


                    1. spiceginger
                      03.09.2021 16:14

                      Примерно так и есть. Никто не сказал что каждая часть вайпера это одна сущность. И его просто стоит держать в голове что бы задумываться о тех же протоколах которые как вы тоже верно отметили тоже должны быть.

                      Я не являюсь адептом вайпера если кому то показалось. Но практически любой код который я видел и который мне нравится будет условно разделен на подобные слои. А мой собственный уж точно.