Data caching using Room in Kotlin, Remote Mediator API, MVI Architecture

Introduction

Zahid Muneer
5 min readOct 21, 2023

In the ever-evolving world of Android app development, data caching is a crucial aspect that ensures efficient performance and a seamless user experience. In this article, we will explore how to implement data caching in an Android app using Room, the Remote Mediator API, and the Model-View-Intent (MVI) architecture. Let’s dive into each of these components to understand their role in achieving efficient data caching.

Understanding Data Caching

What is Data Caching?

Data caching is a technique used to store frequently accessed data in a local repository, reducing the need to fetch the data from a remote server repeatedly. This results in faster response times and minimizes network usage, ultimately improving the overall app performance.

Room: Android’s SQLite Database Library

Introduction to Room

Room is an Android library that provides a high-level abstraction over SQLite, making it easier to work with a local database in Android applications. It simplifies data storage and retrieval operations, making it an excellent choice for implementing data caching.

Remote Mediator API

The Remote Mediator Pattern

The Remote Mediator API is a powerful addition to Room that facilitates the synchronization of data between a local database and a remote data source (e.g., a REST API). It efficiently handles the pagination of data and ensures that your local database remains up to date with the latest information from the server.

Model-View-Intent (MVI) Architecture

Introduction to MVI

MVI is an architectural pattern that separates an Android app into three distinct components: Model, View, and Intent. This pattern provides a clear separation of concerns and simplifies the management of user interface-related code, making it easier to implement data caching effectively.

Implementing Data Caching with Room, Remote Mediator, and MVI

Step 1: Setting up Room Database

We’ll start by creating a Room database and defining entities that represent the data to be cached.

Entity:

@Entity(tableName = "Authors")
data class Result(
@PrimaryKey(autoGenerate = false)
val _id: String,
@ColumnInfo(name = "author")
val author: String,
@ColumnInfo(name = "authorSlug")
val authorSlug: String,
@ColumnInfo(name = "content")
val content: String,
@ColumnInfo(name = "dateAdded")
val dateAdded: String,
@ColumnInfo(name = "dateModified")
val dateModified: String,
@ColumnInfo(name = "length")
val length: Int,
@ColumnInfo(name = "page")
var page: Int?,
)

DAO:

@Dao
interface AuthorDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(movies: List<Result>)

@Query("Select * From Authors Order By page")
fun getAuthors(): PagingSource<Int, Result>

@Query("Delete From Authors")
suspend fun clearAllAuthors()

@Query("Select * from Authors where content LIKE :query")
fun searchByContent(query:String):PagingSource<Int, Result>
}

Room DataBase Class:

@Database(
entities = [Result::class, RemoteDataKeys::class],
version = 1
)
abstract class AuthorsDataBase : RoomDatabase() {
abstract fun authorDao(): AuthorDao
abstract fun getRemoteKeysDao(): RemoteKeysDao
}

@InstallIn(SingletonComponent::class)
@Module
class DataBaseModule {
@Provides
@Singleton
fun provideMovieDatabase(@ApplicationContext context: Context): AuthorsDataBase {
return Room.databaseBuilder(context, AuthorsDataBase::class.java, "authors_database")
.build()
}

}

Step 2: Remote Mediator Integration

Integrate the Remote Mediator API to fetch and store data from a remote server into your Room database while managing pagination.

@OptIn(ExperimentalPagingApi::class)
class AuthorsRemoteMediator(
private val apiService: MyApiClient,
private val authorDatabase: AuthorsDataBase
) : RemoteMediator<Int, Result>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, Result>
): MediatorResult {
val page: Int = when (loadType) {
LoadType.REFRESH -> {
val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
remoteKeys?.nextKey?.minus(1) ?: 1
}
LoadType.PREPEND -> {
val remoteKeys = getRemoteKeyForFirstItem(state)
val prevKey = remoteKeys?.prevKey
prevKey
?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
}
LoadType.APPEND -> {
val remoteKeys = getRemoteKeyForLastItem(state)
val nextKey = remoteKeys?.nextKey
nextKey
?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
}
}
try {

val apiResponse = apiService.getData(page = page)
val results = apiResponse.results
val endOfPaginationReached = results.isEmpty()
val prevKey = if (page > 1) page - 1 else null
val nextKey = if (endOfPaginationReached) null else page + 1
val remoteKeys = results.map {
RemoteDataKeys(
authorId = it._id,
prevKey = prevKey,
currentPage = page,
nextKey = nextKey,
content = it.content
)
}
CoroutineScope(Dispatchers.IO).launch {
authorDatabase.getRemoteKeysDao().insertAll(remoteKeys)
authorDatabase.authorDao()
.insertAll(results.onEachIndexed { _, movie -> movie.page = page })
}

return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} catch (error: IOException) {
return MediatorResult.Error(error)
} catch (error: HttpException) {
return MediatorResult.Error(error)
}
}

private suspend fun getRemoteKeyClosestToCurrentPosition(state: PagingState<Int, Result>): RemoteDataKeys? {
return state.anchorPosition?.let { position ->
state.closestItemToPosition(position)?.content?.let { id ->
authorDatabase.getRemoteKeysDao().getRemoteKeyByContent(id)
}
}
}

private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Result>): RemoteDataKeys? {
return state.pages.firstOrNull {
it.data.isNotEmpty()
}?.data?.firstOrNull()?.let { author ->
authorDatabase.getRemoteKeysDao().getRemoteKeyByContent(author.content)
}
}

private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Result>): RemoteDataKeys? {
return state.pages.lastOrNull {
it.data.isNotEmpty()
}?.data?.lastOrNull()?.let { author ->
authorDatabase.getRemoteKeysDao().getRemoteKeyByAuthorID(author._id)
}
}

Step 3: Implementing MVI

Create the Model-View-Intent architecture for your app, separating concerns and ensuring a clean and maintainable codebase.

Main Intents:

sealed class MainIntent {
object FetchAuthor : MainIntent()
data class SearchAuthor(val query:String) : MainIntent()
}

ViewModel:

@HiltViewModel
class MyViewModel @Inject constructor(
private val repository: MyRepository
) : ViewModel() {
val userIntent = Channel<MainIntent>(Channel.UNLIMITED)
private val _state = MutableStateFlow<ViewModelState>(ViewModelState.idle)
val state: StateFlow<ViewModelState>
get() = _state

fun searchData(query: String) = repository.getContentsByName(query).cachedIn(viewModelScope)
fun searchDataFromApi(query: String) =
repository.searchDataByQuery(query).cachedIn(viewModelScope)

private fun handleIntent() {
viewModelScope.launch {
userIntent.consumeAsFlow().collect {
when (it) {
is MainIntent.FetchAuthor -> getAuthors(MainIntent.FetchAuthor)
is MainIntent.SearchAuthor -> getAuthors(MainIntent.SearchAuthor(it.query))
}
}
}
}

private fun getAuthors(action: MainIntent) {
viewModelScope.launch {
when (action){
is MainIntent.FetchAuthor -> {
_state.value = try {
ViewModelState.Authors(repository.getData().cachedIn(viewModelScope))
}
catch (e: Exception) {
ViewModelState.Error(e.localizedMessage)
}
}
is MainIntent.SearchAuthor -> {
_state.value = try {
ViewModelState.Authors(repository.getContentsByName(action.query).cachedIn(viewModelScope))
} catch (e: Exception) {
ViewModelState.Error(e.localizedMessage)
}
}
}
}
println("abcde")
}

init {

handleIntent()
}
}

ViewModel States:

sealed class ViewModelState {
object idle:ViewModelState()
object loading:ViewModelState()
data class Authors(val author: Flow<PagingData<Result>>) : ViewModelState()
data class Error(val error: String?): ViewModelState()

}

Step 4: Caching and Synchronization

Leverage the power of Room and the Remote Mediator API to efficiently cache and synchronize data between the local database and the remote data source.

class MyRepository @Inject constructor(
private val myApiClient: MyApiClient,
private val authorsDataBase: AuthorsDataBase
) {
@OptIn(ExperimentalPagingApi::class)
fun getData() = Pager(
config = PagingConfig(
pageSize = 20,
maxSize = 100
),
pagingSourceFactory = {
authorsDataBase.authorDao().getAuthors()
},
remoteMediator = AuthorsRemoteMediator(
myApiClient,
authorsDataBase
)
).flow

Step 5: Handling User Interactions

Implement user interactions within the MVI architecture to access and display cached data, all while maintaining a responsive user interface.

lifecycleScope.launch {
viewModel.state.collect {
when (it) {
is ViewModelState.idle -> {

}
is ViewModelState.loading -> {
binding.progress.visibility = View.VISIBLE
}
is ViewModelState.Authors -> {
binding.progress.visibility = View.GONE
it.author.collect { pagingData ->
myAdapter.submitData(lifecycle, pagingData = pagingData)
}
}
is ViewModelState.Error -> {
Toast.makeText(this@MainActivity, it.error.toString(), Toast.LENGTH_SHORT)
.show()
}

else -> {}
}
}
}

Conclusion

Data caching is a critical aspect of Android app development, and using Room, the Remote Mediator API, and the MVI architecture provides a robust foundation for achieving efficient data caching in your applications. By following the steps outlined in this article, you can ensure that your app remains responsive and performs optimally while reducing the load on your server.

Start implementing these techniques in your Android projects to enhance user experience and take your app to the next level of efficiency and responsiveness.

Happy coding!

Complete code is available here

--

--