前言

Android下使用列表控件,如RecyclerViewListView,很容易遇到滚动不流畅的问题。本文记录我的一次性能优化过程。

常见范式

我们经常这样使用列表控件:

  1. (使用ListView)重写Adapter.getView(),在其中创建或重用View和ViewHolder,将业务数据结构显示到View中。
  2. (使用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>

根据赞的显示方式来设置GridLayoutTextView的Visibility。

我的方法是:根据需要动态改变布局结构、保正显示效果的同时精简数量和层级。优化后是这样:

<LinearLayout android:orientation="vertical" >

    <!-- 赞区域 -->
    <ViewStub android:background="heart.9.png" />

    <!-- 评论区域 -->
    <LinearLayout
        android:orientation="vertical"
        android:background="comment.9.png"
        android:paddingStart="?dp"
        /> <!-- 评论列表容器 -->

</LinearLayout>

首先,原赞区域的GridLayoutTextView都放到单独的布局文件中,原布局中换上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。

总结

整个优化过程的核心思想是:

  1. 尽量减少滚动时UI线程中的耗时操作。能挪到后台线程的就不在UI线程中做,必须在UI线程做、但能预先处理的就提早处理;
  2. 以空间换时间。用缓存来减少GC、创建等耗时操作;

需要说明的是,一些优化方法是有代价的:

  1. 预处理可能增加用户的等待时间。以这个项目为例,进入某Activity后,一边显示进度条,一边从服务端获取一批Topic显示给用户。预处理和预先创建会较明显地增加用户的等待时间;
  2. 缓存数据越多,OOM的风险越大;
  3. 增加了复杂度;

后记

除前述优化方法之外,还有一些零星的、未能实际应用的优化方法。比如:

  • onBindViewHolder()中检查之前显示的内容是否就是当前将要显示的内容。如果是,return即可。这种方法可以在反方向滚动时,避免不必要的更新view操作。但前提条件是内容不会动态改变,在这个项目中Topic的内容是可能动态改变的,使用这个方法后反而增加了复杂度,权衡得失后,放弃了这个优化方法;
  • 使用Tracer for OpenGL检查OpenGL渲染过程;

此外,即便经过这一系列的优化,卡顿问题也未能根除。后续我可能会继续改进优化方案,如果有新的心得体会,再补上。也希望有同好分享经验,相互交流,共同进步,谢谢。

最后,聊一下我对性能优化的一些建议:

  1. 先分析性能瓶颈,再对症下药。对于有经验的研发,这个过程可以适当地借助自己的经验法则。忌讳想当然的「优化」。
  2. 善用工具。比如Traceview、Memory Monitor、Hierarchy Viewer。本文所述的优化过程重度依赖这些工具。

参考资料