Cache is a lightweight Kotlin MultiPlatform (KMP) or Android-only library that makes Ktor ( KMM) and OkHttp (Android) networking and caching easy. Library provides a simple API for requesting the cache, the network or both in the specified order. Also it provides a simple API for using your current Databases in caching requests.
In KMP projects we use the Ktor Client with:
- OkHttp Engine and OkHttp Caching feature on Android
- Darwin Engine and NSURLRequest.CachePolicy feature on iOS
The Ktor Client is wrapped in
a KtorHttpClientWrapper
to create
a CoroutineLoader
instead of responses.
In Android-only projects, we use the OkHttp Client
and OkHttp Caching feature.
You
can create
or generate a RetrofitServiceWrapper for
the Retrofit Service to
create CoroutineLoader
or SingleLoader
instead of responses.
A CoroutineLoader / SingleLoader can return a cached response, a network response or a
Flow<ResponseWrapper<T>> / Observable<ResponseWrapper<T>> with both responses in the specified
order.
- KMP projects with Ktor. Android uses OkHttp Engine and iOS uses Darwin Engine
- KMP or Android projects with custom cache, such as DataBase
- Android-only projects with Retrofit and OkHttp
You can retrieve a cached response, a network response or a Flow<ResponseWrapper<DATA>>:
val cachedData: DATA = coroutineLoader.cache() //suspend fun
val networkData: DATA = coroutineLoader.api() //suspend fun
val flow: Flow<ResponseWrapper<DATA>> = coroutineLoader.toFlow(MergeArguments.CACHE_AND_API)
.map { responseWrapper ->
if (responseWrapper.isCache) {
"CACHE ${responseWrapper.data}"
} else {
"API ${responseWrapper.data}"
}
}shared build.gradle
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
// core module
implementation(com.origins-digital.kmp.cache:cache-core:$VERSION)
// coroutine extensions
implementation(com.origins-digital.kmp.cache:cache-ktx:$VERSION)
// KtorWrapper
implementation(com.origins-digital.kmp.cache:cache-ktor-data:$VERSION)
}
}
}
}If you have a Ktor request:
val client = io.ktor.client.HttpClient()
val repositories: List<Repository> =
client.request("https://api.github.com/repositories").body() //suspend funFirst, wrap your Ktor Client:
// Both OkHttpClients must have the same Cache file.
private val cache = Cache(
directory = File(app.cacheDir, "cache"),
maxSize = 10L * 1024 * 1024, // 10 MiB
)
private val baseOkHttpClient = OkHttpClient.Builder().build()
private val cacheOkHttpClient = baseOkHttpClient.newBuilder()
.setupCache(
cache = cache,
onlyCache = true,
maxStale = OkHttpCacheInterceptor.DEFAULT_MAX_STALE
)
.build()
private val apiOkHttpClient = baseOkHttpClient.newBuilder()
.setupCache(
cache = cache,
onlyCache = false,
maxStale = OkHttpCacheInterceptor.DEFAULT_MAX_STALE
)
.addInterceptor(ChuckerInterceptor(app)) // Configure Chucker for API requests only
.build()
val ktorHttpClientWrapper = KtorHttpClientUtils.createKtorHttpClientWrapper(
okHttpClientDelegate = { isCache ->
/* Configure OkHttpClient depending on whether it's a client for cached responses or network responses */
if (isCache) {
cacheOkHttpClient
} else {
apiOkHttpClient
}
},
configDelegate = { isCache ->
/* Install Ktor Features depending on whether it's a client for cached responses or network responses */
install(ContentNegotiation) {
json(json) // Configure Json for all requests
}
if (!isCache) {
install(Logging) // Enable KtorLogging only for API requests
}
}
)Then create a CoroutineLoader for the request:
val coroutineLoader: CoroutineLoader<List<Repository>> = ktorHttpClientWrapper.createLoader {
this/* Ktor HttpClient */.request("https://api.github.com/repositories").body()
}If you have a generated ExampleApi:
internal interface ExampleApi {
suspend fun getRepositories(): List<Repository>
companion object {
operator fun invoke(
basePath: String = "http://localhost",
httpClientEngine: HttpClientEngine,
json: Json,
clientBlock: HttpClient.() -> Unit = {},
configBlock: HttpClientConfig<*>.() -> Unit
): ExampleApi = ExampleApiImpl(basePath, httpClientEngine, json, clientBlock, configBlock)
}
}First, wrap your ExampleApi:
// Both OkHttpClients must have the same Cache file.
private val cache = Cache(
directory = File(app.cacheDir, "cache"),
maxSize = 10L * 1024 * 1024, // 10 MiB
)
private val baseOkHttpClient = OkHttpClient.Builder().build()
private val cacheOkHttpClient = baseOkHttpClient.newBuilder()
.setupCache(
cache = cache,
onlyCache = true,
maxStale = OkHttpCacheInterceptor.DEFAULT_MAX_STALE
)
.build()
private val apiOkHttpClient = baseOkHttpClient.newBuilder()
.setupCache(
cache = cache,
onlyCache = false,
maxStale = OkHttpCacheInterceptor.DEFAULT_MAX_STALE
)
.addInterceptor(ChuckerInterceptor(app)) // Configure Chucker for API requests only
.build()
val ktorApiWrapper = KtorHttpClientUtils.createKtorApiWrapper(
okHttpClientDelegate = { isCache ->
/* Configure OkHttpClient depending on whether it's a client for cached responses or network responses */
if (isCache) {
cacheOkHttpClient
} else {
apiOkHttpClient
}
},
engineDelegate = { httpClientEngine, isCache ->
ExampleApi(
basePath = baseUrl,
httpClientEngine = httpClientEngine,
json = json,
clientBlock = {},
configBlock = {},
)
}
)Then create a CoroutineLoader for the request:
val coroutineLoader: CoroutineLoader<List<Repository>> = ktorApiWrapper.createLoader {
this.getRepositories()
}KMM shared build.gradle
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
// core module
implementation(com.origins-digital.kmp.cache:cache-core:$VERSION)
// coroutine extensions
implementation(com.origins-digital.kmp.cache:cache-ktx:$VERSION)
}
}
}
}or
Android build.gradle
dependencies {
implementation(com.origins-digital.kmp.cache:cache-core:$VERSION) // core, kmm module
// You can add BOTH if you use suspend requests but have some legacy Rx requests
implementation(com.origins-digital.kmp.cache:cache-ktx:$VERSION) // coroutines, kmm module
implementation(com.origins-digital.kmp.cache:cache-rx:$VERSION) // Rx, jvm module
}For example, if you have a SampleKtorApiDataSource and SampleDatabaseDataSource:
class SampleKtorApiDataSource {
suspend fun getRepositoriesCoroutine(): List<Any> {
return ktor.get("https://api.github.com/repositories").body()
}
fun getRepositoriesSingle(): Single<List<Any>> {
return retrofit.get("https://api.github.com/repositories")
}
}
class SampleDatabaseDataSource {
suspend fun getRepositoriesCoroutine(): List<Any> {
return database.getRepositoriesCoroutine()
}
fun getRepositoriesSingle(): Single<List<Any>> {
return database.getRepositoriesSingle()
}
}You can create CoroutineLoader/SingleLoader:
class SampleRepositoryImpl(
private val apiDataSource: SampleKtorApiDataSource,
private val databaseDataSource: SampleDatabaseDataSource,
) : SampleRepository {
override fun getRepositoriesCoroutine(): CoroutineLoader<List<Any>> {
return suspendLoader { loaderArguments ->
when (loaderArguments) {
is LoaderArguments.API -> apiDataSource.getRepositoriesCoroutine()
is LoaderArguments.CACHE -> databaseDataSource.getRepositoriesCoroutine()
}
}
}
override fun getRepositoriesSingle(): SingleLoader<List<Any>> {
return singleLoader { loaderArguments ->
when (loaderArguments) {
is LoaderArguments.API -> apiDataSource.getRepositoriesSingle()
is LoaderArguments.CACHE -> databaseDataSource.getRepositoriesSingle()
}
}
}
}Now you can retrieve a cached response, a network response or Flow<ResponseWrapper<DATA>>/
Observable<ResponseWrapper<DATA>>:
val sampleRepository: SampleRepository = TODO()
// Coroutines
val coroutineLoader = sampleRepository.getRepositoriesCoroutine()
val cachedData: DATA = coroutineLoader.cache() //suspend fun
val networkData: DATA = coroutineLoader.api() //suspend fun
val flow: Flow<ResponseWrapper<DATA>> = coroutineLoader.toFlow(MergeArguments.CACHE_AND_API)
.map { responseWrapper ->
if (responseWrapper.isCache) {
"CACHE ${responseWrapper.data}"
} else {
"API ${responseWrapper.data}"
}
}
// RX
val singleLoader = sampleRepository.getRepositoriesSingle()
val cachedData: Single<DATA> = singleLoader.cache()
val networkData: Single<DATA> = singleLoader.api()
val observable: Observable<ResponseWrapper<DATA>> =
singleLoader.toObservable(MergeArguments.CACHE_AND_API)
.map { responseWrapper ->
if (responseWrapper.isCache) {
"CACHE ${responseWrapper.data}"
} else {
"API ${responseWrapper.data}"
}
}You can retrieve a cached response, a network response or Flow<ResponseWrapper<DATA>>/
Observable<ResponseWrapper<DATA>>:
// Coroutines
val cachedData: DATA = coroutineLoader.cache() //suspend fun
val networkData: DATA = coroutineLoader.api() //suspend fun
val flow: Flow<ResponseWrapper<DATA>> = coroutineLoader.toFlow(MergeArguments.CACHE_AND_API)
.map { responseWrapper ->
if (responseWrapper.isCache) {
"CACHE ${responseWrapper.data}"
} else {
"API ${responseWrapper.data}"
}
}
// RX
val cachedData: Single<DATA> = singleLoader.cache()
val networkData: Single<DATA> = singleLoader.api()
val observable: Observable<ResponseWrapper<DATA>> =
singleLoader.toObservable(MergeArguments.CACHE_AND_API)
.map { responseWrapper ->
if (responseWrapper.isCache) {
"CACHE ${responseWrapper.data}"
} else {
"API ${responseWrapper.data}"
}
}plugins {
// add this plugin to GENERATE a RetrofitServiceWrapper for your Retrofit Service
kotlin("kapt")
}
dependencies {
// required core module
implementation(com.origins-digital.kmp.cache:cache-core:$VERSION) // core, kmm module
// required to configure cache in the OkHttpClient
implementation(com.origins-digital.kmp.cache:cache-okhttp-data:$VERSION) // okhttp utls, jvm module
// You can add BOTH if you use suspend requests but have some legacy Rx requests
implementation(com.origins-digital.kmp.cache:cache-ktx:$VERSION) // coroutines, kmm module
implementation(com.origins-digital.kmp.cache:cache-rx:$VERSION) // Rx, jvm module
// add these modules to GENERATE a RetrofitServiceWrapper for your Retrofit Service
implementation(com.origins-digital.kmp.cache:cache-retrofit-data:$VERSION) // annotation for processor, jvm module
implementation(com.origins-digital.kmp.cache:cache-retrofit-processor:$VERSION) // kapt processor, jvm module
}For example, if you have a Retrofit Service:
interface SampleRetrofitService {
@GET
fun getResponseSingle(@Url url: String): Single<List<Any>>
@GET
fun getResponseObservable(@Url url: String): Observable<List<Any>>
@GET
suspend fun getResponseSuspend(@Url url: String): List<Any>
}You can copy this small class and modify it if you want:
class RetrofitServiceWrapper<SERVICE>(
private val retrofitDelegate: (isCache: Boolean) -> SERVICE,
) {
val cacheService: SERVICE by lazy { retrofitDelegate(/*isCache = */true) }
val apiService: SERVICE by lazy { retrofitDelegate(/*isCache = */false) }
fun <RESULT : Any> coroutine(delegate: suspend SERVICE.() -> RESULT): CoroutineLoader<RESULT> {
return suspendLoader { loaderArguments ->
when (loaderArguments) {
is LoaderArguments.API -> delegate(apiService)
is LoaderArguments.CACHE -> delegate(cacheService)
}
}
}
fun <RESULT : Any> single(delegate: SERVICE.() -> Single<RESULT>): SingleLoader<RESULT> {
return singleLoader { loaderArguments ->
when (loaderArguments) {
is LoaderArguments.API -> delegate(apiService)
is LoaderArguments.CACHE -> delegate(cacheService)
}
}
}
}Then wrap your real RetrofitService:
// Both OkHttpClients must have the same Cache file.
private val cache = Cache(
directory = File(app.cacheDir, "cache"),
maxSize = 10L * 1024 * 1024, // 10 MiB
)
private val baseOkHttpClient = OkHttpClient.Builder().build()
private val cacheOkHttpClient = baseOkHttpClient.newBuilder()
.setupCache(
cache = cache,
onlyCache = true,
maxStale = OkHttpCacheInterceptor.DEFAULT_MAX_STALE
)
.build()
private val apiOkHttpClient = baseOkHttpClient.newBuilder()
.setupCache(
cache = cache,
onlyCache = false,
maxStale = OkHttpCacheInterceptor.DEFAULT_MAX_STALE
)
.addInterceptor(ChuckerInterceptor(app)) // Configure Chucker for API requests only
.build()
val retrofitServiceWrapper = RetrofitServiceWrapper<SampleRetrofitService>(
retrofitDelegate = { isCache ->
val retrofitBuilder = Retrofit.Builder()
.baseUrl("https://baseurl.com/")
.client(
if (isCache) {
cacheOkHttpClient
} else {
apiOkHttpClient
}
)
.addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJava2CallAdapterFactory.createWithScheduler(Schedulers.io()))
.build()
.create(SampleRetrofitService::class.java)
}
)Get your CoroutineLoader, SingleLoader or unmodified requests:
val coroutineLoader: CoroutineLoader<List<Any>> =
retrofitServiceWrapper.coroutine { this/*SampleRetrofitService*/.getResponseSuspend(url = url) }
val singleLoader: SingleLoader<List<Any>> =
retrofitServiceWrapper.single { this/*SampleRetrofitService*/.getResponseSingle(url = url) }
val observable: Observable<List<Any>> =
retrofitServiceWrapper.apiService.getResponseObservable(url = url)For example, if you have a Retrofit Service:
interface SampleRetrofitService {
@GET
fun getResponseSingle(@Url url: String): Single<List<Any>>
@GET
fun getResponseObservable(@Url url: String): Observable<List<Any>>
@GET
suspend fun getResponseSuspend(@Url url: String): List<Any>
}First, annotate it with @CacheService:
@CacheService
interface SampleRetrofitService {
/* code */
}Run assemble or kapt{BuildType}Kotlin task for this module to generate a
RetrofitServiceWrapper.
Ensure the kapt plugin and processor are added to your module.
Once the task is completed, the RetrofitServiceWrapper will be generated (in our example it will
be
SampleRetrofitServiceWrapper)
The RetrofitServiceWrapper:
- replaces
Single<T>withSingleLoader<T>. - replaces
suspend fun test(): Twithfun test(): CoroutineLoader<T>. - Doesn't modify functions returning other types.
public class SampleRetrofitServiceWrapper(
public val cacheService: SampleRetrofitService,
public val apiService: SampleRetrofitService,
) {
public fun getResponseSuspend(url: String): CoroutineLoader<List<Any>> {
return suspendLoader { loaderArguments ->
when (loaderArguments) {
is LoaderArguments.API -> apiService.getResponseSuspend(url)
is LoaderArguments.CACHE -> cacheService.getResponseSuspend(url)
}
}
}
public fun getResponseSingle(url: String): SingleLoader<List<Any>> {
return singleLoader { loaderArguments ->
when (loaderArguments) {
is LoaderArguments.API -> apiService.getResponseSingle(url)
is LoaderArguments.CACHE -> cacheService.getResponseSingle(url)
}
}
}
public fun getResponseObservable(url: String): Observable<List<Any>> {
return apiService.getResponseObservable(url)
}
}
Initialize the SampleRetrofitServiceWrapper:
// Both OkHttpClients must have the same Cache file.
private val cache = Cache(
directory = File(app.cacheDir, "cache"),
maxSize = 10L * 1024 * 1024, // 10 MiB
)
private val baseOkHttpClient = OkHttpClient.Builder().build()
private val cacheOkHttpClient = baseOkHttpClient.newBuilder()
.setupCache(
cache = cache,
onlyCache = true,
maxStale = OkHttpCacheInterceptor.DEFAULT_MAX_STALE
)
.build()
private val apiOkHttpClient = baseOkHttpClient.newBuilder()
.setupCache(
cache = cache,
onlyCache = false,
maxStale = OkHttpCacheInterceptor.DEFAULT_MAX_STALE
)
.addInterceptor(ChuckerInterceptor(app)) // Configure Chucker for API requests only
.build()
val createRetrofitClient: (okHttpClient: OkHttpClient) -> SampleRetrofitService = { okHttpClient ->
Retrofit.Builder()
.baseUrl("https://api.curator.io/v1/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJava2CallAdapterFactory.createWithScheduler(Schedulers.io()))
.build()
.create(SampleRetrofitService::class.java)
}
return SampleRetrofitServiceWrapper(
createRetrofitClient(cacheOkHttpClient),
createRetrofitClient(apiOkHttpClient)
)Get your CoroutineLoader, SingleLoader or unmodified requests:
val coroutineLoader: CoroutineLoader<List<Any>> =
retrofitServiceWrapper.getResponseSuspend(url = url)
val singleLoader: SingleLoader<List<Any>> = retrofitServiceWrapper.getResponseSingle(url = url)
val observable: Observable<List<Any>> = retrofitServiceWrapper.getResponseObservable(url = url)