0%

堆糖Android首页的列表性能优化记录

在堆糖6.7.0版本中,首页经过了较大的改版,从原有的单页面列表页改为由8个横向滑动的页面经由ViewPager承载,每个页面都是一个包含8种cell类型的列表页。在某些cell中还包含了占据较高面积比例的图片,而且这些图片的展示对于清晰度还有需求——毕竟堆糖图片社交出身,不同于普通的新闻客户端,堆糖对于列表项中的图片精细度还是有些追求的。

小林是这次首页改版业务的开发负责人,接到任务之后很快就基本完成了页面开发——堆糖有一套通用的List页面实现,集成了状态控制、下拉刷新、上拉加载等列表页通用功能,开发者仅需要对于每个cell的view进行开发即可,效率很高。

然而,在后端数据就绪开始联调的时候却发现列表存在较为严重的卡顿状况。首页是一个应用的门脸,是用户使用频次最高、操作最频繁的部位,这种流畅度是不可接受的。于是我开始着手review小林的代码期以提高列表页面的浏览流畅度。

追踪高开销的绑定操作

列表项的创建、绑定数据操作是主要的耗时来源,review中也是从这里着手开始分析耗时操作。由于堆糖所有列表页都继承于同一个实现,因此我们可以很轻松的追踪到我们在绑定列表数据的时候究竟耗时情况是怎么样

1
2
3
4
5
6
7
8
9
//bind data
long startTime = 0;
if (BuildConfig.DEBUG) {
startTime = System.currentTimeMillis();
}
setupItem(holder.itemView, ((ItemVH) holder).type, position, data);
if (BuildConfig.DEBUG) {
P.d(String.format(Locale.getDefault(), "Bind data cost %d ms, Holder type= %d",(System.currentTimeMillis() - startTime), ((ItemVH) holder).getItemType()));
}

这里setupItem是一个抽象类,是所有item绑定数据的入口方法,通过这段代码,我们在Logcat中立刻就定位到了耗时非常严重的几个item类型。

工作线程提前处理数据,避免主线程重复操作数据

我们首先定位到了这么一段代码:

1
2
3
4
5
if (view instanceof CommonAdItemView) {
CommonAdItemView commonAdItemView = (CommonAdItemView) view;
commonAdItemView.setData(GsonUtil.parseJson(data.getEntity(),
HomeItemModel.NormalAdModel.class));
}

后端在返回的数据实体里针对不同的数据类型返回了不同类型的真实数据实体entity,客户端需要本地根据数据实体提供的类型参数二次解析为特定的可用的数据实体绑定进item。

但是在这段代码中我们看到,每一次绑定操作都调用了一次Gson的反序列化操作以生成数据实体——Json的解析与对象的拼装是一件非常耗时的事情,涉及到的IO、反射都是重性能开销操作,这里不仅错误的在主线程绑定数据时进行Json解析,而且没有做任何缓存处理,同一段数据会被不断地反序列化——哪怕这段数据实体已经被反序列化过了。

正确的做法应该是在网络框架返回数据之后,在工作线程将数据实体按照对应的规则解析并保存到相应的对象中返回,在使用的过程中无需再做任何的反序列化操作了。

处理完这一点之后,发现确实列表在加载时的卡顿感减轻了不少,但是在快速滑动列表的时候依然会出现丢帧、跳页的现象,没关系,我们的优化才刚刚开始。

谨慎分配高占用对象

在代码中我们发现了这么一段:

1
2
3
4
5
6
7
for (int i = 0; i < (blogModels.size() >= 3 ? 3 : blogModels.size()); i++) {
……
AlbumImageView imageView = new AlbumImageView(getContext());
imageView.loadImageWithDp(blogModels.get(i).getPhotoUrl(), 112, 112);
binding.layoutImgs.addView(imageView);
……
}

这段代码的原意是,在一个线性布局容器中根据返回图片数量的多少将图片控件生成并添加到线性布局中。这段代码如果不是在列表项中,是没有任何问题的,很灵活也很高效,但是在列表项中这样的做法就存在很严重的问题了——在每一次绑定数据的时候都经历了:清空线性布局中的图片控件、新建若干图片控件、部署若干图片控件、图片控件载入图片若干步步骤,其中清空布局并重新部署控件会引起控件的重新绘制,这是性能开销其一;其二,每次都新建图片控件并在其上载入图片,既付出了额外的性能时间消耗,更大大加重了内存负担——内存占用飙升、GC被频繁触发——所以,在item的数据绑定过程中,大对象的分配、布局的更新重绘等操作一定要谨慎,能够重用的一定要重用,不需要刷新布局的情况下尽可能低性能开销的数据绑定方式。

优化列表项的布局层级

绑定数据部分的问题基本被排查完毕之后,似乎setData部分已经从逻辑上没什么问题了,列表的加载现在的确相对之前顺滑了一些,渲染时间直方图也没那么吓人了,但从主观使用角度上看,还是显得有些卡顿,从客观的渲染时间直方图上看来,主线程上的渲染耗时还是存在优化的空间,于是此时我们开始检查View的渲染层级问题。

果然,在自定义View的时候,代码中采用的是组合式的定义方法,继承自一些ViewGroup,将定义好的xml文件inflate进该布局。但是在布局文件的定义中并没有使用merge标签,这也导致了在所有的item上都存在一层多余的布局。

与此同时,代码中多次使用了线性布局的嵌套来完成一些复杂控件的布局,然而实际上这些布局并没有那么高的灵活度需求,直接使用相对布局完成控件平铺摆放实际上已经达到了设计要求。

针对以上两点,我们也针对性的对布局文件进行了调整,很多item缩减了至少两层视图层级,这无疑也提升了view的渲染速度。

检查错误的图片加载操作

处理完这上述几点之后,列表的滑动卡顿感得到了一定程度的缓解,但是我们依然在列表加载大量图片的时候感受到了深深地卡顿感,于是我们将矛头转向了图片加载相关的处理实现上去寻求解决方案。

堆糖在加载网络图片的时候,CDN会根据请求末尾附的图片缩略/裁切规则返回最适合当前控件尺寸的图片,因此,在编写加载网络图片的代码时有一项约定,即开发者必须将待加载图片的控件实际宽高像素值传入图片加载方法作为方法参数,以此加载到最适合当前控件的图片,避免了图片过大带来的内存问题。

这里我们插一句题外话——为什么要显式的对加载图片的宽高进行设定?让图片控件自己获知其宽高难道不行吗?毕竟在每个地方都传入参数还是很烦人的,我只需要传入一条图片的原始url就能正确的加载出图片难道不是美滋滋?

这个问题我们也曾经认真的思考过、试验过,但是最终还是放弃了,原因有以下两点:

首先,自动获取控件的大小不是做不到,有很多方法可以完成,但是要么需要通过在消息队列队尾postRunnable来实现,要么需要手动设置ViewTreeObserver监听并手动取消监听来实现——前一种方法会造成图片加载在刚开始的时候有延迟,后一种方案在某些机型上会产生无限循环调用该方法的问题,所以最终都没有采纳。

第二,即便我们能正确、快速、无性能消耗的获取到控件的大小——这种方式依然有一个最致命的缺陷摆在我们面前:我们永远只有在获取到图片控件实际大小之后才能发出load图片的请求——实际上load图片和图片控件渲染到屏幕上是两个并行的过程,我完全可以向图片框架发出加载图片的请求的同时将图片控件布局到屏幕中,并在图片控件布局到屏幕上之后将根据图片加载框架的图片返回情况将图片内容渲染上去,也就是说,如果我们采用自动获取图片控件大小的方式加载图片,我们永远只能将「获取图片控件宽高」和「加载图片」这两个步骤串行执行,这是绝对无法接受的。

事实上,开发者在完成加载图片的代码写作的时候,在绝大多数情况下对于图片控件的大小都是可知的,无论是像素值还是DP值,都可以通过简单的计算得到。在开发过程中增加这种并不会为开发者带来太大负担的约定,对于整个应用的图片加载性能的提升却是显著的。

OK,话说回正题,在检查图片是否加载正确这一点时,我首先利用mitmproxy抓取了加载这个列表时加载的图片url,在分析图片url的时候,发现某些图片url返回的图片尺寸出奇的高,这是很不正常的一件事,首页列表项中的图片大小实际上只有300*300左右的大小,但是竟然有些url返回的图片大小超过了1000*1000像素。于是我开始检查加载图片的相关代码,果然,在应该传入控件px值的地方,我们错误的调用了应该传入dp值的方法,于是加载的实际大小尺寸几乎是原本尺寸的三倍。

将这些方法修正之后,首页的卡顿得到了巨大的缓解,抖动的内存终于平复了下来。

在肉眼可接受范围内降低加载图片的精度

俗话说得好,开发是很简单的,优化是无止境的,难道首页的体验优化就到此为止了吗?当然不。实际上,在完成上述多个地方的优化之后,首页的卡顿感已经减轻了许多了,只是在一些相对低端的手机上,列表的滑动体验依然有继续优化的空间。

我们相继研究了今日头条、网易新闻、腾讯新闻等等类似的客户端的列表滑动表现,其中今日头条的列表性能表现令我们深感意外——在有许多图片的情况下依然保持着相当高的顺滑度,那么他们是怎么做到的呢?在注意到头条新闻配图似乎都有那么一点点糊的这一点之后,我们对今日头条列表加载时载入的图片进行了分析。果然,头条在载入图片的时候并没有完全按照控件大小去一比一的加载对应分辨率的图片,而是选择加载相同比例,但是对于分辨率进行缩减之后的图片。什么意思呢?就是说,一个空间大小为300*300像素的图片控件,只对应的请求120*120的图片。

这样做的好处是显而易见的,首先,小图片传输速率快,能够更快的从服务器获取到本地;其次,小图片在解码效率、渲染时间、内存占用上也相对于一比一的大图更加高效;最后,适度的降尺寸加载图片实际上并不会影响到用户对于图片的观感。因此,在产品认可的情况下,适度的降低列表项图片的显示质量,既可以提升图片加载速度,又可以降低CDN的流量消耗,是个很不错的优化思路。

试试看滑动的时候不加载图片呢?

在降低图片精度的同时,我们也尝试了与微博相似的做法——在列表滑动的时候不载如图片。在fresco框架中实现这一点并不难,我们只需要在 Recycler的ViewOnScrollListener中做如下控制即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (newState == RecyclerView.SCROLL_STATE_SETTLING
|| newState == RecyclerView.SCROLL_STATE_DRAGGING) {
if (!Fresco.getImagePipeline().isPaused()) {
Fresco.getImagePipeline().pause();
}
} else {
if (Fresco.getImagePipeline().isPaused()) {
Fresco.getImagePipeline().resume();
}
}
}

这样做确实能够让列表如飞一般的滑动,但是有个很重要的问题也同时存在:当列表停止滑动之后需要等一小段时间图片才会慢慢地加载出来,这一点在跟产品聊过之后被很强硬的否定了——其实也可以理解,堆糖毕竟不是一个真正的「信息流」应用,用户在使用内容流列表的时候,对于内容的呈现质量的要求其实并不算低。太长时间的等待加载图片对于用户的体验其实是种损害。

总结

至此,基本上首页的列表相关的优化已经结束了,从一开始的卡到飞起,到现阶段基本稳定在16ms的渲染延时基线下,堆糖的首页优化也暂时告一段落。本文中提到的若干列表优化的方法和思路是今后进行列表项开发时必须时时刻刻牢记在脑海中的principle,本文也作为一次我在列表性能优化方面的体会的总结。