Model-View-Intent и индикатор загрузки/обновления

  • Markdown

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

Метки:
Поделиться публикацией
Комментарии 0

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.