推广

RecyclerView 性能优化 | 把加载表项耗时减半 (下)

iseeyu2年前 (2024-02-22)推广136

界面用列表的形式,展示了一个主播排行榜。

先回顾下上一篇做的两次优化:

  1. 用动态构建布局取代 xml,蒸发 IO 和 反射的性能损耗,缩短构建表项布局耗时。
  2. 替换表项根布局,由更简单的PercentLayout取代ConstraintLayout,以缩短 measure + layout 时间。

关于这两点的详细讲解可以点击RecyclerView 性能优化 | 把加载表项耗时减半 (上)

耗时的 Glide 首次异步加载

如上图所示,每个表项有两张图片的内容来自网路,使用 Glide 进行异步加载。

我把替换表项根布局的思路沿用到图片加载上:是不是因为 Glide 太复杂导致onBindViewHolder()执行太久?

做一个实验,把注释掉图片加载,再跑一遍 demo:

measure + layout=160,     unknown delay=19,     anim=0,    touch=0,     draw=12,  total=161
measure + layout=0,     unknown delay=134,     anim=2,    touch=0,     draw=0,    total=138
measure + layout=0,     unknown delay=0,     anim=0,    touch=0,     draw=0,   total=3
复制代码

令我感到吃惊的是,measure + layout耗时一下子从 288 ms 缩减到 160 ms。原来加载图片对列表加载性能影响如此之大!

我在onBindViewHolder()的前后打了 log,以便更直观的检测 Glide 加载图片对性能的影响:

class RankProxy : VarietyAdapter.Proxy<Rank, RankViewHolder>() {
    // 构建表项
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {...}

    // 绑定表项数据
    override fun onBindViewHolder(holder: RankViewHolder, data: Rank, index: Int, action: ((Any?) -> Unit)?) {
        // 开始计时
        val start = System.currentTimeMillis()
        holder.tvCount?.text = data.count.formatNums()
        // Glide 加载第一张图片
        holder.ivAvatar?.let {
            Glide.with(holder.ivAvatar.context).load(data.avatarUrl).into(it)
        }
        // Glide 加载第二张图片
        holder.ivLevel?.let {
            Glide.with(holder.ivLevel.context).load(data.levelUrl).into(it)
        }
        holder.tvRank?.text = data.rank.toString()
        holder.tvName?.text = data.name
        holder.tvLevel?.text = data.level.toString()
        holder.tvTag?.text = data.tag
        // 结束计时
        Log.w("test", "bind view duration = ${System.currentTimeMillis() - start}")
    }
}
复制代码

运行 demo,log 如下:


03-20 18:22:04.243 17994 17994 W ttaylor : rank bind view duration = 41
03-20 18:22:04.252 17994 17994 W ttaylor : rank bind view duration = 2
03-20 18:22:04.261 17994 17994 W ttaylor : rank bind view duration = 2
03-20 18:22:04.270 17994 17994 W ttaylor : rank bind view duration = 1
03-20 18:22:04.279 17994 17994 W ttaylor : rank bind view duration = 1
...
复制代码

绑定列表第一个表项特别耗时!而且是很夸张的 41 ms,这让我好奇 Glide 第一次启动时做了些啥?

经过一番 Glide 源码的走查(过程略),我发现 Glide 会启动一个叫GlideExecutor的线程池来进行图片的异步加载。

线程池的构建是昂贵的,耗时的。

有没有什么办法让 Glide 不使用自己的线程池,而使用整个 App 通用的线程池进行加载?

我想到的解决方案是:“在协程中,使用 Glide 的同步方法加载图片。”

ImageView新增一个扩展方法:

fun ImageView.load(url: String) {
    viewScope.launch {
        val bitmap = Glide.with(context).asBitmap().load(url).submit().get()
        withContext(Dispatchers.Main) { setImageBitmap(bitmap) }
    }
}
复制代码

扩展方法启动了一个协程并使用 Glide 的submit()加载图片,这个方法会返回一个FutureTarget,调用它的get()就可以同步地获得 Bitmap 对象。然后切换到主线程将其设置给 ImageView。

其中的viewScope是一个CoroutineScope对象,我把它声明为View的扩展属性。

val View.viewScope: CoroutineScope
    get() {
        // 获取现有 viewScope 对象
        val key = "ViewScope".hashCode()
        var scope = getTag(key) as? CoroutineScope
        // 若不存在则新建 viewScope 对象
        if (scope == null) {
            scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
            // 将 viewScope 对象缓存为 View 的 tag
            setTag(key,scope)
            val listener = object : View.OnAttachStateChangeListener {
                override fun onViewAttachedToWindow(v: View?) {
                }

                override fun onViewDetachedFromWindow(v: View?) {
                    // 当 view detach 时 取消协程的任务
                    scope.cancel()
                }

            }
            addOnAttachStateChangeListener(listener)
        }
        return scope
    }
复制代码

viewScope这个扩展属性的语义是:“每个 View 都有一个和它生命周期绑定的 CoroutinScope 用于启动协程”。这种动态扩展类并绑定生命周期的写法是参照了ViewModelScope,详细讲解可以点击读源码长知识 | 动态扩展类并绑定生命周期的新方式。

用新的扩展函数重写onBindViewHolder()

class RankProxy : VarietyAdapter.Proxy<Rank, RankViewHolder>() {
    override fun onBindViewHolder(holder: RankViewHolder, data: Rank, index: Int, action: ((Any?) -> Unit)?) {
        holder.tvCount?.text = data.count.formatNums()
        holder.ivAvatar?.load(data.avatarUrl)// 使用协程加载图片
        holder.ivLevel?.load(data.levelUrl)// 使用协程加载图片
        holder.tvRank?.text = data.rank.toString()
        holder.tvName?.text = data.name
        holder.tvLevel?.text = data.level.toString()
        holder.tvTag?.text = data.tag
    }
}
复制代码

运行一下 demo,看看数据

measure + layout=251,     unknown delay=19,     anim=0,    touch=0,     draw=12,  total=300
measure + layout=0,     unknown delay=290,     anim=2,    touch=0,     draw=0,    total=321
measure + layout=0,     unknown delay=0,     anim=0,    touch=0,     draw=0,   total=3
复制代码

measure + layout时间从 288 ms 缩减到 251 ms,往减半又迈出了一步。

表项数量影响绘制性能

在之前一系列 RecyclerView 源码阅读过程中,得出很多结论,其中有一个结论和加载性能有关:

填充表项是一个 while 循环,有多少表项需要被填充,就会循环多少次。

源码如下:

public class LinearLayoutManager {
    // 根据剩余空间填充表项
    int fill(RecyclerView.Recycler recycler, LayoutState layoutState,RecyclerView.State state, boolean stopOnFocusable) {
        ...
        // 计算剩余空间
        int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
        // 循环,当剩余空间 > 0 时,继续填充更多表项
        while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
            ...
            // 填充单个表项
            layoutChunk(recycler, state, layoutState, layoutChunkResult)
            ...
        }
    }

    // 填充单个表项
    void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,LayoutState layoutState, LayoutChunkResult result) {
        // 1.获取下一个该被填充的表项视图 onCreateViewHolder(), onBindViewHoder() 在这里被调用
        View view = layoutState.next(recycler);
        // 2.使表项成为 RecyclerView 的子视图
        addView(view);
        ...
    }
}
复制代码

onCreateViewHolder()onBindViewHoder()都会在这个循环中被调用。所以,表项越多,绘制越耗时。

立马做了实验,先让整个屏幕只显示 2 个表项:

增加了 RecyclerView 的上边距,让整个屏幕中只显示了 2 个表项,看一眼性能日志:

measure + layout=120,   anim=0,    touch=0,     draw=1,    first draw = false   total=126
measure + layout=0,    anim=0,    touch=0,     draw=0,    first draw = false   total=124
measure + layout=12,    anim=0,    touch=0,     draw=0,    first draw = true    total=15
复制代码

measure + layout只用了 120 ms(关于如何获取性能日志可以点击RecyclerView 性能优化 | 把加载表项耗时减半 (上))

为了优化首次加载列表的性能,可不可以把第一屏的所有表项都合并成一个表项?

列表数据是服务器返回的,个数是可变的。如果用 xml 静态地构建布局就无法做到动态地合并表项,遂只能通过 Kotlin DSL 动态地构建表项:

class RankProxy : VarietyAdapter.Proxy<RankBean, RankViewHolder>() {
    // 构建表头视图
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val itemView = parent.context.run {
            LinearLayout { // 构建 LinearLayout
                layout_id = "container"
                layout_width = match_parent
                layout_height = wrap_content
                orientation = vertical
                margin_start = 20
                margin_end = 20
                padding_bottom = 16
                shape = shape {
                    corner_radius = 20
                    solid_color = "#ffffff"
                }

                PercentLayout { // 构建 PercentLayout
                    layout_width = match_parent
                    layout_height = 60
                    shape = shape {
                        corner_radii = intArrayOf(20, 20, 20, 20, 0, 0, 0, 0)
                        solid_color = "#ffffff"
                    }

                    TextView { // 构建 TextView
                        layout_id = "tvTitle"
                        layout_width = wrap_content
                        layout_height = wrap_content
                        textSize = 16f
                        textColor = "#3F4658"
                        textStyle = bold
                        top_percent = 0.23f 
                        start_to_start_of_percent = parent_id 
                        margin_start = 20
                    }

                    TextView { // 构建 TextView
                        layout_id = "tvRank"
                        layout_width = wrap_content
                        layout_height = wrap_content
                        textSize = 11f
                        textColor = "#9DA4AD"
                        left_percent = 0.06f 
                        top_percent = 0.78f 
                    }

                    TextView { // 构建 TextView
                        layout_id = "tvName"
                        layout_width = wrap_content
                        layout_height = wrap_content
                        textSize = 11f
                        textColor = "#9DA4AD"
                        left_percent = 0.18f 
                        top_percent = 0.78f 
                    }

                    TextView { // 构建 TextView
                        layout_id = "tvCount"
                        layout_width = wrap_content
                        layout_height = wrap_content
                        textSize = 11f
                        textColor = "#9DA4AD"
                        margin_end = 20
                        end_to_end_of_percent = parent_id 
                        top_percent = 0.78f 
                    }
                }
            }
        }
        return RankViewHolder(itemView)
    }
}

// 表项实体类
data class RankBean(
    val title: String,
    val rankColumn: String,
    val nameColumn: String,
    val countColumn: String,
    val ranks: List<Rank> // 所有主播信息
)

// 主播信息实体类
data class Rank(
    val rank: Int,
    val name: String,
    val count: Int,
    val avatarUrl: String,
    val levelUrl: String,
    val level: Int ,
    val tag: String
)

class RankViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
    val tvTitle = itemView.find<TextView>("tvTitle")
    val tvRankColumn = itemView.find<TextView>("tvRank")
    val tvAnchormanColumn = itemView.find<TextView>("tvName")
    val tvSumColumn = itemView.find<TextView>("tvCount")
    val container = itemView.find<LinearLayout>("container")
}
复制代码

使用 DSL 在onCreateViewHolder()中动态构建了表头:

表头是列表中静态的部分,这部分数据不依赖服务器返回。整个 item 是一个纵向的LinearLayout,这为动态地纵添加表项提供了方便。

数据结构也得重构一下,把服务器返回的List<Rank>结构包在一个更大的RankBean结构中。以便在一次onBindViewHolder()中获取所有的主播排名信息,然后遍历List<Rank>,逐个构建表项视图并填充到LinearLayout中:

class RankProxy : VarietyAdapter.Proxy<RankBean, RankViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        // 构建表头及容器
    }

    // 动态构建表项并同时绑定数据
    override fun onBindViewHolder(holder: RankViewHolder, data: RankBean, index: Int, action: ((Any?) -> Unit)?) {
        holder.tvAnchormanColumn?.text = data.nameColumn
        holder.tvRankColumn?.text = data.rankColumn
        holder.tvSumColumn?.text = data.countColumn
        holder.tvTitle?.text = data.title

        holder.container?.apply {
            // 遍历所有主播
            data.ranks.forEachIndexed { index, rank ->
                // 为每一个主播数据构造一个 PercentLayout
                PercentLayout {
                    layout_width = match_parent
                    layout_height = 35
                    background_color = "#ffffff"

                    TextView { // 构建排名控件
                        layout_id = "tvRank"
                        layout_width = 18
                        layout_height = wrap_content
                        textSize = 14f
                        textColor = "#9DA4AD"
                        left_percent = 0.08f
                        center_vertical_of_percent = parent_id
                        text = rank.rank.toString()
                    }

                    ImageView { // 构建头像控件
                        layout_id = "ivAvatar"
                        layout_width = 20
                        layout_height = 20
                        scaleType = scale_center_crop
                        center_vertical_of_percent = parent_id
                        left_percent = 0.15f
                        Glide.with(this.context).load(rank.avatarUrl).into(this)
                    }

                    TextView { // 构建姓名控件
                        layout_id = "tvName"
                        layout_width = wrap_content
                        layout_height = wrap_content
                        textSize = 11f
                        textColor = "#3F4658"
                        gravity = gravity_center
                        maxLines = 1
                        includeFontPadding = false
                        start_to_end_of_percent = "ivAvatar"
                        top_to_top_of_percent = "ivAvatar"
                        margin_start = 5
                        ellipsize = TextUtils.TruncateAt.END
                        text = rank.name
                    }

                    TextView { //构建标签控件
                        layout_id = "tvTag"
                        layout_width = wrap_content
                        layout_height = wrap_content
                        textSize = 8f
                        textColor = "#ffffff"
                        text = "save"
                        gravity = gravity_center
                        padding_vertical = 1
                        includeFontPadding = false
                        padding_horizontal = 2
                        shape = shape {
                            corner_radius = 4
                            solid_color = "#8cc8c8c8"
                        }
                        start_to_start_of_percent = "tvName"
                        top_to_bottom_of_percent = "tvName"
                    }

                    ImageView { // 构建等级图标控件
                        layout_id = "ivLevel"
                        layout_width = 10
                        layout_height = 10
                        scaleType = scale_fit_xy
                        center_vertical_of_percent = "tvName"
                        start_to_end_of_percent = "tvName"
                        margin_start = 5
                        Glide.with(this.context).load(rank.levelUrl).submit()
                    }

                    TextView { // 构建等级标签控件
                        layout_id = "tvLevel"
                        layout_width = wrap_content
                        layout_height = wrap_content
                        textSize = 7f
                        textColor = "#ffffff"
                        gravity = gravity_center
                        padding_horizontal = 2
                        shape = shape {
                            gradient_colors = listOf("#FFC39E", "#FFC39E")
                            orientation = gradient_left_right
                            corner_radius = 20
                        }
                        center_vertical_of_percent = "tvName"
                        start_to_end_of_percent = "ivLevel"
                        margin_start = 5
                        text = rank.level.toString()
                    }

                    TextView { // 构建粉丝数控件
                        layout_id = "tvCount"
                        layout_width = wrap_content
                        layout_height = wrap_content
                        textSize = 14f
                        textColor = "#3F4658"
                        gravity = gravity_center
                        center_vertical_of_percent = parent_id
                        end_to_end_of_percent = parent_id
                        margin_end = 20
                        text = rank.count.formatNums()
                    }
                }
            }
        }
    }
}
复制代码

运行 demo,看下数据:

measure + layout=170,     unknown delay=41,     anim=0,    touch=0,     draw=18, total= 200
measure + layout=0,     unknown delay=250,     anim=1,    touch=0,     draw=0,   total=289
measure + layout=4,     unknown delay=4,     anim=0,    touch=0,     draw=2,    total=13
measure + layout=4,     unknown delay=0,     anim=0,    touch=0,     draw=1,    total=13
复制代码

measure + layout耗时一下从 251 ms,缩减到 170 ms,提升巨大。

可见,显示在屏幕上的表项数量对列表绘制性能影响很大,数量越多绘制越慢。

虽然这个做法,让首次加载 RecyclerView 提速不少,但也有缺点。它为列表新增了一种表项类型,而且这个表项的 ViewHolder 持有超多的 View,难免增加内存压力。并且它无法被后续表项复用。

这个做法对于 Demo 这种场景,也不失为一种优化加载速度的方法,即将可能显示在首屏的表项都合并一个新的表项类型,当下拉刷新时,还是正常的一个个加载原有的表项。

总结

经过了 4 次优化,把列表首次加载时间从 370 ms 缩短到 170 ms,有 54% 的提升。回顾一下这 4 次优化:

  1. 用动态构建布局取代 xml,蒸发 IO 和 反射的性能损耗,缩短构建表项布局耗时。
  2. 替换表项根布局,由更简单的PercentLayout取代ConstraintLayout,以缩短 measure + layout 时间。
  3. 使用协程 + Glide 同步加载方法,以缩减加载图片耗时。
  4. 将列表首屏显示的表项合并成一个新的表项类型,以缩短填充表项耗时。

作者:唐子玄
链接:https://juejin.cn/post/6942276625090215943

扫描二维码推送至手机访问。

版权声明:本文由西安泽虎代运营发布,如需转载请注明出处。

转载请注明出处https://www.0291.com.cn/post/56191.html

相关文章

快手广告的落地页要如何设计呢?

  现在快手平台的流量大家有目共睹,依托流量的红利,快手平台的商业价值也越来越大,所以在快手上开展广告的平台也越来越多。快手广告的主要形式就是落地页了,下面小编就给大家介绍一下快手广告落地页的相关问题  一、快手广告如何跳转落地页:  目前快手广告跳转至落地页的方式主要有3...

留意网络营销四大雷区,避免网站被黑或者品牌受损。

留意网络营销四大雷区,避免网站被黑或者品牌受损。

的优势是什么?利用网络资源有效地营销其产品和企业,增加曝光率,增强可视性,使其产品和企业为更多人所知。这就是网络营销的力量所在。除了提高可视性之外,交易成本节约也得到了很大的提高。 然而,网络营销中也有许多“雷区”,有的是哑雷,虽然不会引发爆炸,但足以吓唬你,使你的营销变得毫无用处;...

品牌全面出海时代:破解社交媒体营销密码白皮书。

品牌全面出海时代:破解社交媒体营销密码白皮书。

中国出海品牌制定社媒营销策略的"四原则" 从自身增长需求出发,结合社媒数据指标,制定对应的社媒营销策略 提高技术能力与快速响应能力,以发挥更独特的销售与营销理念 重点关注系统集成与数据流通利用率,在"营销–销售–服务"全流程中贯彻数据驱动理念 加强品牌资产与社媒数据的安全合规性保...

Flutter 仿网易云音乐App(基础版)

Flutter 仿网易云音乐App(基础版)

image image image 歌曲播放和卡片切换 如正版一样,歌曲播放进度在播放/暂停 按钮的边框显示(页面下方,由黑变红) 没登录的话,一般只能听12秒 目前只做了 模块(‘超带感的说唱精选’)的点播功能, 其他地方可以直接套用(1、2行代码即可),控...

《创造101》中王菊爆火背后的新媒体营销逻辑。

《创造101》中王菊爆火背后的新媒体营销逻辑。

“菊外人”、“地狱空荡荡,王菊在土创”、“菊手之劳”你的朋友圈是否也被这些词刷屏了,看完这篇文章,你不再是个“菊外人”! 最近腾讯视频的自制明星养成类综艺节目《创造101》成为众多年轻群体和互联网新媒体人的关注。 就算你不怎么看综艺节目,但是你至少被人安利过“王菊”了。...

关于企业建网站的几个建议和观点。

关于企业建网站的几个建议和观点。

关于网站建设,根据上海网站建设公司—点瑞云网站建设多年的的建站经验,整理了一下,为将做网站和做了网站的朋友提供参考。 1、定位明确网站的定位非常重要,要综合考虑所开发网站的客户群体是否丰富,网络应用是否普遍,竞争环境是否激烈,并符不符合自己拥有的现实资源。 2、做专做透做专:网站内容不要过于广泛...

现在,非常期待与您的又一次邂逅

我们努力让每一部企业宣传片和抖音短视频成为商业大片