Model-View-Intent and Download / Update Indicator

    Good afternoon! Many Android applications download data from the server and at this time show a download indicator, and after that they allow you to update the data. There can be a dozen screens in the application, on almost all of them you need:


    • when going to the screen, show the download indicator ( ProgressBar) while the data is being loaded from the server;
    • in case of a download error, show the error message and the "Retry download" button;
    • in case of a successful download, give the user the opportunity to update the data ( SwipeRefreshLayout);
    • if an error occurred while updating the data, show the corresponding message ( Snackbar).

    When developing applications, I use the MVI architecture (Model-View-Intent) in the Mosby implementation , more about which you can read on Habré or find the original article on MVI on the mosby developer's site . In this article I am going to talk about creating base classes that would allow us to separate the load / update logic described above from other data actions.


    The first thing we’ll start with the creation of base classes is the creation ViewState, which plays a key role in MVI. ViewStatecontains data on the current state of the View (which may be an activity, fragment or ViewGroup). Given what the state of the screen may be, regarding downloads and updates, ViewStateit looks like this:


    // Здесь и далее LR используется для сокращения Load-Refresh.
    data class LRViewState>(
            val loading: Boolean,
            val loadingError: Throwable?,
            val canRefresh: Boolean,
            val refreshing: Boolean,
            val refreshingError: Throwable?,
            val model: M
    )

    The first two fields contain information about the current status of the download (whether the download is currently taking place and if an error has occurred). The following three fields contain information about updating the data (whether the user can update the data and whether the update is currently taking place and if an error has occurred). The last field is the model that is meant to be displayed on the screen after it is loaded.


    The LRViewStatemodel implements the interface InitialModelHolder, which I will talk about now.
    Not all data that will be displayed on the screen or will still be used somehow within the screen must be downloaded from the server. For example, there is a model that consists of a list of people that is downloaded from the server, and several variables that determine the sort order or filter people in the list. The user can change the sorting and search parameters even before the list is downloaded from the server. In this case, the list is the initial (initial) part of the model, which takes a long time to load and which needs to be shown at boot time ProgressBar. It is in order to highlight which part of the model is the source interface is used InitialModelHolder.


    interface InitialModelHolder {
        fun changeInitialModel(i: I): InitialModelHolder
    }

    Here, the parameter Ishows what the initial part of the model will be, and the method changeInitialModel(i: I)that the class model should implement allows you to create a new model object in which its initial (initial) part is replaced with the one that was passed to the method as a parameter i.


    It’s clear why you need to change some part of the model to another, if you recall one of the main advantages of MVI - State Reducer (more details here ). State Reducer allows you to apply to an existing object ViewStatepartial changes ( the Partial Changes ) and thereby create a new instance of ViewState. In the future, the method changeInitialModel(i: I)will be used in State Reducer in order to create a new ViewState instance with the loaded data.


    Now is the time to talk about Partial Change. A partial change contains information about what needs to be changed ViewState. All partial changes implement the interface PartialChange. This interface is not part of Mosby and is designed so that all partial changes (those related to download / update and those that do not apply) have a common "root".


    Partial changes are conveniently combined into sealedclasses. Further you can see the partial changes that can be applied to 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(val i: I) : LRPartialChange()
    }

    The next step is to create a basic interface for the View.


    interface LRView> : MvpView {
        fun load(): Observable
        fun retry(): Observable
        fun refresh(): Observable
        fun render(vs: LRViewState)
    }

    Here, the parameter Kis the key that will help the presenter determine which data to download. The key can be, for example, an entity ID. The parameter Mdetermines the type of model (field type modelc LRViewState). The first three methods are intents (in terms of MVI) and serve to transmit events from Viewk Presenter. The implementation of the method renderwill display ViewState.


    Now that we have the LRViewStateinterface LRView, we can create it LRPresenter. Let's consider it in parts.


    abstract class LRPresenter, V : LRView>
            : MviBasePresenter>() {
        protected abstract fun initialModelSingle(key: K): Single
        open protected val reloadIntent: Observable = Observable.never()
        protected val loadIntent: Observable = intent { it.load() }
        protected val retryIntent: Observable = intent { it.retry() }
        protected val refreshIntent: Observable = intent { it.refresh() }
        ...
        ...
    }

    Parameters LRPresenterare:


    • Kthe key by which the initial part of the model is loaded;
    • I type of the initial part of the model;
    • M type of model;
    • Vtype Viewwith which this one works Presenter.

    The implementation of the method initialModelSingleshould return io.reactivex.Singleto load the initial part of the model with the passed key . The field reloadIntentcan be overridden by successor classes and is used to reload the initial part of the model (for example, after certain user actions). The following three fields create intentions for receiving events from View.


    The next LRPresenterstep is to create a method io.reactivex.Observablethat will transfer partial changes related to downloading or updating. In the following, it will be shown how successor classes can use this method.


    protected fun loadRefreshPartialChanges(): Observable = Observable.merge(
                Observable
                        .merge(
                                Observable.combineLatest(
                                        loadIntent,
                                        reloadIntent.startWith(Any()),
                                        BiFunction { k, _ -> k }
                                ),
                                retryIntent
                        )
                        .switchMap {
                            initialModelSingle(it)
                                    .toObservable()
                                    .map { LRPartialChange.InitialModelLoaded(it) }
                                    .onErrorReturn { LRPartialChange.LoadingError(it) }
                                    .startWith(LRPartialChange.LoadingStarted)
                        },
                refreshIntent
                        .switchMap {
                            initialModelSingle(it)
                                    .toObservable()
                                    .map { LRPartialChange.InitialModelLoaded(it) }
                                    .onErrorReturn { LRPartialChange.RefreshError(it) }
                                    .startWith(LRPartialChange.RefreshStarted)
                        }
        )

    And the last part LRPresenteris State Reducer , which applies to ViewStatepartial changes related to downloading or updating (these partial changes were transferred from Observablecreated in the method loadRefreshPartialChanges).


    @CallSuper
    open protected fun stateReducer(viewState: LRViewState, change: PartialChange): LRViewState {
        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
                )
            }
        }
    }

    Now it remains to create a basic fragment or activity that will implement LRView. In my applications, I follow the SingleActivityApplication approach, so let's create one LRFragment.


    To display the download and update indicators, as well as to receive events about the need to repeat the download and update, an interface was created LoadRefreshPanelto which the LRFragmentdisplay will delegate ViewStateand which will be the facade of the events. Thus, the successor fragments will not be required to have the SwipeRefreshLayout“Retry download” button.


    interface LoadRefreshPanel {
        fun retryClicks(): Observable
        fun refreshes(): Observable
        fun render(vs: LRViewState<*>)
    }

    In the demo application, the LRPanelImpl class was created , which is a SwipeRefreshLayoutnested class ViewAnimator. ViewAnimatorAllows you to display either ProgressBarthe error panel or the model.


    Given LoadRefreshPanelLRFragmentwill look like this:


    abstract class LRFragment, V : LRView, P : MviBasePresenter>> : MviFragment(), LRView {
        protected abstract val key: K
        protected abstract fun viewForSnackbar(): View
        protected abstract fun loadRefreshPanel(): LoadRefreshPanel
        override fun load(): Observable = Observable.just(key)
        override fun retry(): Observable = loadRefreshPanel().retryClicks().map { key }
        override fun refresh(): Observable = loadRefreshPanel().refreshes().map { key }
        @CallSuper
        override fun render(vs: LRViewState) {
            loadRefreshPanel().render(vs)
            if (vs.refreshingError != null) {
                Snackbar.make(viewForSnackbar(), R.string.refreshing_error_text, Snackbar.LENGTH_SHORT)
                    .show()
            }
        }
    }

    As you can see from the above code, the download starts immediately after attaching the presenter, and everything else is delegated LoadRefreshPanel.


    Now creating a screen on which it is necessary to implement the download / update logic becomes a simple task. For example, consider a screen with details about a person (a rider, in our case).


    The entity class is trivial.


    data class Driver(
            val id: Long,
            val name: String,
            val team: String,
            val birthYear: Int
    )

    The model class for a screen with details consists of one entity:


    data class DriverDetailsModel(
            val driver: Driver
    ) : InitialModelHolder {
        override fun changeInitialModel(i: Driver) = copy(driver = i)
    }

    Presenter class for a screen with details:


    class DriverDetailsPresenter : LRPresenter() {
        override fun initialModelSingle(key: Long): Single = 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)
        }
    }

    The method initialModelSinglecreates Singleto load the entity according to the transferred id(approximately every 2nd time an error is thrown to show what the UI of the error looks like). The method bindIntentsuses the loadRefreshPartialChangesfrom method LRPresenterto create Observable, transmitting partial changes.


    Let's move on to creating a fragment with details.


    class DriverDetailsFragment
        : LRFragment(),
          DriverDetailsView {
        override val key by lazy { arguments.getLong(driverIdKey) }
        override fun loadRefreshPanel() = object : LoadRefreshPanel {
            override fun retryClicks(): Observable = RxView.clicks(retry_Button)
            override fun refreshes(): Observable = 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) {
            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()
            }
        }
        ...
        ...
    }

    In this example, the key is stored in the fragment arguments. The model is displayed in the fragment method . An interface implementation is also created that is responsible for displaying the load. In the above example, it is not used for loading time , but instead data fields display dots, which symbolizes loading; appears in case of error, but update is not provided ( ).render(vs: LRViewState)LoadRefreshPanelProgressBarretry_panelObservable.never()


    A demo application that uses the described classes can be found on GitHib .
    Thanks for attention!


    Also popular now: