上一篇介绍了如何高效地量化绘制性能,并对 RecyclerView 加载速度做了 2 次优化,使得表项加载耗时从 370 ms 缩减到 288 ms。这一篇继续介绍后续的 2 种优化手段。把这些优化叠加在一起,就能实现加载耗时减半的效果。
这次性能调优的界面如下:
界面用列表的形式,展示了一个主播排行榜。
先回顾下上一篇做的两次优化:
- 用动态构建布局取代 xml,蒸发 IO 和 反射的性能损耗,缩短构建表项布局耗时。
- 替换表项根布局,由更简单的
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 次优化:
- 用动态构建布局取代 xml,蒸发 IO 和 反射的性能损耗,缩短构建表项布局耗时。
- 替换表项根布局,由更简单的
PercentLayout
取代ConstraintLayout
,以缩短 measure + layout 时间。
- 使用协程 + Glide 同步加载方法,以缩减加载图片耗时。
- 将列表首屏显示的表项合并成一个新的表项类型,以缩短填充表项耗时。
其实我还有一些更大胆的想法,后续的博客会陆续介绍,欢迎关注我,及时获得博客更新提醒~~~
Talk is cheap, show me the code
推荐阅读
RecyclerView 系列文章目录如下: