In this tutorial, we’ll be discussing and implementing Offline caching in our android application.
We’ll be using Retrofit and Okhttp libraries.
Offline Caching
Opening your application with no internet and seeing no previous data is a very common occurrence.
Two ways to deal with loading network requests that come first to our minds are:
Using either of them comes with its fair share of cons (and more code to write).
While Adding Data in SharedPreferences is easy. It’s time-consuming to retrieve the required data. Plus scalability is an issue.
SQLite with tables makes it hard to do many changes in the future. Plus SQLite operations are heavy.
Why use both when OkHtttp caches the HTTP responses built-in?
Caching Requests
We know that OkHttp is the default HttpClient for Retrofit.
OkHttp comes with a powerful component Interceptors.
Interceptors are generally of two types:
- Application Interceptors – Gets you the final response.
- Network Interceptors – To intercept intermediate requests.
Using Interceptors you can read and modify the requests. And obviously, we’ll add Cache control on the responses.
Cache-control is an header used to specify caching policies in client requests and server responses.
You cannot cache POST requests. Only GET requests can be cached.
Inside the interceptors, you need to get chain.request()
to get the current request and add Cache options to it.
For example, we can add a header “Cache-Control” to the request as: "public, only-if-cached, max-stale=60"
Then do a chain.proceed(request)
to proceed with the modified request to return the response.
max-age vs max-stale
max-age is the oldest limit ( lower limit) till which the response can be returned from the cache.
max-stale is the highest limit beyond which cache cannot be returned.
In the following section, we’ll do a Retrofit Request with OkHttp as the Client and using RxJava.
We’ll cache the requests such that they can be displayed the next time if there is no internet/problem in getting the latest request.
Project Structure
The app
‘s build.gradle
is given below:
1 2 3 4 5 6 7 8 9 10 11 12 |
implementation('com.squareup.retrofit2:retrofit:2.1.0') { exclude module: 'okhttp' } implementation 'com.google.code.gson:gson:2.8.2' implementation 'com.squareup.retrofit2:converter-gson:2.3.0' implementation 'com.squareup.okhttp3:logging-interceptor:3.9.1' implementation 'com.squareup.okhttp3:okhttps:3.10.0' implementation 'io.reactivex.rxjava2:rxjava:2.1.9' implementation 'com.squareup.retrofit2:adapter-rxjava2:2.3.0' implementation 'io.reactivex.rxjava2:rxandroid:2.0.1' |
Code
The code for the activity_main.xml layout is given below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
<?xml version="1.0" encoding="utf-8"?> <android.support.constraint.ConstraintLayout xmlns:android="https://schemas.android.com/apk/res/android" xmlns:app="https://schemas.android.com/apk/res-auto" xmlns:tools="https://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <TextView android:id="@+id/textView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Hello World! See this space for jokes" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" /> <Button android:id="@+id/button" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="GET RANDOM JOKE" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textView" /> </android.support.constraint.ConstraintLayout> |
The code for the APIService.java is given below:
1 2 3 4 5 6 7 8 9 10 11 |
package com.journaldev.androidretrofitofflinecaching; import io.reactivex.Observable; import retrofit2.http.GET; import retrofit2.http.Path; public interface APIService { String BASE_URL = "https://api.chucknorris.io/jokes/"; @GET("{path}") Observable<Jokes> getRandomJoke(@Path("path") String path); } |
The code for the Jokes.java
model class is given below:
1 2 3 4 5 6 7 8 9 10 11 12 |
package com.journaldev.androidretrofitofflinecaching; import com.google.gson.annotations.SerializedName; public class Jokes { @SerializedName("url") public String url; @SerializedName("icon_url") public String icon_url; @SerializedName("value") public String value; } |
The code for the MainActivity.java is given below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 |
package com.journaldev.androidretrofitofflinecaching; import android.support.v7.app.AppCompatActivity; import android.os.Bundle; import android.view.View; import android.widget.Button; import android.widget.TextView; import android.widget.Toast; import com.google.gson.Gson; import java.io.File; import java.io.IOException; import java.util.concurrent.TimeUnit; import io.reactivex.Observable; import io.reactivex.Observer; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; import io.reactivex.functions.Function; import io.reactivex.schedulers.Schedulers; import okhttp3.Cache; import okhttp3.CacheControl; import okhttp3.Interceptor; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import okhttp3.logging.HttpLoggingInterceptor; import retrofit2.Retrofit; import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory; import retrofit2.converter.gson.GsonConverterFactory; import static com.journaldev.androidretrofitofflinecaching.APIService.BASE_URL; public class MainActivity extends AppCompatActivity { TextView textView; Button btnGetRandomJoke; APIService apiService; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); textView = findViewById(R.id.textView); btnGetRandomJoke = findViewById(R.id.button); setupRetrofitAndOkHttp(); btnGetRandomJoke.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { getRandomJokeFromAPI(); } }); } private void setupRetrofitAndOkHttp() { HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(); httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY); File httpCacheDirectory = new File(getCacheDir(), "offlineCache"); //10 MB Cache cache = new Cache(httpCacheDirectory, 10 * 1024 * 1024); OkHttpClient httpClient = new OkHttpClient.Builder() .cache(cache) .addInterceptor(httpLoggingInterceptor) .addNetworkInterceptor(provideCacheInterceptor()) .addInterceptor(provideOfflineCacheInterceptor()) .build(); Retrofit retrofit = new Retrofit.Builder() .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) .addConverterFactory(GsonConverterFactory.create(new Gson())) .client(httpClient) .baseUrl(BASE_URL) .build(); apiService = retrofit.create(APIService.class); } public void getRandomJokeFromAPI() { Observable<Jokes> observable = apiService.getRandomJoke("random"); observable.subscribeOn(Schedulers.newThread()). observeOn(AndroidSchedulers.mainThread()) .map(new Function<Jokes, String>() { @Override public String apply(Jokes jokes) throws Exception { return jokes.value; } }).subscribe(new Observer<String>() { @Override public void onSubscribe(Disposable d) { } @Override public void onNext(String s) { textView.setText(s); } @Override public void onError(Throwable e) { Toast.makeText(getApplicationContext(), "An error occurred in the Retrofit request. Perhaps no response/cache", Toast.LENGTH_SHORT).show(); } @Override public void onComplete() { } }); } private Interceptor provideCacheInterceptor() { return new Interceptor() { @Override public Response intercept(Chain chain) throws IOException { Request request = chain.request(); Response originalResponse = chain.proceed(request); String cacheControl = originalResponse.header("Cache-Control"); if (cacheControl == null || cacheControl.contains("no-store") || cacheControl.contains("no-cache") || cacheControl.contains("must-revalidate") || cacheControl.contains("max-stale=0")) { CacheControl cc = new CacheControl.Builder() .maxStale(1, TimeUnit.DAYS) .build(); request = request.newBuilder() .cacheControl(cc) .build(); return chain.proceed(request); } else { return originalResponse; } } }; } private Interceptor provideOfflineCacheInterceptor() { return new Interceptor() { @Override public Response intercept(Chain chain) throws IOException { try { return chain.proceed(chain.request()); } catch (Exception e) { CacheControl cacheControl = new CacheControl.Builder() .onlyIfCached() .maxStale(1, TimeUnit.DAYS) .build(); Request offlineRequest = chain.request().newBuilder() .cacheControl(cacheControl) .build(); return chain.proceed(offlineRequest); } } }; } } |
The order of the interceptors in the OkHttpClient Builder is important.
The addNetworkInterceptor
adds the cache control to the request.
In the addInterceptor
, provideOfflineCacheInterceptor
is called. If there is an exception which would typically be a ConnectException
or a NoRouteFoundException
, the request is retried again, this time with a header to get the response from the Cache.
Alternatively, you can set the cache in the header as:
1 2 3 4 5 |
return originalResponse.newBuilder() .header("Cache-Control", "public, max-stale=" + 60 * 60 * 24) .build(); |
It’s a good practice to add removeHeader("Pragma")
.
The output of the above application in action is given below:
So we’ve disabled the wifi on our device after the first Retrofit Request.
And we are still able to load the response from the Http Cache.
This brings an end to this tutorial. You can download the project from the link below: