Android性能(1): 使用SysTrace定位及优化性能问题实战 - clarkehe/Android GitHub Wiki

使用SysTrace定位及优化性能问题实战 手游宝游戏专区视频列表优化

  1. FPS性能测量工具及原理 最近对手游宝游戏专区的一些核心页面进行了性能测试。测试工具是KM上推荐的FPS自动化测试脚本。针对ListView窗口还有一个专门的scrolltest.py脚本,其主要原理是对ListView窗口不断模拟滑动操作,让ListView不断上或下滑动,同时定时(每秒)通过命令:adb shell dumpsys SurfaceFlinger —latency [包名/窗口名] 获取当前窗口最近128帧的绘制日志信息。在日志信息中有每一帧的更新时间戳,通过 (最后一帧时间戳 - 第一帧时间戳)/帧数 算出1秒内更新了多少帧,从而计算出FPS。Android从4.1 Butter Project 引用垂直同步(VSync)刷新机制,Android系统刷新频率是每秒60次,因此最高的FPS就是60。在实际App中,FPS最低要求是每秒30帧,这样在使用中才不会有明显不流畅的感觉。

     下面截图就是命令adb shell dumpsys SurfaceFlinger输出。第一行:16666667,是刷新周期(单位纳秒),也就是60/1秒,即16毫秒。接下来每一行就是每一帧的更新时间戳(单位也是纳秒),每一行有三个时间戳(从开机算起),根据Android系统的源码,分别是:帧应该显示给用户的时间(0延迟)、帧实际显示给用户的时间、帧准备好的时间,计算FPS只需要关注第二个:用户实际看到帧的时间。
    

fclarkes-MacBook-Pro:trunk fclarke$ adb shell dumpsys SurfaceFlinger --latency com.tencent.gamejoy/com.tencent.gamemgc.generalgame.home.ZoneHomeActivity2 16666666 235759681187512 235759725723679 235759681187512 235759700584429 235759742338512 235759700584429 235759714796095 235759759030012 235759714796095 235759745150346 235759775829971 235759745150346 235759755347054 235759792402929 235759755347054 235759771081262 235759809037929 235759771081262 ...... 专区首页最近128帧的时间截信息

void FrameTracker::dumpStats(String8& result) const {

Mutex::Autolock lock(mMutex);

processFencesLocked();

const size_t o = mOffset;
for (size_t i = 1; i < NUM_FRAME_RECORDS; i++) {
    const size_t index = (o+i) % NUM_FRAME_RECORDS;

    result.appendFormat("%" PRId64 "\t%" PRId64 "\t%" PRId64 "\n",
        mFrameRecords[index].desiredPresentTime,
        mFrameRecords[index].actualPresentTime,
        mFrameRecords[index].frameReadyTime);
}

result.append("\n");

} 输出每一帧三个时间截的源码

  1. FPS相差很大的两个ListView 最新与视频是专区主页的其中两个Tab页,这两个Tab页都有一个ListView。下图是分别对这两个Tab页的ListView进行自动化测试的结果(ListView是支持分页面从服务器拉取数据,测试结果是基于本地有缓存,不用从服务器拉取数据,图片除外。测试机器为三星S4 GT-I9502 4.4.2系统,下同)。

     从下图可看出,首页ListView的FPS都在30以上,50上下,而视频ListView的FPS都在30以下,有时还是0(1秒内一帧都没有刷新)。在自动化脚本运行时,也能观察到首页非常流畅,视频非常卡顿,还能看到有卡死不动的情况。
    

“最新”与“视频” ListView FPS对比

   首页与视频都是两个差不多的ListView,到底是什么导致这两个ListView的性能差异这么大?接下来,我们借助Android性能分析工具来定位视频页的性能瓶颈。
  1. FPS性能分析及优化 3.1 SysTrace初步分析 SysTrace是Android 4.1新增的性能数据采样及分析工具,它可帮助开发者收集Android关键子系统(如surfaceflinger、WindowManagerService等Framework部分关键模块、服务,View系统等)的运行信息,从而帮助开发者更直观的分析系统瓶颈,改进性能。

     简而言之,SysTrace可以帮助我们定量的分析在ListView滑动过程中,系统发生了什么,包括CPU运行了那些App、系统进程,系统进程及App在执行那部分功能的代码,执行了几次,花了多长时间。
    
     SysTrace已经集成在Android Device Monitor工具中,从Android Studio打开Android Device Monitor,选中要测试的设备,点击SysTrace按钮就可开始性能数据采集。
    
     开始采集后,运行脚本,对视频ListView进行滑动操作,就能收集到在滑动过程的性能数据。下图就是采集到的性能数据在某个时间点的截图。
    

视频ListView 滑动时,SysTrace结果

   从截图中可以明显看到在ListView滑动过程中,主线程除执行帧回调的performTraversals操作外,还有obtainView、setupListItem操作,且obtainView与SetupListItem操作较长(接近20ms),这必然会导致帧延迟(Schedule Delay),系统不能及时渲染而导致FPS较低。

    obtainView与setupListItem是什么操作?

    SysTrace收集的性能数据,都是系统或App在程序中通过Trace机制显式输出的。上图中的PerformTraversals、obtainView和setupListItem都是Trace输出的方法名称。我们可在Android源码中直接搜索方法名称,就可找到输出Trace信息的地方。

/**
 * Get a view and have it show the data associated with the specified
 * position. This is called when we have already discovered that the view is
 * not available for reuse in the recycle bin. The only choices left are
 * converting an old view or making a new one.
 *
 * @param position The position to display
 * @param isScrap Array of at least 1 boolean, the first entry will become true if the returned view was taken from the scrap heap, false if otherwise.
 *
 * @return A view displaying the data associated with the specified position
 */
View obtainView(int position, boolean[] isScrap) {
    Trace.traceBegin(Trace.TRACE_TAG_VIEW, "obtainView");

    isScrap[0] = false;

    // Check whether we have a transient state view. Attempt to re-bind the
    // data and discard the view if we fail.
    final View transientView = mRecycler.getTransientStateView(position);

    ......

} AbsListView::obtainView源码

/**
 * Add a view as a child and make sure it is measured (if necessary) and
 * positioned properly.
 *
 * @param child The view to add
 * @param position The position of this child
 * @param y The y position relative to which this view will be positioned
 * @param flowDown If true, align top edge to y. If false, align bottom
 *        edge to y.
 * @param childrenLeft Left edge where children should be positioned
 * @param selected Is this position selected?
 * @param recycled Has this view been pulled from the recycle bin? If so it does not need to be remeasured.
 */
private void setupChild(View child, int position, int y, boolean flowDown, int childrenLeft, boolean selected, boolean recycled) {
    Trace.traceBegin(Trace.TRACE_TAG_VIEW, "setupListItem");

    final boolean isSelected = selected && shouldShowSelector();
    final boolean updateChildSelected = isSelected != child.isSelected();
    ......

} ListView::setupChild源码

  通过搜索源码就知道,obtainView与setupListItem分别是AbsListView::obtainView与ListView::setupChild函数的Trace信息输出(有两个参数:第一个TAG类型,第二个就是输出Trace信息的方法名称),用于定量测试这两个函数的耗时。

  结合ListView原理及源码,ListView在Scroll和Fling的时候,列表会滚动,将滚出屏幕外的Item移除,获取新的或复用旧的Item填充到因为滚动而空出来的区域。AbsListView::obtainView就是获取新的或复用旧的Item,ListView::setupChild是将新获取的Item添加到父ListView并根据需要重新测量与布局。AbsListView::obtainView最终会调用到ListView对应Adapter的getView方法,ListView::setupChild会调用Item的onMeasure与onLayout方法。

3.2 深入分析obtainView 与 setupListItem 从SysTrace 分析中我们看到,obtainView与setupListItem执行了较多次数,且每次在主线程中占用了较长的执行时间。那么到底是什么触发了他们的多次调用?它们又做了什么事情导致执行时间了较长的时间?

   在分析之前,先介绍下视频ListView使用的Item的布局。最外层是一个水平方向的线性布局,有两个宽度一样的自定义子View:VideoShowCard。

 

    我们先看下obtainView。前面说过,obtainView最终会调用到ListView对应Adapter的getView方法。我们在getView的实现中,加入Trace输出信息,并再次按相同场景进行SysTrace。

    加入自定义Trace信息后发现,obtainView耗时主是消耗在Adapter的getView方法上,getView则主要消耗在两次的setImage上。setImage是异步加设置视频的封面图片。

if(imgUrl == null || !imgUrl.equals(videoItem.pic_url)) { imgUrl = videoItem.pic_url;

Trace.beginSection("VideoShowCard#setData#setImage@" + mIndex);

coverImageView.setAsyncImageUrl(imgUrl);

Trace.endSection();

} 代码中加入自定义Trace信息

加入自定义Trace信息后,分析obtainView

    同样的方式,分析setupListView。从图中可以看到setupListItem的主要时间花在了VideoItemView的onMeasure方法上。更细致观察发现,子布局VideoShowCard一共调用了6次,Item有两个VideoShowCard,每个VideoShowCard调用了三次,第一次的时间最长。

加入自定义Trace信息后,分析setupListItem

3.3 优化 VideoShowCard#onMeasure 与 VideoShowCard#setData#setImage 3.3.1 VideoShowCard#onMeasure优化 按正常理解,子View测量的次数至少一次,可能会有多次。现在是调用了三次,是否有优化的空间?

    查看Item的布局,一个父LinearLayout水平方向包含两个子VideoShowCard。Xml文件如下:(说明:MyLinearLayout是从LinearLayout继承的一个子类。这里是为了方便分析,在LinearLayout的onMeasure方法中加入Trace信息而引入的,没什么实质的作用。)

<com.tencent.gamemgc.generalgame.video.widget.MyLinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginLeft="@dimen/mgc_video_margin_edge_margin"
    android:layout_marginRight="@dimen/mgc_video_margin_edge_margin"
    android:orientation="horizontal" >

    <com.tencent.gamemgc.generalgame.video.widget.VideoShowCard
        android:id="@+id/layout_hot_video_left"
        android:layout_width="0dip"
        android:layout_height="match_parent"
        android:layout_marginRight="@dimen/mgc_video_margin_distance"
        android:layout_weight="1" >
    </com.tencent.gamemgc.generalgame.video.widget.VideoShowCard>

    <com.tencent.gamemgc.generalgame.video.widget.VideoShowCard
        android:id="@+id/layout_hot_video_right"
        android:layout_width="0dip"
        android:layout_height="match_parent"
        android:layout_marginLeft="@dimen/mgc_video_margin_distance"
        android:layout_weight="1" >
    </com.tencent.gamemgc.generalgame.video.widget.VideoShowCard>

</com.tencent.gamemgc.generalgame.video.widget.MyLinearLayout>

视频ListView的Item的xml布局文件

     VideoShowCard的onMeasure是它的父View调用的,打开LinearLayout的onMeasure源码分析,发现有三处调用子View的onMeasure方法的地方。这三处的调用都是有一定条件的(主是根据xml的属性),难道三处调用的条件都满足?

     先分析最后一处,这一处调用onMeasure的函数是forceUniformHeight。 

/** * Measures the children when the orientation of this LinearLayout is set * to {@link #HORIZONTAL}. * * @param widthMeasureSpec Horizontal space requirements as imposed by the parent. * @param heightMeasureSpec Vertical space requirements as imposed by the parent. * * @see #getOrientation() * @see #setOrientation(int) * @see #onMeasure(int, int) */ void measureHorizontal(int widthMeasureSpec, int heightMeasureSpec) {

......

// Check against our minimum height
maxHeight = Math.max(maxHeight, getSuggestedMinimumHeight());

setMeasuredDimension(widthSizeAndState | (childState&MEASURED_STATE_MASK),
    resolveSizeAndState(maxHeight, heightMeasureSpec,
            (childState<<MEASURED_HEIGHT_STATE_SHIFT)));

if (matchHeight) {
    forceUniformHeight(count, widthMeasureSpec);
}

} LinearLayout水平测量,最后一次测量子View的代码

    调用的前提是matchHeight为True。举个例子,一个水平线性布局有两个子View,这两个子View的height都是MATCH_PARENT,父线性布局的height是WRAP_CONTENT,那么父的水平线性布局的最终height应该是这两个子View中较高的那一个。由于两个子View的height都是MATCH_PARENT,线性布局在确定好自己的height后,又会把这个height设置给每一个子View,让它们重新布局。forceUniformHeight做的就是这个再统一设置子View高度的事情。

    再看下上面xml中的两个VideoShowCard,height都是MATCH_PARENT,父线性布局的height是WRAP_CONTENT。可不可以优化掉forceUniformHeight的这次布局呢?可以的。两个VideoShowCard都是相同的子布局,他们分别测量高度就是一样的,没有必要再统一设置一次。可以将两个VideoShowCard的height改为WRAP_CONTENT就好了。

    接着分析第一处的onMeaure调用,这一处的调用与weight(权重)及父线性布局的widthMode有关。

void measureHorizontal(int widthMeasureSpec, int heightMeasureSpec) {

 ......
        if (widthMode == MeasureSpec.EXACTLY && lp.width == 0 && lp.weight > 0) {
            // Optimization: don't bother measuring children who are going to use
            // leftover space. These views will get measured again down below if
            // there is any leftover space.
            if (isExactly) {
                mTotalLength += lp.leftMargin + lp.rightMargin;
            } else {
                final int totalLength = mTotalLength;
                mTotalLength = Math.max(totalLength, totalLength +
                        lp.leftMargin + lp.rightMargin);
            }

            // Baseline alignment requires to measure widgets to obtain the
            // baseline offset (in particular for TextViews). The following
            // defeats the optimization mentioned above. Allow the child to
            // use as much space as it wants because we can shrink things
            // later (and re-measure).
            if (baselineAligned) {
                final int freeSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
                child.measure(freeSpec, freeSpec);
            } else {
                skippedMeasure = true;
            }
        } else {
            int oldWidth = Integer.MIN_VALUE;
            .....
        }
......

} LinearLayout水平测量,第一次测量子View的代码

    widthMode是父线性布局的宽度mode,lp是当前子View的布局属性。根据xml中的属性设置,这两个条件都满足;再看下baselineAligned,是父LinearLayout的一个属性,默认值是True。baselineAligned是什么意思?

    先看下什么是BaseLine?BaseLine是与文本绘制相关的一个属性。在绘制文字时,要指定文字绘制的位置,这个位置就是由baseLine来指定的。下图给出了文本与BaseLine的关系。BaseLine在文本是绘制时的基点,不在文字的左上角,而是在在文本的中间位置。



    BaseLineAligned是指父线性布局下面有多个直接的子TextView,这些TextView的BaseLine默认对齐。还是举个例子,对比下启用BaseLineAligned和禁用的效果。



   启用、禁用父LineayLayout的BaseLineAligned属性效果对比

    如果把上面其中一个TextView放到一个子LinearLayout中,这个子LinearLayout和另一个TextView一起作兄弟子View。此时,不管启用还是禁用父LineayLayout的BaseLineAligned属性,则效果和禁用BaseLineAligned属性是一样的。可见BaseLineAligned是作用于直接的子TextView。

   了解了BaseLineAligned属性,两个VideoShowCard的父线性布局的BaseLineAligned属性是可以禁用的,这样又少了一次onMeasure的调用。

    经过上面两步优化,每个VideoShowCard的onMeasure调用由三次变成了一次。下面是优化的的xml,标红是的修改的点。

<com.tencent.gamemgc.generalgame.video.widget.MyLinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:baselineAligned="false"
    android:layout_marginLeft="@dimen/mgc_video_margin_edge_margin"
    android:layout_marginRight="@dimen/mgc_video_margin_edge_margin"
    android:orientation="horizontal" >

    <com.tencent.gamemgc.generalgame.video.widget.VideoShowCard
        android:id="@+id/layout_hot_video_left"
        android:layout_width="0dip"
        android:layout_height="wrap_content"
        android:layout_marginRight="@dimen/mgc_video_margin_distance"
        android:layout_weight="1" >
    </com.tencent.gamemgc.generalgame.video.widget.VideoShowCard>

    <com.tencent.gamemgc.generalgame.video.widget.VideoShowCard
        android:id="@+id/layout_hot_video_right"
        android:layout_width="0dip"
        android:layout_height="wrap_content"
        android:layout_marginLeft="@dimen/mgc_video_margin_distance"
        android:layout_weight="1" >
    </com.tencent.gamemgc.generalgame.video.widget.VideoShowCard>

优化后的视频ListView的Item的xml布局文件

    下图是优化xml后抓取的SysTrace。一个setupListItem下面,只有两次VideoShowCard#onMeasure调用,而不是之前的六次。

优化布局后的SysTrace

3.3.2 VideoShowCard#setData#setImage优化 在前面SysTrace中可看出obtainView主要时间消耗在两次的setImage上。setImage的功能是设置视频封面图片。setImage已经是异步实现了,但还是消耗了较长的时间。为了查清setImage的时间消耗在那,我们对setImage及子函数加入更详细的Trace信息,终于找到了“真凶”,如下图所示:

   obtainView的主要时间消耗在两个VideoShowCard的setData方法上,依次向下看,最后消耗时间最长的方法是getExternalCacheDir。从图上还可看出getExternalCacheDir虽然消耗了较长时间,但直正占用CPU时间执行的只有很短的时间,其他的时间都是挂起的,这说明getExternalCacheDir中有IO或等待操作。( 说明:在定位SetImage耗时时,Android SDK工具进行了升级,抓取的SysTrace略有不同,但更直观了。)

SysTrace确定setImage耗时点

    上面说到,getExternalCacheDir虽然消耗了较长时间,但真正占用CPU执行的时间却很短。怎么看出呢?两个地方有体现,如下图。第一个地方是:最下方代表getExternalCacheDir执行时长的紫色长条中包含一个浅色小长条,这个浅色小长条代表是在整个getExternalCacheDir调用过程中没有占有CPU的总时长;第二个地方是:代表obtainView执行时长的绿色上条上方有一个高度很小的长条,有三种颜色:深绿色、蓝色、空白(白色)。白色就代表在这个时间片断App程序被挂起了,没有占用CPU。另外,白色区域在时间上,恰好对应最下方的getExternalCacheDir方法执行时间,这说明App挂起是在调用getExternalCacheDir中发生的。

SysTrace确定setImage的耗时(标识App挂起)

   由于getExternalCacheDir获取的缓存目录是固定的,可以考虑获取一次后,缓存下来,下次直接使用。

  下面是优化了setImage中缓存目录后的SysTrace截图。

优化setImage后的SysTrace

    另外,可能有人会有疑问,专区首页的ListView也有用到图片异步加载,为什么FPS没有受到影响?原因是这样的:由于一些原因,整个App的图片异步加载库有两套。首页的ListView图片加载使用的是另一个库,所以没有这个问题;如果切换成和视频使用的一样的加载库,则首页ListView的FPS性能也一样受到较大的影响。

    需要补充说明的是,getExternalCacheDir在另外的手机测试,发现在其调用过程中,App也有挂起,但时间相对较短,对FPS的影响也小。这与手机的硬件配置、系统环境有一定关系。

3.4 其他优化

  1. ListView在Fling时,调整图片加载策略。

     ListView默认会有Fling动画,在Fling过程中,ListView通常是较快滚动,我们可以考虑不加载真正的图片,只使用默认图,在Fling结束后,再刷新一下。在Fling过程中,还可暂停图片异步加载线程,防止抢占主线程的时间片。
    
  2. 在ObtainView中,减少日志的输出。

     减少在ObtainView及子函数中的日志输出,防止CPU时间片断被系统logcat抢占,造成ObtainView执行时间过长。
    
  3. 消除过度绘制。

     打开Developer Options中的Show GPU Overdraw(显示GPU 过度绘制)的第二个选项,我们可以观察到下面的结果。
    
    
    
     从上图可以看出,视频ListView的Item过度绘制比较严重,都被重绘了3次以上(同一个像素点被先后绘制了4次,前3次都是无效的)。过度绘制也会导致帧渲染绘制时间变长,从而影响FPS。过度绘制不仅会影响性能,也导致不必要的电量消耗。  
    

    过度绘制一般是由叠加的背景造成的,我们可以适当调整布局,减少背景的叠加。

3.5 优化后的FPS 根据上面的分析,对主要的性能点进行优化后,再次测试FPS,有了非常大的提高。如下图如示。

视频ListView优化FPS对比

    回头看整个优化过程,改动的代码与布局并不多,加起来还不到20行,但性能却极大的提升。性能提升也并不难,关键是利用好工具,找准关键点,进行针对性的优化,才能达到事半功倍的效果。

    另外,本文用到的性能优化点不一定适应于其他的产品或业务,但分析思路与方法都是一样的。

参考资料: Android手Q合流白皮书(流畅度篇)

http://km.oa.com/group/20867/articles/show/154783

Systrace | Android Developers

http://developer.android.com/intl/zh-cn/tools/help/systrace.html

Android Source Code

https://android.googlesource.com/platform/frameworks/native/+/2c9b11f/services/surfaceflinger/FrameTracker.cpp