Добрый день! Многие 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.
Спасибо за внимание!
