记录一次项目中的《Recyclerview的优化》

记录一次项目中的《Recyclerview的优化》

  • 前言
  • 整体结构
    • 问题的开端:
    • 定位问题:
    • 问题的解决:
    • 其他解决方案:
  • 高效的更新DiffUtil
    • 什么是DiffUtil
    • 核心类DiffUtil.Callback
    • 简单看一下源码
    • 异步化Diff计算过程
  • 分页的预加载
    • 优化的思路
    • 实现(第一种)
  • 使用公共监听
    • 错误的做法
    • 建议的做法
  • 总结

前言

看这篇文章可以让你了解到:在一个复杂的RecyclerView中,有数百个Item,每个Item都包含大量的数据和图像。如何有效地加载和显示这些数据,同时保持列表的平滑滚动?

整体结构

问题的开端:

在一个在线购物APP上遇到的问题(不是多点APP),有数百个商品的展示的Item,每个Item都有大量的数据和图片展示。也就是数据量很大,导致商品列表加载和显示过程很慢。

定位问题:

  • Window.addOnFrameMetricsAvailableListener() 相当于adb shell dumpsys gfxinfo 精细的获取渲染时间,精确到秒
  • Android 的 Profiler

问题的解决:

  • 高效的更新:DiffUtil
  • 分页的预加载(通过滑动的监听)
  • 设置公用的addXxListener监听(点击、长按等等)
  • 其他优化:在开发中就要注意到的
    • 增加缓存,空间换时间:RecycleView.setItemViewCacheSize(size);
    • 滑动过程中的加载图片策略:不要简单根据滑动状态判断,建议通过滑动速度、惯性滑动来判断。
    • 减少XML的解析时间,能通过new view创建再添加的视图,就可以替换掉XML中的布局View
    • 减少渲染层级:利用自定义View(特别不推荐ConstraintLayout,他在初次渲染渲染很慢)
    • 能固定高度的Item就固定高度,减少测量时间
      • RecyclerView.setHasFixedSize(true) 的作用是告诉 RecyclerView,其中的项(Item)的大小在途中不会改变。
    • 没有动画要求,就把默认动画关掉
      • ((SimpleItemAnimator) rv.getItemAnimator()).setSupportsChangeAnimations(false); 把默认动画关闭

其他解决方案:

  • 用动态构建Kotlin 的 DSL 布局取代 xml
    蒸发 IO 和 反射的性能损耗,缩短构建表项布局耗时。有点过了,减少了代码可读性,毕竟不能预览界面。还有其他人员后续维护。 时间减少几十ms 得不偿失。
  • Paging 3
    这两种解决方案都是同一个问题:有学习、维护的成本。特别对于现有项目来说,改动过大,牵扯业务过多,出现问题难定位。

高效的更新DiffUtil

什么是DiffUtil

DiffUtil 是一个用于计算两个列表之间差异的实用工具类。它通过比较两个列表的元素,找出它们之间的差异,并生成更新操作的列表,以便进行最小化的更新操作。
当然这种最小化的更新操作完全可以通过严格的去使用notify相关的API去控制,所以我认为DiffUtil是一种最小化更新操作的规范形式。(ps:毕竟难免的会错误的触发notify导致资源的浪费)

注意:强调的是DiffUtil的更新,如果只是单独的添加还是希望去用notifyItemInserted(),单独的添加的操作在业务中你肯定是知道的。
原理:了解一下就可以了:DiffUtil 使用最长公共子序列(Longest Common Subsequence,LCS)算法来比较两个数据集之间的差异。算法首先创建会一个矩阵,矩阵的行表示旧数据集的元素,列表示新数据集的元素。之后通过回溯构建最长公共子序列,通过比较不属于最长公共子序列的元素,来确定两个数据集之间的差异。

核心类DiffUtil.Callback

我们在使用DiffUtil也是主要去使用DiffUtil.Callback,他掌握着重要的监控差异性的几个抽象方法。

  • getOldListSize():获取旧数据集的大小
  • getNewListSize():// 获取新数据集的大小
  • areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean
    • 分别获取新老列表中对应位置的元素,并定义什么情况下新老元素是同一个对象
  • areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean
    • 分别获取新老列表中对应位置的元素,并定义什么情况下同一对象内容是否相同
  • getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any?
    • 分别获取新老列表中对应位置的元素,如果这两个元素相同,但是内容发生改变,可以通过这个方法获取它们之间的差异信息,从而只更新需要改变的部分,减少不必要的更新操作。

具体的代码和解释如下

class RvAdapter : RecyclerView.Adapter<RvAdapter.ViewHolder>() {
    class ViewHolder(itemView : View) : RecyclerView.ViewHolder(itemView)

    var oldList: MutableList<MessageData> = mutableListOf() // 老列表
    var newList: MutableList<MessageData> = mutableListOf() // 新列表

    val diffUtilCallBack = object : DiffUtil.Callback(){
        override fun getOldListSize(): Int {
            // 获取旧数据集的大小
            return oldList.size
        }

        override fun getNewListSize(): Int {
            // 获取新数据集的大小
            return newList.size
        }

        override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
            // 分别获取新老列表中对应位置的元素
            // 定义什么情况下新老元素是同一个对象(通常是业务id)
            val oldItem = oldList[oldItemPosition]
            val newItem = newList[newItemPosition]
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
            // 定义什么情况下同一对象内容是否相同 (由业务逻辑决定)
            // areItemsTheSame() 返回true时才会被调用
            val oldItem = oldList[oldItemPosition]
            val newItem = newList[newItemPosition]
            return oldItem.content == newItem.content
        }

        override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
            // 可以通过这个方法获取它们之间的差异信息
            // 具体定义同一对象内容是如何地不同 (返回值会作为payloads传入onBindViewHoder())
            // 当areContentsTheSame()返回false时才会被调用
            val oldItem = oldList[oldItemPosition]
            val newItem = newList[newItemPosition]
            return if (oldItem.content === newItem.content) null else newItem.content
        }
    }

    fun upDataList(oldList: MutableList<MessageData>, newList: MutableList<MessageData>){
        this.oldList = oldList
        this.newList = newList
        // 利用DiffUtil比对结果
        val diffResult = DiffUtil.calculateDiff(diffUtilCallBack)
        // 将比对结果应用到 adapter
        diffResult.dispatchUpdatesTo(this)
    }
    
    // 其他常规的函数
    .....
    .....
}

简单看一下源码

就看一下diffResult.dispatchUpdatesTo(this)做了什么吧。差异性的算法刚才上面说了一嘴

// 将比对结果应用到Adapter
public void dispatchUpdatesTo(final RecyclerView.Adapter adapter) {
    dispatchUpdatesTo(new AdapterListUpdateCallback(adapter));
}

// 将比对结果应用到ListUpdateCallback
public void dispatchUpdatesTo(@NonNull ListUpdateCallback updateCallback) {...}

// 基于 RecyclerView.Adapter 实现的列表更新回调
public final class AdapterListUpdateCallback implements ListUpdateCallback {
    private final RecyclerView.Adapter mAdapter;
    public AdapterListUpdateCallback(@NonNull RecyclerView.Adapter adapter) {
        mAdapter = adapter;
    }
    @Override
    public void onInserted(int position, int count) {
            // 区间插入
        mAdapter.notifyItemRangeInserted(position, count);
    }
    @Override
    public void onRemoved(int position, int count) {
            // 区间移除
        mAdapter.notifyItemRangeRemoved(position, count);
    }
    @Override
    public void onMoved(int fromPosition, int toPosition) {
            // 移动
        mAdapter.notifyItemMoved(fromPosition, toPosition);
    }
    @Override
    public void onChanged(int position, int count, Object payload) {
            // 区间更新
        mAdapter.notifyItemRangeChanged(position, count, payload);
    }
}

所以我上面说了:我认为DiffUtil是一种最小化更新操作的规范形式。

异步化Diff计算过程

上面说了,是通过算法进行计算,来统计我们的差异性。那当遇到大数据,难免的会遇到计算带来的耗时问题。
所以将这个Diff过程进行异步处理,是有必要做的。(ps:直接用协程得了)

suspend fun upDataList(oldList: MutableList<MessageData>, newList: MutableList<MessageData>) =
    withContext(Dispatchers.Default) {
        this@RvAdapter.oldList = oldList
        this@RvAdapter.newList = newList

        // 利用DiffUtil比对结果
        val diffResult = DiffUtil.calculateDiff(diffUtilCallBack)
        withContext(Dispatchers.Main) {
            // 将比对结果应用到 adapter
            diffResult.dispatchUpdatesTo(this@RvAdapter)
        }
    }

  • 异步操作下要注意线程安全问题:可以使用Mutex来保护oldList和newList的访问和修改

修改后的代码

private val updateListMutex = Mutex()

suspend fun upDataList(oldList: MutableList<MessageData>, newList: MutableList<MessageData>) = withContext(Dispatchers.Default) {
    // 加锁,保护数据的访问和修改
    updateListMutex.withLock {
        this@RvAdapter.oldList = oldList
        this@RvAdapter.newList = newList
    }

    // 利用DiffUtil比对结果
    val diffResult = DiffUtil.calculateDiff(diffUtilCallBack)

    withContext(Dispatchers.Main) {
        // 加锁,保护数据的访问和修改
        updateListMutex.withLock {
            // 将比对结果应用到 adapter
            diffResult.dispatchUpdatesTo(this@RvAdapter)
        }
    }
}
  • DiffUtil是通过比较两个数据对象的引用来判断它们是否相同的

如果你用的都是不可变的对象,也就是Final修饰的那就没问题。
如果是可变对象,那么你要重写equals和hashCode方法以便DiffUtil正确比较数据项,具体代码按实际业务来。

分页的预加载

优化的思路

当然分页加载数据是必须项:关于列表的内容,都需要由服务器返回的分页数据。这样避免了一次性加载过度数据带来的请求延迟。也减轻了服务器的压力。
那我们要怎么优化这个分页呢?
既然预加载作为我们优化加载速度重要的一个思想。那么在分页中是不是也可以加入这个思想呢?
也就是说:在一页数据还未看完时就请求下一页数据。那么我们可以通过两种思想去做:

  • 在一页数据还未看完时就请求下一页数据
  • 第一次请求2页内容,当滑动过当前页所有Item时,就请求后续页的内容(当然这个预加载的页数也可以是3或者更多)

实现(第一种)

两种方法都是提前加载下一页的数据,来进行优化用户的感知。
我们这里只说一下第一种方式,第二种方式是类似的。

  • 第一步:重写RecyclerView的Adapter监听列表的绑定Item的position,当达到阈值时去请求数据
class PreloadAdapter : RecyclerView.Adapter<ViewHolder>() {

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        checkPreload(position)    // 判断是否达到阈值
    }

注意:这里对RecyclerView了解的可能会问,那onBindViewHolder会在RecyclerView预加载的时候就会被回调。并不是当前Item显示在页面的时候。
答:当然,但是第一点你可以去设置RecyclerView预加载的个数,第二点如果预加载的时候就会被回调那么请求被提前了,有什么不好呢?

  • 第二步:监听滑动状态,当确定是滑动触发时再加载
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
    recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
        override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
            // 更新滚动状态
            scrollState = newState
            super.onScrollStateChanged(recyclerView, newState)
        }
    })
}
  • 第三步:防止列表滚动到底部触发了一次预加载后,又往回滚动。 再次滚下来,当预加载未完成,会再次触发的风险。
// 增加预加载状态标记位
var isPreloading = false
  • 完整代码
class PreloadAdapter : RecyclerView.Adapter<ViewHolder>() {
    // 增加预加载状态标记位
    var isPreloading = false
    // 预加载回调
    var onPreload: (() -> Unit)? = null
    // 预加载偏移量
    var preloadItemCount = 0
    // 列表滚动状态
    private var scrollState = SCROLL_STATE_IDLE

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        checkPreload(position)
    }

    override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
        recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
                // 更新滚动状态
                scrollState = newState
                super.onScrollStateChanged(recyclerView, newState)
            }
        })
    }

    // 判断是否进行预加载
    private fun checkPreload(position: Int) {
        if (onPreload != null
            && position == max(itemCount - 1 - preloadItemCount, 0)// 索引值等于阈值
            && scrollState != SCROLL_STATE_IDLE // 列表正在滚动
            && !isPreloading // 预加载不在进行中
        ) {
            isPreloading = true // 表示正在执行预加载
            onPreload?.invoke()
        }
    }
}
  • 第四步:调用
val preloadAdapter = PreloadAdapter().apply {
    // 在距离列表尾部还有2个表项的时候预加载
    preloadItemCount = 2
    onPreload = {
        // 预加载业务逻辑
    }
}

使用公共监听

我们可以利用自定义公共的监听来减少监听对象的创建时间,提高性能,并且使用 holder.getAdapterPosition() 方法获取准确的 ID 或 Tag 进行判断。

错误的做法

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    holder.itemText.text = mItemList[position]
    holder.itemText.setOnClickListener({ 
        // 具体点击业务
    })
}

这样会在每次绑定View,也就是执行onBindViewHolder都去对itemText设置监听对象,这样大量的频繁的创建对象,你这是要干嘛!!!!

建议的做法

  • 第一步:首先,在 RecyclerView 的适配器中定义一个接口,作为公用的监听器:
interface RecyclerViewListener {
    fun onItemClick(position: Int)
    fun onItemLongClick(position: Int)
}
  • 第二步:然后,在 RecyclerView 的 ViewHolder 中设置监听器:
class RecyclerViewHolder(itemView: View, private val listener: RecyclerViewListener) : RecyclerView.ViewHolder(itemView),
    View.OnClickListener, View.OnLongClickListener {
    private val textView: TextView = itemView.findViewById(R.id.textView)

    init {
        itemView.setOnClickListener(this)
        itemView.setOnLongClickListener(this)
    }

    override fun onClick(v: View) {
        val position = adapterPosition
        listener.onItemClick(position)
    }

    override fun onLongClick(v: View): Boolean {
        val position = adapterPosition
        listener.onItemLongClick(position)
        return true
    }

    fun bindData(data: String) {
        textView.text = data
    }
}
  • 第三步:接下来,在适配器中设置监听器:
class RecyclerViewAdapter(private val dataList: List<String>, private val listener: RecyclerViewListener) :
    RecyclerView.Adapter<RecyclerViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerViewHolder {
        val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item_layout, parent, false)
        return RecyclerViewHolder(itemView, listener)
    }

    override fun onBindViewHolder(holder: RecyclerViewHolder, position: Int) {
        val data = dataList[position]
        holder.bindData(data)
    }

    override fun getItemCount(): Int {
        return dataList.size
    }
}
  • 第四步:最后,在使用 RecyclerView 的地方,设置公用的监听器并创建适配器:
val listener = object : RecyclerViewListener {
    override fun onItemClick(position: Int) {
        // 处理点击事件
    }

    override fun onItemLongClick(position: Int) {
        // 处理长按事件
    }
}

val recyclerView: RecyclerView = findViewById(R.id.recyclerView)
recyclerView.layoutManager = LinearLayoutManager(this)
val adapter = RecyclerViewAdapter(dataList, listener)
recyclerView.adapter = adapter

通过这种方式,可以减少监听对象的创建时间,提高性能,并且使用 holder.adapterPosition属性获取准确的 ID 或 Tag 进行判断。

总结

本篇文章,记录了我在项目中对RecyclerView的优化调研,和实际的优化手段。
大家收藏备用哦!!!!!!

你可能感兴趣的:(Android,实战项目问题,android,kotlin)