Presentation Base

Мы используем нашу архитектуру уже на протяжении нескольких лет. Как показывает практика одним из самым сложных аспектов этого подхода является presentation слой. За время работы я повидал множество различный вариаций и даже мутаций исходного подхода с явным состоянием, в которых терялись основные его свойства и преимущества. В данном цикле статей мы вместе поэтапно построим презентер для достаточно нетривиального экрана, тем самым пролив свет на многие темные участки state подхода.

Общая информация

В этом разделе поговорим об общих концепциях, связанных с нашей реализацией presentation слоя.

Основы презентеров

Начнем с того, что вспомним, на чем базируются наши презентеры. Все презентеры в наших проектах наследуются от класса PresenterBase (пакет presentation-base), с типовым параметром V означающим тип view контракта.

PresenterBase
import android.os.Bundle import androidx.annotation.CallSuper import androidx.lifecycle.ViewModel import io.reactivex.disposables.CompositeDisposable abstract class PresenterBase<V> : ViewModel() { protected val compositeDisposable = CompositeDisposable() @Volatile var view: V? = null private set @CallSuper open fun attachView(view: V) { val previousView = this.view check(previousView == null) { "Previous view is not detached! previousView = $previousView" } this.view = view } @CallSuper open fun detachView(view: V) { val previousView = this.view if (previousView === view) { this.view = null } else { throw IllegalStateException("Unexpected view! previousView = $previousView, getView to unbind = $view") } } @CallSuper override fun onCleared() { compositeDisposable.dispose() } open fun onSaveInstanceState(outState: Bundle) {} open fun onRestoreInstanceState(savedInstanceState: Bundle) {} }

Наши презентеры расширяют класс ViewModel из пакета androidx.lifecycle:lifecycle-viewmodel. Это позволяет решить проблему сохранения состояния при различных изменениях конфигурации (поворота экрана и т.д.), так как инстанс презентера остается тем же. Кроме этого это помогает без дополнительных действий понять, что экран больше не нужен и очистить ресурсы в onCleared.

Так как во всех наших проектах используется RxJava, то в базовом классе презентера также было добавлено поле compositeDisposable, которое автоматически очищается при закрытии экрана.

Также в PresenterBase присутствуют знакомые lifecycle методы onSaveInstanceState и onRestoreInstanceState, которые позволяют сохранить состояние в критических ситуациях (например когда закончилась память и наше приложение было выгружено), однако зачастую для восстановления экрана нам достаточно входных данных. Из-за этого эти методы по умолчанию имеют пустую реализацию, а не являются abstract.

Внедрение зависимостей

Теперь перейдем к тому, как презентеры попадают в Fragment или Activity. Всё начинается с класса DaggerViewModelFactory (пакет view-injection).

DaggerViewModelFactory
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import javax.inject.Inject import javax.inject.Provider class DaggerViewModelFactory @Inject constructor( private val viewModelMap: Map<Class<out ViewModel>, @JvmSuppressWildcards Provider<ViewModel>> ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun <T : ViewModel?> create(modelClass: Class<T>): T = viewModelMap[modelClass]?.get() as T }

Этот класс автоматически собирает все доступные провайдеры презентеров в текущем scope.

Для того чтобы DaggerViewModelFactory нашел презентер его надо объявить следующим образом.

CommentsModule
@Module internal abstract class CommentsModule { @Binds @IntoMap @ViewModelKey(CommentsPresenter::class) internal abstract fun bindCommentsPresenter(commentsPresenter: CommentsPresenter): ViewModel }

Здесь CommentsPresenter автоматически инжектится из-за аннотации @Inject у конструктора и байндится в тип ViewModel (иначе бы тут использовалась аннотация @Provides). Таким образом dagger создает мапу, где ключом будет CommentsPresenter::class, а значением инстанс CommentsPresenter приведенный к типу ViewModel. Однако для того, чтобы зависимости не были инстанциированы раньше времени используются провайдеры, таким образом получаем итоговую мапу типа Map<Class<out ViewModel>, @JvmSuppressWildcards Provider<ViewModel>>, которая находится в аргументах DaggerViewModelFactory.

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

CommentsActivity
class CommentsActivity : Activity { ... @Inject internal lateinit var viewModelFactory: ViewModelProvider.Factory private lateinit var commentsPresenter: CommentsPresenter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) injectComponent() commentsPresenter = ViewModelProviders .of(this, viewModelFactory) .get(CommentsPresenter::class.java) } private fun injectComponent() { App.component() .commentsComponentBuilder() .build() .inject(this) } ... override fun onStart() { super.onStart() commentsPresenter.attachView(this) } override fun onStop() { commentsPresenter.detachView(this) super.onStop() } ... }

Получив таким образом презентер, не забываем о методах attachView и detachView, которые мы вызываем в методах onStart и onStop соотвественно. Именно в этих методах, так как их вызов гарантирован, а также так как они обозначают видит ли экран пользователь. Иногда допускается использование методов onResume/onPause.

Аргументы презентера

Теперь когда мы получили инстанс презентера и подключили к нему наш экран, обсудим как мы передаём в презентер идентифицирующие входные данные. Предполагается, что данные между экранами передаются по классическому алгоритму во view слое через Fragment.arguments или Intent.

Определение

Идентифицирующими данными называем данные, которые характеризуют экран, например: id элемента (самый частый пример), сам объект с данными элемента, запрос, характеризующий список и т.д.

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

Есть два основных подхода как можно передать входные данные через аргументы конструктора презентера и через отдельные методы презентера. Каждый подход предназначен для отдельных видов данных. Допускается одновременное использование сразу обоих методов. Рассмотрим каждый из них подробнее.

Передача аргументов через конструктор

Через аргументы конструктора презентера передаются данные, которые не будут изменяться за всё время существования презентера. Обычно это различные идентификаторы детальных экранов.

Для того, чтобы передать данные через аргумент конструктора нужно:

  1. Добавить Qualifier
CourseId
import javax.inject.Qualifier @Qualifier annotation class CourseId
  1. Добавить аргумент с Qualifier аннотацией в сам презентер
CoursePresenter
class CoursePresenter @Inject constructor( @CourseId private val courseId: Long )
  1. Объявить аргумент в билдере dagger компоненты
CourseComponent
@Subcomponent interface CourseComponent { @Subcomponent.Builder interface Builder { fun build(): CourseComponent @BindsInstance fun courseId(@CourseId courseId: Long): Builder } }
  1. Передать аргумент при создании компоненты
Activity / Fragment
private fun injectComponent(courseId: Long) { App.component .courseComponentBuilder() .courseId(courseId) .build() .inject(this) }

Передача аргументов через отдельные методы

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

Передача данных через отдельные методы:

  1. Определяемся с точками входа в экран (для примера экран имеет 4 точки входа: диплинк, id, объект, пустые данные)
  2. Под каждую точку входа, для которых тип данных отличается, в презентере создаем отдельный метод
LessonPresenter
class LessonPresenter : PresenterBase<LessonView>() { private var state: LessonView.State = LessonView.State.Idle set(value) { field = value view?.setState(value) } /** * Data initialization variants */ // вход по объекту fun onLesson( lesson: Lesson, unit: Unit, section: Section, isFromNextLesson: Boolean, forceUpdate: Boolean = false ) { obtainLessonData( lessonInteractor.getLessonData(lesson, unit, section, isFromNextLesson), forceUpdate ) } // вход по id fun onLastStep( lastStep: LastStep, forceUpdate: Boolean = false ) { obtainLessonData( lessonInteractor.getLessonData(lastStep), forceUpdate ) } // вход по deeplink fun onDeepLink( deepLinkData: LessonDeepLinkData, forceUpdate: Boolean = false ) { obtainLessonData( lessonInteractor.getLessonData(deepLinkData), forceUpdate ) } // вход по пустым данным fun onEmptyData() { if (state == LessonView.State.Idle) { state = LessonView.State.LessonNotFound } } private fun obtainLessonData( lessonDataSource: Maybe<LessonData>, forceUpdate: Boolean = false ) { // проверка допустимости перехода из текущего состояния if (state != LessonView.State.Idle && !(state == LessonView.State.NetworkError && forceUpdate) && !((state as? LessonView.State.LessonLoaded)?.stepsState is LessonView.StepsState.NetworkError && forceUpdate) ) { return } state = LessonView.State.Loading compositeDisposable += lessonDataSource .observeOn(mainScheduler) .subscribeOn(backgroundScheduler) .subscribeBy( onComplete = { state = LessonView.State.LessonNotFound }, onSuccess = { state = LessonView.State.LessonLoaded(it, LessonView.StepsState.Idle); resolveStepsState() }, onError = { state = LessonView.State.NetworkError } ) } }

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

  1. Добавляем метод setDataToPresenter во view слое
LessonActivity
private fun setDataToPresenter(forceUpdate: Boolean = false) { val lastStep = intent.getParcelableExtra<LastStep>(EXTRA_LAST_STEP) val lesson = intent.getParcelableExtra<Lesson>(EXTRA_LESSON) val unit = intent.getParcelableExtra<Unit>(EXTRA_UNIT) val section = intent.getParcelableExtra<Section>(EXTRA_SECTION) val isFromNextLesson = intent.getBooleanExtra(EXTRA_BACK_ANIMATION, false) val deepLinkData = intent.getLessonDeepLinkData() when { lastStep != null -> // вход по id lessonPresenter.onLastStep(lastStep, forceUpdate) deepLinkData != null -> // вход по deeplink lessonPresenter.onDeepLink(deepLinkData, forceUpdate) lesson != null && unit != null && section != null -> // вход по объекту lessonPresenter.onLesson(lesson, unit, section, isFromNextLesson, forceUpdate) else -> // вход по пустым данным lessonPresenter.onEmptyData() } }
  1. Вызываем метод setDataToPresenter при инициализации без аргументаforceUpdate, например в конце onCreate, onCreateView или в onStart
LessonActivity
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ... setDataToPresenter() }
  1. При необходимости вещаем лисенер на SwipeRefreshLayout или кнопку tryAgain
LessonActivity
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ... tryAgain .setOnClickListener { setDataToPresenter(forceUpdate = true) } }

Тут уже с forceUpdate = true.

Полный код LessonActivity
Полный код LessonPresenter

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

Теперь, когда мы научились подключать презентер к экрану и передавать в него данные, перейдем к внутренностям самого презентера. Если абстрагироваться, то по своей сути презентер у нас представляет детерминированный конечный автомат, где текущее состояние хранится в переменной state, а публичные методы представляют собой переходы. Этот подход позволяет сделать view слой максимально простым и сильно сократить количество неопределенности в презентере, так как в любой момент можно получить полное состояние экрана.

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

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

Контракт

Рассмотрим классическую реализацию presentation слоя для экрана со списком с пагинацией:

SubmissionsView
interface SubmissionsView { sealed class State { object Idle : State() object Loading : State() object NetworkError : State() object ContentEmpty : State() class Content(val items: PagedList<SubmissionItem.Data>) : State() class ContentLoading(val items: PagedList<SubmissionItem.Data>) : State() } fun setState(state: State) fun showNetworkError() }
SubmissionPresenter
class SubmissionsPresenter @Inject constructor( ... ) : PresenterBase<SubmissionsView>() { private var state: SubmissionsView.State = SubmissionsView.State.Idle set(value) { field = value view?.setState(value) } override fun attachView(view: SubmissionsView) { super.attachView(view) view.setState(state) } }

Проанализировав наш экран мы поняли, что для него нужно 6 состояний, а именно:

  1. Idle изначальное состояние, когда экран еще не проинициализирован никакими данными. Это состояние присутсвует всегда, кроме очень редких случаев, когда данные передаются в конструкторе. Это стартовое состояние и однажды покинув его, в него нельзя больше вернуться.
  2. Loading состояние загрузки, когда никаких данных нет (временное состояние)
  3. NetworkError состояние ошибки при изначальной загрузке данных
  4. ContentEmpty состояние когда был получен пустой список в ответ наа первый запрос
  5. Content основное состояние экрана, когда список загружен
  6. ContentLoading состояние загрузки следующей страницы (временное состояние)

В презентере состояние хранится в переменной state и при каждом изменении синхронизируется со view. Также состояние явно синхронизируется в методе attachView.

Очень важно, чтобы состояния были действительно полноценными состояниями, а не действиями или чем-то ещё. Чтобы это проверить можно для каждого выделенного состояния смоделировать следующую ситуацию: происходит поворот экрана, view полностью пересоздается и в него сеттится текущее состояние. Если view при этом не может полноценно восстановиться по этим данным, то вы делаете что-то не так.

Переходы между состояниями

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

SubmissionPresenter
fun fetchSubmissions(stepId: Long, forceUpdate: Boolean = false) { if (state != SubmissionsView.State.Idle && !((state == SubmissionsView.State.NetworkError || state is SubmissionsView.State.Content) && forceUpdate)) { return } val oldState = state state = SubmissionsView.State.Loading compositeDisposable += submissionInteractor .getSubmissionItems(stepId) .observeOn(mainScheduler) .subscribeOn(backgroundScheduler) .subscribeBy( onSuccess = { state = if (it.isEmpty()) { SubmissionsView.State.ContentEmpty } else { SubmissionsView.State.Content(it) } }, onError = { if (oldState is SubmissionsView.State.Content) { state = oldState view?.showNetworkError() } else { state = SubmissionsView.State.NetworkError } } ) }

Метод fetchSubmissions обозначает загрузку первой страницы списка. Кроме всего прочего он также является методом, через который передаются данные. Переход по fetchSubmissions может выполняться только из состояния Idle, либо из состояний NetworkError или Content в случае рефреша экрана пользователем (forceUpdate = true), что и проверяется в первых строчках.

fun fetchNextPage(stepId: Long) { val oldState = (state as? SubmissionsView.State.Content) ?.takeIf { it.items.hasNext } ?: return state = SubmissionsView.State.ContentLoading(oldState.items) compositeDisposable += submissionInteractor .getSubmissionItems(stepId, page = oldState.items.page + 1) .observeOn(mainScheduler) .subscribeOn(backgroundScheduler) .subscribeBy( onSuccess = { state = SubmissionsView.State.Content(oldState.items + it) }, onError = { state = oldState; view?.showNetworkError() } ) }

Рассмотрим теперь переход fetchNextPage, который обозначает загрузку следующей страницы. Такой переход возможно совершить только из состояния Content. Также зачастую очень удобно объединять проверку состояний вместе со smart cast, таким образом далее в теле метода будет известно текущее состояние экрана, сохраненное в переменной oldState, и можно будет пользоваться его свойствами. Например в данной ситуации мы достаем оттуда номер текущей страницы (строчка 8).

Обработка ошибок

Что при таком подходе с состояниями делать в случае получения ошибки? В общем случае при возникновении ошибки мы пытаемся откатить изменения состояния, сделанные нашим переходом, и сообщить пользователю про ошибку.

Таким образом, если при переходе по fetchNextPage срабатывает onError, то мы откатываемся до предыдущего состояния, которое было до перехода и пишем пользователю об ошибке.

В fetchSubmissions ситуация немного иная. Так как мы не можем перейти в Idle, то нам приходится переходить в NetworkError.

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