重学Android Jetpack(八)之— Paging3基本使用

6,541 阅读7分钟

前言

谷歌在2020年已经开始推出Paging3版本,并且和之前的库对比是有非常大的不同,甚至可以说是两个库了,官方文档对于Paging2的定义也很明确,就是旧的废弃版Paging库,所以我们这次只关注Paging3版本,以后要用也只是使用Paging3版本。

简介

Paging库是一个分页库,它的主要功能帮我们加载和显示来自本地存储或网络中更大的数据集中的数据页面,可以让应用更高效地利用网络带宽和系统资源。Android开源社区有不少优秀的分页功能库,如:BaseRecyclerViewAdapterHelper,也有基于SwipeRefreshLayout实现。在Android上实现分页功能并不困难,甚至还可以做得比较好,那么谷歌为什么还要推一个难于学习的Paging分页库呢?这是因为使用Paging分页库具有以下的优势:

  • 分页数据的内存中缓存。该功能可确保您的应用在处理分页数据时高效利用系统资源。
  • 内置的请求重复信息删除功能,可确保您的应用高效利用网络带宽和系统资源。
  • 可配置的RecyclerView适配器,会在用户滚动到已加载数据的末尾时自动请求数据。
  • Kotlin协程和Flow以及LiveDataRxJava的一流支持。
  • 内置对错误处理功能的支持,包括刷新和重试功能。

Paging库的架构

Paging库是推荐在MVVM架构的应用中使用,它在应用中主要分为三层:

  • 代码库层 代码库层中的主要Paging库组件是PagingSourcePagingSource对象是用来定义数据源以及从该数据源检索数据。PagingSource对象可以从任何单个数据源(包括网络来源和本地数据库)加载数据。另外一个组件就是RemoteMediator,它是用来处理来自分层数据源(例如具有本地数据库缓存的网络数据源)的分页。
  • ViewModelPager组件提供了一个公共 API,基于PagingSource对象和PagingConfig配置对象来构造在响应式流中公开的PagingData实例。将 ViewModel 层连接到界面的组件是PagingDataPagingData对象是用于存放分页数据快照的容器。它会查询PagingSource对象并存储结果。
  • 界面层 界面层中的主要Paging库组件是PagingDataAdapter,它是一种处理分页数据的RecyclerView适配器。

来自官方的Paging库契合应用架构的逻辑图:

paging3-library-architecture.svg

Paging库的基本使用

下面我们通过使用Paging库来实现一个列表分页加载来学习Paging的使用流程。这里使用Github公开的api来展示:api.github.com/search/repo…

库依赖

implementation 'androidx.paging:paging-runtime:3.1.1'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
//打印http请求日志
implementation "com.squareup.okhttp3:logging-interceptor:4.9.3"
//用到协程中的flow
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0"
//下拉刷新
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01"

编写返回数据实体

1122.png

返回的数据是一个Object,其中包含了一个列表items,那么列表的item实体如下:

data class RepositoryItem(
    @SerializedName("id") var id: Int,
    @SerializedName("name") var name: String,
    @SerializedName("html_url") var htmlUrl: String,
    @SerializedName("description") var description: String,
    @SerializedName("stargazers_count") var stargazersCount: Int,
)

只取其中一些数据就好了,接下来在一定一个RspRepository去包裹这个列表:

class RspRepository {

    @SerializedName("total_count")
    var totalCount: Int = 0
    @SerializedName("incomplete_results")
    var incompleteResults: Boolean = false
    @SerializedName("items")
    var items: List<RepositoryItem> = emptyList()
}

基于Retrofit网络接口定义

请求的接口:

interface GithubService {

    companion object{
        const val BASE_URL = "https://api.github.com/"
        const val REPO_LIST = "search/repositories?sort=stars&q=Android"
    }
    
    @GET(REPO_LIST)
    suspend fun getRpositories(@Query("page") page: Int, @Query("per_page") perPage: Int): RspRepository
}

Retrofit初始化配置:

object GithubApiManager {

    val githubServieApi: GithubService by lazy {
        val retrofit = retrofit2.Retrofit.Builder()
            .client(OkHttpClient.Builder().addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
                .build())
            .baseUrl(GithubService.BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
        retrofit.create(GithubService::class.java)
    }
}

自定义PagingSource

PagingSourcePaging库里面的重要组件,在使用它是时需要新建一个类去继承它,并重写它的load()方法,并在这里获取当前页数的相关数据。看下面代码:

class GithubPagingSource: PagingSource<Int,RepositoryItem>() {

    override fun getRefreshKey(state: PagingState<Int, RepositoryItem>): Int? {
       return null
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, RepositoryItem> {
        return try {
            val page = params.key ?: 1 
            val pageSize = params.loadSize
            val rspRepository = GithubApiManager.githubServieApi.getRpositories(page, pageSize)
            val items = rspRepository.items
            val preKey = if (page > 1) page - 1 else null
            val nextKey = if (items.isNotEmpty()) page + 1 else null
            LoadResult.Page(items, preKey, nextKey)
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }
}

PagingSource<Int,RepositoryItem>Int类型的泛型就是页数的数据类型,一般都是整型啦,第二项看类型就知道是列表的每一项数据item。

load()方法是我们加载数据的地方,首先我们通过params得到了key的值,这个key就是当前的页数,上面代码中我们做了判断,如果key为null,我们则把当前页数置为1,而params.loadSize代表的是每页加载的数量。接下来就通过定义的GithubService接口调用getRpositories()方法获取服务器返回的对应数据。

最后调用的LoadResult.Page(items, preKey, nextKey),它返回的就是load(params: LoadParams<Int>)方法的返回值LoadResult对象,LoadResult.Page(items, preKey, nextKey)方法的三个参数分别是获取到的数据列表,上一页的页数以及下一页的页数,并且在前面我们判断了如果当前页是第一页或者最后一页,那么它的上一页或者下一页就为null

getRefreshKey()方法是代表在refresh时,从最后请求的页面开始请求,默认返回null则是请求第一页。实际开发场景中,如果请求出错调用refresh方法刷新数据时,当前已经请求到了前三页的数据,则可以通过设置在refresh后从第四页数据开始加载。如果getRefreshKey()返回null,调用refresh后会重新开始从第一页开始加载,这里我们直接返回null即可。

创建一个Repository类来管理数据源GithubPagingSource

object Repository {

    private const val PAGE_SIZE = 25

    fun getGithubPagingData(): Flow<PagingData<RepositoryItem>>{
        return Pager(
            config = PagingConfig(PAGE_SIZE),
            pagingSourceFactory = {GithubPagingSource()}
        ).flow
    }
}

Repository是一个单例,它里面主要定义了一个getGithubPagingData()方法,返回值的是Flow<PagingData<RepositoryItem>>,这里用到了协程中的Flow,也是官方推荐使用协程的Flow代替LiveData的一种表现,在获取的数据的时候我们可以通过Flow的末端操作符collect来拿到值进行操作。pagingSourceFactory = {GithubPagingSource()}这段代码就是把加载数据的GithubPagingSource传给了Pager,这样Paging就会用它来作为用于分页的数据源。

定义ViewModel

class MainViewModel: ViewModel() {

    fun getPagingData(): Flow<PagingData<RepositoryItem>>{

        return Repository.getGithubPagingData().cachedIn(viewModelScope)
    }
}

在ViewModel中获取Repository管理的数据源,achedIn()函数作用是在viewModelScope这个作用域内对服务器返回的数据进行缓存,可以在系统配置发生改变时,如横竖屏切换,可以直接读缓存而不用在网络请求数据。

和RecyclerView结合使用

这里要先说明一下Paging是必须和RecyclerView一起结合使用的,我们先来定义RecyclerView的item布局item_repository.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@drawable/shape_repository_item"
    android:paddingStart="16dp"
    android:paddingEnd="16dp"
    android:layout_marginTop="8dp"
    android:id="@+id/ll_item">

    <TextView
        android:id="@+id/tv_name"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textColor="@color/purple_700"
        android:textSize="18sp"
        android:textStyle="bold"
        android:maxLines="1"
        android:ellipsize="end"
        android:layout_marginTop="8dp" />

    <TextView
        android:id="@+id/tv_desc"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textColor="@color/black"
        android:textSize="16sp"
        android:maxLines="8"
        android:ellipsize="end"
        android:layout_marginTop="4dp" />


    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:layout_gravity="end"
        android:layout_marginTop="4dp"
        android:layout_marginBottom="8dp">

        <ImageView
            android:layout_width="24dp"
            android:layout_height="24dp"
            android:layout_gravity="center_vertical"
            android:src="@drawable/icon_star"
            android:scaleType="centerCrop" />

        <TextView
            android:id="@+id/tv_star"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textColor="@color/black"
            android:textSize="16sp"
            android:maxLines="1"
            android:ellipsize="end"
            android:layout_marginStart="2dp"/>

    </LinearLayout>

</LinearLayout>

上面代码的图片资源可以到这里下载:www.iconfont.cn/?spm=a313x.…

定义RecyclerView的适配器,结合Paging使用是必须继承PagingDataAdapter

class RepositoryAdapter(private val context: Context): PagingDataAdapter<RepositoryItem,RepositoryAdapter.ViewHolder>(COMPARATOR) {
    
    companion object{
        private val COMPARATOR = object : DiffUtil.ItemCallback<RepositoryItem>() {
            override fun areItemsTheSame(oldItem: RepositoryItem, newItem: RepositoryItem): Boolean {
                return oldItem.id == newItem.id
            }

            override fun areContentsTheSame(oldItem: RepositoryItem, newItem: RepositoryItem): Boolean {
                return oldItem == newItem
            }
        }
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val item = getItem(position)
        item?.let {
            holder.tvName.text = it.name
            holder.tvDesc.text = it.description
            holder.tvStar.text = it.stargazersCount.toString()
        }

        holder.llItem.setOnClickListener {

            val intent = Intent(context,CommonWebActivity::class.java)
            intent.putExtra("url",item?.htmlUrl)
            context.startActivity(intent)

        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_repository, parent, false)
        return ViewHolder(view)
    }


    class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView){
        val tvName: TextView = itemView.findViewById(R.id.tv_name)
        val tvDesc: TextView = itemView.findViewById(R.id.tv_desc)
        val tvStar: TextView = itemView.findViewById(R.id.tv_star)
        val llItem: LinearLayout = itemView.findViewById(R.id.ll_item)
    }
}

这里和我们平时用的RecyclerView.Adapter差不多,主要是多了一个通过DiffUtil实现的COMPARATOR,这个在COMPARATOR是一个必须的参数。我们也看到适配器并没有传递数据源列表,因为这些都通过Paging自身管理了。

Activity中的布局:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity"
    android:background="#EEEEEE">

    <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
        android:id="@+id/refreshLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_margin="8dp">

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

    </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>


    <ProgressBar
        android:id="@+id/progress_bar"
        style="?android:attr/progressBarStyleInverse"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Activity的逻辑代码:

class MainActivity : AppCompatActivity() {

    private val recyclerView by bindView<RecyclerView>(R.id.recycler_view)
    private val progressBar by bindView<ProgressBar>(R.id.progress_bar)
    private val refreshLayout by bindView<SwipeRefreshLayout>(R.id.refreshLayout)

    private val mAdapter = RepositoryAdapter(this)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val mainViewModel = ViewModelProvider(this).get(MainViewModel::class.java)

        recyclerView?.layoutManager = LinearLayoutManager(this)
        recyclerView?.adapter = mAdapter
        lifecycleScope.launch {
            mainViewModel.getPagingData().collect { pagingData ->
                mAdapter.submitData(pagingData)
            }
        }
        mAdapter.addLoadStateListener {
            when (it.refresh) {
                is LoadState.NotLoading -> {
                    progressBar?.visibility = View.INVISIBLE
                    recyclerView?.visibility = View.VISIBLE
                    refreshLayout?.isRefreshing = false

                }
                is LoadState.Loading -> {
                    refreshLayout?.isRefreshing = true
                    progressBar?.visibility = View.VISIBLE
                    recyclerView?.visibility = View.INVISIBLE
                }
                is LoadState.Error -> {
                    progressBar?.visibility = View.INVISIBLE
                    refreshLayout?.isRefreshing = false
                }
            }
        }

        refreshLayout?.setOnRefreshListener {
            recyclerView?.swapAdapter(mAdapter,true)
            mAdapter.refresh()
        }
    }
}

这里代码都比较简单,关键方法就是mAdapter.submitData(pagingData),此方法调用就会让Paging的分页功能开始执行。mainViewModel.getPagingData().collectFlow订阅获取数据的体现,类似LiveDataobserver。效果如下:

1234.gif

如果我们要在底部添加加载状态的效果可以通过继承LoadStateAdapter来实现,然后通过在RecycleView设置adapter时通过mAdpter.withLoadStateFooter(FooterAdapter { mAdpter.retry() })调用就行了。

总结

Paging3最基本的使用就介绍完了,对于的一般开发使用已经是足够的了。当然,它还有其他的用法,如RemoteMediator结合Room使用,这里就不展开赘述了。相对于我们平时用的比较多的分页开源框架,如:BaseRecyclerViewAdapterHelper,Paging3无需我们再去监听类似loadMore()这样的方法而去对page的处理,我们主要按照Paging3的规则编写好逻辑代码告诉它如何加载数据,具体加载那一页的数据就交给Paging3处理即可。