Our journey from standard Activity and AsyncTasks to modern MVP architecture using RxJava.
The project code should be divided into independent modules that work with each other as a well-oiled mechanism - a photo of Chester Alvarez .The ecosystem of development tools for Android is developing very quickly. Every week someone creates new tools, updates existing libraries, writes new articles, or makes reports. If you go on vacation for a month, then by the time you return, the latest version of the Support Library and / or Google Play Services will already be published.
I have been developing Android applications at
ribot for the past three years, and all this time, the architecture of our applications, and the technologies we use, have been constantly evolving and improving. This article will take you through the way we have walked, showing the lessons we have learned, the mistakes we have made, and the reasoning that led to all these architectural changes.
')
Good old times
Back in 2012, the structure of our projects looked very simple. We did not have any libraries to work with the network, and
AsyncTask
was still our friend. The diagram below shows the approximate architecture of those solutions:
The code was divided into two levels: the data layer (data layer), which was responsible for receiving / storing data received through both the REST API and various local storages, and the view layer (responsible for processing and displaying data).
APIProvider
provides methods that allow activations and fragments to interact with the REST API. These methods use
URLConnection
and
AsyncTask
to execute the request in the background thread, and then deliver the results to the activation via callback functions.
CacheProvider
works the same
CacheProvider
: there are methods that retrieve data from
SharedPreferences
or SQLite, and there are callback functions that return results.
Problems
The main problem with this approach is that the level of representation has too much responsibility. Let's imagine a simple script in which an application should load a list of posts from a blog, cache them into SQLite, and then display them in a
ListView
.
Activity
should do the following:
- Call the
APIProvider#loadPosts(Callback)
method. - Wait for the
onSuccess()
method call in the transferred Callback
, and then call CacheProvider#savePosts(Callback)
. - Wait for the
onSuccess()
method call in the transferred Callback
, and then display the data in the ListView
. - Separately handle two possible errors that may occur in both the
APIProvider
and the CacheProvider
.
And this is another simple example. In real life, it may happen that the API returns the data not in the form in which our view level expects it, which means the
Activity
will have to somehow transform and / or filter the data before it can work with it. Or, for example,
loadPosts()
will take an argument that you need to get from somewhere (for example, an email address that we will request through the Play Services SDK). Surely the SDK will return the address asynchronously, via a callback function, which means we now have three levels of nesting callback functions. If we continue to wind down more and more complexity, we end up with what is called a callback hell.
Sum up:
- Activations and fragments become too hefty and difficult to maintain.
- Too many levels of nesting lead to the fact that the code becomes ugly and inaccessible to understand, which leads to the complication of adding new functionality or making changes.
- Unit testing is difficult (if it doesn’t become impossible at all), since a lot of logic is in activites or fragments that are not very useful for unit testing.
New architecture with RxJava
We have used the above approach for two years. During this time, we made several changes that lessened the pain and suffering from the problems described. For example, we added several auxiliary classes, and carried some logic into them to offload activations and fragments, and also we started using
Volley in the
APIProvider
. Despite these changes, the code was still difficult to test, and the callback-hell periodically broke through here and there.
The situation began to change in 2014, when we read several articles on
RxJava . We tried it on several pilot projects, and realized that a solution to the problem of nested callback functions was found. If you are not familiar with reactive programming, then we recommend reading
this introduction here. In short, RxJava allows you to manage your data through asynchronous streams (for example, a translator: in this case, streams are streams, not to be confused with threads - streams of execution), and provides many
operators that can be applied to streams to transform, filter, or combine data as you need.
Taking into account all the bumps that we have filled over the past two years, we began to think over the architecture of the new application, and came to the following:
The code is still divided into two levels: the data layer contains the
DataManager
and a set of helper classes, the presentation layer consists of Android SDK classes, such as
Activity
,
Fragment
,
ViewGroup
, and so on.
The helper classes (the third column in the diagram) have very limited areas of responsibility, and implement them in a consistent manner. For example, most projects have classes for accessing the REST API, reading data from the database, or interacting with third-party SDKs. Different applications will have a different set of helper classes, but the following will be the most commonly used:
PreferencesHelper
: works with data in SharedPreferences
.DatabaseHelper
: works with SQLite.- Retrofit services that make calls to the REST API. We started using Retrofit instead of Volley, because it supports RxJava. And the API is more pleasant.
Many public methods of helper classes return RxJava
Observables
.
DataManager
is a central part of the new architecture. It makes extensive use of RxJava operators in order to combine, filter, and transform data received from helpers. The task of the
DataManager
is to release activations and fragments from the work of “combing” the data - it will perform all the necessary transformations within itself and give out the data ready for display.
The code below shows what a method from the
DataManager
might look like. It works as follows:
- Uploads a list of posts via Retrofit.
- Caches data in a local database through
DatabaseHelper
. - Filters posts, selecting those that were published today, as the presentation level should only display them.
public Observable<Post> loadTodayPosts() { return mRetrofitService.loadPosts() .concatMap(new Func1<List<Post>, Observable<Post>>() { @Override public Observable<Post> call(List<Post> apiPosts) { return mDatabaseHelper.savePosts(apiPosts); } }) .filter(new Func1<Post, Boolean>() { @Override public Boolean call(Post post) { return isToday(post.date); } }); }
View-level components will simply call this method and subscribe to the
Observable
returned to them. Once the subscription is completed, posts returned by the received
Observable
can be added to the
Adapter
to display them in a
RecyclerView
or something similar.
The last element of this architecture is the
event bus . Event bus allows us to run messages about certain events occurring at the data level, and components that are at the presentation level can subscribe to these messages. For example, the
signOut()
method in
DataManager
can trigger a message indicating that the corresponding
Observable
completed its work, and then the activators subscribed to this event can redraw their interface to show that the user has logged out.
How is this approach better?
Observables
and operators from RxJava save us from nested callback functions.
DataManager
takes over the work that was previously performed at the presentation level, thus unloading activations and fragments.- Moving part of the code to the
DataManager
and the helpers makes unit testing of activations and fragments easier. - A clear division of responsibility and the allocation of
DataManager
as the only point of interaction with the level of data makes the entire architecture more test-friendly. Helper Classes, or DataManager
, can be easily replaced with special stubs.
And what problems remain?
- In large and complex projects,
DataManager
may become too bloated, and its support will be significantly more difficult. - Although we have made presentation-level components (such as activations and fragments) more lightweight, they still contain a noticeable amount of logic that spins around managing RxJava subscriptions, analyzing errors, and so on.
We try Model View Presenter
Over the past year, separate architectural patterns have begun to gain popularity in the Android community, as
MVP , or
MVVM . After researching these patterns in a
test project , as well as a
separate article , we found that MVP can bring significant changes to the architecture of our projects. Since we have already divided the code into two levels (data and presentation), the introduction of MVP looked natural. We just needed to add a new level of presenters, and transfer part of the code from the views to it.
The data level remains unchanged, but now it is called a
model to match the name of the corresponding level from the MVP.
Presenters are responsible for loading data from the model and calling the appropriate methods at the presentation level when the data is loaded. Presenters subscribe to the
Observables
returned by the
DataManager
. Therefore, they should work with such entities as
subscriptions and
schedulers . Moreover, they can analyze errors that occur, or apply additional operators to data streams, if necessary. For example, if we need to filter some data, and this filter most likely will not be used anywhere else, it makes sense to bring this filter to the presenter level, and not to the
DataManager
.
Below is one of the methods that can be at the presenter level. Here comes the subscription to
Observable
, returned by the
dataManager.loadTodayPosts()
method, which we defined in the previous section.
public void loadTodayPosts() { mMvpView.showProgressIndicator(true); mSubscription = mDataManager.loadTodayPosts().toList() .observeOn(AndroidSchedulers.mainThread()) .subscribeOn(Schedulers.io()) .subscribe(new Subscriber<List<Post>>() { @Override public void onCompleted() { mMvpView.showProgressIndicator(false); } @Override public void onError(Throwable e) { mMvpView.showProgressIndicator(false); mMvpView.showError(); } @Override public void onNext(List<Post> postsList) { mMvpView.showPosts(postsList); } }); }
mMvpView
is the
mMvpView
-level component that the presenter works with. Usually it will be
Activity
,
Fragment
or
ViewGroup
.
As in the previous architecture, the presentation layer contains standard components from the Android SDK. The difference is that now these components do not subscribe directly to
Observables
. Instead, they implement the
MvpView
interface, and provide a list of clear and understandable methods, such as
showError()
or
showProgressIndicator()
. Presentation-level components are also responsible for handling user interaction (for example, push events), and calling the appropriate methods in a presenter. For example, if we have a button that loads a list of posts, our
Activity
will have to
OnClickListener
the
presenter.loadTodayPosts()
method in
OnClickListener
.
If you want to take a look at a working example, you can take a look at our repository on Github . Well, if you wanted more, you can see our recommendations for building architecture .
How is this approach better?
- Activations and fragments become even more lightweight, since their work is now reduced to drawing / updating the user interface and handling user interaction events. This makes it even easier to maintain them.
- Writing unit tests for presenters is very simple - you just need to lock the presentation level. Previously, this code was part of the presentation level, and unit testing was not possible. The architecture becomes even more testable.
- If the
DataManager
becomes too bloated, we can always transfer part of the code to the presenters.
And what problems remain?
- In the case of a large amount of code
DataManager
still may become too bloated. So far this has not happened, but we do not blame for this development of events.
It is important to mention that the approach I described is not ideal. In general, it would be naive to believe that there is somewhere the very unique and unique architecture that will take and solve all your problems once and for all. Android's ecosystem will continue to evolve at high speeds, and we will have to keep up to date, exploring, reading and experimenting. What for? To continue making great Android apps.
I hope you enjoyed my article and found it useful. If so, do not forget to click on the Recommend button (comment of the translator: go to the original article, and click on the heart button at the end of the article). Also, I would like to hear your thoughts on our current approach.