📜 ⬆️ ⬇️

Android client application architecture

Client-server applications are the most common and at the same time the most difficult to develop. Problems arise at any stage, from choosing the means to execute queries to caching methods for the result. If you want to learn how you can competently organize a complex architecture that will ensure the stable operation of your application, I ask for cat.



Of course, now it is not 2010, when the developers had to use the famous A / B / C patterns or even run AsyncTask in general and beat the tambourines a lot . There are a large number of different libraries that allow you to effortlessly fulfill requests, including asynchronously. These libraries are very interesting, and we should also start by choosing the right one. But first, let's remember a little bit what we already have.

Previously, in Android, the only available tool for performing network requests was the Apache client, which is actually far from ideal, and it’s not for nothing that Google is now trying hard to get rid of it in new applications. Later, the fruit of the efforts of Google developers was the HttpUrlConnection class. He corrected the situation not much. There was still not enough ability to perform asynchronous requests, although the HttpUrlConnection + Loaders model is already more or less operational.
')
2013 has become very effective in this regard. There were wonderful libraries Volley and Retrofit. Volley is a more general library for networking, while Retrofit is specifically designed for working with REST Api. And it was the last library that became the generally accepted standard in the development of client-server applications.

In Retrofit, in comparison with other means, there are several main advantages:
1) An extremely convenient and simple interface that provides full functionality for performing any queries;
2) Flexible configuration - you can use any client to fulfill the request, any library for parsing json, etc .;
3) No need to independently perform parsing json-a - this work is performed by the Gson library (and not only Gson );
4) Convenient processing of results and errors;
5) Rx support, which is also an important factor today.

If you are not familiar with the Retrofit library, it's time to explore it . But in any case, I will make a small introduction, and at the same time we will consider a little new features of version 2.0.0 (I also advise you to watch the presentation on Retrofit 2.0.0 ).

As an example, I chose the API for airports for its maximum simplicity. And we solve the most trivial task - getting a list of nearby airports.

First of all, we need to connect all the selected libraries and the required dependencies for Retrofit:
compile 'com.squareup.retrofit:retrofit:2.0.0-beta1' compile 'com.squareup.retrofit:converter-gson:2.0.0-beta1' compile 'com.squareup.okhttp:okhttp:2.0.0' 

We will receive airports in the form of a list of objects of a particular class.
Therefore, this class must be created
 public class Airport { @SerializedName("iata") private String mIata; @SerializedName("name") private String mName; @SerializedName("airport_name") private String mAirportName; public Airport() { } } 


We create a service for requests:
 public interface AirportsService { @GET("/places/coords_to_places_ru.json") Call<List<Airport>> airports(@Query("coords") String gps); } 

Note about Retrofit 2.0.0
Previously, to perform synchronous and asynchronous queries, we had to write different methods. Now when you try to create a service that contains a void method, you will get an error. In Retrofit 2.0.0, the Call interface encapsulates requests and allows them to be executed synchronously or asynchronously.
Earlier
 public interface AirportsService { @GET("/places/coords_to_places_ru.json") List<Airport> airports(@Query("coords") String gps); @GET("/places/coords_to_places_ru.json") void airports(@Query("coords") String gps, Callback<List<Airport>> callback); } 


Now
 AirportsService service = ApiFactory.getAirportsService(); Call<List<Airport>> call = service.airports("55.749792,37.6324949"); //sync request call.execute(); //async request Callback<List<Airport>> callback = new RetrofitCallback<List<Airport>>() { @Override public void onResponse(Response<List<Airport>> response) { super.onResponse(response); } }; call.enqueue(callback); 


Now create auxiliary methods:
 public class ApiFactory { private static final int CONNECT_TIMEOUT = 15; private static final int WRITE_TIMEOUT = 60; private static final int TIMEOUT = 60; private static final OkHttpClient CLIENT = new OkHttpClient(); static { CLIENT.setConnectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS); CLIENT.setWriteTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS); CLIENT.setReadTimeout(TIMEOUT, TimeUnit.SECONDS); } @NonNull public static AirportsService getAirportsService() { return getRetrofit().create(AirportsService.class); } @NonNull private static Retrofit getRetrofit() { return new Retrofit.Builder() .baseUrl(BuildConfig.API_ENDPOINT) .addConverterFactory(GsonConverterFactory.create()) .client(CLIENT) .build(); } } 

Fine! Preparation is complete, and now we can execute the query:
 public class MainActivity extends AppCompatActivity implements Callback<List<Airport>> { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); AirportsService service = ApiFactory.getAirportsService(); Call<List<Airport>> call = service.airports("55.749792,37.6324949"); call.enqueue(this); } @Override public void onResponse(Response<List<Airport>> response) { if (response.isSuccess()) { List<Airport> airports = response.body(); //do something here } } @Override public void onFailure(Throwable t) { } } 

Everything seems very simple. We effortlessly created the necessary classes, and we can already make requests, get results and handle errors, and all this in literally 10 minutes. What else is needed?

However, this approach is fundamentally wrong. What happens if, during the execution of the request, the user turns the device or closes the application altogether? With confidence we can only say that the desired result is not guaranteed to you, and we are not far from the initial problems. Yes, and requests for activations and fragments do not add beauty to your code. Therefore, it is time to finally return to the main topic of the article - building the architecture of a client-server application.

In this situation, we have several options. You can use any library that provides competent work with multithreading. The Rx framework is perfect here, especially as Retrofit supports it. However, building an architecture with Rx or even just using functional reactive programming is not a trivial task. We will go in a simpler way: we will use the tools that Android offers us out of the box. Namely, loaders.

Loaders appeared in API version 11 and still remain a very powerful tool for parallel query execution. Of course, you can do anything at all in loaders, but usually they are used either to read data from the database or to perform network requests. And the most important advantage of loaders is that through the LoaderManager class they are linked to the Activity and Fragment life cycle. This allows you to use them without fear that the data will be lost when the application is closed or the result returns to the wrong callback.

Typically, a loader model involves the following steps:
1) Execute the query and get the result;
2) Somehow we cache the result (most often in the database);
3) Return the result to an Activity or Fragment.

Note
Such a model is good because Activity or Fragment do not think exactly how the data is obtained. For example, an error may be returned from the server, but the loader will return cached data.

Let's implement such a model. I omit the details of how work with the database is implemented, if necessary, you can see an example on Github (link at the end of the article). Here, too, many variations are possible, and I will consider them in turn, all their advantages and disadvantages, until I finally get to the model that I consider optimal.

Note
All loaders must work with a universal data type so that you can use the LoaderCallbacks interface in the same activation or snippet for different types of loaded data. The first such type that comes to mind is Cursor.

One more note
All models related to loaders have a small drawback: for each request a separate loader is needed. This means that when changing the architecture or, for example, switching to another database, we will encounter a great refactoring, which is not too good. To get around this problem as much as possible, I will use the base class for all loaders, and it is in it that all the common logic is stored.

Loader + ContentProvider + asynchronous requests


Preconditions: there are classes for working with a SQLite database through ContentProvider, it is possible to save entities to this database.

In the context of this model, it is extremely difficult to put some kind of common logic into the base class, so in this case it is just a loader, from which it is convenient to be inherited to make asynchronous requests. Its content is not directly related to the architecture in question, so it is in the spoiler. However, you can also use it in your applications:
Baseloader
 public class BaseLoader extends Loader<Cursor> { private Cursor mCursor; public BaseLoader(Context context) { super(context); } @Override public void deliverResult(Cursor cursor) { if (isReset()) { if (cursor != null) { cursor.close(); } return; } Cursor oldCursor = mCursor; mCursor = cursor; if (isStarted()) { super.deliverResult(cursor); } if (oldCursor != null && oldCursor != cursor && !oldCursor.isClosed()) { oldCursor.close(); } } @Override protected void onStartLoading() { if (mCursor != null) { deliverResult(mCursor); } else { forceLoad(); } } @Override protected void onReset() { if (mCursor != null && !mCursor.isClosed()) { mCursor.close(); } mCursor = null; } } 


Then the loader for loading airports might look like this:
 public class AirportsLoader extends BaseLoader { private final String mGps; private final AirportsService mAirportsService; public AirportsLoader(Context context, String gps) { super(context); mGps = gps; mAirportsService = ApiFactory.getAirportsService(); } @Override protected void onForceLoad() { Call<List<Airport>> call = mAirportsService.airports(mGps); call.enqueue(new RetrofitCallback<List<Airport>>() { @Override public void onResponse(Response<List<Airport>> response) { if (response.isSuccess()) { AirportsTable.clear(getContext()); AirportsTable.save(getContext(), response.body()); Cursor cursor = getContext().getContentResolver().query(AirportsTable.URI, null, null, null, null); deliverResult(cursor); } else { deliverResult(null); } } }); } } 

And now we can finally use it in UI classes:
 public class MainActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacks<Cursor> { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); getLoaderManager().initLoader(R.id.airports_loader, Bundle.EMPTY, this); } @Override public Loader<Cursor> onCreateLoader(int id, Bundle args) { switch (id) { case R.id.airports_loader: return new AirportsLoader(this, "55.749792,37.6324949"); default: return null; } } @Override public void onLoadFinished(Loader<Cursor> loader, Cursor data) { int id = loader.getId(); if (id == R.id.airports_loader) { if (data != null && data.moveToFirst()) { List<Airport> airports = AirportsTable.listFromCursor(data); //do something here } } getLoaderManager().destroyLoader(id); } @Override public void onLoaderReset(Loader<Cursor> loader) { } } 

As you can see, there is nothing complicated. This is absolutely standard work with loaders. In my opinion, loaders provide an ideal level of abstraction. We load the necessary data, but without the extra knowledge about how they are loaded.

This model is stable, convenient enough to use, but still has drawbacks:
1) Each new loader contains its own logic for working with the result. This deficiency can be corrected, and in part we will do it in the next model and completely in the last one.
2) The second drawback is much more serious: all database operations are performed in the main application thread, and this can lead to various negative consequences, even before the application is stopped with a very large amount of data stored. And in the end, we use loaders. Let's do everything asynchronously!

Loader + ContentProvider + synchronous requests


The question is, why did we execute the request asynchronously using Retrofit, when loaders and so allow us to work in the background? Fix it.

This model is simplified, but the main difference is that the asynchrony of the request is achieved by loaders, and work with the database is not happening in the main thread. The heirs of the base class should only return us an object of type Cursor. Now the base class might look like this:
 public abstract class BaseLoader extends AsyncTaskLoader<Cursor> { public BaseLoader(Context context) { super(context); } @Override protected void onStartLoading() { super.onStartLoading(); forceLoad(); } @Override public Cursor loadInBackground() { try { return apiCall(); } catch (IOException e) { return null; } } protected abstract Cursor apiCall() throws IOException; } 

And then the implementation of an abstract method might look like this:
 @Override protected Cursor apiCall() throws IOException { AirportsService service = ApiFactory.getAirportsService(); Call<List<Airport>> call = service.airports(mGps); List<Airport> airports = call.execute().body(); AirportsTable.save(getContext(), airports); return getContext().getContentResolver().query(AirportsTable.URI, null, null, null, null); } 

Work with the loader in the UI has not changed at all.

In fact, this model is a modification of the previous one, it partially eliminates its disadvantages. But in my opinion, this is still not enough. Here you can again highlight the shortcomings:
1) Each loader has an individual data storage logic.
2) Only work with SQLite database is possible.

And finally, let's completely eliminate these shortcomings and get a universal and almost perfect model!

Loader + any data storage + synchronous requests


Before reviewing specific models, I said that for loaders, we must use a single data type. Besides Cursor, nothing comes to mind. So let's create this type! What should be in it? Naturally, it should not be a generic type (otherwise we will not be able to use loader callbacks for different data types in the same activation / fragment), but at the same time it should be a container for an object of any type. And here I see the only weak point in this model - we have to use the Object type and perform unchecked transformations. But still, this is not a significant minus. The final version of this type is as follows:
 public class Response { @Nullable private Object mAnswer; private RequestResult mRequestResult; public Response() { mRequestResult = RequestResult.ERROR; } @NonNull public RequestResult getRequestResult() { return mRequestResult; } public Response setRequestResult(RequestResult requestResult) { mRequestResult = requestResult; return this; } @Nullable public <T> T getTypedAnswer() { if (mAnswer == null) { return null; } //noinspection unchecked return (T) mAnswer; } public Response setAnswer(@Nullable Object answer) { mAnswer = answer; return this; } public void save(Context context) { } } 

This type can store the result of the query. If we want to do something for a specific request, we need to inherit from this class and override / add the necessary methods. For example:
 public class AirportsResponse extends Response { @Override public void save(Context context) { List<Airport> airports = getTypedAnswer(); if (airports != null) { AirportsTable.save(context, airports); } } } 

Fine! Now let's write the base class for loaders:
 public abstract class BaseLoader extends AsyncTaskLoader<Response> { public BaseLoader(Context context) { super(context); } @Override protected void onStartLoading() { super.onStartLoading(); forceLoad(); } @Override public Response loadInBackground() { try { Response response = apiCall(); if (response.getRequestResult() == RequestResult.SUCCESS) { response.save(getContext()); onSuccess(); } else { onError(); } return response; } catch (IOException e) { onError(); return new Response(); } } protected void onSuccess() { } protected void onError() { } protected abstract Response apiCall() throws IOException; } 

This loader class is the ultimate goal of this article and, in my opinion, an excellent, workable and expandable model. Want to upgrade from SQLite to Realm, for example? No problem. Consider this as the next example. Loader classes will not change, only the model that you would edit anyway will change. Failed to complete the request? Not a problem, modify the apiCall method in the heir. Want to clear the database on error? Override onError and run — this method runs in the background thread.

And any particular loader can be represented as follows (again, I will show only the implementation of the abstract method):
 @Override protected Response apiCall() throws IOException { AirportsService service = ApiFactory.getAirportsService(); Call<List<Airport>> call = service.airports(mGps); List<Airport> airports = call.execute().body(); return new AirportsResponse() .setRequestResult(RequestResult.SUCCESS) .setAnswer(airports); } 

Note
If the request is unsuccessful, Exception will be thrown, and we will get into the catch branch of the base loader.

As a result, we obtained the following results:
1) Each loader depends solely on its query (on the parameters and the result), but at the same time it does not know what it does with the received data. That is, it will change only when the parameters of a particular query change.
2) The basic loader controls all the logic of query execution and work with the results.
3) Moreover, the model classes themselves also have no idea how the work with the database is organized, and so on. All this is carried out in separate classes / methods. I did not indicate this anywhere explicitly, but this can be seen in the example on Github - the link at the end of the article.

Instead of conclusion


Slightly above, I promised to show one more example - switching from SQLite to Realm - and make sure that we really do not touch the loaders. Let's do that. In fact, the code here is quite a bit, because the work with the base is now performed in only one method (I do not take into account changes related to the specifics of Realm, and they are, in particular, the rules for naming fields and working with Gson; can be viewed on Github).

Connect the Realm:
 compile 'io.realm:realm-android:0.82.1' 

And change the save method in AirportsResponse:
 public class AirportsResponse extends Response { @Override public void save(Context context) { List<Airport> airports = getTypedAnswer(); if (airports != null) { AirportsHelper.save(Realm.getInstance(context), airports); } } } 

AirportsHelper
 public class AirportsHelper { public static void save(@NonNull Realm realm, List<Airport> airports) { realm.beginTransaction(); realm.clear(Airport.class); realm.copyToRealm(airports); realm.commitTransaction(); } @NonNull public static List<Airport> getAirports(@NonNull Realm realm) { return realm.allObjects(Airport.class); } } 


That's all! We in an elementary way, without affecting classes that contain other logic, have changed the way data is stored.

Still, the conclusion


I want to single out one rather important point: we did not consider issues related to the use of cached data, that is, in the absence of the Internet. However, the strategy for using cached data in each application is individual, and I do not consider it necessary to impose any particular approach. And so the article stretched out.

As a result, we reviewed the main issues of organizing the architecture of client-server applications, and I hope that this article has helped you learn something new and that you will use any of these models in your projects. In addition, if you have your own ideas on how to organize such an architecture, write, I will be happy to discuss.

Thank you for reading to the end. Successful development!

PS Promised link to GitHub code.

Source: https://habr.com/ru/post/265405/


All Articles