即学即用Android Jetpack - Paging 3

技术不止,文章有料,加 JiuXinDev 入群,Android 搬砖路上不孤单

前言

又到了学习 Android Jetpack 的时间了,之前我已经写过了一篇《即学即用Android Jetpack - Paging》,不过Android Jetpack 一直都在更新,这不,Paging 2 已经升级到了 Paging 3,那么,大家可能有很多要关注的问题,比如:

  • Paging 3 到底升级了那些东西?
  • 如何使用最新的 Paging 3?

这是我本期要和大家讨论的东西。本期的最终效果:

效果图

如果想学习 Android Jetpack,可以参考我之前的文章《学习Android Jetpack? 实战和教程这里全都有!》,这已经是 《即学即用Android Jetpack》系列的第八篇啦~

本篇的源码地址:【Hoo】

目录

目录.png

一、Paging 2 和 Paging 3 有什么不同?

如果你没有使用过 Paging 2,那么你可以跳过本章节(友情提醒~)。

如果你使用过 Paging 2,你会发现 Paging 3 简直是大刀阔斧,很多 API 的使用方式都变了,简单说一下主要改变的东西:

  1. 支持 Kotlin 中的 Flow。
  2. 简化数据源 PagingSource 的实现。
  3. 增加请求数据时状态的回调,支持设置 Header 和 Footer。
  4. 支持多种方式请求数据,比如网络请求和数据库请求。

二、介绍

友情提示
官方文档:Paging 3
谷歌实验室:官方教程
官方Demo:Github仓库查询Demo

1. 定义

看一下官方的定义:

The Paging library helps you load and display pages of data from a larger dataset from local storage or over network.

就是帮助你采用分页的方式解决显示包含本地数据库和网络的大数据集的问题。

2. 优点

Paging 3 的优点:

  • 使用内存帮你缓存数据。
  • 内置请求去重,帮助你更有效率的显示数据。
  • 自动发起请求当 RecyclerView 滑到底部的时候。
  • 支持 Kotlin 中的 协程Flow,还有 LiveDataRxJava2
  • 内置状态处理,包括刷新、错误、加载等状态。

3. 几个重要的类

先看一下结构:

Paging3架构.png

里面几个类的作用:

  • PagingSource:单一的数据源。
  • RemoteMediator:其实 RemoteMediator 也是单一的数据源,它会在 PagingSource 没有数据的时候,再使用 RemoteMediator 提供的数据,如果既存在数据库请求,又存在网络请求,通常 PagingSource 用于进行数据库请求,RemoteMediator 进行网络请求。
  • PagingData:单次分页数据的容器。
  • Pager:用来构建 Flow<PagingData> 的类,实现数据加载完成的回调。
  • PagingDataAdapter:分页加载数据的 RecyclerView 的适配器。

简述一下就是 PagingSourceRemoteMediator 充当数据源的角色,ViewModel 使用 Pager 中提供的 Flow<PagingData> 监听数据刷新,每当 RecyclerView 即将滚动到底部的时候,就会有新的数据的到来,最后,PagingAdapter 复杂展示数据。

三、实战

为了简单起见,先从单一的数据源开始。

第一步 引入依赖

dependencies {
  def paging_version = "3.0.0-alpha08"

  implementation "androidx.paging:paging-runtime:$paging_version"

  // 用于测试
  testImplementation "androidx.paging:paging-common:$paging_version"

  // [可选] RxJava 支持
  implementation "androidx.paging:paging-rxjava2:$paging_version"

  // ... 其他配置不重要,具体可以查看官方文档
}

第二步 配置数据源

Paging 2 中,里面有三种 Page Source,开发者使用的时候,还得去思考对应的场景,想的脑瓜子疼。

Paging 3 中,如果只有单一的数据源,那么你要做的很简单,继承 PagingSource 即可。

private const val SHOE_START_INDEX = 0;

class CustomPageDataSource(private val shoeRepository: ShoeRepository) : PagingSource<Int, Shoe>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Shoe> {
        val pos = params.key ?: SHOE_START_INDEX
        val startIndex = pos * params.loadSize + 1
        val endIndex = (pos + 1) * params.loadSize
        return try {
            // 从数据库拉去数据
            val shoes = shoeRepository.getPageShoes(startIndex.toLong(), endIndex.toLong())
            // 返回你的分页结果,并填入前一页的 key 和后一页的 key
            LoadResult.Page(
                shoes,
                if (pos <= SHOE_START_INDEX) null else pos - 1,
                if (shoes.isNullOrEmpty()) null else pos + 1
            )
        }catch (e:Exception){
            LoadResult.Error(e)
        }
        
    }
}

正常用 LoadResult.Page() 返回结果,出现错误的情况用 LoadResult.Error 返回错误。

第三步 生成可观察的数据集

这里可观察数据集包括 LiveDataFlow 以及 RxJava 中的 ObservableFlowable,其中,RxJava 需要单独引入扩展库去支持的。

这里的可观察数据集 shoes 使用的是 Flow:

class ShoeModel constructor(private val shoeRepository: ShoeRepository) : ViewModel() {

    /**
     * @param config 分页的参数
     * @param pagingSourceFactory 单一数据源的工厂,在闭包中提供一个PageSource即可
     * @param remoteMediator 同时支持网络请求和数据库请求的数据源
     * @param initialKey 初始化使用的key
     */
    var shoes = Pager(config = PagingConfig(
        pageSize = 20
        , enablePlaceholders = false
        , initialLoadSize = 20
    ), pagingSourceFactory = { CustomPageDataSource(shoeRepository) }).flow

    // ... 省略
}

可以看到,我们中了的 shoes: Flow<PagingData<Shoe>> 是由 Pager().flow 提供的。Pager 中的参数稍微解释一下:

  • PagerConfig 可以提供分页的参数,比如每一页的加载数、初始加载数和最大数等。
  • pagingSourceFactoryremoteMediator 都是数据源,我们使用其中的一个即可。

第四步 创建Adapter

和普通的 Adapter 没有特别大的区别,主要是:

  • 继承 PagingDataAdapter
  • 提供 DiffUtil.ItemCallback<Shoe>

实际代码:

/**
 * 鞋子的适配器 配合Data Binding使用
 */
class ShoeAdapter constructor(val context: Context) :
    PagingDataAdapter<Shoe, ShoeAdapter.ViewHolder>(ShoeDiffCallback()) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return ViewHolder(
            ShoeRecyclerItemBinding.inflate(
                LayoutInflater.from(parent.context)
                , parent
                , false
            )
        )
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val shoe = getItem(position)
        holder.apply {
            bind(onCreateListener(shoe!!.id), shoe)
            itemView.tag = shoe
        }
    }

    /**
     * Holder的点击事件
     */
    private fun onCreateListener(id: Long): View.OnClickListener {
        return View.OnClickListener {
            val intent = Intent(context, DetailActivity::class.java)
            intent.putExtra(BaseConstant.DETAIL_SHOE_ID, id)
            context.startActivity(intent)
        }
    }


    class ViewHolder(private val binding: ShoeRecyclerItemBinding) : RecyclerView.ViewHolder(binding.root) {

        fun bind(listener: View.OnClickListener, item: Shoe) {
            binding.apply {
                this.listener = listener
                this.shoe = item
                executePendingBindings()
            }
        }
    }
}

class ShoeDiffCallback: DiffUtil.ItemCallback<Shoe>() {
    override fun areItemsTheSame(oldItem: Shoe, newItem: Shoe): Boolean {
        return oldItem.id == newItem.id
    }

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

第五步 在UI中使用

先来简单一点的,如果只显示数据,我们要做的是:

  1. 创建和设置适配器。
  2. 开启一个协程
  3. 在协程中接收 Flow 提供的数据。

我的代码:

    private fun onSubscribeUi(binding: ShoeFragmentBinding) {
        binding.lifecycleOwner = this

        // 初始化RecyclerView部分
        val adapter = ShoeAdapter(context!!)
        binding.recyclerView.adapter = adapter
        job = viewModel.viewModelScope.launch(Dispatchers.IO) {
            viewModel.shoes.collect() {
                adapter.submitData(it)
            }
        }
        // ... 省略
    }

第六步 设置Header和Footer

显然 Paging 3 还有更多的功能,对!它还支持添加 HeaderFooter,官方示例是把它们用作上拉刷新和下拉加载更多的控件。

官方实例

不过 Hoo 中并没有使用 HeaderFooter,我们来看看官方怎么使用的:

  1. 创建一个 HeaderAdapter or FooterAdapter 继承自 LoadStateAdapter,跟普通 Adapter 不一样的地方在于它在 onBindViewHolder 方法中提供了 LoadState 参数,它可以提供当前 PagingLoadingNotLoadingError 的状态。
  2. 跟普通 Adapter 一样创建自己需要的 ViewHolder
  3. 稍微修改一下第五步中设置 ShoeAdapter,调用它的withLoadStateHeaderAndFooter 方法绑定 HeaderFooter 的适配器:
    private fun onSubscribeUi(binding: ShoeFragmentBinding) {
        binding.lifecycleOwner = this

        // 初始化RecyclerView部分
        val adapter = ShoeAdapter(context!!).withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
        )
        binding.recyclerView.adapter = adapter
        // ... 省略
    }

需要说明一下,我这里使用的伪代码。想要看详细的教程,可以官网的这一步教程

第七步 监听数据加载的状态

PagingDataAdapter 除了添加 HeaderFooter,还可以监听数据的加载状态,状态对应的类是 LoadState,它有三种状态:

  1. Loading:数据加载中。
  2. NotLoading:内存中有已经获取的数据,即使往下滑,Paging 也不需要请求更多的数据。
  3. Error:请求数据时返回了一个错误。

监听数据状态的代码:

adapter.addLoadStateListener {state:CombinedLoadStates->
    //... 状态监听
}

监听方法就是这么简单,可以看到这个 state 并不是 LoadState,而是一个 CombinedLoadStates,顾名思义,就是多个 LoadState 组合而成的状态类,它里面有:

  1. refresh:LoadState:刷新时的状态,因为可以调用 PagingDataAdapter#refresh() 方法进行数据刷新。
  2. append:LoadState:可以理解为 RecyclerView 向下滑时数据的请求状态。
  3. prepend:LoadState:可以理解为RecyclerView 向上滑时数据的请求状态。
  4. sourcemediator 分别包含上面123的属性,source 代表单一的数据源,mediator 代表多数据源的场景,sourcemediator 二选一。

解释了这么多,说一下我的玩法,下拉刷新我使用了第三方的刷新控件 SmartRefreshLayout,这就意味着我要自己处理 SmartRefreshLayout 的加载状态。布局文件不贴了,就是 SmartRefreshLayout + RecyclerView + FloatingActionButton

成果图(Gif有一点点问题):

刷新效果

使用代码就是:

    private fun onSubscribeUi(binding: ShoeFragmentBinding) {
        binding.lifecycleOwner = this

        // 初始化RecyclerView部分
        val adapter = ShoeAdapter(context!!)
        // 数据加载状态的回调
        adapter.addLoadStateListener { state:CombinedLoadStates ->
            currentStates = state.source
            // 如果append没有处于加载状态,但是refreshLayout出于加载状态,refreshLayout停止加载状态
            if (state.append is LoadState.NotLoading && binding.refreshLayout.isLoading) {
                refreshLayout.finishLoadMore()
            }
            // 如果refresh没有出于加载状态,但是refreshLayout出于刷新状态,refreshLayout停止刷新
            if (state.source.refresh is LoadState.NotLoading && binding.refreshLayout.isRefreshing) {
                refreshLayout.finishRefresh()
            }
        }
        binding.recyclerView.adapter = adapter
        binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)

                val lastPos = getLastVisiblePosition(binding.recyclerView)
                if (!(lastPos == adapter.itemCount - 1 && currentStates?.append is LoadState.Loading)) {
                    binding.refreshLayout.finishLoadMore()
                }
            }
        })
        job = viewModel.viewModelScope.launch(Dispatchers.IO) {
            viewModel.shoes.collect {
                adapter.submitData(it)
            }
        }
        binding.refreshLayout.setRefreshHeader(DropBoxHeader(context))
        binding.refreshLayout.setRefreshFooter(ClassicsFooter(context))
        binding.refreshLayout.setOnLoadMoreListener {
            // 如果当前数据已经全部加载完,就不再加载
            if(currentStates?.append?.endOfPaginationReached == true)
                binding.refreshLayout.finishLoadMoreWithNoMoreData()
        }

       //... 省略无关代码
    }

到这儿,正常的流程就走通了。

四、更多

1. 使用RemoteMediator

RemoteMediator 的使用机制跟 PageSource 没什么区别,默认是先走 PageSource 的数据,如果 PageSource 提供的数据返回为空的情况,才会走 RemoteMediator 的数据,所以你可以用:

  • PageSource:进行数据库请求提供数据。
  • RemoteMediator:进行网络请求,然后对请求成功的数据进行数据库的存储。

鉴于使用 RemoteMediator 跟普通的方式没什么本质区别,我这里就不再做更多介绍,感兴趣的同学可以查看文档。

2. 谈谈我遇到的一些坑

界面

之前点击品牌按钮, Paing 2 对应的版本使用的 LiveData,这是我之前的写法:

class ShoeModel constructor(shoeRepository: ShoeRepository) : ViewModel() {
    // 品牌的观察对象 默认观察所有的品牌
    private val brand = MutableLiveData<String>().apply {
        value = ALL
    }

    // 鞋子集合的观察类
    val shoes: LiveData<PagedList<Shoe>> = brand.switchMap {
        // Room数据库查询,只要知道返回的是LiveData<List<Shoe>>即可
        if (it == ALL) {
            // LivePagedListBuilder<Int,Shoe>( shoeRepository.getAllShoes(),PagedList.Config.Builder()
            LivePagedListBuilder<Int, Shoe>(
                CustomPageDataSourceFactory(shoeRepository) // DataSourceFactory
                , PagedList.Config.Builder()
                    .setPageSize(10) // 分页加载的数量
                    .setEnablePlaceholders(false) // 当item为null是否使用PlaceHolder展示
                    .setInitialLoadSizeHint(10) // 预加载的数量
                    .build()
            )
                .build()
            //shoeRepository.getAllShoes()
        } else {
            val array: Array<String> =
                when (it) {
                    NIKE -> arrayOf("Nike", "Air Jordan")
                    ADIDAS -> arrayOf("Adidas")
                    else -> arrayOf(
                        "Converse", "UA"
                        , "ANTA"
                    )
                }
            shoeRepository.getShoesByBrand(array)
                .createPagerList(6, 6)
        }
    }

    fun setBrand(brand: String) {
        this.brand.value = brand
    }

    companion object {
        public const val ALL = "所有"

        public const val NIKE = "Nike"
        public const val ADIDAS = "Adidas"
        public const val OTHER = "other"
    }
}

切换到 Paging 3 的时候遇到一些坑:

先把可观察的数据集从 Flow 转到 LiveData,也就是从 Pager(...).flowPager(...).livedata:

class ShoeModel constructor(private val shoeRepository: ShoeRepository) : ViewModel() {

    /**
     * @param config 分页的参数
     * @param pagingSourceFactory 单一数据源的工厂,在闭包中提供一个PageSource即可
     * @param remoteMediator 同时支持网络请求和数据库请求的数据源
     * @param initialKey 初始化使用的key
     */
    var shoes = Pager(config = PagingConfig(
        pageSize = 20
        , enablePlaceholders = false
        , initialLoadSize = 20
    ), pagingSourceFactory = { CustomPageDataSource(shoeRepository) }).liveData

    //....
}

紧接着,我在 ShoeFragment 进行数据的监听:

job = viewModel.viewModelScope.launch() {
    viewModel.shoes.observe(viewLifecycleOwner, Observer<PagingData<Shoe>> {
        adapter.submitData(viewLifecycleOwner.lifecycle,it)
    })
}

第一个坑出现了,在 Paging 2 中,PagingSouce 获取数据是处在子线程的,而 Paging 3 中,获取数据处在当前线程,不会做线程切换,如果直接使用数据库查询,会报错,因为 Room 中规定,数据库查询不能在主线程

好家伙,既然主线程不可以请求数据库,那么我就把它放在IO线程,在 launch 方法中加上 Dispatchers.IO

job = viewModel.viewModelScope.launch(Dispatchers.IO) {
    viewModel.shoes.observe(viewLifecycleOwner, Observer<PagingData<Shoe>> {
        adapter.submitData(viewLifecycleOwner.lifecycle,it)
    })
}

第二个坑安排上,LiveData#observe(...) 方法不能发生在后台线程

这就没办法了,只能使用 Flow 去实现,不过 Flow 也有坑,当数据源发生变化的时候,数据需要重新监听,代码也没有了之前的优雅:

class ShoeModel constructor(private val shoeRepository: ShoeRepository) : ViewModel() {

    var shoes = Pager(config = PagingConfig(
        pageSize = 20
        , enablePlaceholders = false
        , initialLoadSize = 20
    ), pagingSourceFactory = { CustomPageDataSource(shoeRepository) }).flow

    fun setBrand(br: String) {
        if (br == ALL) {
            shoes = Pager(config = PagingConfig(
                pageSize = 20
                , enablePlaceholders = false
                , initialLoadSize = 20
            ), pagingSourceFactory = { CustomPageDataSource(shoeRepository) }).flow
        } else {
            val array: Array<String> =
                when (br) {
                    NIKE -> arrayOf("Nike", "Air Jordan")
                    ADIDAS -> arrayOf("Adidas")
                    else -> arrayOf(
                        "Converse", "UA"
                        , "ANTA"
                    )
                }
            shoes = shoeRepository.getShoesByBrand(array).createPager(20, 20).flow
        }
    }

    //....
}

class ShoeFragment : Fragment() {
    // ... 省略

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // ... 省略
        onSubscribeUi(binding)
        return binding.root
    }

    /**
     * 鞋子数据更新的通知
     */
    private fun onSubscribeUi(binding: ShoeFragmentBinding) {
        // ... 省略
        mNike.setOnClickListener {
            viewModel.setBrand(ShoeModel.NIKE)
            reInitSubscribe(adapter)
            shoeAnimation()
        }

        mAdi.setOnClickListener {
            viewModel.setBrand(ShoeModel.ADIDAS)
            reInitSubscribe(adapter)
            shoeAnimation()
        }

        mOther.setOnClickListener {
            viewModel.setBrand(ShoeModel.OTHER)
            reInitSubscribe(adapter)
            shoeAnimation()
        }
    }

    private fun reInitSubscribe(adapter: ShoeAdapter) {
        job?.cancel()
        job = viewModel.viewModelScope.launch(Dispatchers.IO) {
            viewModel.shoes.collect() {
                adapter.submitData(it)
            }
        }
    }

    private fun setViewVisible(isShow: Boolean) {
        nikeGroup.visibility = if (isShow) View.VISIBLE else View.GONE
        adiGroup.visibility = if (isShow) View.VISIBLE else View.GONE
        otherGroup.visibility = if (isShow) View.VISIBLE else View.GONE
    }   
}

期间也试了 LiveDataFlow 的操作符,也没起作用,可能自己研究的不深,有了解的同学可以探讨一下哈~

五、总结

相比较 Paging 2 而言,Paging 3 的功能确实更加健全了,也值得去使用。

在后续的文章中,我还将和大家探讨一下 Paging 3 的源码,当然,Paging 3 现在还处于 Alpha 阶段,并不适用于我们的生产环境,所以我还打算和大家聊聊如何更好的使用 Paging 2。

表情包
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,012评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,589评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,819评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,652评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,954评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,381评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,687评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,404评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,082评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,355评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,880评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,249评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,864评论 3 232
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,007评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,760评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,394评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,281评论 2 259

推荐阅读更多精彩内容