前言
Android下使用列表控件,如RecyclerView
和ListView
,很容易遇到滚动不流畅的问题。本文记录我的一次性能优化过程。
常见范式
我们经常这样使用列表控件:
- (使用
ListView
)重写Adapter.getView()
,在其中创建或重用View和ViewHolder,将业务数据结构显示到View中。 - (使用
RecylerView
)重写Adapter.onCreateViewHolder()
和Adapter.onBindViewHolder()
,在前者中创建View和ViewHolder,在后者中将业务数据结构显示到View中。
这个范式用了一种优化技术:View重用。避免了频繁的create view和find view过程,这两个过程都是很耗时的。但仅这一种优化手段不足以解决所有的性能问题。
优化过程
接下来,我将基于一个实际项目的简化版来说明如何进行优化。
一、减少Overdraw
Overdraw是常见性能损耗点,且多是无谓的性能损耗。相关资料非常丰富,这里不赘述。只提醒一点:善用Window.setBackgroundDrawable*()
。
二、内存优化
优化内存使用也是一种常用的性能优化手段。它的机理是:GC会暂停所有线程,而频繁触发GC会加剧掉帧问题。
来看一下主题的设计图,其中图片矩阵可以容纳0~9张图片。
优化方法是:尽量缓存、重用ImageView。比如:在onBindViewHolder()
中发现图片矩阵控件中已经包含了7个ImageView(上次该控件显示的主题有7张图片),当前要显示的主题有5张图片,那么直接重用前5个ImageView,隐藏剩下的2个ImageView。
三、精简布局
来看一下主题的赞和评论区域。其中赞区域会有两种展现形式(针对不同的应用场景)。想想如果是你来实现,会怎么布局。
布局方式可能会是这样:
<LinearLayout android:orientation="vertical" >
<!-- 赞区域 -->
<LinearLayout android:orientation="horizontal" >
<ImageView /> <!-- 心形图标 -->
<GridLayout /> <!-- 头像形式的赞 -->
<TextView /> <!-- 文字形式的赞 -->
</LinearLayout>
<!-- 评论区域 -->
<LinearLayout android:orientation="horizontal" >
<ImageView /> <!-- 评论图标 -->
<LinearLayout
android:orientation="vertical"
/> <!-- 评论列表容器 -->
</LinearLayout>
</LinearLayout>
根据赞的显示方式来设置GridLayout
和TextView
的Visibility。
我的方法是:根据需要动态改变布局结构、保正显示效果的同时精简数量和层级。优化后是这样:
<LinearLayout android:orientation="vertical" >
<!-- 赞区域 -->
<ViewStub android:background="heart.9.png" />
<!-- 评论区域 -->
<LinearLayout
android:orientation="vertical"
android:background="comment.9.png"
android:paddingStart="?dp"
/> <!-- 评论列表容器 -->
</LinearLayout>
首先,原赞区域的GridLayout
和TextView
都放到单独的布局文件中,原布局中换上ViewStub
,根据需要动态inflate。其次,赞和评论图标改成.9图作为Background
、并辅以paddingStart
(具体数值由评论图标的宽度计算得到)。
后者的缺点是.9图可能因为拉伸变得不够清晰,但实测下来效果满足需要。
四、改进布局
常用的方法有:
- 在不增加布局层次及复杂度的前提下,用LinearLayout替换RelativeLayout;
- 减少LinearLayout.layout_weight属性的使用,分享一下项目中一个LinearLayout的实际数据:
- 使用layout_weight,measure耗时5.86ms;
- 弃用layout_weight,measure耗时2.28ms;
五、预处理
需要引入一点业务数据结构来辅助说明。
// 约定:为了行文简洁,本文中所有代码都隐藏了诸如public关键字、构造函数等非核心内容。
// 主题
class Topic {
String mText; // 文字内容。
List<String> mImages; // 图片链接。
List<User> mLikes; // 点赞的用户。
List<Comment> mComments; // 评论。
User mCreater; // 发表人。
long mCreateTime; // 发表的时间戳。
}
// 评论
class Comment {
String mText; // 文字内容。
User mCreater; // 发表人。
}
// 用户
class User {
String mName; // 用户名称。
String mAvatarUrl; // 头像的下载地址。
}
这其中有部分数据是可以预处理的,得到的数据结构如下:
class PreprocessResult {
CharSequence mText; // 包含Spannable(比如超链接)的正文。
String mLikes; // 用逗号分隔的文字形式的点赞用户的名称。
List<CharSequence> mComments; // 包含Spannable的评论列表。
String mCreateTime; // 发表时间。
}
然后在onBindViewHolder()
中就可以直接将PreprocessResult
的内容设置到相应的view上,避免在UI线程中动态处理造成掉帧。
这种方法的缺点是增加了复杂度:PreprocessResult需要和Topic保持同步更新。比如用户点赞、发表评论之后,需要同步修改PreprocessResult和Topic。
六、预创建
分析发现,滚动过程会因为动态创建TopicView而卡顿。TopicView是用来显示整个主题的自定义View,包含前述的赞和评论等子布局。即便经过精简,整个布局依然比较复杂,inflate过程很耗时。于是预先创建了一些TopicView备用,在onCreateViewHolder()
中尽量使用它们,如果备用已经用完再动态创建。
七、优化TextView
经过上述这几步优化之后,流畅度提升了很多,但偶尔还是会出现较明显的卡顿现象。分析发现是TextView.setText()
耗时过长导致的,在高强度测试下(长文主题,并且有很多评论),平均一次长文的调用就可能耗时22ms,已经超过了保证帧率的16ms上限。
因为这个项目的业务逻辑决定了主题、评论的文字内容不会被修改,只有增删。于是采用的优化方法是:对于主题正文和评论,用StaticLayout
配合自定义View展示文字。自定义View代码如下:
public class StaticLayoutView extends View {
private StaticLayout mLayout;
public void setLayout(StaticLayout layout) {
mLayout = layout;
int height = layout.getHeight();
if ((mLayout.getWidth() != layout.getWidth()) ||
(mLayoutHeight != height)) {
requestLayout();
}
mLayoutHeight = height;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.save();
if (null != mLayout) {
canvas.translate(getPaddingLeft(), getPaddingTop());
mLayout.draw(canvas);
}
canvas.restore();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (null != mLayout) {
setMeasuredDimension(getMeasuredWidth(),
mLayoutHeight + getPaddingTop() + getPaddingBottom());
}
}
}
StaticLayoutView相比TextView简单、高效很多,更重要的是StaticLayout
可以在非UI线程创建、初始化。于是前述「预处理」过程中的数据结构变为:
class PreprocessResult {
StaticLayout mText; // <= 之前是CharSequence
String mLikes;
List<StaticLayout> mComments; // <= 之前是List<CharSequence>
String mCreateTime;
}
优化后,在高强度测试环境下,平均帧率从34上升至47。实际环境中的平均帧率约为51。
总结
整个优化过程的核心思想是:
- 尽量减少滚动时UI线程中的耗时操作。能挪到后台线程的就不在UI线程中做,必须在UI线程做、但能预先处理的就提早处理;
- 以空间换时间。用缓存来减少GC、创建等耗时操作;
需要说明的是,一些优化方法是有代价的:
- 预处理可能增加用户的等待时间。以这个项目为例,进入某Activity后,一边显示进度条,一边从服务端获取一批Topic显示给用户。预处理和预先创建会较明显地增加用户的等待时间;
- 缓存数据越多,OOM的风险越大;
- 增加了复杂度;
后记
除前述优化方法之外,还有一些零星的、未能实际应用的优化方法。比如:
- 在
onBindViewHolder()
中检查之前显示的内容是否就是当前将要显示的内容。如果是,return即可。这种方法可以在反方向滚动时,避免不必要的更新view操作。但前提条件是内容不会动态改变,在这个项目中Topic的内容是可能动态改变的,使用这个方法后反而增加了复杂度,权衡得失后,放弃了这个优化方法; - 使用Tracer for OpenGL检查OpenGL渲染过程;
此外,即便经过这一系列的优化,卡顿问题也未能根除。后续我可能会继续改进优化方案,如果有新的心得体会,再补上。也希望有同好分享经验,相互交流,共同进步,谢谢。
最后,聊一下我对性能优化的一些建议:
- 先分析性能瓶颈,再对症下药。对于有经验的研发,这个过程可以适当地借助自己的经验法则。忌讳想当然的「优化」。
- 善用工具。比如Traceview、Memory Monitor、Hierarchy Viewer。本文所述的优化过程重度依赖这些工具。
参考资料
- Traceview War Story
- Optimizing Your UI
- Android Performance Patterns
- Android Performance Case Study by Romain Guy
- Android Performance Case Study(同名不同文) by Romain Guy
- Android性能优化典范 by 胡凯
- TextView预渲染研究 by Ragnarok
- Improving Comment Rendering on Android by Instagram