Всем привет! Сегодня я бы хотел поговорить об архитектуре 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()
}
Давайте рассмотрим минусы данного подхода:
- Приходится создавать интерфейс View под каждый экран. На больших проектах будем иметь много лишнего кода и файлов, которые затрудняют навигацию по пакетам.
- Presenter сложно переиспользовать, так как он завязан на View, а она может иметь специфичные методы.
- Отсутствует определенное состояние. Представим, что мы делаем запрос в сеть, и в этот момент наша активити умирает и создается новая. Данные пришли, когда 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
}
}
}
}
Пройдёмся по тем пунктам, которые отмечены выше:
- В MVVM у VIew больше нет интерфейса, так как она просто подписывается на observable поля в ViewModel.
- ViewModel проще переиспользовать, так как она ничего не знает о View. (вытекает из первого пункта)
- В 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))
}
}
}
}
Что мы имеем:
- В MVI нам также не требуется создавать кучу контрактов для View. Нужно только определить функцию render(State).
- Переиспользовать это, к сожалению, не так просто, так как у нас есть State, который может быть довольно специфичным.
- В 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)
thelongrunsmoke
19.09.2019 11:20+1На самом деле, ни одной из перечисленных вами проблем нет.
Презентер опирается на интерфейс, а никак не на реализацию, так что вы можете использовать его для разных вариантов одного вью. А хранение состояний в презентере — одно из худших решений, которое может придти разработчику в голову.
Храните всё в виртуальной БД уровня модели, используйте потоки, обычные или реактивные, для ленивого доступа, и забудьте о проблемах MVP.
На самом деле, у MVP есть минусы, но связаны они с совсем другими аспектами.
agent10
19.09.2019 12:25-1Звучит всё как: «ах, ох, всё устарело и ваще фигня, на хайпе сейчас RIBs»
На MVP реализовывались и продолжают реализовываться проекты разного масштаба. Его хорошо выбирать когда не нужна сложная поддержка как в MVI/RIBs, или кому не очень нравится замешивание работы логики/маппинга/UI внутри ViewModels в MVVM.
rboots
19.09.2019 16:45Правильнее сказать «Не стоит использовать MVP для любых проектов, есть проекты, для которых лучше подходят другие подходы». А то посыл статьи получается похож на «никогда не используйте самосвалы, я пробовал ездить на самосвале на работу — сложно парковать».
psinetron
Не хочу разводить споров, но не стоит так категорично относиться к MVP
Видимо вы не правильно готовите MVP.
Просто пробегусь по минусам:
Создайте общий view и используйте его для тех случаев, когда вам не нужны лишние view или конструкции повторяются
Презентер завязан на интерфейс VIew, а не на VIEW в понимании компонента андроида. Презентер не касается визуальной составляющей, а общается через интерфейсы, которые вообще привязаны к визуальной составляющей только в одностороннем порядке. Это позволяет вам использовать один презентер для разных активностей имеющих набор общих данных но разную визуальную составляющую
Такое может возникнуть например в случае, когда мы открыли activity, и потом резко повернули экран, чтобы активити пересоздалось. Тогда используйте Observable и получайте уже загруженные данные.
Вот MVVM на мой взгляд как-раз и получается чрезмерно перегруженным и уж очень сильно зависит от VIEW в понимании визуальной составляющей. В своем примере вы приводите пример obsrvable, но о минусах MVVM не упоминаете ) Исходя только из приведенного примера MVVM можно вообще использовать котлиновские callback.
Возможно просто я не умею готовить MVVM ;)
Denis_Egorov Автор
1. Не очень понял, что значит «общая View»
2. Да, presenter завязан на интерфейс. Но интерфейс же завязан на View. Т.е переиспользовать вы сможете, если контракт View содержит два метода: showData(Data) и showError()
3. Это уже переход к MVVM)
Спасибо за комментарий!
psinetron
по первому пункту — ниже уже ответили. Под общим вью подразумевается общий интерфейс View. Когда рассказываешь про MVP главное не путать interface View из ООП и View который рендерится и отображается на экране.
по второму пункту — активность (давайте будем использовать это, вместо слова интерфейс, так мы не запутаемся) имплементирует interface View, а не зависит от него. Это же не абстрактный класс, а именно интерфейс
3. Observable это паттерн в более узком смысле, чем MVVM
DarthVictor
Не знаком ни с Андроидом, ни с Котлином, но видимо имелось в виду
что вместо
можно писать
и вместо указывать
И кажется, что таких интерфейсов будет не так много как вьюх. Но это не точно.