# 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`.
В следующей статье мы займемся разбором более комплексных экранов, включающих вложенные состояния и асинхронные действия на элементах.