Мы используем нашу архитектуру уже на протяжении нескольких лет. Как показывает практика одним из самым сложных аспектов этого подхода является presentation
слой. За время работы я повидал множество различный вариаций и даже мутаций исходного подхода с явным состоянием, в которых терялись основные его свойства и преимущества. В данном цикле статей мы вместе поэтапно построим презентер для достаточно нетривиального экрана, тем самым пролив свет на многие темные участки state
подхода.
В этом разделе поговорим об общих концепциях, связанных с нашей реализацией presentation
слоя.
Начнем с того, что вспомним, на чем базируются наши презентеры. Все презентеры в наших проектах наследуются от класса PresenterBase
(пакет presentation-base
), с типовым параметром V
означающим тип view контракта.
Наши презентеры расширяют класс ViewModel
из пакета androidx.lifecycle:lifecycle-viewmodel
. Это позволяет решить проблему сохранения состояния при различных изменениях конфигурации (поворота экрана и т.д.), так как инстанс презентера остается тем же. Кроме этого это помогает без дополнительных действий понять, что экран больше не нужен и очистить ресурсы в onCleared
.
Так как во всех наших проектах используется RxJava, то в базовом классе презентера также было добавлено поле compositeDisposable
, которое автоматически очищается при закрытии экрана.
Также в PresenterBase
присутствуют знакомые lifecycle методы onSaveInstanceState
и onRestoreInstanceState
, которые позволяют сохранить состояние в критических ситуациях (например когда закончилась память и наше приложение было выгружено), однако зачастую для восстановления экрана нам достаточно входных данных. Из-за этого эти методы по умолчанию имеют пустую реализацию, а не являются abstract
.
Теперь перейдем к тому, как презентеры попадают в Fragment
или Activity
. Всё начинается с класса DaggerViewModelFactory
(пакет view-injection
).
Этот класс автоматически собирает все доступные провайдеры презентеров в текущем scope.
Для того чтобы DaggerViewModelFactory
нашел презентер его надо объявить следующим образом.
Здесь CommentsPresenter
автоматически инжектится из-за аннотации @Inject
у конструктора и байндится в тип ViewModel
(иначе бы тут использовалась аннотация @Provides
). Таким образом dagger создает мапу, где ключом будет CommentsPresenter::class
, а значением инстанс CommentsPresenter
приведенный к типу ViewModel
. Однако для того, чтобы зависимости не были инстанциированы раньше времени используются провайдеры, таким образом получаем итоговую мапу типа Map<Class<out ViewModel>, @JvmSuppressWildcards Provider<ViewModel>>
, которая находится в аргументах DaggerViewModelFactory
.
Далее в фрагмент или активити, где планируется использование презентера, инжектится наша фабрика, которая передается в ViewModelProviders
.
Получив таким образом презентер, не забываем о методах attachView
и detachView
, которые мы вызываем в методах onStart
и onStop
соотвественно. Именно в этих методах, так как их вызов гарантирован, а также так как они обозначают видит ли экран пользователь. Иногда допускается использование методов onResume
/onPause
.
Теперь когда мы получили инстанс презентера и подключили к нему наш экран, обсудим как мы передаём в презентер идентифицирующие входные данные. Предполагается, что данные между экранами передаются по классическому алгоритму во view
слое через Fragment.arguments
или Intent
.
Идентифицирующими данными называем данные, которые характеризуют экран, например: id элемента (самый частый пример), сам объект с данными элемента, запрос, характеризующий список и т.д.
Перед тем как выбрать способ передачи стоит понять входные точки экрана, то есть каким образом можно попасть на экран и с какими данными. Например допустим, что у нас есть экран с детальной информацией об элементе. В него можно попасть по нажатию на элемент списка, по диплинку и через быстрый переход, все эти 3 пункта и будут нашими входными точками.
Есть два основных подхода как можно передать входные данные – через аргументы конструктора презентера и через отдельные методы презентера. Каждый подход предназначен для отдельных видов данных. Допускается одновременное использование сразу обоих методов. Рассмотрим каждый из них подробнее.
Через аргументы конструктора презентера передаются данные, которые не будут изменяться за всё время существования презентера. Обычно это различные идентификаторы детальных экранов.
Для того, чтобы передать данные через аргумент конструктора нужно:
Qualifier
Qualifier
аннотацией в сам презентерЭтим способом можно передавать почти любые входные данные, однако он добавляет больше неопределенности в код, нежели неизменяемые аргументы конструктора. По конвенции передача данных в презентер осуществляется в методе setDataToPresenter
с аргументом forceUpdate: Boolean = false
, который символизирует рефреш экрана пользователем.
Передача данных через отдельные методы:
Очень важно проверять аргумент forceUpdate
и текущее состояние, чтобы не допустить переинициализации при изменениях конфигурации.
setDataToPresenter
во view
слоеsetDataToPresenter
при инициализации без аргументаforceUpdate
, например в конце onCreate
, onCreateView
или в onStart
SwipeRefreshLayout
или кнопку tryAgain
Тут уже с forceUpdate = true
.
Полный код LessonActivity
Полный код LessonPresenter
Теперь, когда мы научились подключать презентер к экрану и передавать в него данные, перейдем к внутренностям самого презентера. Если абстрагироваться, то по своей сути презентер у нас представляет детерминированный конечный автомат, где текущее состояние хранится в переменной state
, а публичные методы представляют собой переходы. Этот подход позволяет сделать view
слой максимально простым и сильно сократить количество неопределенности в презентере, так как в любой момент можно получить полное состояние экрана.
Перед тем, как начинать писать реализацию presentation
слоя очень важно подумать о том, какие состояния могут быть у экрана и какими данными они определяются. Очень часто оказываются, что экрану не нужны все описанные типы состояний, либо же вместо отдельного класса состояния можно просто использовать nullable
поле в презентере, либо же экрану вообще не требуется презентер. Поэтому прежде всего необходимо осознать, что нужно для реализации экрана.
Также иногда бывают ситуации, когда в презентере хочется иметь два состояния. Например есть 2 независимые или слабозависимые части экрана. Так можно делать, но с очень большой осторожностью, так как в таком случае общее количество состояний view
получается как декартово произведение множеств состояний, что достаточно сложно тестировать. Лучшим решением будет либо разделение экрана на два независимых фрагмента, либо использование вложенных состояний.
Рассмотрим классическую реализацию presentation
слоя для экрана со списком с пагинацией:
Проанализировав наш экран мы поняли, что для него нужно 6 состояний, а именно:
Idle
– изначальное состояние, когда экран еще не проинициализирован никакими данными. Это состояние присутсвует всегда, кроме очень редких случаев, когда данные передаются в конструкторе. Это стартовое состояние и однажды покинув его, в него нельзя больше вернуться.Loading
– состояние загрузки, когда никаких данных нет (временное состояние)NetworkError
– состояние ошибки при изначальной загрузке данныхContentEmpty
– состояние когда был получен пустой список в ответ наа первый запросContent
– основное состояние экрана, когда список загруженContentLoading
– состояние загрузки следующей страницы (временное состояние)В презентере состояние хранится в переменной state
и при каждом изменении синхронизируется со view
. Также состояние явно синхронизируется в методе attachView
.
Очень важно, чтобы состояния были действительно полноценными состояниями, а не действиями или чем-то ещё. Чтобы это проверить можно для каждого выделенного состояния смоделировать следующую ситуацию: происходит поворот экрана,
view
полностью пересоздается и в него сеттится текущее состояние. Еслиview
при этом не может полноценно восстановиться по этим данным, то вы делаете что-то не так.
Как было описано выше публичные методы – это переходы между состояниями, поэтому на входе каждого такого метода должна быть проверка на то, что мы можем из текущего состояния совершить данный переход.
Метод fetchSubmissions
обозначает загрузку первой страницы списка. Кроме всего прочего он также является методом, через который передаются данные. Переход по fetchSubmissions
может выполняться только из состояния Idle
, либо из состояний NetworkError
или Content
в случае рефреша экрана пользователем (forceUpdate = true
), что и проверяется в первых строчках.
Рассмотрим теперь переход fetchNextPage
, который обозначает загрузку следующей страницы. Такой переход возможно совершить только из состояния Content
. Также зачастую очень удобно объединять проверку состояний вместе со smart cast, таким образом далее в теле метода будет известно текущее состояние экрана, сохраненное в переменной oldState, и можно будет пользоваться его свойствами. Например в данной ситуации мы достаем оттуда номер текущей страницы (строчка 8).
Что при таком подходе с состояниями делать в случае получения ошибки? В общем случае при возникновении ошибки мы пытаемся откатить изменения состояния, сделанные нашим переходом, и сообщить пользователю про ошибку.
Таким образом, если при переходе по fetchNextPage
срабатывает onError
, то мы откатываемся до предыдущего состояния, которое было до перехода и пишем пользователю об ошибке.
В fetchSubmissions
ситуация немного иная. Так как мы не можем перейти в Idle
, то нам приходится переходить в NetworkError
.
В следующей статье мы займемся разбором более комплексных экранов, включающих вложенные состояния и асинхронные действия на элементах.