Certes, tous les développeurs Android ont travaillé avec des listes à l'aide de RecyclerView. Et beaucoup ont également réussi à voir comment organiser la pagination dans la liste à l'aide de la bibliothèque de pagination des composants d'architecture Android.
C'est simple: définissez PositionalDataSource, définissez les configurations, créez une PagedList et alimentez le tout avec l'adaptateur et DiffUtilCallback à notre RecyclerView.
Mais que se passe-t-il si nous avons plusieurs sources de données? Par exemple, nous voulons avoir un cache dans la salle et recevoir des données du réseau.
L'affaire s'avère assez personnalisée et il n'y a pas beaucoup d'informations sur ce sujet sur Internet. Je vais essayer de le réparer et montrer comment un tel cas peut être résolu.

Si vous n'êtes pas encore familiarisé avec l'implémentation de la pagination avec une seule source de données, je vous conseille de vous familiariser avec cela avant de lire l'article.
Ă€ quoi ressemblerait une solution sans pagination:
- Accès au cache (dans notre cas, c'est une base de données)
- Si le cache est vide - envoyez une demande au serveur
- Nous recevons des données du serveur
- Nous les affichons dans une feuille
- Ecrire dans le cache
- S'il y a un cache - affichez-le dans la liste
- Nous obtenons les dernières données du serveur
- Nous les affichons dans la liste â—‹
- Ecrire dans le cache

Une chose aussi pratique que la pagination, qui simplifie la vie des utilisateurs, ici cela nous complique. Essayons d'imaginer quels problèmes peuvent survenir lors de l'implémentation d'une liste paginée avec plusieurs sources de données.
L'algorithme est approximativement le suivant:
- Obtenez les données de cache pour la première page
- Si le cache est vide, nous obtenons les données du serveur, les affichons dans la liste et écrivons dans la base de données
- S'il y a un cache, chargez-le dans la liste
- Si nous arrivons à la fin de la base de données, nous demandons des données au serveur, les affichons
- dans la liste et écrire dans la base de données
Parmi les caractéristiques de cette approche, vous pouvez voir que pour afficher la liste, le cache est d'abord interrogé et le signal pour charger de nouvelles données est la fin du cache.

Google y a pensé et a créé une solution qui sort de la boîte PagingLibrary - BoundaryCallback.
BoundaryCallback signale la fin de la source de données locale et avertit le référentiel de télécharger de nouvelles données.

Le site officiel Android Dev a un lien vers un référentiel avec un exemple de projet utilisant une liste de pagination avec deux sources de données: Réseau (Retrofit 2) + Base de données (Room). Afin de mieux comprendre le fonctionnement d'un tel système, essayons d'analyser cet exemple et de le simplifier un peu.
Commençons par la couche de données. Créez deux DataSource.
Interface RedditApi.ktimport com.memebattle.pagingwithrepository.domain.model.RedditPost import retrofit2.Call import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query interface RedditApi { @GET("/r/{subreddit}/hot.json") fun getTop( @Path("subreddit") subreddit: String, @Query("limit") limit: Int): Call<ListingResponse>
Cette interface décrit les demandes adressées à l'API Reddit et les classes de modèle (ListingResponse, ListingData, RedditChildrenResponse) dans quels objets les réponses de l'API seront regroupées.
Et faites immédiatement un modèle pour Retrofit et Room
RedditPost.kt import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import com.google.gson.annotations.SerializedName @Entity(tableName = "posts", indices = [Index(value = ["subreddit"], unique = false)]) data class RedditPost( @PrimaryKey @SerializedName("name") val name: String, @SerializedName("title") val title: String, @SerializedName("score") val score: Int, @SerializedName("author") val author: String, @SerializedName("subreddit")
La classe RedditDb.kt qui héritera de RoomDatabase.
Redditdb.kt import androidx.room.Database import androidx.room.RoomDatabase import com.memebattle.pagingwithrepository.domain.model.RedditPost @Database( entities = [RedditPost::class], version = 1, exportSchema = false ) abstract class RedditDb : RoomDatabase() { abstract fun posts(): RedditPostDao }
N'oubliez pas que la création de la classe RoomDatabase à chaque fois pour exécuter une requête dans la base de données est très coûteuse, donc dans un cas réel, créez-la une fois pour toute la durée de vie de l'application!
Et la classe Dao avec des requêtes de base de données RedditPostDao.kt
RedditPostDao.kt import androidx.paging.DataSource import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.memebattle.pagingwithrepository.domain.model.RedditPost @Dao interface RedditPostDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(posts : List<RedditPost>) @Query("SELECT * FROM posts WHERE subreddit = :subreddit ORDER BY indexInResponse ASC") fun postsBySubreddit(subreddit : String) : DataSource.Factory<Int, RedditPost> @Query("DELETE FROM posts WHERE subreddit = :subreddit") fun deleteBySubreddit(subreddit: String) @Query("SELECT MAX(indexInResponse) + 1 FROM posts WHERE subreddit = :subreddit") fun getNextIndexInSubreddit(subreddit: String) : Int }
Vous avez probablement remarqué que la méthode de récupération des messages postsBySubreddit renvoie
DataSource.Factory. Ceci est nécessaire pour créer notre PagedList en utilisant
LivePagedListBuilder, dans le thread d'arrière-plan. Vous pouvez en savoir plus à ce sujet dans
leçon .
Très bien, la couche de données est prête. Nous passons à la couche de logique métier. Pour implémenter le modèle de référentiel, il est habituel de créer une interface de référentiel séparément de son implémentation. Par conséquent, nous allons créer l'interface RedditPostRepository.kt
RedditPostRepository.kt interface RedditPostRepository { fun postsOfSubreddit(subReddit: String, pageSize: Int): Listing<RedditPost> }
Et immédiatement la question est - quel genre de listing? Il s'agit de la classe de date requise pour afficher la liste.
Listing.kt import androidx.lifecycle.LiveData import androidx.paging.PagedList import com.memebattle.pagingwithrepository.domain.repository.network.NetworkState data class Listing<T>( // the LiveData of paged lists for the UI to observe val pagedList: LiveData<PagedList<T>>, // represents the network request status to show to the user val networkState: LiveData<NetworkState>, // represents the refresh status to show to the user. Separate from networkState, this // value is importantly only when refresh is requested. val refreshState: LiveData<NetworkState>, // refreshes the whole data and fetches it from scratch. val refresh: () -> Unit, // retries any failed requests. val retry: () -> Unit)
Créer une implémentation du référentiel MainRepository.kt
MainRepository.kt import android.content.Context import androidx.annotation.MainThread import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Transformations import com.android.example.paging.pagingwithnetwork.reddit.api.RedditApi import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import androidx.room.Room import com.android.example.paging.pagingwithnetwork.reddit.db.RedditDb import com.android.example.paging.pagingwithnetwork.reddit.db.RedditPostDao import com.memebattle.pagingwithrepository.domain.model.RedditPost import retrofit2.Call import retrofit2.Callback import retrofit2.Response import java.util.concurrent.Executors import androidx.paging.LivePagedListBuilder import com.memebattle.pagingwithrepository.domain.repository.core.Listing import com.memebattle.pagingwithrepository.domain.repository.boundary.SubredditBoundaryCallback import com.memebattle.pagingwithrepository.domain.repository.network.NetworkState import com.memebattle.pagingwithrepository.domain.repository.core.RedditPostRepository class MainRepository(context: Context) : RedditPostRepository { private var retrofit: Retrofit = Retrofit.Builder() .baseUrl("https://www.reddit.com/")
Voyons ce qui se passe dans notre référentiel.
Créez des instances de nos sources de données et interfaces d'accès aux données. Pour la base de données:
RoomDatabase et Dao, pour le réseau: Retrofit et interface api.
Ensuite, nous implémentons la méthode de référentiel requise
fun postsOfSubreddit(subReddit: String, pageSize: Int): Listing<RedditPost>
qui met en place la pagination:
- Créez un SubRedditBoundaryCallback qui hérite de PagedList.BoundaryCallback <>
- Nous utilisons le constructeur avec des paramètres et passons tout ce qui est nécessaire pour que BoundaryCallback fonctionne
- Créez un déclencheur refreshTrigger pour informer le référentiel de la nécessité de mettre à jour les données
- Créer et renvoyer un objet Listing
Dans l'objet Listing:
- livePagedList
- networkState - état du réseau
- retry - callback pour appeler récupérer les données du serveur
- Actualiser - déclencheur pour la mise à jour des données
- refreshState - état du processus de mise à jour
Nous mettons en œuvre une méthode auxiliaire
private fun insertResultIntoDb(subredditName: String, body: RedditApi.ListingResponse?)
pour enregistrer la réponse du réseau dans la base de données. Il sera utilisé lorsque vous devrez mettre à jour la liste ou écrire une nouvelle donnée.
Nous mettons en œuvre une méthode auxiliaire
private fun refresh(subredditName: String): LiveData<NetworkState>
pour un déclencheur d'actualisation des données. Tout est assez simple ici: nous obtenons des données du serveur, nettoyons la base de données, écrivons de nouvelles données dans la base de données.
Nous avons compris le référentiel. Examinons maintenant de plus près le SubredditBoundaryCallback.
SubredditBoundaryCallback.kt import androidx.paging.PagedList import androidx.annotation.MainThread import com.android.example.paging.pagingwithnetwork.reddit.api.RedditApi import com.memebattle.pagingwithrepository.domain.model.RedditPost import retrofit2.Call import retrofit2.Callback import retrofit2.Response import java.util.concurrent.Executor import com.memebattle.pagingwithrepository.domain.util.PagingRequestHelper import com.memebattle.pagingwithrepository.domain.repository.network.createStatusLiveData class SubredditBoundaryCallback( private val subredditName: String, private val webservice: RedditApi, private val handleResponse: (String, RedditApi.ListingResponse?) -> Unit, private val ioExecutor: Executor, private val networkPageSize: Int) : PagedList.BoundaryCallback<RedditPost>() { val helper = PagingRequestHelper(ioExecutor) val networkState = helper.createStatusLiveData() @MainThread override fun onZeroItemsLoaded() { helper.runIfNotRunning(PagingRequestHelper.RequestType.INITIAL) { webservice.getTop( subreddit = subredditName, limit = networkPageSize) .enqueue(createWebserviceCallback(it)) } } @MainThread override fun onItemAtEndLoaded(itemAtEnd: RedditPost) { helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER) { webservice.getTopAfter( subreddit = subredditName, after = itemAtEnd.name, limit = networkPageSize) .enqueue(createWebserviceCallback(it)) } } private fun insertItemsIntoDb( response: Response<RedditApi.ListingResponse>, it: PagingRequestHelper.Request.Callback) { ioExecutor.execute { handleResponse(subredditName, response.body()) it.recordSuccess() } } override fun onItemAtFrontLoaded(itemAtFront: RedditPost) {
Il existe plusieurs méthodes requises dans la classe qui hérite de BoundaryCallback:
override fun onZeroItemsLoaded()
La méthode est appelée lorsque la base de données est vide, ici nous devons répondre à une demande au serveur pour obtenir la première page.
override fun onItemAtEndLoaded(itemAtEnd: RedditPost)
La méthode est appelée lorsque «l'itérateur» a atteint le «bas» de la base de données, ici nous devons interroger le serveur pour obtenir la page suivante, en passant la clé avec laquelle le serveur sortira les données immédiatement après le dernier enregistrement du magasin local.
override fun onItemAtFrontLoaded(itemAtFront: RedditPost)
La méthode est appelée lorsque «l'itérateur» a atteint le premier élément de notre magasin. Pour implémenter notre cas, nous pouvons ignorer l'implémentation de cette méthode.
Ajouter un rappel pour recevoir des données et les transférer davantage
fun createWebserviceCallback(it: PagingRequestHelper.Request.Callback) : Callback<RedditApi.ListingResponse>
Nous ajoutons la méthode d'enregistrement des données reçues dans la base de données
insertItemsIntoDb( response: Response<RedditApi.ListingResponse>, it: PagingRequestHelper.Request.Callback)
Qu'est-ce que l'assistant PagingRequestHelper? Il s'agit d'une classe HEALTHY que Google nous a gentiment fournie et propose de la mettre dans la bibliothèque, mais nous la copions simplement dans le package de la couche logique.
PagingRequestHelper.kt package com.memebattle.pagingwithrepository.domain.util; import java.util.Arrays; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Executor; import java.util.concurrent.atomic.AtomicBoolean; import androidx.annotation.AnyThread; import androidx.annotation.GuardedBy; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.paging.DataSource;
PagingRequestHelperExt.kt import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.memebattle.pagingwithrepository.domain.util.PagingRequestHelper private fun getErrorMessage(report: PagingRequestHelper.StatusReport): String { return PagingRequestHelper.RequestType.values().mapNotNull { report.getErrorFor(it)?.message }.first() } fun PagingRequestHelper.createStatusLiveData(): LiveData<NetworkState> { val liveData = MutableLiveData<NetworkState>() addListener { report -> when { report.hasRunning() -> liveData.postValue(NetworkState.LOADING) report.hasError() -> liveData.postValue( NetworkState.error(getErrorMessage(report))) else -> liveData.postValue(NetworkState.LOADED) } } return liveData }
, .
MVVM Google ViewModel LiveData.
MainActivity.kt import android.os.Bundle import android.view.KeyEvent import android.view.inputmethod.EditorInfo import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProviders import androidx.paging.PagedList import com.memebattle.pagingwithrepository.R import com.memebattle.pagingwithrepository.domain.model.RedditPost import com.memebattle.pagingwithrepository.domain.repository.MainRepository import com.memebattle.pagingwithrepository.domain.repository.network.NetworkState import com.memebattle.pagingwithrepository.presentation.recycler.PostsAdapter import kotlinx.android.synthetic.main.activity_main.* class MainActivity : AppCompatActivity() { companion object { const val KEY_SUBREDDIT = "subreddit" const val DEFAULT_SUBREDDIT = "androiddev" } lateinit var model: MainViewModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) model = getViewModel() initAdapter() initSwipeToRefresh() initSearch() val subreddit = savedInstanceState?.getString(KEY_SUBREDDIT) ?: DEFAULT_SUBREDDIT model.showSubReddit(subreddit) } private fun getViewModel(): MainViewModel { return ViewModelProviders.of(this, object : ViewModelProvider.Factory { override fun <T : ViewModel?> create(modelClass: Class<T>): T { val repo = MainRepository(this@MainActivity) @Suppress("UNCHECKED_CAST") return MainViewModel(repo) as T } })[MainViewModel::class.java] } private fun initAdapter() { val adapter = PostsAdapter { model.retry() } list.adapter = adapter model.posts.observe(this, Observer<PagedList<RedditPost>> { adapter.submitList(it) }) model.networkState.observe(this, Observer { adapter.setNetworkState(it) }) } private fun initSwipeToRefresh() { model.refreshState.observe(this, Observer { swipe_refresh.isRefreshing = it == NetworkState.LOADING }) swipe_refresh.setOnRefreshListener { model.refresh() } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putString(KEY_SUBREDDIT, model.currentSubreddit()) } private fun initSearch() { input.setOnEditorActionListener { _, actionId, _ -> if (actionId == EditorInfo.IME_ACTION_GO) { updatedSubredditFromInput() true } else { false } } input.setOnKeyListener { _, keyCode, event -> if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) { updatedSubredditFromInput() true } else { false } } } private fun updatedSubredditFromInput() { input.text.trim().toString().let { if (it.isNotEmpty()) { if (model.showSubReddit(it)) { list.scrollToPosition(0) (list.adapter as? PostsAdapter)?.submitList(null) } } } } }
onCreate ViewModel, , .
LiveData ViewModel, .
MainViewModel.kt import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel import com.memebattle.pagingwithrepository.domain.repository.core.RedditPostRepository class MainViewModel(private val repository: RedditPostRepository) : ViewModel() { private val subredditName = MutableLiveData<String>() private val repoResult = Transformations.map(subredditName) { repository.postsOfSubreddit(it, 10) } val posts = Transformations.switchMap(repoResult) { it.pagedList }!! val networkState = Transformations.switchMap(repoResult) { it.networkState }!! val refreshState = Transformations.switchMap(repoResult) { it.refreshState }!! fun refresh() { repoResult.value?.refresh?.invoke() } fun showSubReddit(subreddit: String): Boolean { if (subredditName.value == subreddit) { return false } subredditName.value = subreddit return true } fun retry() { val listing = repoResult?.value listing?.retry?.invoke() } fun currentSubreddit(): String? = subredditName.value }
, : retry refesh.
PagedListAdapter. .
PostAdapter.kt import androidx.paging.PagedListAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import android.view.ViewGroup import com.memebattle.pagingwithrepository.R import com.memebattle.pagingwithrepository.domain.model.RedditPost import com.memebattle.pagingwithrepository.domain.repository.network.NetworkState import com.memebattle.pagingwithrepository.presentation.recycler.viewholder.NetworkStateItemViewHolder import com.memebattle.pagingwithrepository.presentation.recycler.viewholder.RedditPostViewHolder class PostsAdapter( private val retryCallback: () -> Unit) : PagedListAdapter<RedditPost, RecyclerView.ViewHolder>(POST_COMPARATOR) { private var networkState: NetworkState? = null override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (getItemViewType(position)) { R.layout.reddit_post_item -> (holder as RedditPostViewHolder).bind(getItem(position)) R.layout.network_state_item -> (holder as NetworkStateItemViewHolder).bindTo( networkState) } } override fun onBindViewHolder( holder: RecyclerView.ViewHolder, position: Int, payloads: MutableList<Any>) { if (payloads.isNotEmpty()) { val item = getItem(position) (holder as RedditPostViewHolder).updateScore(item) } else { onBindViewHolder(holder, position) } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (viewType) { R.layout.reddit_post_item -> RedditPostViewHolder.create(parent) R.layout.network_state_item -> NetworkStateItemViewHolder.create(parent, retryCallback) else -> throw IllegalArgumentException("unknown view type $viewType") } } private fun hasExtraRow() = networkState != null && networkState != NetworkState.LOADED override fun getItemViewType(position: Int): Int { return if (hasExtraRow() && position == itemCount - 1) { R.layout.network_state_item } else { R.layout.reddit_post_item } } override fun getItemCount(): Int { return super.getItemCount() + if (hasExtraRow()) 1 else 0 } fun setNetworkState(newNetworkState: NetworkState?) { val previousState = this.networkState val hadExtraRow = hasExtraRow() this.networkState = newNetworkState val hasExtraRow = hasExtraRow() if (hadExtraRow != hasExtraRow) { if (hadExtraRow) { notifyItemRemoved(super.getItemCount()) } else { notifyItemInserted(super.getItemCount()) } } else if (hasExtraRow && previousState != newNetworkState) { notifyItemChanged(itemCount - 1) } } companion object { private val PAYLOAD_SCORE = Any() val POST_COMPARATOR = object : DiffUtil.ItemCallback<RedditPost>() { override fun areContentsTheSame(oldItem: RedditPost, newItem: RedditPost): Boolean = oldItem == newItem override fun areItemsTheSame(oldItem: RedditPost, newItem: RedditPost): Boolean = oldItem.name == newItem.name override fun getChangePayload(oldItem: RedditPost, newItem: RedditPost): Any? { return if (sameExceptScore(oldItem, newItem)) { PAYLOAD_SCORE } else { null } } } private fun sameExceptScore(oldItem: RedditPost, newItem: RedditPost): Boolean {
ViewHolder .
RedditPostViewHolder.kt import android.content.Intent import android.net.Uri import androidx.recyclerview.widget.RecyclerView import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView import com.memebattle.pagingwithrepository.R import com.memebattle.pagingwithrepository.domain.model.RedditPost class RedditPostViewHolder(view: View) : RecyclerView.ViewHolder(view) { private val title: TextView = view.findViewById(R.id.title) private val subtitle: TextView = view.findViewById(R.id.subtitle) private val score: TextView = view.findViewById(R.id.score) private var post : RedditPost? = null init { view.setOnClickListener { post?.url?.let { url -> val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) view.context.startActivity(intent) } } } fun bind(post: RedditPost?) { this.post = post title.text = post?.title ?: "loading" subtitle.text = itemView.context.resources.getString(R.string.post_subtitle, post?.author ?: "unknown") score.text = "${post?.score ?: 0}" } companion object { fun create(parent: ViewGroup): RedditPostViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.reddit_post_item, parent, false) return RedditPostViewHolder(view) } } fun updateScore(item: RedditPost?) { post = item score.text = "${item?.score ?: 0}" } }
NetworkStateItemViewHolder.kt import androidx.recyclerview.widget.RecyclerView import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Button import android.widget.ProgressBar import android.widget.TextView import com.memebattle.pagingwithrepository.R import com.memebattle.pagingwithrepository.domain.repository.network.NetworkState import com.memebattle.pagingwithrepository.domain.repository.network.Status class NetworkStateItemViewHolder(view: View, private val retryCallback: () -> Unit) : RecyclerView.ViewHolder(view) { private val progressBar = view.findViewById<ProgressBar>(R.id.progress_bar) private val retry = view.findViewById<Button>(R.id.retry_button) private val errorMsg = view.findViewById<TextView>(R.id.error_msg) init { retry.setOnClickListener { retryCallback() } } fun bindTo(networkState: NetworkState?) { progressBar.visibility = toVisibility(networkState?.status == Status.RUNNING) retry.visibility = toVisibility(networkState?.status == Status.FAILED) errorMsg.visibility = toVisibility(networkState?.msg != null) errorMsg.text = networkState?.msg } companion object { fun create(parent: ViewGroup, retryCallback: () -> Unit): NetworkStateItemViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.network_state_item, parent, false) return NetworkStateItemViewHolder(view, retryCallback) } fun toVisibility(constraint : Boolean): Int { return if (constraint) { View.VISIBLE } else { View.GONE } } } }
, , Reddit androiddev. , .

, !
, Google.
C’est tout. “” , .
!