# Presentation -- Base Мы используем [нашу архитектуру](https://hackmd.io/e207QR5VQ-yiKagxY3CSAA) уже на протяжении нескольких лет. Как показывает практика одним из самым сложных аспектов этого подхода является `presentation` слой. За время работы я повидал множество различный вариаций и даже мутаций исходного подхода с явным состоянием, в которых терялись основные его свойства и преимущества. В данном цикле статей мы вместе поэтапно построим презентер для достаточно нетривиального экрана, тем самым пролив свет на многие темные участки `state` подхода. ## Общая информация В этом разделе поговорим об общих концепциях, связанных с нашей реализацией `presentation` слоя. ### Основы презентеров Начнем с того, что вспомним, на чем базируются наши презентеры. Все презентеры в наших проектах наследуются от класса `PresenterBase` (пакет `presentation-base`), с типовым параметром `V` означающим тип view контракта. ###### PresenterBase ```kotlin= 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 ```kotlin= 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 ```kotlin= @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`. Однако для того, чтобы зависимости не были инстанциированы раньше времени используются [провайдеры](https://habr.com/ru/post/336414/), таким образом получаем итоговую мапу типа `Map<Class<out ViewModel>, @JvmSuppressWildcards Provider<ViewModel>>`, которая находится в аргументах `DaggerViewModelFactory`. Далее в фрагмент или активити, где планируется использование презентера, инжектится наша фабрика, которая передается в `ViewModelProviders`. ###### CommentsActivity ```kotlin= 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 элемента (самый частый пример), сам объект с данными элемента, запрос, характеризующий список и т.д. Перед тем как выбрать способ передачи стоит понять входные точки экрана, то есть каким образом можно попасть на экран и с какими данными. Например допустим, что у нас есть [экран с детальной информацией об элементе](https://github.com/StepicOrg/stepik-android/blob/master/app/src/main/java/org/stepik/android/view/course/ui/activity/CourseActivity.kt). В него можно попасть по нажатию на элемент списка, по диплинку и через быстрый переход, все эти 3 пункта и будут нашими входными точками. Есть два основных подхода как можно передать входные данные -- через аргументы конструктора презентера и через отдельные методы презентера. Каждый подход предназначен для отдельных видов данных. Допускается одновременное использование сразу обоих методов. Рассмотрим каждый из них подробнее. #### Передача аргументов через конструктор Через аргументы конструктора презентера передаются данные, которые **не будут изменяться за всё время существования презентера**. Обычно это различные идентификаторы детальных экранов. Для того, чтобы передать данные через аргумент конструктора нужно: 1. Добавить `Qualifier` ###### CourseId ```kotlin= import javax.inject.Qualifier @Qualifier annotation class CourseId ``` 2. Добавить аргумент с `Qualifier` аннотацией в сам презентер ###### CoursePresenter ```kotlin= class CoursePresenter @Inject constructor( @CourseId private val courseId: Long ) ``` 3. Объявить аргумент в билдере dagger компоненты ###### CourseComponent ```kotlin= @Subcomponent interface CourseComponent { @Subcomponent.Builder interface Builder { fun build(): CourseComponent @BindsInstance fun courseId(@CourseId courseId: Long): Builder } } ``` 4. Передать аргумент при создании компоненты ###### Activity / Fragment ```kotlin= private fun injectComponent(courseId: Long) { App.component .courseComponentBuilder() .courseId(courseId) .build() .inject(this) } ``` #### Передача аргументов через отдельные методы Этим способом можно передавать почти любые входные данные, однако он добавляет больше неопределенности в код, нежели неизменяемые аргументы конструктора. По конвенции передача данных в презентер осуществляется в методе `setDataToPresenter` с аргументом `forceUpdate: Boolean = false`, который символизирует рефреш экрана пользователем. Передача данных через отдельные методы: 1. Определяемся с точками входа в экран (для примера экран имеет 4 точки входа: диплинк, id, объект, пустые данные) 2. Под каждую точку входа, для которых тип данных отличается, в презентере создаем отдельный метод ###### LessonPresenter ```kotlin= 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` и текущее состояние, чтобы не допустить переинициализации при изменениях конфигурации. 3. Добавляем метод `setDataToPresenter` во `view` слое ###### LessonActivity ```kotlin= 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() } } ``` 4. Вызываем метод `setDataToPresenter` при инициализации **без аргумента`forceUpdate`**, например в конце `onCreate`, `onCreateView` или в `onStart` ###### LessonActivity ```kotlin= override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ... setDataToPresenter() } ``` 5. При необходимости вещаем лисенер на `SwipeRefreshLayout` или кнопку `tryAgain` ###### LessonActivity ```kotlin= override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ... tryAgain .setOnClickListener { setDataToPresenter(forceUpdate = true) } } ``` Тут уже с `forceUpdate = true`. [Полный код LessonActivity](https://github.com/StepicOrg/stepik-android/blob/master/app/src/main/java/org/stepik/android/view/lesson/ui/activity/LessonActivity.kt) [Полный код LessonPresenter](https://github.com/StepicOrg/stepik-android/blob/master/app/src/main/java/org/stepik/android/presentation/lesson/LessonPresenter.kt) ### Управление состоянием экрана в презентере Теперь, когда мы научились подключать презентер к экрану и передавать в него данные, перейдем к внутренностям самого презентера. Если абстрагироваться, то по своей сути презентер у нас представляет [детерминированный конечный автомат](https://ru.wikipedia.org/wiki/Конечный_автомат), где текущее состояние хранится в переменной `state`, а публичные методы представляют собой переходы. Этот подход позволяет сделать `view` слой максимально простым и сильно сократить количество неопределенности в презентере, так как в любой момент можно получить полное состояние экрана. Перед тем, как начинать писать реализацию `presentation` слоя **очень важно подумать о том, какие состояния могут быть у экрана и какими данными они определяются**. Очень часто оказываются, что экрану не нужны все описанные типы состояний, либо же вместо отдельного класса состояния можно просто использовать `nullable` поле в презентере, либо же экрану вообще не требуется презентер. Поэтому прежде всего необходимо **осознать**, что нужно для реализации экрана. Также иногда бывают ситуации, когда в презентере хочется иметь два состояния. Например есть 2 независимые или слабозависимые части экрана. Так можно делать, но с очень большой осторожностью, так как в таком случае общее количество состояний `view` получается как декартово произведение множеств состояний, что достаточно сложно тестировать. Лучшим решением будет либо разделение экрана на два независимых фрагмента, либо использование вложенных состояний. #### Контракт Рассмотрим классическую реализацию `presentation` слоя для экрана со списком с пагинацией: ###### SubmissionsView ```kotlin= 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 ```kotlin= 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 ```kotlin= 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`), что и проверяется в первых строчках. ```kotlin= 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`. В следующей статье мы займемся разбором более комплексных экранов, включающих вложенные состояния и асинхронные действия на элементах.