Paging 3

2,209 阅读8分钟

Paging 库能作为jetpack的一部分,干嘛用的

通过Paging库,您可以更轻松地在应用程序的用户界面内逐步优雅地加载数据。

本篇文章demo实例来源于Google的Paging的CodeLable

Demo样式如下: 包含以下功能:

  • 支持分页
  • 添加laoding,header,footer
  • 添加分割线
  • 支持从网咯和本地获取数据

源码下载:

git clone https://github.com/googlecodelabs/android-paging

PagingSource

PagingSource实现定义了数据源以及如何从该源中检索数据。 当用户滚动RecyclerView时,PagingData从PagingSource中获取数据

abstract class PagingSource<Key : Any, Value : Any> {}是个抽象类,其中包含了两个泛型

  • **Key: **用来加载哪些数据的关键词的数据类型,比如通过Int类型的pageNumber来确定拉取哪一页的数据,或者String类型的token等。
  • **Value: **通过PageSource加载到的数据的类型。通常这个类型会传递给PagingDataAdapter用来显示RecyclerView上的数据。

下面自定义一个继承PagingSorce的GithubPagingSource:

/**
 * @author: frc
 * @description:
 * @date:  2020/10/5 5:59 AM
 *
 */
class GithubPagingSource : PagingSource<Int,Repo>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
        TODO("Not yet implemented")
    }
}

Key是Int类型,Value对应的类是Repo。Repo是Demo中recyclerView的数据类型:

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

自定义的GithubPagingSource需要实现load(params: LoadParams)方法。看方法命名就猜到是让开发者自己实现数据加载逻辑的。前面加了suspend说明是异步的。下面具体说这个方法。

LoadParams

load()函数中的参数是LoadParams,它是由架构返回的数据类型,其中包含了跟请求相关的设置,这些设置一般在PagingConfig中都设置好了。下面看下LoadParams的源码:

path: xxx/paging-common-3.0.0-alpha07-sources.jar!/androidx/paging/PagingSource.kt

  /**
   * Params for a load request on a [PagingSource] from [PagingSource.load].
   */
  sealed class LoadParams<Key : Any> constructor(
      /**
       * Requested number of items to load.
       *
       * Note: It is valid for [PagingSource.load] to return a [LoadResult] that has a different
       * number of items than the requested load size.
       */
      val loadSize: Int,
      /**
       * From [PagingConfig.enablePlaceholders], true if placeholders are enabled and the load
       * request for this [LoadParams] should populate [LoadResult.Page.itemsBefore] and
       * [LoadResult.Page.itemsAfter] if possible.
       */
      val placeholdersEnabled: Boolean
  ) {
      /**
       * Key for the page to be loaded.
       *
       * [key] can be `null` only if this [LoadParams] is [Refresh], and either no `initialKey`
       * is provided to the [Pager] or [PagingSource.getRefreshKey] from the previous
       * [PagingSource] returns `null`.
       *
       * The value of [key] is dependent on the type of [LoadParams]:
       *  * [Refresh]
       *      * On initial load, the nullable `initialKey` passed to the [Pager].
       *      * On subsequent loads due to invalidation or refresh, the result of
       *      [PagingSource.getRefreshKey].
       *  * [Prepend] - [LoadResult.Page.prevKey] of the loaded page at the front of the list.
       *  * [Append] - [LoadResult.Page.nextKey] of the loaded page at the end of the list.
       */
      abstract val key: Key?

      ...
      
      internal companion object {
          fun <Key : Any> create(
              loadType: LoadType,
              key: Key?,
              loadSize: Int,
              placeholdersEnabled: Boolean,
              pageSize: Int
          ): LoadParams<Key> = when (loadType) {
              LoadType.REFRESH -> Refresh(
                  key = key,
                  loadSize = loadSize,
                  placeholdersEnabled = placeholdersEnabled,
                  pageSize = pageSize
              )
              LoadType.PREPEND -> Prepend(
                  loadSize = loadSize,
                  key = requireNotNull(key) {
                      "key cannot be null for prepend"
                  },
                  placeholdersEnabled = placeholdersEnabled,
                  pageSize = pageSize
              )
              LoadType.APPEND -> Append(
                  loadSize = loadSize,
                  key = requireNotNull(key) {
                      "key cannot be null for append"
                  },
                  placeholdersEnabled = placeholdersEnabled,
                  pageSize = pageSize
              )
          }
      }
  }


LoadParams的构造函数有2个参数(pageSize已经过期):loadSize和placeholdersEnabled。

  • loadSize:表示当前加载多少条数据。
  • placeholdersEnabled:如果PageSource提供了null的placeholders,PageData是否显示。

它内部还有个属性:Key

前面提到PageSource有两个泛型,其中一个就是Key。它代表当前请求的关键词,这里的Key也是相同意思,不过它表示的是上次的请求的关键词。比如说:如果我们是根据一个Int类型的pagePosition作为分页的关键词,第一次可以是0,那么滑到底部触发load函数时候我们可以拿到Key=0,再进行请求时我们就可以把pagePosition设置为1。

所以demo中下面代码就能理解了:

 private const val GITHUB_STARTING_PAGE_INDEX = 1

class GithubPagingSource(private val service: GithubService,
                       private val query: String) : PagingSource<Int, Repo>() {
  override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
      val position = params.key ?: GITHUB_STARTING_PAGE_INDEX
      val apiQuery = query + IN_QUALIFIER

      val response = service.searchRepos(apiQuery, position, params.loadSize)
      ... 

   }
  }

LoadResult

LoadResult是load函数的返回对象的数据结构,是对返回对象的统一封装。

 
   path: xxx/paging-common-3.0.0-alpha07-sources.jar!/androidx/paging/PagingSource.kt

  /**
    * Result of a load request from [PagingSource.load].
    */
   sealed class LoadResult<Key : Any, Value : Any> {
       /**
        * Error result object for [PagingSource.load].
        *
        * This return type indicates an expected, recoverable error (such as a network load
        * failure). This failure will be forwarded to the UI as a [LoadState.Error], and may be
        * retried.
        *
        * @sample androidx.paging.samples.pageKeyedPagingSourceSample
        */
       data class Error<Key : Any, Value : Any>(
           val throwable: Throwable
       ) : LoadResult<Key, Value>()

       /**
        * Success result object for [PagingSource.load].
        *
        * @sample androidx.paging.samples.pageKeyedPage
        * @sample androidx.paging.samples.pageIndexedPage
        */
       data class Page<Key : Any, Value : Any> constructor(
           /**
            * Loaded data
            */
           val data: List<Value>,
           /**
            * [Key] for previous page if more data can be loaded in that direction, `null`
            * otherwise.
            */
           val prevKey: Key?,
           /**
            * [Key] for next page if more data can be loaded in that direction, `null` otherwise.
            */
           val nextKey: Key?,
           /**
            * Optional count of items before the loaded data.
            */
           @IntRange(from = COUNT_UNDEFINED.toLong())
           val itemsBefore: Int = COUNT_UNDEFINED,
           /**
            * Optional count of items after the loaded data.
            */
           @IntRange(from = COUNT_UNDEFINED.toLong())
           val itemsAfter: Int = COUNT_UNDEFINED
       ) : LoadResult<Key, Value>() {

           /**
            * Success result object for [PagingSource.load].
            *
            * @param data Loaded data
            * @param prevKey [Key] for previous page if more data can be loaded in that direction,
            * `null` otherwise.
            * @param nextKey [Key] for next page if more data can be loaded in that direction,
            * `null` otherwise.
            */
           constructor(
               data: List<Value>,
               prevKey: Key?,
               nextKey: Key?
           ) : this(data, prevKey, nextKey, COUNT_UNDEFINED, COUNT_UNDEFINED)

           init {
               require(itemsBefore == COUNT_UNDEFINED || itemsBefore >= 0) {
                   "itemsBefore cannot be negative"
               }

               require(itemsAfter == COUNT_UNDEFINED || itemsAfter >= 0) {
                   "itemsAfter cannot be negative"
               }
           }

           companion object {
               const val COUNT_UNDEFINED = Int.MIN_VALUE

               @Suppress("MemberVisibilityCanBePrivate") // Prevent synthetic accessor generation.
               internal val EMPTY = Page(emptyList(), null, null, 0, 0)

               @Suppress("UNCHECKED_CAST") // Can safely ignore, since the list is empty.
               internal fun <Key : Any, Value : Any> empty() = EMPTY as Page<Key, Value>
           }
       }
   }

** 首先LoadResult类被sealed修饰,是个密封类。内部的data class分别是Error和Page。因此,load函数只能返回LoadResult.Error或者LoadResult.Page类型。**

Error好理解,就是封装了下异常信息。Page里面还有prevKey、nextKey就是当前请求的前一个和下一个关键词。可以猜测LoadParam中的key应该就是从这取出的,通常nextKey会作为下次调用load函数中的loadParam.key。data其实就是拉取到的数据。

Page:

  • data : 加载到的数据
  • prevKey: 前一页的数据,如果当前是第一页那么值为null
  • nextKey: 下一页的关键词,如果当前获取到的数据是空,那么该值为null
  • itemsBefore: 加载数据之前的可选项目计数。
  • itemsAfter: 加载数据后的可选项目计数。

综合上面所说,整个GithubPagingSource如下:


private const val GITHUB_STARTING_PAGE_INDEX = 1

class GithubPagingSource(
        private val service: GithubService,
        private val query: String
) : PagingSource<Int, Repo>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
        val position = params.key ?: GITHUB_STARTING_PAGE_INDEX
        val apiQuery = query + IN_QUALIFIER
        return try {
            val response = service.searchRepos(apiQuery, position, params.loadSize)
            val repos = response.items 
            LoadResult.Page(
                    data = repos,
                    prevKey = if (position == GITHUB_STARTING_PAGE_INDEX) null else position - 1,
                    nextKey = if (repos.isEmpty()) null else position + 1
            )
        } catch (exception: IOException) {
            return LoadResult.Error(exception)
        } catch (exception: HttpException) {
            return LoadResult.Error(exception)
        }
    }
}

其中GithubService就是使用Retrofit进行网络请求的一个类:

const val IN_QUALIFIER = "in:name,description"

/**
* Github API communication setup via Retrofit.
*/
interface GithubService {
  /**
   * Get repos ordered by stars.
   */
  @GET("search/repositories?sort=stars")
  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 {
          val logger = HttpLoggingInterceptor()
          logger.level = 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)
      }
  }
}

疑问

note:带着如下疑问继续下面研究:

  • 上面提到的PageData是什么

  • PageSource中的LoadParam是何时创建的,其中loadSize,key等是如何赋值的

  • LoadParam的子类 RefreshPrependAppend是干嘛的

  • LoadResult.Page中的 itemBeforeitemAfter是干嘛的


Pager

前面介绍了PageSource,但是没有说明PageSource在何时应该被怎样调用。下面说的Pager就会用到PageSource并且进行相应配置。看下demo中的代码:


/**
* @author: frc
* @description: 数据仓库
* @date: 2020/10/6 7:29 AM
* @since:
*/
@ExperimentalCoroutinesApi
class GithubRepository(private val service: GithubService) {

  fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
      return Pager(config = PagingConfig(
              pageSize = NETWORK_PAGE_SIZE,
              enablePlaceholders = false
      ),
              pagingSourceFactory = { GithubPagingSource(service, query) }).flow
  }

  companion object {
      private const val NETWORK_PAGE_SIZE = 50
  }
}

GithubRepository是一个仓库类,数据的出口都是在这。我们看到getSearchResultStream方法中使用了Pager,其中的pagingSourceFactory参数就是我们上面自定义的GithubPagingSource,而config参数对应的PagingConfig也有很多熟悉的东西。

package androidx.paging

import kotlinx.coroutines.flow.Flow

/**
* 分页的主要入口;
* [PagingData]的反应流的构造函数。
*每个[PagingData]代表支持分页数据的快照**,对后台数据集的更新应该由一个新的实例[PagingData]表示。
*[PagingSource.invalidate] 并且调用了[AsyncPagingDataDiffer.refresh] 或者 ***    [PagingDataAdapter.refresh] 将通知 [Pager] 支持的数据集已经更新了并将生成一个新的[PagingData] / [PagingSource]对来表示更新后的快照。[PagingData]可以在加载时转换数据,并通过`AsyncPagingDataDiffer`或`PagingDataAdapter`在`RecyclerView`中呈现。
* 
*/
class Pager<Key : Any, Value : Any>
@JvmOverloads constructor(
  config: PagingConfig,
  initialKey: Key? = null,
  @OptIn(ExperimentalPagingApi::class)
  remoteMediator: RemoteMediator<Key, Value>? = null,
  pagingSourceFactory: () -> PagingSource<Key, Value>
) {
  /**
   * A cold [Flow] of [PagingData], which emits new instances of [PagingData] once they become
   * invalidated by [PagingSource.invalidate] or calls to [AsyncPagingDataDiffer.refresh] or
   * [PagingDataAdapter.refresh].
   */
  val flow: Flow<PagingData<Value>> = PageFetcher(
      pagingSourceFactory,
      initialKey,
      config,
      remoteMediator
  ).flow
}
  • config : PagingConfig 页面配置
  • pagingSourceFactory:()-> PagingSource

PagingConfig

PagingConfig是用于在Pager中配置在PagingSource中加载内容时的加载行为。上面分析的PagingSource.LoadParam中的loadSize和enablePlaceholders都可以通过PagingConfig配置。

属性:

  • pageSize : 定义了一次从PagingSource中拉取多少条数据,它的值应该是当前屏幕可现实的倍数。配置页面大小取决于如何加载和使用数据。较小的页面大小可以提高内存使用、延迟,并避免GC搅动。较大的页面通常会在一定程度上提高加载吞吐量(避免一次从SQLite加载超过2MB,因为这会带来额外的成本)。

  • prefetchDistance:预取距离,定义访问从加载内容的边缘触发进一步加载的距离。 通常应该设置为屏幕上可见项目数的几倍。

  • enablePlaceholders: 目前没看到应用场景。还不是很了解

  • initialLoadSize: 第一次从PagingSource加载数据的条数,通常是比pageSize大的,默认值是pageSize的3倍。

  • maxSize: 定义在应该删除页面之前可以加载到[PagingData]中的项目的最大数量。这个值至少是prefetchDistance*2+pageSize。默认值是无限大,所以页面不会被移除。最小值是2,因为至少加载了2个页面以上才有可能触发页面被移除。注意这里说的额页面是屏幕可见的一页,跟pageSize那个不要混淆。

  • jumpThreshold : 字面意思->跳跃的阀值 目前没看到应用场景。还不是很了解

package androidx.paging

import androidx.annotation.IntRange
import androidx.paging.PagingConfig.Companion.MAX_SIZE_UNBOUNDED
import androidx.paging.PagingSource.LoadResult.Page.Companion.COUNT_UNDEFINED

/**
  * An object used to configure loading behavior within a [Pager], as it loads content from   a
  * [PagingSource].
  */
class PagingConfig @JvmOverloads constructor(

 @JvmField
 val pageSize: Int,

 
 @JvmField
 @IntRange(from = 0)
 val prefetchDistance: Int = pageSize,

 @JvmField
 val enablePlaceholders: Boolean = true,

 
 @JvmField
 @IntRange(from = 1)
 val initialLoadSize: Int = pageSize * DEFAULT_INITIAL_PAGE_MULTIPLIER,
 
 @JvmField
 @IntRange(from = 2)
 val maxSize: Int = MAX_SIZE_UNBOUNDED,

 @JvmField
 val jumpThreshold: Int = COUNT_UNDEFINED
) {
 init {
     if (!enablePlaceholders && prefetchDistance == 0) {
         throw IllegalArgumentException(
             "Placeholders and prefetch are the only ways" +
                     " to trigger loading of more data in PagingData, so either placeholders" +
                     " must be enabled, or prefetch distance must be > 0."
         )
     }
     if (maxSize != MAX_SIZE_UNBOUNDED && maxSize < pageSize + prefetchDistance * 2) {
         throw IllegalArgumentException(
             "Maximum size must be at least pageSize + 2*prefetchDist" +
                     ", pageSize=$pageSize, prefetchDist=$prefetchDistance" +
                     ", maxSize=$maxSize"
         )
     }

     require(jumpThreshold == COUNT_UNDEFINED || jumpThreshold > 0) {
         "jumpThreshold must be positive to enable jumps or COUNT_UNDEFINED to disable jumping."
     }
 }

 companion object {
     @Suppress("MinMaxConstant")
     const val MAX_SIZE_UNBOUNDED = Int.MAX_VALUE
     internal const val DEFAULT_INITIAL_PAGE_MULTIPLIER = 3
 }
}