Всем привет! Сегодня я бы хотел поговорить об архитектуре 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


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

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


  1. psinetron
    19.09.2019 11:19
    +1

    Не хочу разводить споров, но не стоит так категорично относиться к MVP
    Видимо вы не правильно готовите MVP.
    Просто пробегусь по минусам:

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

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

    Presenter сложно переиспользовать, так как он завязан на View, а она может иметь специфичные методы.

    Презентер завязан на интерфейс VIew, а не на VIEW в понимании компонента андроида. Презентер не касается визуальной составляющей, а общается через интерфейсы, которые вообще привязаны к визуальной составляющей только в одностороннем порядке. Это позволяет вам использовать один презентер для разных активностей имеющих набор общих данных но разную визуальную составляющую

    Отсутствует определенное состояние. Представим, что мы делаем запрос в сеть, и в этот момент наша активити умирает и создается новая. Данные пришли, когда View еще не привязана к Presenter…

    Такое может возникнуть например в случае, когда мы открыли activity, и потом резко повернули экран, чтобы активити пересоздалось. Тогда используйте Observable и получайте уже загруженные данные.

    Вот MVVM на мой взгляд как-раз и получается чрезмерно перегруженным и уж очень сильно зависит от VIEW в понимании визуальной составляющей. В своем примере вы приводите пример obsrvable, но о минусах MVVM не упоминаете ) Исходя только из приведенного примера MVVM можно вообще использовать котлиновские callback.
    Возможно просто я не умею готовить MVVM ;)


    1. Denis_Egorov Автор
      19.09.2019 11:31
      -1

      1. Не очень понял, что значит «общая View»
      2. Да, presenter завязан на интерфейс. Но интерфейс же завязан на View. Т.е переиспользовать вы сможете, если контракт View содержит два метода: showData(Data) и showError()
      3. Это уже переход к MVVM)
      Спасибо за комментарий!


      1. psinetron
        19.09.2019 11:37
        +1

        по первому пункту — ниже уже ответили. Под общим вью подразумевается общий интерфейс View. Когда рассказываешь про MVP главное не путать interface View из ООП и View который рендерится и отображается на экране.
        по второму пункту — активность (давайте будем использовать это, вместо слова интерфейс, так мы не запутаемся) имплементирует interface View, а не зависит от него. Это же не абстрактный класс, а именно интерфейс
        3. Observable это паттерн в более узком смысле, чем MVVM


      1. DarthVictor
        19.09.2019 12:32

        1. Не очень понял, что значит «общая View»

        Не знаком ни с Андроидом, ни с Котлином, но видимо имелось в виду
        что вместо
        interface MovieView : MvpView {
            fun showMovies(movies: List<Movie>)
            fun showError()
        }
        

        можно писать
        interface ListView<Model> : MvpView {
            fun showLists(elements: List<Model>)
            fun showError()
        }
        

        и вместо
        MovieView
        указывать
        ListView<Movie>

        И кажется, что таких интерфейсов будет не так много как вьюх. Но это не точно.


  1. thelongrunsmoke
    19.09.2019 11:20
    +1

    На самом деле, ни одной из перечисленных вами проблем нет.
    Презентер опирается на интерфейс, а никак не на реализацию, так что вы можете использовать его для разных вариантов одного вью. А хранение состояний в презентере — одно из худших решений, которое может придти разработчику в голову.
    Храните всё в виртуальной БД уровня модели, используйте потоки, обычные или реактивные, для ленивого доступа, и забудьте о проблемах MVP.


    На самом деле, у MVP есть минусы, но связаны они с совсем другими аспектами.


  1. agent10
    19.09.2019 12:25
    -1

    Звучит всё как: «ах, ох, всё устарело и ваще фигня, на хайпе сейчас RIBs»
    На MVP реализовывались и продолжают реализовываться проекты разного масштаба. Его хорошо выбирать когда не нужна сложная поддержка как в MVI/RIBs, или кому не очень нравится замешивание работы логики/маппинга/UI внутри ViewModels в MVVM.


  1. arkivanov
    19.09.2019 15:18

    Очень хорошая статья! Можно было ещё упомянуть Time Travel в MVI.


  1. rboots
    19.09.2019 16:45

    Правильнее сказать «Не стоит использовать MVP для любых проектов, есть проекты, для которых лучше подходят другие подходы». А то посыл статьи получается похож на «никогда не используйте самосвалы, я пробовал ездить на самосвале на работу — сложно парковать».