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

    Добрый день! Многие 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

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