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