Good day! Many Android applications download data from the server and show the download indicator at this time, and then allow updating data. The application can have a dozen screens, almost every one of them needs:
ProgressBar
) while the data is being loaded from the server;SwipeRefreshLayout
);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 MVI article on the mosby developer website . In this article, I'm going to talk about creating base classes that would allow us to separate the load / update logic described above from other actions on data.
The first thing we start creating base classes for is the creation of a ViewState
, which plays a key role in MVI. ViewState
contains information about the current View state (which can be an activation, a fragment or a ViewGroup
). Given the state of the screen regarding download and refresh, the ViewState
looks like this:
// 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 )
The first two fields contain information about the current state of the download (whether the download is now taking place or if an error has occurred) The following three fields contain information about updating data (whether the user can update the data and whether the update is currently in progress and whether an error has occurred). The last field is the model that is meant to be shown on the screen after it has been loaded.
In LRViewState
model implements the InitialModelHolder
interface, which I will now InitialModelHolder
.
Not all data that will be displayed on the screen or will 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 loaded from the server, and several variables that determine the sorting order or filtering of people in the list. The user can change the sorting and search parameters even before the list is loaded from the server. In this case, the list is the initial (initial) part of the model, which is loaded for a long time and for the load time of which it is necessary to show the ProgressBar
. It is in order to highlight what part of the model is the original interface InitialModelHolder
is used.
interface InitialModelHolder<in I> { fun changeInitialModel(i: I): InitialModelHolder<I> }
Here, the parameter I
shows what the original part of the model will be, and the method changeInitialModel(i: I)
, which should implement the model class, allows you to create a new model object in which its initial (initial) part is replaced with the one that is passed to the method as parameter i
.
It’s clear why we need to change one part of the model to another if we recall one of the main advantages of the MVI, the State Reducer (for more, see here ). State Reducer allows you to apply partial changes ( Partial Changes ) to an existing ViewState
object and thereby create a new ViewState instance. In the future, the changeInitialModel(i: I)
method will be used in the State Reducer to create a new ViewState instance with loaded data.
Now it’s time to talk about Partial Change. A partial change contains information about what exactly needs to be changed in ViewState
. All partial changes implement the PartialChange
interface. This interface is not part of Mosby and is designed to ensure that all partial changes (those related to download / update and those that do not apply) have a common "root".
Partial changes are convenient to combine into sealed
classes. Further you can see partial changes that can be applied to the 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() }
The next step is to create a base interface for the 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>) }
Here, the K
parameter is the key that will help the presenter determine which data needs to be loaded. The key can be, for example, the entity ID. The M
parameter defines the type of the model (the type of the model
field in the LRViewState
). The first three methods are intents (in terms of MVI) and are used to transfer events from View
to Presenter
. The implementation of the render
method will display the ViewState
.
Now that we have an LRViewState
and an LRView
interface, we can create an LRPresenter
. Consider it in parts.
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
parameters are:
K
key for loading the initial part of the model;I
type of the initial part of the model;M
type of model;V
type View
, with which this Presenter
works.The implementation of the initialModelSingle
method should return io.reactivex.Single
to load the initial part of the model using the transferred key . The reloadIntent
field can be overridden by descendant classes and is used to reloadIntent
original part of the model (for example, after certain user actions). The following three fields create intents for receiving events from the View
.
Next in LRPresenter
is a method for creating io.reactivex.Observable
, which will transfer partial changes related to the download or update. In the following, it will be shown how heir classes can use this method.
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) } )
And the last part of the LRPresenter
is the State Reducer , which applies partial changes to the ViewState
associated with loading or updating (these partial changes were transferred from the Observable
created in the loadRefreshPartialChanges
method).
@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 ) } } }
Now it remains to create a basic fragment or activity that will be implemented by LRView
. In my applications, I follow the SingleActivityApplication approach, so we will create a LRFragment
.
A LoadRefreshPanel
interface was created to display load and refresh indicators, as well as to receive events about the need to repeat downloads and updates, to which the LRFragment
will delegate the ViewState
display and which will be the façade of the events. Thus, heir fragments will not be required to have SwipeRefreshLayout
and the "Retry" button.
interface LoadRefreshPanel { fun retryClicks(): Observable<Any> fun refreshes(): Observable<Any> fun render(vs: LRViewState<*>) }
In the demo application, the LRPanelImpl class was created, which is a SwipeRefreshLayout
with SwipeRefreshLayout
embedded in it. ViewAnimator
allows ViewAnimator
to display either the ProgressBar
, or the error panel, or a model.
Given the LoadRefreshPanel
LRFragment
will look like this:
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() } } }
As can be seen from the above code, the download starts immediately after the presenter joins, and the rest is delegated to LoadRefreshPanel
.
Now creating a screen on which to implement the load / update logic becomes an easy task. For example, consider the screen with details about the person (the driver, 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 the screen with details consists of one entity:
data class DriverDetailsModel( val driver: Driver ) : InitialModelHolder<Driver> { override fun changeInitialModel(i: Driver) = copy(driver = i) }
Presenter class for the screen with details:
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) } }
The initialModelSingle
method creates a Single
to load an entity on the transferred id
(approximately every 2nd time an error is issued to show how the UI error looks like). The bindIntents
method uses the loadRefreshPartialChanges
method from LRPresenter
to create an Observable
that LRPresenter
partial changes.
Let's move on to creating a fragment with details.
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() } } ... ... }
In this example, the key is stored in the fragment arguments. The model is render(vs: LRViewState<DriverDetailsModel>)
in the render(vs: LRViewState<DriverDetailsModel>)
method render(vs: LRViewState<DriverDetailsModel>)
fragment. An implementation of the LoadRefreshPanel
interface is also created, which is responsible for displaying the load. In the above example, the ProgressBar
is not used for the load time, but instead the data fields display dots, which symbolizes the load; retry_panel
appears in case of an error, and the update is not provided ( Observable.never()
).
A demo application that uses the described classes can be found on GitHib .
Thanks for attention!
Source: https://habr.com/ru/post/335112/
All Articles