MVI в мобильном приложении Playtomic

Прошлым летом мы окончательно решили перейти от классической архитектуры представлений MVP+UIKit/Android к более современной архитектуре с SwiftUI/Compose в качестве основных действующих лиц для нашего слоя представлений. Вместе с изменениями фреймворка пользовательского интерфейса мы обнаружили необходимость перехода к более реактивной архитектуре, которая лучше соответствует природе декларативных пользовательских интерфейсов.

Мы провели некоторое время, анализируя некоторые из наиболее популярных реактивных архитектур: MVVM, MVI и TCA. Не вдаваясь в подробности принятия решений (это заняло бы целый пост), мы решили, что MVI лучше всего подходит для нашего проекта. С его помощью мы могли получить лучшее разделение задач и управление состояниями, чем в MVVM, однонаправленный поток данных, единый источник истины и простоту тестирования, без дополнительных сложностей, добавляемых TCA.

После примерно полугода работы с MVI, команда очень довольна: все люди в команде считают его хорошим/отличным выбором и с удовольствием работают с ним, единственным недостатком является многословность.

Давайте немного расскажем о том, как мы это делаем:

Слой управления состоянием

Вот как выглядит наш базовый класс MVI на обеих платформах

open class BaseMVIPresenter<S: ViewState, A: ViewAction> {
    public var currentViewState: S { _viewState.value! }
    public var viewState: Observable<S> { _viewState }
    fileprivate let _viewState: MutableObservable<S>

    public init(initialState: S) {
        _viewState = MutableObservable(value: initialState)
    }

    func dispatch(action: A) {
        fatalError("Must be implemented by the children")
    }
}

open class MVIPresenter<S: ViewState, A: ViewAction, R: ActionResult>: BaseMVIPresenter<S, A> {
    private var middlewares: [MVIMiddleware<S, A, R>] = []

    public func with(middleware: MVIMiddleware<S, A, R>) -> Self {
        middlewares.append(middleware)
        return self
    }

    open func handle(action: A, results:  @escaping (R) -> Void) {
        fatalError("Must be implemented by the children")
    }

    open func reduce(currentViewState: S, result: R) -> S {
        fatalError("Must be implemented by the children")
    }

    override public func dispatch(action: A) {
        middlewares.forEach { element in
            element.handle(action: action, presenter: self)
        }
        handle(action: action) { [weak self] result in
            Executor.execute(inBackground: false) {
                guard let self = self else { return }
                self.middlewares.forEach { middleware in
                    middleware.handle(result: result, presenter: self)
                }
                self._viewState.value = self.reduce(currentViewState: self.viewState.value!, result: result)
            }
        }
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

interface BaseMVIPresenter<ViewState, ViewAction> {
    val currentViewState: ViewState get() = viewState.value!!
    val viewState: Observable<ViewState>
    fun dispatch(action: ViewAction)
}

abstract class MVIPresenter<S : ViewState, A : ViewAction, R : ActionResult>(initialState: S) : BaseMVIPresenter<S, A> {
    override val viewState: Observable<S>
        get() = _viewState
    private val _viewState = MutableObservable(value = initialState)
    internal var middlewares = mutableListOf<MVIMiddleware<S, A, R>>()

    abstract fun handle(action: A, results: (R) -> Unit)

    abstract fun reduce(currentViewState: S, result: R): S

    override fun dispatch(action: A) {
        middlewares.forEach { element ->
            element.handle(action = action, presenter = this)
        }
        handle(action) { result ->
            Executor.execute(inBackground = false) {
                this.middlewares.forEach { middleware ->
                    middleware.handle(result = result, presenter = this)
                }
                this._viewState.value = this.reduce(currentViewState = this.viewState.value!!, result = result)
            }
        }
    }
}

fun <S : ViewState, A : ViewAction, R : ActionResult, T : MVIPresenter<S, A, R>> T.with(middleware: MVIMiddleware<S, A, R>): T {
    middlewares.add(middleware)
    return this
}
Вход в полноэкранный режим Выход из полноэкранного режима

Примечание: В MVI нет определения, должна ли часть управления данными выполняться в презентаторе, модели представления или в чем-либо еще. Однако в нашем случае мы решили назвать их «презентаторами», чтобы быть более вписанными в остальную часть приложения, но на практике они поддерживают состояние как классическая ViewModel в Android.

Затем, при создании функции, нам нужно предоставить реализацию 2 методов:

  • handle: Этот метод принимает действия, вызванные другим компонентом (обычно представлением), и обрабатывает побочные эффекты. Он испускает новые события (называемые «результаты действия») с результатами воздействия, например, сетевой вызов. Он не выполняет никакого управления состоянием или манипулирования, он просто испускает новые события результатов.

  • reduce: Учитывая состояние и результат действия, этот метод вычисляет следующее состояние представления. Обратите внимание, что он ведет себя как чистая функция, без побочных эффектов.

В отличие от других более простых реализаций MVI, мы решили разделить код на эти два метода, чтобы изолировать побочные эффекты от манипуляции состоянием, что значительно упрощает наши тесты и делает наше общее решение более надежным и чистым. Дополнительным преимуществом является отсутствие условий гонки, как в других архитектурах, поскольку все вызовы reduce выполняются последовательно, без возможности частичного обновления.

Кроме того, мы добавили дополнительную часть вокруг ведущих, называемую Middleware, которые способны реагировать на события без управления состоянием. Например, мы можем добавить все отслеживание аналитики в промежуточное ПО или все действия навигации. Таким образом, наш ведущий остается пуристом, выполняя только часть управления состоянием, и у нас есть набор небольших промежуточных программ с единственной целью, что еще раз упрощает тестирование и поддержку.

Наконец, вы можете видеть, что обе реализации платформы довольно похожи, и они обе избегают использования специфических для платформы API, таких как Combine или Flows (хотя они используются внутри платформы), чтобы максимизировать отказ от кода при транспонировании, а также уменьшить кривую обучения.

Пример Presenter выглядит следующим образом:

internal class LessonDetailPresenter(...): MVIPresenter<LessonDetailState, LessonDetailAction, LessonDetailResult>(LessonDetailState.none) {

    override fun handle(action: LessonDetailAction, results: (LessonDetailResult) -> Unit) {
        when (action) {
            is LessonDetailAction.onAppear -> loadLesson(results, lessonId)            
            is LessonDetailAction.tapConfirmCancelEnrollment -> unregisterFromLesson(results)
            is LessonDetailAction.resendConfirmation -> resendConfirmation(results)
            ...
        }
    }

    override fun reduce(currentViewState: LessonDetailState, result: LessonDetailResult): LessonDetailState {
        return when (result) {
            is LessonDetailResult.lessonLoading -> LessonDetailState.loading
            is LessonDetailResult.lessonLoaded -> LessonDetailState.detail(sections = result.lesson.mapToLessonDetail(me = userId))
            ...
        }
    }

    private fun loadLesson(results: (LessonDetailResult) -> Unit, lesson: LessonId) {
        results(LessonDetailResult.lessonLoading)
        activityService.fetchLesson(lessonId)
            .then { results(LessonDetailResult.lessonLoaded(it)) }
            .catchError { results(LessonDetailResult.loadLessonByIdFailed(error)) }
        }
    }
    ...
}
Вход в полноэкранный режим Выход из полноэкранного режима

И некоторое связанное промежуточное ПО для навигации:

internal class LessonDetailNavigator(...) : MVIMiddleware<LessonDetailState, LessonDetailAction, LessonDetailResult>() {

    override fun handle(action: LessonDetailAction, presenter: MVIPresenter<LessonDetailState, LessonDetailAction, LessonDetailResult>) {
        when (action) {
            LessonDetailAction.tapOpenMaps -> openMaps(presenter = presenter)
            LessonDetailAction.tapAddToCalendar -> addLessonToCalendar(presenter = presenter)
            ...
        }
    }

    override fun handle(result: LessonDetailResult, presenter: MVIPresenter<LessonDetailState, LessonDetailAction, LessonDetailResult>) {
        when (result) {
            is LessonDetailResult.loadLessonByIdFailed -> dismiss()
            else -> {}
        }
    }
    ...
}
Вход в полноэкранный режим Выход из полноэкранного режима

Наш слой представлений

Затем, наши представления в основном просто получают 2 параметра:

public struct LessonDetailView: View {
    @ObservedObject var state: ObservableViewState<LessonDetailState>
    let dispatcher: (LessonDetailAction) -> Void

    public var body: some View {
        ...
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима
@Composable
private fun LessonDetailView(
    viewState: LiveData<LessonDetailState>,
    dispatcher: (LessonDetailAction) -> Unit
) {
    ...
}
Ввести полноэкранный режим Выход из полноэкранного режима

Как вы видите, мы используем state hoisting для инкапсуляции инъекции ведущего (на уровне представления он не знает, какой класс стоит за управлением состоянием). Это также делает наш код гораздо более многоразовым, и очень упрощает настройку предварительного просмотра, поскольку нам не нужно подражать каким-либо данным, сервису или ведущему, достаточно просто передать состояние представления. Для передачи состояния мы используем родительский UIViewController/Fragment, так как наше приложение теперь смешанное и только часть представлений в SwiftUI/Compose. Они выглядят следующим образом:

final class LessonDetailViewViewController: SwiftUIViewController<LessonDetailState, LessonDetailAction, LessonDetailView> {
    override func contentView(
        viewState: ObservableViewState<LessonDetailState>,
        dispatcher: @escaping (LessonDetailAction) -> Void
    ) -> LessonDetailView {
        LessonDetailView(state: viewState, dispatch: dispatcher)
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима
class LessonDetailFragment : ComposeFragment<LessonDetailState, LessonDetailAction>() {
    @Composable
    override fun ContentView(
        viewState: LiveData<LessonDetailState>,
        dispatcher: (LessonDetailAction) -> Unit
    ) = LessonDetailView(viewState = viewState, dispatcher = dispatcher)    
}
Войти в полноэкранный режим Выход из полноэкранного режима

Где SwiftUIViewController и ComposeFragment являются базовыми классами, которые внедряют презентер и создают UIHostingController/ComposeView для использования SwiftUI/Compose внутри с содержимым, возвращаемым конкретным методом contentView в каждом случае.

Выводы

Существует масса вариантов и архитектур, которые можно использовать со SwiftUI/Combine. В Playtomic мы выбрали версию MVI, где у нас есть четкое разделение различных обязанностей, единый источник истины и простой и однонаправленный поток данных. Это также позволяет создавать очень простые представления и легко переносить данные с одной платформы на другую.
пока что только один недостаток: необходимость в дополнительных шаблонах.

Ссылки

  • Путешествие GoDaddy Studio с управлением состояниями: Отличная статья, объясняющая некоторые проблемы, которые они обнаружили в MVP, MVVM и MVI в их более простой форме. Мы нашли себя очень созвучными их пути.

Оцените статью
Procodings.ru
Добавить комментарий