• Android进阶宝典 -- 手写RecyclerView分页组件


    在Android应用中,列表有着举足轻重的地位,几乎所有的应用都有列表的身影,但是对于列表的交互体验一直是一个大问题。在性能比较好的设备上,列表滑动几乎看不出任何卡顿,但是放在低端机上,卡顿会比较明显,而且列表中经常会伴随图片的加载,卡顿会更加严重,因此本章从手写分页加载组件入手,并对列表卡顿做出对应的优化

    1 分页加载组件

    为什么要分页加载,通常列表数据存储在服务端会超过100条,甚至上千条,如果服务端一次性返回,我们一次性接受直接加载,如果其中有图片加载,肯定直接报OOM,应用崩溃,因此我们通常会跟服务端约定分页的规则,服务端会按照页码从0开始给数据,或者在数据中返回下一页对应的索引,当出发分页加载时,就会拿到下一页的页码请求新一页的数据。

    目前在JetPack组件中,Paging是使用比较多的一个分页加载组件,但是Paging使用的场景有限,因为流的限制,导致只能是单一数据源,而且数据不能断,只能全部加载进来,因此决定手写一个分页加载组件,适用多种场景。

    1.1 功能定制

    如果想要自己写一个分页加载库,首先需要明白,分页加载组件需要做什么事?

    对于RecyclerView来说,它的主要功能就是创建视图并绑定数据,因此我们先定义分页列表的基础能力,绑定视图和数据

    interface IPagingList<T> {
    
        fun bindView(context: Context,lifecycleOwner: LifecycleOwner, recyclerView: RecyclerView,adapter: PagingAdapter<T>,mode: ListMode) {}
        fun bindData(model: List<BasePagingModel<T>>) {}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    bindData:

    bindData就不多说了,就是绑定数据,首先我们拿到的数据一定是一个列表数据,因为并不知道业务方需要展示的数据类型是啥样的,因此需要泛型修饰,那么BasePagingModel是干什么的呢?

    open class BasePagingModel(
        var pageCount: String = "", //页码
        var type: Int = 1, //分页类型 1 带日期 2 普通列表
        var time: String = "", //如果是带日期的model,那么需要传入此值
        var itemData: T? = null
    )
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    首先BasePagingModel是分页列表中数据的基类,其中存储的元素包括pageCount,代表传进来的数据列表是哪一页,type用来区分列表数据类型,time可以代表当前数据在服务端的时间(主要场景就是列表中数据展示需要带时间,并根据某一天进行数据聚合),itemData代表业务层需要处理的数据。

    bindView:

    对于RecyclerView来说,创建视图、展示数据需要适配器,因此这里传入了RecyclerView还有通用的适配器PagingAdapter

    abstract class PagingAdapter : RecyclerView.Adapter() {
    
        private var datas: List>? = null
        private var maps: MutableMap>>? = null
    
    
        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
            return buildBusinessHolder(parent, viewType)
        }
    
        override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
            if (datas != null) {
                bindBusinessData(holder, position, datas)
            } else if (maps != null) {
                bindBusinessMapData(holder, position, maps)
            }
        }
    
        abstract fun getHolderWidth(context: Context):Int
    
        override fun getItemCount(): Int {
            return if (datas != null) datas!!.size else 0
        }
    
        open fun bindBusinessMapData(
            holder: RecyclerView.ViewHolder,
            position: Int,
            maps: MutableMap>>?
        ) {
        }
    
        open fun bindBusinessData(
            holder: RecyclerView.ViewHolder,
            position: Int,
            datas: List>?
        ) {
        }
    
        abstract fun buildBusinessHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder
    
    
        fun setPagingData(datas: List>) {
            this.datas = datas
            notifyDataSetChanged()
        }
    
        fun setPagingMapData(maps: MutableMap>>) {
            this.maps = maps
            notifyDataSetChanged()
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51

    这一章,我们先介绍使用场景比较多的单数据列表

    PagingAdapter是一个抽象类,携带的数据同样是业务方需要处理的数据,是一个泛型,创建视图方法buildBusinessHolder交给业务方实现,这里我们关注两个数据相关的方法
    bindBusinessData和setPagingData,当调用setPagingData方法时,将处理好的数据列表发进来,然后调用notifyDataSetChanged方法刷新列表,这个时候会调用bindBusinessData将列表中的数据绑定并展示出来。

    这里我们还需要关注一个方法,这个方法业务方必须要实现,这个方法有什么作用呢?

    abstract fun getHolderWidth(context: Context):Int
    
    • 1

    这个方法用于返回列表中每个ItemView的尺寸宽度,因为在分页组件中会判断当前列表可见的ItemView有多少个。这里大家可能会有疑问,RecyclerView的LayoutManager不是有对应的api吗,像

    findFirstVisibleItemPosition()
    findLastVisibleItemPosition()
    findFirstCompletelyVisibleItemPosition()
    findLastCompletelyVisibleItemPosition()
    
    • 1
    • 2
    • 3
    • 4

    为什么不用呢?因为我们的分页组件是要兼容多种视图形式的,虽然我们今天讲到的普通列表用这个是没有问题的,但是有些视图类型是不能兼容这个api的,后续会介绍。

    1.2 手写分页列表

    先把第一版的代码贴出来,有个完整的体系

    class PagingList<T> : IPagingList<T>, IModelProcess<T>, LifecycleEventObserver {
    
        private var mTotalScroll = 0
        private var mCallback: IPagingCallback? = null
        private var currentPageIndex = ""
    
        //模式
        private var mode: ListMode = ListMode.DATE
        private var adapter: PagingAdapter<T>? = null
    
        //支持的类型 普通列表
        private val dateMap: MutableMap<String, MutableList<BasePagingModel<T>>> by lazy {
            mutableMapOf()
        }
        private val simpleList: MutableList<BasePagingModel<T>> by lazy {
            mutableListOf()
        }
    
        override fun bindView(
            context: Context,
            lifecycleOwner: LifecycleOwner,
            recyclerView: RecyclerView,
            adapter: PagingAdapter<T>,
            mode: ListMode
        ) {
            this.mode = mode
            this.adapter = adapter
            recyclerView.adapter = adapter
            recyclerView.layoutManager =
                LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
            addRecyclerListener(recyclerView)
            lifecycleOwner.lifecycle.addObserver(this)
        }
    
        private fun addRecyclerListener(recyclerView: RecyclerView) {
            recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
    
                override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
    
                    super.onScrollStateChanged(recyclerView, newState)
    
                    if (newState == RecyclerView.SCROLL_STATE_IDLE) {
    
                        if (!recyclerView.canScrollHorizontally(1) && currentPageIndex == "-1" && mTotalScroll > 0) {
                            //滑动到底部
                            mCallback?.scrollEnd()
                        }
                        //获取可见item的个数
                        val visibleCount = getVisibleItemCount(recyclerView.context, recyclerView)
    
                        if (recyclerView.childCount > 0 && visibleCount >= (getListCount(mode) ?: 0)) {
                            if (currentPageIndex != "-1") {
                                //请求下一页数据
                                mCallback?.scrollRefresh()
                            }
                        }
                    } else {
                        //暂停刷新
                        mCallback?.scrolling()
                    }
                }
    
                override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                    super.onScrolled(recyclerView, dx, dy)
    
                    if (!recyclerView.canScrollHorizontally(1) && currentPageIndex == "-1" && mTotalScroll > 0) {
                        //滑动到底部
                        mCallback?.scrollEnd()
                    }
                    mTotalScroll += dx
                    //滑动超出2屏
    //                binding.ivBackFirst.visibility =
    //                    if (mTotalScroll > ScreenUtils.getScreenWidth(requireContext()) * 2) View.VISIBLE else View.GONE
                }
            })
        }
    
        override fun bindData(model: List<BasePagingModel<T>>) {
            //处理数据
            dealPagingModel(model)
            //adapter刷新数据
            if (mode == ListMode.DATE) {
                adapter?.setPagingMapData(dateMap)
            } else {
                adapter?.setPagingData(simpleList)
            }
        }
    
        fun setScrollListener(callback: IPagingCallback) {
            this.mCallback = callback
        }
    
        override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
            if (event == Lifecycle.Event.ON_RESUME) {
                //TODO 加载图片
    //            Glide.with(requireContext()).resumeRequests()
            } else if (event == Lifecycle.Event.ON_PAUSE) {
                //TODO 停止加载图片
            } else if (event == Lifecycle.Event.ON_DESTROY) {
                //TODO 页面销毁不会加载图片
            }
        }
    
        /**
         * 获取可见的item个数
         */
        private fun getVisibleItemCount(context: Context, recyclerView: RecyclerView): Int {
    
            var totalCount = 0
            //首屏假设全部占满
            totalCount +=
                ScreenUtils.getScreenWidth(recyclerView.context) / adapter?.getHolderWidth(context)!!
            totalCount += mTotalScroll / adapter?.getHolderWidth(context)!!
    
            return (totalCount + 1)
        }
    
        override fun getTotalCount(): Int? {
            return getListCount(mode)
        }
    
        override fun dealPagingModel(data: List<BasePagingModel<T>>) {
    
            this.currentPageIndex = updateCurrentPageIndex(data)
    
            if (mode == ListMode.DATE) {
                data.forEach { model ->
                    val time = DateFormatterUtils.check(model.time)
                    if (dateMap.containsKey(time)) {
                        model.itemData?.let {
                            dateMap[time]?.add(model)
                        }
                    } else {
                        val list = mutableListOf<BasePagingModel<T>>()
                        list.add(model)
                        dateMap[time] = list
                    }
                }
    
            } else {
                simpleList.addAll(data)
            }
        }
    
        private fun updateCurrentPageIndex(data: List<BasePagingModel<T>>): String {
            if (data.isNotEmpty()) {
                return data[0].pageCount
            }
            return "-1"
        }
    
        private fun getListCount(mode: ListMode): Int? {
    
            var count = 0
            if (mode == ListMode.DATE) {
                dateMap.keys.forEach { key ->
                    //获取key下的元素个数
                    count += dateMap[key]?.size ?: 0
                }
            } else {
                count = simpleList.size
            }
            return count
        }
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166

    首先,PagingList实现了IPagingList接口,我们先看实现,在bindView方法中,其实就是给RecyclerView设置了适配器,然后注册了RecyclerView的滑动监听,我们看下监听器中的主要实现。

    onScrollStateChanged方法主要用于监听列表是否在滑动,当列表的状态为SCROLL_STATE_IDLE时,代表列表停止了滑动,这里做了两件事:

    (1)首先判断列表是否滑动到了底部

    if (!recyclerView.canScrollHorizontally(1) && currentPageIndex == "-1" && mTotalScroll > 0) {
        //滑动到底部
        mCallback?.scrollEnd()
    }
    
    • 1
    • 2
    • 3
    • 4

    这里需要满足三个条件:recyclerView.canScrollHorizontally(1)如果返回了false,那么代表列表不能继续滑动;还有就是会判断currentPageIndex是否是最后一页,如果等于-1那么就是最后一页,同样需要判断滑动的距离,综合来说就是【如果列表滑动到了最后一页而且不能再继续滑动了,那么就是到底了】,这里可以展示尾部的到底UI。

    (2)判断是否能够触发分页加载

    /**
     * 获取可见的item个数
     */
    private fun getVisibleItemCount(context: Context, recyclerView: RecyclerView): Int {
    
        var totalCount = 0
        //首屏假设全部占满
        totalCount +=
            ScreenUtils.getScreenWidth(recyclerView.context) / adapter?.getHolderWidth(context)!!
        totalCount += mTotalScroll / adapter?.getHolderWidth(context)!!
    
        return (totalCount + 1)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    首先这里会判断展示了多少ItemView,之前提到的适配器中的getHolderWidth这里就用到了,首先我们会假设首屏全部占满了ItemView,然后根据列表滑动的距离,判断后续有多少ItemView展示出来,最终返回结果。

    我们先不看下面的逻辑,因为分页加载涉及到了数据的处理,因此我们先看下bindData的实现

    override fun bindData(model: List<BasePagingModel<T>>) {
        //处理数据
        dealPagingModel(model)
        //adapter刷新数据
        if (mode == ListMode.DATE) {
            adapter?.setPagingMapData(dateMap)
        } else {
            adapter?.setPagingData(simpleList)
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    在调用bindData时会传入一页的数据,dealPagingModel方法用于处理数据,首先获取当前数据的页码,用于判断是否需要继续分页加载。

    override fun dealPagingModel(data: List<BasePagingModel<T>>) {
    
        this.currentPageIndex = updateCurrentPageIndex(data)
    
        if (mode == ListMode.DATE) {
            data.forEach { model ->
                val time = DateFormatterUtils.check(model.time)
                if (dateMap.containsKey(time)) {
                    model.itemData?.let {
                        dateMap[time]?.add(model)
                    }
                } else {
                    val list = mutableListOf<BasePagingModel<T>>()
                    list.add(model)
                    dateMap[time] = list
                }
            }
    
        } else {
            simpleList.addAll(data)
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    剩下的工作用于组装数据,simpleList用于存储全部的列表数据,每次传入一页数据,都会存在这个集合中。处理完数据之后,将数据塞进adapter,用于刷新数据。

    然后我们回到前面,我们在拿到了可见的ItemView的个数之后,首先会判断recyclerView展示的ItemView个数,如果等于0,那么就说明没有数据,就不需要触发分页加载。

    if (recyclerView.childCount > 0 && visibleCount >= (getListCount(mode) ?: 0)) {
        if (currentPageIndex != "-1") {
            //请求下一页数据
            mCallback?.scrollRefresh()
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    假设每页展示10条数据,这个时候getListCount方法返回的就是总的数据个数(10),如果visibleCount超过了List的总个数,那么就需要触发分页加载,因为之前我们提到,最后一页的index就是-1,所以这里判断如果是最后一页,就不需要分页加载了。

    1.3 生命周期管理

    在PagingList中,我们实现了LifecycleEventObserver接口,这里的作用是什么呢?

    就是我们知道,在列表中经常会有图片的加载,那么在图片加载时如果滑动列表,那么势必会产生卡顿,因此我们在滑动的过程中不会去加载图片,而是在滑动停止时,重新加载,这个优化体验是没有问题,用户不会关注滑动时的状态。

    那么这里会存在一个问题,例如我们在滑动的过程中退出到后台,这个时候列表滑动停止时加载图片,可能存在上下文找不到的场景导致应用崩溃,因此我们传入生命周期的目的在于:让列表具备感知生命周期的能力,当列表处在不可见的状态时,不能进行多余的网络请求。

    2022-09-04 15:41:43.541 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:43.651 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:43.661 2763-2763/com.lay.paginglist E/MainActivity: scrollRefresh--
    2022-09-04 15:41:43.668 2763-2763/com.lay.paginglist E/MyAdapter: bindBusinessData --- 
    2022-09-04 15:41:43.674 2763-2763/com.lay.paginglist E/MyAdapter: bindBusinessData --- 
    2022-09-04 15:41:43.877 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:43.885 2763-2763/com.lay.paginglist E/MyAdapter: bindBusinessData --- 
    2022-09-04 15:41:43.950 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:44.101 2763-2763/com.lay.paginglist E/MyAdapter: bindBusinessData --- 
    2022-09-04 15:41:44.175 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:44.318 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:44.467 2763-2763/com.lay.paginglist E/MyAdapter: bindBusinessData --- 
    2022-09-04 15:41:44.475 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:45.188 2763-2777/com.lay.paginglist I/.lay.paginglis: WaitForGcToComplete blocked RunEmptyCheckpoint on ProfileSaver for 12.247ms
    2022-09-04 15:41:47.008 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:47.099 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:47.186 2763-2763/com.lay.paginglist E/MyAdapter: bindBusinessData --- 
    2022-09-04 15:41:47.322 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:47.403 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:47.404 2763-2763/com.lay.paginglist E/MyAdapter: bindBusinessData --- 
    2022-09-04 15:41:47.514 2763-2763/com.lay.paginglist E/MyAdapter: bindBusinessData --- 
    2022-09-04 15:41:47.606 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:47.650 2763-2763/com.lay.paginglist E/MyAdapter: bindBusinessData --- 
    2022-09-04 15:41:47.683 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:47.781 2763-2763/com.lay.paginglist E/MyAdapter: bindBusinessData --- 
    2022-09-04 15:41:47.889 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:47.950 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:47.963 2763-2763/com.lay.paginglist E/MyAdapter: bindBusinessData --- 
    2022-09-04 15:41:48.156 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:48.182 2763-2763/com.lay.paginglist E/MyAdapter: bindBusinessData --- 
    2022-09-04 15:41:48.231 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:48.489 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:48.533 2763-2763/com.lay.paginglist E/MainActivity: scrolling--
    2022-09-04 15:41:48.593 2763-2763/com.lay.paginglist E/MainActivity: scrollEnd--
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35

    我们可以看下具体的实现效果就是,当触发分页加载时,scrollRefresh会被回调,这里可以进行网络请求,拿到数据之后再次调用bindData方法,然后继续往下滑动,当滑动到最后一页时,scrollEnd被回调,具体的使用,可以在demo中查看。

    2 github

    之前有小伙伴提到这个事情,希望在github上放出源码,所以就做了
    github传送门

    大家可以在v1.0分支查看源码,在app模块中有一个demo大家可以看具体的使用方式,分页列表的代码在paging模块中
    在这里插入图片描述

    如果有帮助到大家,希望大家点个star,这个库会在后面的日子里持续更新,如果大家在业务中碰到新的需求,也可以随时留言

  • 相关阅读:
    Spring-web-Mvc
    【VPX610】 青翼科技基于6U VPX总线架构的高性能实时信号处理平台
    城市物流管理系统的设计与实现
    cmd/bat 输出符,控制台日志输出到文件
    狂神的MySQL(1)
    ubuntu16.04上安装gstreamer
    psycopg2.pool.PoolError: connection pool exhausted
    [NLP] 使用Llama.cpp和LangChain在CPU上使用大模型
    【洛谷】P3835 【模板】可持久化平衡树
    从零入手人工智能(2)——搭建开发环境
  • 原文地址:https://blog.csdn.net/qq_33235287/article/details/126695717