Добрый день! Многие Android-приложения загружают данные с сервера и в это время показывают индикатор загрузки, а после этого позволяют обновить данные. В приложении может быть с десяток экранов, практически на каждом из них нужно:
- при переходе на экран показывать индикатор загрузки (
ProgressBar
) в то время, как данные грузятся с сервера; - в случае ошибки загрузки показать сообщение об ошибке и кнопку "Повторить загрузку";
- в случае успешной загрузки дать пользователю возможность обновлять данные (
SwipeRefreshLayout
); - если при обновлении данных произошла ошибка, показать соответствующее сообщение (
Snackbar
).
При разработке приложений я использую архитектуру MVI (Model-View-Intent) в реализации Mosby, подробнее о которой можно почитать на Хабре или найти оригинальную статью о MVI на сайте разработчика mosby. В этой статье я собираюсь рассказать о создании базовых классов, которые позволили бы отделить описанную выше логику загрузки/обновления от остальных действий с данными.
Первое, с чего мы начнем создание базовых классов, это создание ViewState
, который играет ключевую роль в MVI. ViewState
содержит данные о текущем состоянии View (которым может быть активити, фрагмент или ViewGroup
). С учетом того, каким может быть состояние экрана, относительно загрузки и обновления, ViewState
выглядит следующим образом:
// Здесь и далее LR используется для сокращения Load-Refresh.
data class LRViewState<out M : InitialModelHolder<*>>(
val loading: Boolean,
val loadingError: Throwable?,
val canRefresh: Boolean,
val refreshing: Boolean,
val refreshingError: Throwable?,
val model: M
)
Первые два поля содержат информацию о текущем состоянии загрузки (происходит ли сейчас загрузка и не произошла ли ошибка). Следующие три поля содержат информацию об обновлении данных (может ли пользователь обновить данные и происходит ли обновление в данный момент и не произошла ли ошибка). Последнее поле представляет собой модель, которую подразумевается показывать на экране после того, как она будет загружена.
В LRViewState
модель реализует интерфейс InitialModelHolder
, о котором я сейчас расскажу.
Не все данные, которые будут отображены на экране или будут еще как-то использоваться в пределах экрана, должны быть загружены с сервера. К примеру, имеется модель, которая состоит из списка людей, который загружается с сервера, и нескольких переменных, которые определяют порядок сортировки или фильтрацию людей в списке. Пользователь может менять параметры сортировки и поиска еще до того, как список будет загружен с сервера. В этом случае список — это исходная (initial) часть модели, которая грузится долго и на время загрузки которой необходимо показывать ProgressBar
. Именно для того, чтобы выделить, какая часть модели является исходной используется интерфейс InitialModelHolder
.
interface InitialModelHolder<in I> {
fun changeInitialModel(i: I): InitialModelHolder<I>
}
Здесь параметр I
показывает какой будет исходная часть модели, а метод changeInitialModel(i: I)
, который должен реализовать класс-модель, позволяет создать новый объект модели, в котором ее исходная (initial) часть заменена на ту, что передана в метод в качестве параметра i
.
То, зачем нужно менять какую-то часть модели на другую, становится понятно, если вспомнить одно из главных преимуществ MVI — State Reducer (подробнее тут). State Reducer позволяет применять к уже имеющемуся объекту ViewState
частичные изменения (Partial Changes) и тем самым создавать новый экземпляр ViewState. В дальнейшем метод changeInitialModel(i: I)
будет использоваться в State Reducer для того, чтобы создать новый экземпляр ViewState с загруженными данными.
Теперь настало время поговорить о частичных изменениях (Partial Change). Частичное изменение содержит в себе информацию о том, что именно нужно изменить в ViewState
. Все частичные изменения реализуют интерфейс PartialChange
. Этот интерфейс не является частью Mosby и создан для того, чтобы все частичные изменения (те, которые касаются загрузки/обновления и те, что не касаются) имели общий "корень".
Частичные изменения удобно объединять в sealed
классы. Далее Вы можете видеть частичные изменения, которые можно применить к LRViewState
.
sealed class LRPartialChange : PartialChange {
object LoadingStarted : LRPartialChange() // загрузка началась
data class LoadingError(val t: Throwable) : LRPartialChange() // загрузка завершилась с ошибкой
object RefreshStarted : LRPartialChange() // обновление началось
data class RefreshError(val t: Throwable) : LRPartialChange() // обновление завершилось с ошибкой
// загрузка или обновления завершились успешно
data class InitialModelLoaded<out I>(val i: I) : LRPartialChange()
}
Следующим шагом является создание базового интерфейса для View.
interface LRView<K, in M : InitialModelHolder<*>> : MvpView {
fun load(): Observable<K>
fun retry(): Observable<K>
fun refresh(): Observable<K>
fun render(vs: LRViewState<M>)
}
Здесь параметр K
является ключем, который поможет презентеру определить какие именно данные нужно загрузить. В качестве ключа может выступать, например, ID сущности. Параметр M
определяет тип модели (тип поля model
в LRViewState
). Первые три метода являются интентами (в понятиях MVI) и служат для передачи событий от View
к Presenter
. Реализация метода render
будет отображать ViewState
.
Теперь, когда у нас есть LRViewState
и интерфейс LRView
, можно создавать LRPresenter
. Рассмотрим его по частям.
abstract class LRPresenter<K, I, M : InitialModelHolder<I>, V : LRView<K, M>>
: MviBasePresenter<V, LRViewState<M>>() {
protected abstract fun initialModelSingle(key: K): Single<I>
open protected val reloadIntent: Observable<Any> = Observable.never()
protected val loadIntent: Observable<K> = intent { it.load() }
protected val retryIntent: Observable<K> = intent { it.retry() }
protected val refreshIntent: Observable<K> = intent { it.refresh() }
...
...
}
Параметры LRPresenter
это:
K
ключ, по которому загружается исходная часть модели;I
тип исходной части модели;M
тип модели;V
типView
, с которой работает данныйPresenter
.
Реализация метода initialModelSingle
должна возвращать io.reactivex.Single
для загрузки исходной части модели по переданному ключу. Поле reloadIntent
может быть переопределено классами-наследниками и используется для повторной загрузки исходной части модели (например, после определенных действий пользователя). Последующие три поля создают интенты для приема событий от View
.
Далее в LRPresenter
идет метод для создания io.reactivex.Observable
, который будет передавать частичные изменения, связанные с загрузкой или обновлением. В дальнейшем будет показано, как классы-наследники могут использовать этот метод.
protected fun loadRefreshPartialChanges(): Observable<LRPartialChange> = Observable.merge(
Observable
.merge(
Observable.combineLatest(
loadIntent,
reloadIntent.startWith(Any()),
BiFunction { k, _ -> k }
),
retryIntent
)
.switchMap {
initialModelSingle(it)
.toObservable()
.map<LRPartialChange> { LRPartialChange.InitialModelLoaded(it) }
.onErrorReturn { LRPartialChange.LoadingError(it) }
.startWith(LRPartialChange.LoadingStarted)
},
refreshIntent
.switchMap {
initialModelSingle(it)
.toObservable()
.map<LRPartialChange> { LRPartialChange.InitialModelLoaded(it) }
.onErrorReturn { LRPartialChange.RefreshError(it) }
.startWith(LRPartialChange.RefreshStarted)
}
)
И последняя часть LRPresenter
это State Reducer, который применяет к ViewState
частичные изменения, связанные с загрузкой или обновлением (эти частичные изменения были переданы из Observable
, созданном в методе loadRefreshPartialChanges
).
@CallSuper
open protected fun stateReducer(viewState: LRViewState<M>, change: PartialChange): LRViewState<M> {
if (change !is LRPartialChange) throw Exception()
return when (change) {
LRPartialChange.LoadingStarted -> viewState.copy(
loading = true,
loadingError = null,
canRefresh = false
)
is LRPartialChange.LoadingError -> viewState.copy(
loading = false,
loadingError = change.t
)
LRPartialChange.RefreshStarted -> viewState.copy(
refreshing = true,
refreshingError = null
)
is LRPartialChange.RefreshError -> viewState.copy(
refreshing = false,
refreshingError = change.t
)
is LRPartialChange.InitialModelLoaded<*> -> {
@Suppress("UNCHECKED_CAST")
viewState.copy(
loading = false,
loadingError = null,
model = viewState.model.changeInitialModel(change.i as I) as M,
canRefresh = true,
refreshing = false
)
}
}
}
Теперь осталось создать базовый фрагмент или активити, который будет реализовывать LRView
. В своих приложениях я придерживаюсь подхода SingleActivityApplication, поэтому создадим LRFragment
.
Для отображения индикаторов загрузки и обновления, а также для получения событий о необходимости повторения загрузки и обновления был создан интерфейс LoadRefreshPanel
, которому LRFragment
будет делегировать отображение ViewState
и который будет фасадом событий. Таким образом фрагменты-наследники не обязаны будут иметь SwipeRefreshLayout
и кнопку "Повторить загрузку".
interface LoadRefreshPanel {
fun retryClicks(): Observable<Any>
fun refreshes(): Observable<Any>
fun render(vs: LRViewState<*>)
}
В демо-приложении был создан класс LRPanelImpl, который представляет собой SwipeRefreshLayout
с вложенным в него ViewAnimator
. ViewAnimator
позволяет отображать либо ProgressBar
, либо панель ошибки, либо модель.
С учетом LoadRefreshPanel
LRFragment
будет выглядеть следующим образом:
abstract class LRFragment<K, M : InitialModelHolder<*>, V : LRView<K, M>, P : MviBasePresenter<V, LRViewState<M>>> : MviFragment<V, P>(), LRView<K, M> {
protected abstract val key: K
protected abstract fun viewForSnackbar(): View
protected abstract fun loadRefreshPanel(): LoadRefreshPanel
override fun load(): Observable<K> = Observable.just(key)
override fun retry(): Observable<K> = loadRefreshPanel().retryClicks().map { key }
override fun refresh(): Observable<K> = loadRefreshPanel().refreshes().map { key }
@CallSuper
override fun render(vs: LRViewState<M>) {
loadRefreshPanel().render(vs)
if (vs.refreshingError != null) {
Snackbar.make(viewForSnackbar(), R.string.refreshing_error_text, Snackbar.LENGTH_SHORT)
.show()
}
}
}
Как видно из приведенного кода, загрузка начинается сразу же после присоединения презентера, а все остальное делегируется LoadRefreshPanel
.
Теперь создание экрана, на котором необходимо реализовать логику загрузки/обновления становится несложной задачей. Для примера рассмотрим экран с подробностями о человеке (гонщике, в нашем случае).
Класс сущности — тривиальный.
data class Driver(
val id: Long,
val name: String,
val team: String,
val birthYear: Int
)
Класс модели для экрана с подробностями состоит из одной сущности:
data class DriverDetailsModel(
val driver: Driver
) : InitialModelHolder<Driver> {
override fun changeInitialModel(i: Driver) = copy(driver = i)
}
Класс презентера для экрана с подробностями:
class DriverDetailsPresenter : LRPresenter<Long, Driver, DriverDetailsModel, DriverDetailsView>() {
override fun initialModelSingle(key: Long): Single<Driver> = Single
.just(DriversSource.DRIVERS)
.map { it.single { it.id == key } }
.delay(1, TimeUnit.SECONDS)
.flatMap {
if (System.currentTimeMillis() % 2 == 0L) Single.just(it)
else Single.error(Exception())
}
override fun bindIntents() {
val initialViewState = LRViewState(false, null, false, false, null,
DriverDetailsModel(Driver(-1, "", "", -1))
)
val observable = loadRefreshPartialChanges()
.scan(initialViewState, this::stateReducer)
.observeOn(AndroidSchedulers.mainThread())
subscribeViewState(observable, DriverDetailsView::render)
}
}
Метод initialModelSingle
создает Single
для загрузки сущности по переданному id
(примерно каждый 2-й раз выдается ошибка, чтобы показать как выглядит UI ошибки). В методе bindIntents
используется метод loadRefreshPartialChanges
из LRPresenter
для создания Observable
, передающего частичные изменения.
Перейдем к созданию фрагмента с подробностями.
class DriverDetailsFragment
: LRFragment<Long, DriverDetailsModel, DriverDetailsView, DriverDetailsPresenter>(),
DriverDetailsView {
override val key by lazy { arguments.getLong(driverIdKey) }
override fun loadRefreshPanel() = object : LoadRefreshPanel {
override fun retryClicks(): Observable<Any> = RxView.clicks(retry_Button)
override fun refreshes(): Observable<Any> = Observable.never()
override fun render(vs: LRViewState<*>) {
retry_panel.visibility = if (vs.loadingError != null) View.VISIBLE else View.GONE
if (vs.loading) {
name_TextView.text = "...."
team_TextView.text = "...."
birthYear_TextView.text = "...."
}
}
}
override fun render(vs: LRViewState<DriverDetailsModel>) {
super.render(vs)
if (!vs.loading && vs.loadingError == null) {
name_TextView.text = vs.model.driver.name
team_TextView.text = vs.model.driver.team
birthYear_TextView.text = vs.model.driver.birthYear.toString()
}
}
...
...
}
В данном примере ключ хранится в аргументах фрагмента. Отображение модели происходит в методе render(vs: LRViewState<DriverDetailsModel>)
фрагмента. Также создается реализация интерфейса LoadRefreshPanel
, которая отвечает за отображение загрузки. В приведенном примере на время загрузки не используется ProgressBar
, а вместо этого поля с данными отображают точки, что символизирует загрузку; retry_panel
появляется в случае ошибки, а обновление не предусмотрено (Observable.never()
).
Демо-приложение, которое использует описанные классы, можно найти на GitHib.
Спасибо за внимание!