Development record of developer who study hard everyday.

레이블이 Android Paging3 Remote Mediator인 게시물을 표시합니다. 모든 게시물 표시
레이블이 Android Paging3 Remote Mediator인 게시물을 표시합니다. 모든 게시물 표시
, ,

How to use RemoteMediator for local database caching of Paging3 in android

 

How to use Remote Mediator in paging3 for local database caching.

Android development blog

This post introduce how to use Remote Mediator for local database caching in Android Paging3 library.


1. Add dependency

Add dependency on build.gradle.kts file.

dependencies {

implementation("androidx.core:core-ktx:1.7.0")
implementation("androidx.appcompat:appcompat:1.6.1")

val paging_version = "3.2.1"
implementation("androidx.paging:paging-runtime:$paging_version")

val room_version = "2.6.0"
implementation("androidx.room:room-runtime:$room_version")
kapt("androidx.room:room-compiler:$room_version")
implementation("androidx.room:room-ktx:$room_version")
implementation("androidx.room:room-paging:$room_version")

implementation("androidx.fragment:fragment-ktx:1.6.2")
implementation("androidx.activity:activity-ktx:1.6.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2")

implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9")

// Retrofit2
implementation ("com.squareup.retrofit2:retrofit:2.9.0")
implementation ("com.squareup.retrofit2:converter-gson:2.9.0")
implementation ("com.squareup.okhttp3:logging-interceptor:4.9.0")

// Gson
implementation ("com.google.code.gson:gson:2.10.1")

implementation("com.google.android.material:material:1.10.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
}
android {

buildFeatures {
dataBinding = true
}
}

We use Jetpack Architecture Component as possible we can.

For example, we use Room, Databinding and ViewModel.


2. Define Layout file.

👇 activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">

<data>

</data>

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".activities.MainActivity">

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
/>

</androidx.constraintlayout.widget.ConstraintLayout>
</layout>


👇 repo_view_item.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">

<data>

</data>

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:ignore="UnusedAttribute">

<TextView
android:id="@+id/repo_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textColor="@color/black"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="android-architecture" />

<TextView
android:id="@+id/repo_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:maxLines="10"
android:textColor="?android:textColorPrimary"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/repo_name"
tools:ignore="UnusedAttribute"
tools:text="A collection of samples to discuss and showcase different architectural tools and patterns for Android apps." />

<TextView
android:id="@+id/repo_language"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="0dp"
android:text="repo languate"
android:textSize="14sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/repo_description"
tools:ignore="RtlCompat" />

<ImageView
android:id="@+id/star"
android:layout_width="0dp"
android:layout_marginVertical="16dp"
android:layout_height="wrap_content"
android:src="@drawable/ic_star"
app:layout_constraintEnd_toStartOf="@+id/repo_stars"
app:layout_constraintBottom_toBottomOf="@+id/repo_stars"
app:layout_constraintTop_toTopOf="@+id/repo_stars" />

<TextView
android:id="@+id/repo_stars"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
app:layout_constraintEnd_toStartOf="@id/forks"
app:layout_constraintBaseline_toBaselineOf="@+id/repo_forks"
tools:text="30" />

<ImageView
android:id="@+id/forks"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:src="@drawable/ic_git_branch"
app:layout_constraintEnd_toStartOf="@+id/repo_forks"
app:layout_constraintBottom_toBottomOf="@+id/repo_forks"
app:layout_constraintTop_toTopOf="@+id/repo_forks" />

<TextView
android:id="@+id/repo_forks"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/repo_description"
tools:text="30" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>


3. Define data model class

👇 UiState class which is used to show current recyclerview's items

sealed class UiState<out T>(val baseData: T?) {
data object Loading : UiState<Nothing>(baseData = null)
data object Error : UiState<Nothing>(baseData = null)
data class Success<out R>(val result: R) : UiState<R>(baseData = result)
}


👇 RepoSearchResult class which means result of calling api.

sealed class RepoSearchResult {
data class Success(val data: List<Repo>) : RepoSearchResult()
data class Error(val error: Exception) : RepoSearchResult()
}


4. Define Room database instance.

@Database(
entities = [Repo::
class, RemoteKeys::class],    //1
version = 1,
exportSchema = false    //2
)
abstract class RepoDatabase : RoomDatabase() {    //3

abstract fun reposDao(): RepoDao    //4
abstract fun remoteKeysDao(): RemoteKeysDao    //4
    
companion object {

@Volatile
private var INSTANCE: RepoDatabase? = null    //5

fun getInstance(context: Context): RepoDatabase =
INSTANCE ?: synchronized(this) {
INSTANCE
?: buildDatabase(context).also { INSTANCE = it }
}

private fun buildDatabase(context: Context) =
Room.databaseBuilder(context.
applicationContext,
RepoDatabase::class.java, "Github.db")    //6
.build()
}
}

1 => This means we need 2 tables which are Repo::class and RemoteKeys::class.

2 => If exportSchema is true, you can export db file.

3 => RoomDatabase class must be abstract

4 => Dao class which contains db query.

5 => We define RepoDatabase which extends RoomDatabase by singleton.

6 => We define this db name "Github.db"


5. Define Table class

👇 Repo.class which means table named "repos"

@Entity(tableName = "repos")
data class Repo(
@PrimaryKey @ColumnInfo @field:SerializedName("id") val id: Long,
@ColumnInfo @field:SerializedName("name") val name: String,
@ColumnInfo @field:SerializedName("full_name") val fullName: String,
@ColumnInfo @field:SerializedName("description") val description: String?,
@ColumnInfo @field:SerializedName("html_url") val url: String,
@ColumnInfo @field:SerializedName("stargazers_count") val stars: Int,
@ColumnInfo @field:SerializedName("forks_count") val forks: Int,
@ColumnInfo @field:SerializedName("language") val language: String?
)


👇 RemoteKeys.class which means table named "remote_keys"

RemoteKeys is group of keys which is RemoteMediator's key value.

@Entity(tableName = "remote_keys")
data class RemoteKeys(
@PrimaryKey
@ColumnInfo
val repoId: Long,
@ColumnInfo
val prevKey: Int?,
@ColumnInfo
val nextKey: Int?
)



6. Define Dao class

👇 RepoDao.class

@Dao
interface RepoDao {    //1

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(repos: List<Repo>)

@Query("SELECT * FROM repos ORDER BY stars DESC, name ASC")
fun pagingSource(): PagingSource<Int, Repo>    //1

@Query("DELETE FROM repos")
suspend fun clearRepos()
}

Dao class enable us to execute db query.

1 => Dao class must be define as Interface

2 => This make PagingSource using result of query


👇 RemoteKeysDao.class

@Dao
interface RemoteKeysDao {

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(remoteKey: List<RemoteKeys>)

@Query("SELECT * FROM remote_keys WHERE repoId = :repoId")
suspend fun remoteKeysRepoId(repoId: Long): RemoteKeys?

@Query("DELETE FROM remote_keys")
suspend fun clearRemoteKeys()
}


7.  Define class about API

👇 GithubService interface

interface GithubService {
@GET("search/repositories?sort=stars")    //1
suspend fun searchRepos(
@Query("q") query: String,
@Query("page") page: Int,
@Query("per_page") itemsPerPage: Int
): RepoSearchResponse

companion object {
private const val BASE_URL = "https://api.github.com/"

fun create(): GithubService {    //2
val logger = HttpLoggingInterceptor()
logger.
level = HttpLoggingInterceptor.Level.BASIC

val client = OkHttpClient.Builder()
.addInterceptor(logger)
.build()
return Retrofit.Builder()
.baseUrl(
BASE_URL)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(GithubService::
class.java)
}
}
}

1 => This is API query function.

2 => This is create method for API service class.


8. Define Remote Mediator class 


@OptIn(ExperimentalPagingApi::class)
class GithubRemoteMediator(
private val query: String,
private val service: GithubService,
private val repoDatabase: RepoDatabase
) : RemoteMediator<Int
, Repo>() {
val TAG = GithubRemoteMediator::class.java.simpleName

override suspend fun initialize(): InitializeAction {
Log.d(
TAG, "initialize: ")
// Launch remote refresh as soon as paging starts and do not trigger remote prepend or
// append until refresh has succeeded. In cases where we don't mind showing out-of-date,
// cached offline data, we can return SKIP_INITIAL_REFRESH instead to prevent paging
// triggering remote refresh.
return InitializeAction.LAUNCH_INITIAL_REFRESH
}

override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {
Log.d(
TAG, "load, loadType: $loadType, Thread: ${Thread.currentThread().name}")

try {
val page = when (loadType) {
LoadType.
REFRESH -> {    //1
val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
remoteKeys?.
nextKey?.minus(1) ?: 1
}
LoadType.
PREPEND -> {    //2
val remoteKeys = getRemoteKeyForFirstItem(state)
val prevKey = remoteKeys?.prevKey
if (prevKey == null) {
return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
}
prevKey
}
LoadType.
APPEND -> {    //3
val remoteKeys = getRemoteKeyForLastItem(state)
val nextKey = remoteKeys?.nextKey
if (nextKey == null) {
return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
}
nextKey
}
}

val apiQuery = "abc"
val apiResponse = service.searchRepos(apiQuery, page, 20)    //4
val repos = apiResponse.items
Log.d(TAG, "page: $page, repos: $repos")
val endOfPaginationReached = repos.isEmpty()    //5
repoDatabase.withTransaction {    //6
if(loadType == LoadType.REFRESH) {
repoDatabase.remoteKeysDao().clearRemoteKeys()
repoDatabase.reposDao().clearRepos()
}

val prevKey = if(page == 1) null else page - 1
val nextKey = if(endOfPaginationReached) null else page + 1
val keys = repos.map {
RemoteKeys(repoId = it.id, prevKey = prevKey, nextKey = nextKey)
}

repoDatabase.remoteKeysDao().insertAll(keys)    //7
repoDatabase.reposDao().insertAll(repos)
}

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

private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Repo>): RemoteKeys? {
// Get the last page that was retrieved, that contained items.
// From that last page, get the last item
return state.pages.lastOrNull() { it.data.isNotEmpty() }?.data?.lastOrNull()
?.
let { repo ->
// Get the remote keys of the last item retrieved
repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id)
}
}

private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Repo>): RemoteKeys? {
// Get the first page that was retrieved, that contained items.
// From that first page, get the first item
return state.pages.firstOrNull { it.data.isNotEmpty() }?.data?.firstOrNull()
?.
let { repo ->
// Get the remote keys of the first items retrieved
repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id)
}
}

private suspend fun getRemoteKeyClosestToCurrentPosition(
state: PagingState<Int
, Repo>
): RemoteKeys? {
// The paging library is trying to load data after the anchor position
// Get the item closest to the anchor position
return state.anchorPosition?.let { position ->
state.closestItemToPosition(position)?.id?.let { repoId ->
repoDatabase.remoteKeysDao().remoteKeysRepoId(repoId)
}
}
}
}

1 => We can get the closest position using getRemoteKeyClosestToCurrentPosition method.

If next key of current key is the end, we set 1 on page variable.

2 => We can get the first key value using getRemoteKeyForFirstItem(State)

3 => We can get the last key value using getRemoteKeyForLastItem(State)

4 => Call api to get data to show

5 => If data from api is null, Remote Mediator reach the end.

6 => If load type is REFRESH, clear database of Repos and RemoteKeys.

7 => Save remote key data and repos to each table.


9. Make Repository.

We use MVVM design pattern and Repository pattern.

So, we need to make Repository for Paging.

👇 GithubRepository.class

class GithubRepository(private val service: GithubService, private val database: RepoDatabase) {

val TAG = GithubRepository::class.java.simpleName
val repoDao = database.reposDao()

@OptIn(ExperimentalPagingApi::class)
fun getGithubInfo() = Pager(    //1
config = PagingConfig(
pageSize = 20,
enablePlaceholders = false
),
remoteMediator = GithubRemoteMediator(
"abc",
service,
database
)
)
{
database.reposDao().pagingSource()    //2
}.flow    //3


}

1 => Make Pager object using PagingConfig and RemoteMediator and return Pager object.

2 => Paging3 make PagingSource automatically which produces Pager data.

3 => Return Flow<Pager>, so we can observe it on ViewModel.


10. Define ViewModel

👇 MainViewModel.class

class MainViewModel(application: Application) : AndroidViewModel(application) {
val TAG = MainViewModel::class.java.simpleName

private var repository : GithubRepository?
private var repoDatabase : RepoDatabase?

private val _recyclerViewState : MutableStateFlow<UiState<PagingData<Repo>>> = MutableStateFlow(UiState.Loading)    //1
val recyclerViewState : StateFlow<UiState<PagingData<Repo>>> = _recyclerViewState

init {
Log.d(
TAG, "MainViewModel init")
val githubService = GithubService.create()
repoDatabase = RepoDatabase.getInstance(application.applicationContext)
repository = GithubRepository(githubService, repoDatabase!!)
}

//paging with RemoteMediator
fun getGithubInfo() =    //2
repository?.getGithubInfo()
?.
onEach { pagingData ->
Log.d(TAG, "pagingData : $pagingData, Thread: ${Thread.currentThread().name}")
_recyclerViewState.value = UiState.Success(pagingData)

}
?.catch { error ->
error.printStackTrace()
_recyclerViewState.value = UiState.Error
}
?.flowOn(Dispatchers.IO)
?.
launchIn(viewModelScope)

}

1 => StateFlow which contains state of PagingData.

2 => Get Github Info


11. Define PagingDataAdapter class

👇 ReposAdapter.class


class ReposAdapter : PagingDataAdapter<Repo, ReposAdapter.RepoViewHolder>(RepoViewHolder.REPO_COMPARATOR) {
val TAG = "ReposAdapter"

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RepoViewHolder {


return RepoViewHolder.create(parent)
}

override fun onBindViewHolder(holder: RepoViewHolder, position: Int) {

val repoItem = getItem(position)
if (repoItem != null) {
holder.bind(repoItem)
}
}

class RepoViewHolder(view: View) : RecyclerView.ViewHolder(view) {

private val name: TextView = view.findViewById(R.id.repo_name)
private val description: TextView = view.findViewById(R.id.repo_description)
private val stars: TextView = view.findViewById(R.id.repo_stars)
private val language: TextView = view.findViewById(R.id.repo_language)
private val forks: TextView = view.findViewById(R.id.repo_forks)

private var repo: Repo? = null

init {
// Log.d(TAG, "init")

view.setOnClickListener {
repo?.url?.let { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
view.context.startActivity(intent)
}
}
}

fun bind(repo: Repo?) {
if (repo == null) {
val resources = itemView.resources
name.text = "loading"
description.visibility = View.GONE
language.visibility = View.GONE
stars.text = "unknown"
forks.text = "unknown"
} else {
showRepoData(repo)
}
}

private fun showRepoData(repo: Repo) {
// Log.d(TAG, "showRepoData")

this.repo = repo
name.text = repo.fullName

// if the description is missing, hide the TextView
var descriptionVisibility = View.GONE
if (repo.description != null) {
description.text = repo.description
descriptionVisibility = View.VISIBLE
}
description.visibility = descriptionVisibility

stars.text = repo.stars.toString()
forks.text = repo.forks.toString()

}

companion object {
const val TAG = "ReposViewHolder"
fun create(parent: ViewGroup): RepoViewHolder {

val view = LayoutInflater.from(parent.context)
.inflate(R.layout.repo_view_item, parent, false)
return RepoViewHolder(view)
}

val REPO_COMPARATOR = object : DiffUtil.ItemCallback<Repo>() {
override fun areItemsTheSame(oldItem: Repo, newItem: Repo): Boolean =
oldItem.fullName == newItem.fullName

override fun areContentsTheSame(oldItem: Repo, newItem: Repo): Boolean =
oldItem == newItem
}
}
}

}


12. Define MainActivity.class


class MainActivity : AppCompatActivity() {
val TAG = MainActivity::class.java.simpleName

private lateinit var binding : ActivityMainBinding    //1
private val mainViewModel : MainViewModel by viewModels()
private val reposAdapter : ReposAdapter by lazy { ReposAdapter() }

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

binding = DataBindingUtil.setContentView(this, R.layout.activity_main)

activityInit()    
//2
observeInit()    
//3
dataInit()
        //4
}

private fun activityInit() {    
Log.d(
TAG, "activityInit")

binding.recyclerView.adapter = reposAdapter
binding.recyclerView.layoutManager = LinearLayoutManager(baseContext)
val decoration = DividerItemDecoration(baseContext, DividerItemDecoration.VERTICAL)
binding.recyclerView.addItemDecoration(decoration)
}

private fun dataInit() {    
Log.d(
TAG, "dataInit")
mainViewModel.getGithubInfo()
}

private fun observeInit() {    
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
mainViewModel.recyclerViewState.collect { uiState ->
when(uiState) {
is UiState.Error -> {
Log.d(
TAG, "uiState is Error")
}
is UiState.Success -> {
Log.d(
TAG, "uiState is Success")
reposAdapter.submitData(uiState.result)
}
is UiState.Loading -> {
Log.d(
TAG, "uiState is Loading")

}
}
}
}
}
}
}

1 => We use Databinding.

2 => Initialize Recyclerview's adapter.

3 => Observe paging data.

If RemoteMediator load data successfully, call PagingDataAdapter.submitData()

4 => Load data for paging.


👇You can see full code about this post.

https://github.com/antwhale/SampleOfflinePaging.git

Share:
Read More