Pull to refresh

Почему вам стоит выкинуть MVP из своих проектов

Reading time4 min
Views15K

Всем привет! Сегодня я бы хотел поговорить об архитектуре Android-приложений.
На самом деле я не очень люблю доклады и статьи на данную тему, но недавно ко мне пришло осознание, с которым я бы хотел поделиться.


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


Почти все реализации, которые я видел, выглядели вот так: мы имеем абстрактный класс, от которого наследуем все свои презентеры.


class MoviePresenter(private val repository: Repository) : BasePresenter<MovieView>() {
    fun loadMovies() {
        coroutineScope.launch {
            when (val result = repository.loadMovies()) {
                is Either.Left -> view?.showError()
                is Either.Right -> view?.showMovies(result.value)
            }
        }
    }
}

Также делаем для каждого экрана интерфейс view, с которым будет работать presenter


interface MovieView : MvpView {
    fun showMovies(movies: List<Movie>)
    fun showError()
}

Давайте рассмотрим минусы данного подхода:


  1. Приходится создавать интерфейс View под каждый экран. На больших проектах будем иметь много лишнего кода и файлов, которые затрудняют навигацию по пакетам.
  2. Presenter сложно переиспользовать, так как он завязан на View, а она может иметь специфичные методы.
  3. Отсутствует определенное состояние. Представим, что мы делаем запрос в сеть, и в этот момент наша активити умирает и создается новая. Данные пришли, когда View еще не привязана к Presenter. Отсюда возникает вопрос, как показать эти данные, когда View привяжется к Presenter? Ответ: только костылями. У Moxy, например, есть ViewState, в котором хранится список ViewCommand. Это решение работает, но мне кажется, что тащить кодогенерацию для сохранения состояния View — лишнее (multidex намного ближе, чем вам кажется. Плюс при сборке будет запускаться обработка аннотаций, что сделает ее более долгой. Да, вы скажете, что у нас теперь появился инкрементальный kapt, но для его работы нужны определенные условия). Плюс ViewCommand не являются Parcelable или Serializable, а это значит, что мы не можем сохранить их в случае смерти процесса. Важно иметь персистентное состояние, чтобы ничего не потерять. Также отсутствие определённого состояния не позволяет его централизованно изменять, а это может привести к трудновоспроизводимым багам.

Посмотрим, решаются ли эти проблемы в других архитектурах.


MVVM


class MovieViewModel(private val repository: Repository) {
    val moviesObservable: ObservableProperty<List<Movie>> = MutableObservableProperty()
    val errorObservable: ObservableProperty<Throwable> = MutableObservableProperty()

    fun loadMovies() {
        coroutineScope.launch {
            when (val result = repository.loadMovies()) {
                is Either.Left -> errorObservable.value = result.value
                is Either.Right -> moviesObservable.value = result.value
            }
        }
    }
}

Пройдёмся по тем пунктам, которые отмечены выше:


  1. В MVVM у VIew больше нет интерфейса, так как она просто подписывается на observable поля в ViewModel.
  2. ViewModel проще переиспользовать, так как она ничего не знает о View. (вытекает из первого пункта)
  3. В MVVM проблема состояния решается, но не полностью. В данном примере мы имеем property во ViewModel, откуда View забирает данные. Когда мы сделаем запрос в сеть, данные сохранятся в property, и View при подписке получит валидные данные (и даже не надо плясать с бубном). Также мы можем сделать property персистентными, что позволит сохранить их в случае смерти процесса.

MVI


Определим Actions, SideEffects и State


sealed class Action {
    class LoadAction(val page: Int) : Action()
    class ShowResult(val result: List<Movie>) : Action()
    class ShowError(val error: Throwable) : Action()
}

sealed class SideEffect {
    class LoadMovies(val page: Int) : SideEffect()
}

data class State(
    val loading: Boolean = false,
    val data: List<Movie>? = null,
    val error: Throwable? = null
)

Дальше идет Reducer


val reducer = { state: State, action: Action ->
    when (action) {
        is Action.LoadAction -> state.copy(loading = true, data = null, error = null) to setOf(
            SideEffect.LoadMovies(action.page)
        )
        is Action.ShowResult -> state.copy(
            loading = false,
            data = action.result,
            error = null
        ) to emptySet()
        is Action.ShowError -> state.copy(
            loading = false,
            data = null,
            error = action.error
        ) to emptySet()
    }
}

и EffectHandler для обработки SideEffects


class MovieEffectHandler(private val movieRepository: MovieRepository) :
    EffectHandler<SideEffect, Action> {
    override fun handle(sideEffect: SideEffect) = when (sideEffect) {
        is SideEffect.LoadMovies -> flow {
            when (val result = movieRepository.loadMovies(sideEffect.page)) {
                is Either.Left -> emit(Action.ShowError(result.value))
                is Either.Right -> emit(Action.ShowResult(result.value))
            }
        }
    }
}

Что мы имеем:


  1. В MVI нам также не требуется создавать кучу контрактов для View. Нужно только определить функцию render(State).
  2. Переиспользовать это, к сожалению, не так просто, так как у нас есть State, который может быть довольно специфичным.
  3. В MVI мы имеем определённое состояние, которое можем менять централизованно через функцию reduce. Благодаря этому мы можем отслеживать изменения состояния. Например, писать все изменения в лог. Тогда мы сможем прочитать последнее состояние, если в работе приложения произошел сбой. Плюс State может быть персистентным, что позволит обработать смерть процесса.

Итог


В MVVM решается проблема со смертью процесса. Но, к сожалению, состояние здесь по-прежнему неопределенное и не может меняться централизованно. Это, конечно, минус, но ситуация всё равно стала явно лучше, чем в MVP. В MVI решается проблема состояния, но сам подход может быть немного сложен. Плюс появляется проблема с UI, так как нынешний UI toolkit в android плох. В MVVM мы обновляем UI кусочками, а в MVI стремимся обновить его целиком. Поэтому для императивного ui MVVM будет вести себя лучше. Если же вы хотите использовать MVI, то советую ознакомиться с теорией virtual/incremental DOM и библиотеками под android: litho, anvil, jetpack compose (придётся ждать). Либо можете считать диффы руками.


Исходя из всех данных, приведенных выше, я бы советовал при проектировании приложения выбирать между MVVM и MVI. Так вы получете более современный и удобный подход (особенно в реалиях Android).


Библиотеки, которые могут помочь в реализации данных подходов:
MVVM — https://github.com/Miha-x64/Lychee
MVI — https://github.com/egroden/mvico, https://github.com/badoo/MVICore, https://github.com/arkivanov/MVIDroid


Всем спасибо за внимание!

Tags:
Hubs:
Total votes 22: ↑17 and ↓5+12
Comments19

Articles