В этой серии статей используется базовое приложение MVP с использованием Retrofit и RxJava для отображения списка репозиториев Github; и преобразует его в современное приложение для Android - попутно он даст представление о различных методах, используемых при создании архитектуры приложений для Android, и объяснит, почему эти методы используются.

Если вы просто хотите увидеть, как код идет сюда, репо будет обновляться по мере продвижения серии.

Часть 1 - Простая инъекция зависимостей с помощью Dagger

Часть 2. Преобразование докладчиков в модели просмотра

Часть 3 - Архитектура одного вида деятельности

Далее, это проект после добавления Dagger. Следующим изменением будет использование MVVM, который быстро обогнал MVP и стал де-факто шаблоном архитектуры Android.

Происхождение ведущих

Изначально Android страдала от проблемы бога, когда большая часть логики приложения была забита действиями и фрагментами.

Чтобы решить эту проблему, сообщество Android приняло Model-View-Presenter. Докладчики позволили выделить логику в отдельные объекты, что способствовало разделению интересов. Докладчики отвечали за реагирование на события путем сбора и обработки данных. Затем они вызвали представление, давая инструкции о том, как его следует перерисовать.

Презентаторы позволили упростить Activity и Fragments, которые теперь должны были только отправлять жизненный цикл и пользовательские события и реализовывать инструкции для визуализации представления.

Проблемные докладчики

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

  • Докладчики и мероприятия / фрагменты были тесно связаны. Если что-то изменилось в одном, то почти всегда должно было произойти изменение другого.
  • Отладка была головной болью. Выяснение того, откуда взялась ошибка, часто означало выполнение зигзагообразных вызовов, идущих от Activity- ›Presenter-› Activity- ›Presenter-› и т. Д.
  • Состояние могло легко оказаться разбросанным между ведущим и просмотром.
  • Чтобы вызвать представление из ведущего, представление Android должно было быть скрыто за интерфейсом, что означало дополнительный файл, который нужно поддерживать для каждого действия / фрагмента.

Но MVP - далеко не единственный архитектурный паттерн, который можно использовать с Android. Существует множество возможностей, каждая из которых имеет свои преимущества и недостатки.

MVVM

С выпуском Jetpack в 2018 году люди Android создали библиотеки ViewModel, Lifecycle и LiveData. В то же время Google начал предлагать столь необходимые рекомендации по архитектуре приложений Android. Результатом всего этого стало то, что сообщество приняло Model-View-ViewModel - обнаружив, что это решило многие проблемы, возникающие в MVP.

Но я хочу подчеркнуть здесь, что MVVM был возможен до того, как были выпущены библиотеки Jetpack. Библиотеки Jetpack упрощают реализацию MVVM, но библиотеки часто имеют цену гибкости. По этой причине мы попробуем использовать MVVM без библиотек Jetpack.

Что такое MVVM?

В MVVM представление (Activity / Fragment) будет уведомлять ViewModel о событиях жизненного цикла (onStart, onStop) и взаимодействиях с пользователем (onClick, onScroll).

На основе этих событий ViewModel будет взаимодействовать с уровнем данных (сеть, базы данных) для сбора и обработки данных. Пока то же самое, что и ведущие.

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

Одним из преимуществ единого источника истины является то, что становится намного проще отлаживать проблемы (и гораздо труднее создавать проблемы в первую очередь). В MVP нужно было следовать сложным логическим путям, чтобы узнать текущее состояние представления или добавить функции. С помощью единого источника истины мы можем точно увидеть, что должно отображаться в представлении, и можем наблюдать за каждым изменением, которое происходит в состоянии - в том порядке, в котором они происходят.

Давайте попробуем преобразовать докладчика, чтобы выяснить, как MVVM сравнивается с MVP. Ведущий начинает выглядеть так:

class MainPresenter @Inject constructor(
        private val githubService: GithubService
) : MainContract.Presenter {

    private val TEST_USER: String = "JakeWharton"
    private var view: MainContract.View? = null
    private val compositeDisposable = CompositeDisposable()

    override fun attachView(view: MainContract.View) {
        this.view = view
    }

    override fun detachView() {
        compositeDisposable.dispose()
        this.view = null
    }

    override fun loadResults() {
        view?.showProgress()
        val disposable = githubService.fetchCodeRepos(TEST_USER)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(
                        {
                            view?.showResults(it)
                        },
                        {
                            view?.showError(it.toString())
                        },
                        {
                            view?.hideProgress()
                        }
                )
        compositeDisposable.add(disposable)
    }
}

События жизненного цикла

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

private var view: MainContract.View? = null

override fun attachView(view: MainContract.View) {
    this.view = view
}

override fun detachView() {
    compositeDisposable.dispose()
    this.view = null
}

Становится

fun onAttach() {
}

fun onDetach() {
    compositeDisposable.dispose()
}

Без прямой ссылки на представление ViewModel имеет гораздо меньшую связь с представлением. Появляется возможность редактировать один без одновременного редактирования другого - большой бонус.

Также важно отметить, что onStart и onStop (или аналогичные) здесь не используются. Слой ViewModel не обязательно должен знать о сложности жизненного цикла Android. Уровень можно упростить, создав язык жизненного цикла для конкретного проекта - в данном случае onAttach и onDetach - который соответствует конкретным требованиям вашего проекта.

А теперь самое интересное.

Просмотр состояния

Глядя на исходный презентатор, есть только одна функция, которая вызывает изменения в состоянии представления. Это та часть, которая может рассказать нам, как моделировать состояние.

override fun loadResults() {
    view?.showProgress()
    val disposable = githubService.fetchCodeRepos(TEST_USER)
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe(
                    {
                        view?.showResults(it)
                    },
                    {
                        view?.showError(it.toString())
                    },
                    {
                        view?.hideProgress()
                    }
            )
    compositeDisposable.add(disposable)
}

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

data class ViewState(
        val loadingIsVisible: Boolean = false,
        val error: String = "",
        val results: List<GithubRepoEntity> = listOf()
)

В этот класс может входить что угодно динамическое. Цвета, текст, списки элементов, состояния анимации и т. Д. - это представление представления в чистом виде.

Полезно использовать значения по умолчанию, чтобы ViewState мог быть неизменным с ненулевыми свойствами. Хотя это и не обязательно, это просто облегчает вашу жизнь.

Чтобы отобразить этот объект данных, представлению необходим способ прослушивания изменений.

Наблюдаемое состояние просмотра

Поскольку в проекте уже используется RxJava, Subjects - хороший кандидат для создания наблюдаемого ViewState. LiveData и Channels тоже хороши, но может хватить даже простого обратного вызова.

val viewStateEmitter = BehaviorSubject.create<ViewState>()

ViewModel просто обновляет Subject с новым состоянием, и представление подписывается на объект, чтобы прослушивать изменения в состоянии. Преимущество использования BehaviourSubject состоит в том, что на представление не нужно подписываться при отправке первого состояния. Как только представление подписывается, последнее переданное состояние будет выдано снова.

Но как обновить тему с новым состоянием?

Управление состоянием

Чтобы управлять состоянием, ViewModel всегда требует ссылки на последнее состояние. Это связано с тем, что каждое значение в объекте состояния может обновляться отдельно, но другие значения состояния никогда не должны стираться. Сохраняя ссылку на старое состояние, мы избавляемся от необходимости воссоздавать ViewState с нуля каждый раз при его обновлении.

private var lastViewState = ViewState()

private fun emit(viewState: ViewState) {
    viewStateEmitter.onNext(viewState)
    lastViewState = viewState
}

Здесь функция emit используется для упрощения обновления состояния. Путем обновления переменной lastViewState и обновления объекта с новым состоянием.

fun loadResults() {
    emit(lastViewState.copy(loadingIsVisible = true))
    val disposable = githubService.fetchCodeRepos(TEST_USER)
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe(
                    {
                        emit(lastViewState.copy(results = it))
                    },
                    {
                        emit(lastViewState.copy(
                                error = it.toString()
                        ))
                    },
                    {
                        emit(lastViewState.copy(
                                loadingIsVisible = false
                        ))
                    }
            )
    compositeDisposable.add(disposable)
}

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

И, наконец, на вид.

Отображение состояний просмотра

Представление (MainActivity) отслеживает изменения состояния и повторно отображает себя каждый раз, когда происходит изменение.

override fun onStart() {
    super.onStart()
    mainViewModel.onAttach()
    val disposable = mainViewModel.viewStateEmitter.subscribe {
        if (it.loadingIsVisible) {
            a_main_progress.visibility = View.VISIBLE
        } else {
            a_main_progress.visibility = View.INVISIBLE
        }
        mainAdapter?.addAll(it.results)
        if (it.error.isNotBlank()) {
            Toast.makeText(this, it.error, Toast.LENGTH_LONG).show()
        }
    }
    compositeDisposable.add(disposable)
}

Вся визуализация представления выполняется внутри функции подписки. Но рендеринг можно в равной степени абстрагировать в частные функции для каждого изменения или использовать привязку данных для непосредственного рендеринга изменений.

Также обратите внимание, что слушатель добавлен в составной одноразовый объект. Убедитесь, что это удалено с помощью эквивалентного метода жизненного цикла.

override fun onStop() {
    super.onStop()
    mainViewModel.onDetach()
    compositeDisposable.dispose()
}

Итак, Presenter был преобразован в ViewModel без каких-либо библиотек!

Преимущество отказа от использования библиотек Jetpack состоит в том, что тестирование значительно упрощается. Быстрый поиск тестовых моделей представления Jetpack или LiveData приведет к множеству вопросов о переполнении стека по этой теме. Избегая использования библиотек, для модульного тестирования ViewModels не требуется ничего, кроме знания тестирования JUnit.

Обратной стороной отказа от библиотек является то, что проект становится менее стандартизированным (что увеличивает кривую обучения для новых разработчиков), а функции, которые легко доступны в библиотеке, приходится создавать вручную.

Решение о том, следует ли полагаться на библиотеки, никогда не бывает легким, и его всегда следует тщательно взвешивать для каждого проекта.

Заключение

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

Единственное существенное изменение - это то, как ViewModel передает изменения обратно в представление. Часто самым большим препятствием при первом использовании MVVM является представление представлений в терминах объектов данных, которые представляют, как визуализируется представление.

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

  • Представление состояния с запечатанными классами.
  • Обмен информацией о жизненном цикле и пользовательских событиях с помощью запечатанных классов.
  • Создание компонентных ViewModels и наличие нескольких ViewModels на экране.
  • Отрисовка состояния с привязкой к данным

MVVM имеет много преимуществ перед MVP, но имеет свои недостатки. По мере роста ViewModels и их состояний понимание того, откуда происходят изменения в состоянии, и написание эффективных модульных тестов может быстро стать проблемой.

Возможное решение этих проблем можно найти в адаптации паттерна Redux, который получил распространение в веб-разработке. В Android этот шаблон часто называют MVI, и, поскольку он основан на концепции MVVM и использует большую часть той же структуры, он вполне может стать преемником MVVM в будущем.

Послесловие

Вы заметите, что код ViewModel на данный момент не очень СУХИЙ, если бы мы использовали тот же шаблон в другой ViewModel, много кода было бы повторено. Ознакомьтесь с окончательным результатом преобразования здесь с общим поведением, извлеченным в базовые классы - если вам нужно объяснение любого из базовых классов, оставьте комментарий.

Возможно, вы заметили небольшую ошибку в этом коде. Если вы запустили проект и вызов github завершился неудачно, появится всплывающее окно с сообщением об ошибке - хорошо, но если следующий вызов github завершится успешно, отобразятся результаты И появится всплывающее сообщение об ошибке.

Это потому, что не все состояние просмотра является состоянием. Некоторые из того, что часто можно принять за состояние, на самом деле можно назвать побочными эффектами. Я расскажу больше о побочных эффектах и ​​возможных решениях для их устранения в следующей статье.