自己平时就爱打开GPU呈现模式,看看自家的App和别家App的应用渲染性能怎么样,去思考他们可能会存在的业务逻辑.当然这个过程还是以自家App为主,不过奈何自己是业务线上的一名普通员工,日常开发功能迭代排期满满,根本无暇顾及性能,更别提所谓优化与思考.不过终于在一件事情之后,我有了时间去关注性能和相关的学习.下面进入正题:
一.发现问题
如上图所示这是一个普通瀑布流展示的推荐信息列表,我们可以清楚直观地发现在渲染过程中黄条耗时过长,经过查阅官方文档,说明是处理/交换缓冲区的问题.
官方对问题的描述很直白:
当此分段较长时 有一点必须要注意:GPU 与 CPU 是并行工作的。Android 系统向 GPU 发出绘制命令,然后继续执行下一个任务。GPU 从队列中读取并处理这些绘制命令。
如果 CPU 发出命令的速度快于 GPU 处理命令的速度,这两个处理器之间的通信队列就会被占满。出现这种情况时,CPU 会阻塞并等待,直到队列中有位置来放置下一个命令。这种队列占满状态通常出现在“交换缓冲区”阶段,因为此时已提交了整个帧的命令。
但是官方的解决方案描述的就是非常模糊:
缓解此问题的关键是降低 GPU 工作的复杂度,就像您在“发出命令”阶段所做的那样。
这句话的白话含义是:你自己干了啥你不清楚吗?好好排查下. 个人理解,其实到了GPU这一步,系统其实是无法区分出上一层级是个啥东西,你上一层是普通应用使用的系统SDK API, 还是自定义OpenGL结合SurfaceView去自行绘制,还是一个游戏应用,系统在这一步是不清楚的,系统只知道这一步,你提交了一些东西交给GPU去渲染,但是任务量比较大.所以文档中并没有提供明确的排查方案.这导致我在面对该问题时,无从下手.
二.思路分析
虽然官方并没有提供明确的排查方向,但是既然是GPU工作负担过重,我们可以直面问题,是哪些情况导致GPU负担过重,根据Android 渲染机制——原理篇(显示原理全过程解析),初期的猜测是应用侧因为频繁调用setLayoutParams()方法导致的频繁更新DisPlayList导致,或者因为是推荐列表图片较多时,使用Glide方式不当导致的.那么顺着这个思路,我做了如下的排查处理:
三.排查过程
初步排查
这里就不展示排查过程中的GPU呈现模式的截图了,而且以下的分析过程并不是按时间顺序排序,这里仅仅是做个排查过程中的总结,同时因为对应业务逻辑简单,排查手法就比较简单粗暴,每一个都是单独分析,没有叠加分析.
- 干掉业务代码中所有调用setLayoutParams()方法,进行分析,调优效果不理想
- 干掉业务代码中除了设置图片以外的其他代码实现,调优效果不理想
- 干掉业务代码中设置图片的代码实现,调优效果好,暂时达到心中目标,但与业务违背
- 干掉业务代码中滑动监听的代码实现,调优效果不理想
这里发现在干掉设置图片的代码后,性能有所提升,按照影响性来讲,很可能是这里的问题.虽然存在单个性能影响不大,多个性能问题叠加影响大的场景,但是因为本例中设置图片的前后性能差别较大,还是适合单一性能问题.所以接下来就进入针对性排查
针对性排查:图片相关
这里代码并没有什么,仅仅是一行图片加载库的简单调用:
ImageLoader.with(getContext()).load(ecItem.picUrl).placeholder(R.color.colorFAFAFA).into(((MineRecommendViewHolder) holder).ivItem);
复制代码
因为业务比较简单, ImageLoader
类仅仅是对Glide库的简单封装,所以,我即使直接使用Glide库进行图片加载,也没有对性能造成影响.但是性能问题出现在这里,让我开始认真审视这句代码.
这句代码传入了ImageView
给Glide,这不禁让我好奇,Glide是调用了哪个方法将图片设置上去的.于是,我在
ImageView
的源码中加入断点,发现Glide最终是调用了ImageView
的setImageDrawable()
方法设置的.
public class DrawableImageViewTarget extends ImageViewTarget<Drawable> {
public DrawableImageViewTarget(ImageView view) {
super(view);
}
/** @deprecated Use {@link #waitForLayout()} instead. */
// Public API.
@SuppressWarnings({"unused", "deprecation"})
@Deprecated
public DrawableImageViewTarget(ImageView view, boolean waitForLayout) {
super(view, waitForLayout);
}
@Override
protected void setResource(@Nullable Drawable resource) {
view.setImageDrawable(resource);
}
}
复制代码
而在ImageView
的setImageDrawable(resource)
源码中:
public void setImageDrawable(@Nullable Drawable drawable) {
if (mDrawable != drawable) {
mResource = 0;
mUri = null;
final int oldWidth = mDrawableWidth;
final int oldHeight = mDrawableHeight;
updateDrawable(drawable);
if (oldWidth != mDrawableWidth || oldHeight != mDrawableHeight) {
requestLayout();
}
invalidate();
}
}
复制代码
我发现在debug时每次调用这个方法,都会调用requestLayout()
方法,这让我是十分不解,是不是Glide使用方式不对导致每次设置图片都要调用requestLayout()
,于是,我打算搜一搜,这一搜,让我找到了性能元凶.
四.发现问题
我利用搜索引擎搜索“glide requestLayout”, 搜索后的其中一条结果是NineGridImageView的onLayout死循环, 而这条结果给了我灵感,会不会我的问题也出现在自定义View上,因为本例中的图片显示View非官方的ImageView而是一个早期引入的自定义View,所以我把当前的列表中的自定义View替换成官方的ImageView,结果渲染性能马上就好了:
从GPU呈现模式上看,黄色变浅而且条基本都缩短在绿线附近,而不像之前在红线附近.
五.问题分析
这个案例中使用的自定义View叫NBImageView
,是由项目早期一个自大的开发者引入的一个网上的自定义View,该View可以实现对图片的一些圆形、圆角、边框处理,根据该自定义View中的一些注释,我搜到了NBImageView
网络上对应的原始版本,叫NiceImagerView
项目链接,发现该项目创建于3、4年前,与本项目的起点时间近乎相同,且人气不高.该项目应该仅仅是其作者的一个小Demo,以至于后期没有对android9及其后续版本进行适配.
该项目的核心是NiceImagerView
类,这个类是继承自系统ImageView,并加入了自身的一些处理,在其中的onDraw()
方法中,是这样的:
override fun onDraw(canvas: Canvas) {
// 使用图形混合模式来显示指定区域的图片
canvas.saveLayer(srcRectF, null, Canvas.ALL_SAVE_FLAG)
if (!isCoverSrc) {
val sx = 1.0f * (_width - 2 * borderWidth - 2 * innerBorderWidth) / _width
val sy = 1.0f * (_height - 2 * borderWidth - 2 * innerBorderWidth) / _height
// 缩小画布,使图片内容不被borders覆盖
canvas.scale(sx, sy, _width / 2.0f, _height / 2.0f)
}
super.onDraw(canvas)
paint.reset()
path.reset()
if (isCircle) {
path.addCircle(_width / 2.0f, _height / 2.0f, radius, Path.Direction.CCW)
} else {
path.addRoundRect(srcRectF, srcRadii, Path.Direction.CCW)
}
paint.isAntiAlias = true
paint.style = Paint.Style.FILL
paint.xfermode = xfermode
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1) {
canvas.drawPath(path, paint)
} else {
srcPath.reset()
srcPath.addRect(srcRectF, Path.Direction.CCW)
// 计算tempPath和path的差集
srcPath.op(path, Path.Op.DIFFERENCE)
canvas.drawPath(srcPath, paint)
}
paint.xfermode = null
// 绘制遮罩
if (maskColor != 0) {
paint.color = maskColor
canvas.drawPath(path, paint)
}
// 恢复画布
canvas.restore()
// 绘制边框
drawBorders(canvas)
}
复制代码
onDraw()
方法一上来就调用了canvas.saveLayer()
方法,这个方法是做什么的呢? 根据《Android自定义开发入门与实战》第一版中的描述,该方法:
会生成一块全新的画布(Bitmap),这块画布的大小就是我们指定的所要保存区域的大小.新生成的画布是全透明的,在调用saveLayer()函数后所有的绘图操作都是在这块画布上进行的.
看到这里,感觉上流程是没有问题,但是当我点进saveLayer()
方法里面查看源码时,却发现官方的注释是这样的:
Note: this method is very expensive, incurring more than double rendering cost for contained content. Avoid using this method, especially if the bounds provided are large. It is recommended to use a hardware layer on a View to apply an xfermode, color filter, or alpha, as it will perform much better than this method.
这注释说的再明确不过了,调用该方法会多出两倍的渲染消耗,并推荐我们使用硬件加速去处理自定义View.
到此,本次的渲染问题排查就暂告一断落.
六.总结
(一).和本例相关的
1.本例中的渲染性能瓶颈到底找到没有
可以说只抓到了一个点.第一在通用层,我们看到自定义ViewNBImageView
在onDraw()
方法中的一处性能瓶颈,至于该方法中其他代码,该类其他代码还有没有性能瓶颈不清楚,这需要详细分析该类代码才能得知,第二在业务层,我们看到替换官方ImageView后的GPU渲染呈现条也没有很优秀的表现,这需要结合业务继续深挖分析.
2.怎么证明本文中分析的自定义View就是本例的性能瓶颈
其实上文中描述的就像我标题写的仅仅是一次排查记录,描述了根据以往经验,我的思路和根据思路我的排查实践.没有任何数据支持本例中的性能瓶颈就是自定义View,其实更科学的方式是使用性能分析工具(例如Systrace)来数据化各个方法的耗时,以此进行分析,得出结论.而自己是在分析列表性能瓶颈时无意间发现了saveLayer()
方法的渲染瓶颈,单纯的觉得应该将整个过程记录下来.而且就算按照下面第3小节的方案去处理,所提升的性能不一定能比肩到本例中直接使用ImageView的替换方案,需要实践,这个后续我可能还会出一篇博客进行分析.
3.如果正如排查所讲是自定义View问题,该如何解决本例问题
其实针对本例来讲,使用该View无非是想实现图片的圆角处理,android应用开发发展到现在,已经有很多框架能够实现这一功能,无非是开发者自身是否知道了解以及使用习惯的问题.
- 继续使用该自定义View,分析整个自定义View类的代码,更改替换其中性能瓶颈的代码
- 替换成其他性能良好的第三方自定义View库
- 自定义Glide库中的
DrawTransformation
去实现该功能 - 使用官方SDK提供的相关类
(二).其他思考
应用开发者在需要引入任何目标三方库的时候,都应该对目标三方库进行评估:
- 该库的社区口碑如何,是否正在维护
- 该库的功能是否能够完全满足我现在所需,是否有超出我需要范围之外的功能,是否在未来确定时间会添加我期望的功能
- 该库的可自定义扩展性如何
- 该库的性能是怎样的,有无数据安全、线程安全问题等