阅读 4857

深入理解 Activty 加载速度优化

如何定义activity加载速度?

个人理解,进入一个activity开始 一直到首屏页面被渲染出来也就是用户可见的状态。这个时间当然是越短越好。这个时间越长, activity的白屏时间就越长,这对于很多低端的手机用户来说是不可忍受的,用户体验极差。

如何得到activity绘制完ui界面的时间?

方案A:onCreate和onResume的时间差

答:先说结论,此测量activity首屏渲染时间的方法为错误。 下面从多个维度来证明这个方案的错误。

首先我们看onResume的函数注释:

注意看这个地方红线标注的单词是指的“交互”这个意思,也就是说,执行到onresume这个方法的时候是指的用户可以交互了, 并没有说可以看到东西了,也没说ui绘制完毕了。有人问 “可以交互“”难道不是在“可以看见“”之后么,你没看到怎么交互呢? 其实这个说法是错误的,如果你的代码写的很烂,手机又很差的话,其实activity在白屏的时候 页面还没渲染出来你就可以点返回 键进行返回了。这个返回的动作 就是可以交互的状态,但是白屏代表着界面还没绘制完毕。这一点你用MONKEY跑自动化测试的时候 可以明显看到。

方案B:命令行查看activity的启动时间

可以看到用命令行启动一个acitivty的时候 下面也是有时间输出的。这个时间一般都会认为相当接近我们想要的activity的启动时间了。我们注意看一下 同样的一条命令, 我们第一次启动这个activity远远比后面几次时间要长。原因就是第一次加载一个activity的时候 很多图片类的资源 文字资源 xml等等信息都是第一次load到内存里,所以比较耗时,后面因为加载过一次所以内存有一些缓存之类的东西所以后面几次时间会比较快(要知道io操作是相当耗时的,直接从内存加载当然快很多)。

我们在源码里搜索一下这段输出的日志关键字,最终定位到这段日志是在activityrecord这个类的这个方法里输出的。

大家可以看一下,这个totalTime 的定义,当前时间 减去 开始运行的时间。可以得出一个结论这个时间已经非常接近 我们想要的时间了。我们的界面绘制时间一定是小于这个总时间的。 有兴趣的同学可以跟踪一下这个mLaunStartTime 到底是在哪里被谁赋值。我这里篇幅所限就不过多论述。

可以给点提示activitystack的这个方法被调用的时候赋值的。

有没有更好的方案C?

方案B的时间虽然可以接近我们想要的结果,但是毕竟这是命令行才能使用,还得有root权限,非root权限的手机你是无法 执行这个命令的,这让我们想统计activity的启动时间带来了困难。一定要找到一个可以从代码层面输出界面绘制时间的方法。

都知道activity的管理者真正是activitythread,所以我们直接找这个类的源码看看。这个方法过长了,我们先放主要的片段

首先我们看第一张图,这里明显的调用了,resume这个方法的回调,但是下面第二张图可以看到里面有个decorView 并且这个decorView 正在被vm add进去,都知道decorView的子view 有个xml布局里面有个framelayout是我们acitivity的rootview,就是那个id为content的layout。可以看出来 这里onResume方法调用就在这个addview 前面了,所以再次证明方案a是多么不靠谱,你acitivity的界面都没add进去呢 怎么可能绘制结束?

这里可能有些绕,但是只要记住activity的层级关系即可:

一个Activity包含了一个Window,这个Window其实是一个PhoneWindow,在PhoneWindow中包含了DecorView,变量名称为mDecor,mDecor有一个子View,这个子View的布局方式根据设定的主题来确定,在这个子View的xml布局中包含了一个FrameLayout元素,这个FrameLayout元素的id为content,这个content对应于PhoneWindow中的mContentParent变量,用户自定义的布局作为mContentParent的子View存在,一般情况下mContentParnet只有一个子View,如果在Activity调用addView方式实际上是给PhoneWindow中的mContentParent添加子View,由于mContentParent是一个FrameLayout,因此新的子view会覆盖通过setContentView添加的子view。

继续跟:

一直跟,跟到这里:

这里我们new 出了ViewRootImpl对象, 我们知道这个对象就是android view的根对象了,负责view绘制的measure, layout, draw的巨长的方法 performTraversals就是这个类的,我们继续看setView方法 这里面最重要的就是调用了requestLayout 这个方法

  @Override
    public void requestLayout() {
        if (!mHandlingLayoutInLayoutRequest) {
            checkThread();
            mLayoutRequested = true;
            scheduleTraversals();
        }
    }
    
    //这个方法其实不难理解,看名字自己翻译下就知道就是遍历做一些事情的意思(至于是什么事当然是ui绘制啊)
    void scheduleTraversals() {
        if (!mTraversalScheduled) {
            mTraversalScheduled = true;
            mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
            //Choreographer 负责帧率刷新的一个类,以后会讲到他。暂时理解成类似于往ui线程post了一个消息就可以了
            mChoreographer.postCallback(
                    Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
            if (!mUnbufferedInputDispatch) {
                scheduleConsumeBatchedInput();
            }
            notifyRendererOfFramePending();
            pokeDrawLockIfNeeded();
        }
    }
    
    //mTraversalRunnable 就是这个类的对象
    final class TraversalRunnable implements Runnable {
        @Override
        public void run() {
            doTraversal();
        }
    }
    final TraversalRunnable mTraversalRunnable = new TraversalRunnable();
    
    
    void doTraversal() {
        if (mTraversalScheduled) {
            mTraversalScheduled = false;
            mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);

            if (mProfile) {
                Debug.startMethodTracing("ViewAncestor");
            }
            //这个方法应该很敏感,很有名的一个方法 就不分析他了 太长了,超出篇幅。
            performTraversals();

            if (mProfile) {
                Debug.stopMethodTracing();
                mProfile = false;
            }
        }
    }
复制代码

分析到这里,应该可以稍微理一理activity绘制的一个大概流程:

1.activitythread 调用handleresumeactivity方法 也就是 先回调onresume方法 2.scheduleTraversals post了一个TraversalRunnable消息。 3.post的这个消息做了一件事 调用了绘制ui的核心方法performTraversals。

这个流程也再次验证了方案a 利用oncreate和onresume时间差的不靠谱

方案C:IdleHandler

方案C 是一个接近靠谱的方法。在阐述这个方法之前,我们先用一张图回归一下Handler Looper和MessageQueue这个东西。

简单来说一下这三者之间的关系: Handler通过sendMessage将消息投递给MessageQueue,Looper通过消息循环(loop)不断的从MessageQueue中取出消息,然后消息被Handler的dispatchMessage分发到handleMessage方法消费掉。

然后我们看一个特殊的源码,来自于MessageQueue:

注意看他的注释:

其实意思就是说,如果我们looper里的消息都处理完了,那么就会回调这个接口,如果这个方法返回false,那么回调这一次以后就会把这个idleHandler给干掉,如果返回true,那么消息处理完毕就继续调用这个iderHandler接口的queueidle方法。

so:我们的正确方案C 就呼之欲出了:

t1 就是oncreate方法的时间戳。 第一个标注红线的 显然是被证明过错误的做法。 而第二个标注红线的 显然是正确的做法。 前面已经分析过,activity的绘制正是从往ui线程的handler里post的 一个消息开始,那么这个消息对应的动作全部处理结束以后, 显然就回回调我们这个idleHandler的了。所以这个方法是目前为止最通用最准确 获取activity启动以后到显示东西到屏幕这一段时间 最准确的方法。

知道activity启动时间了以后能做什么?

简单来说,在大部分低端手机中,我们总是希望用户进入一个新页面的时候能尽快看到这个页面想要展示的内容,尤其在弱网环境 或者大量数据需要从网络中获取时,我们总是希望界面能先展示一些固定的结构,甚至基本要素。然后等对应的接口回来以后再进去 填充数据,否则页面白白的区域显示时间过长,体验不佳(这点头条新浪微博微信等做的尤其出色)

如何加快activity的启动时间?

cpu的时间片总是固定的,硬件所限,为了让ui线程尽快的处理完毕,我们总是希望这一段时间内尽可能的只有ui线程在跑, 这样ui线程获取的时间片更多,执行速度起来就会很快,如果你一开始就在oncreate方法里做了太多的诸如网络操作, io操作,数据库操作,那必然的是ui线程获取cpu时间变少,速度变慢。

确定我们的延迟加载方案

我们来看这样一段程序:

 TextView textView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        textView = (TextView) findViewById(R.id.tv);
        Log.v("wuyue", "textView height==" + textView.getWidth());
    }

    @Override
    protected void onResume() {
        super.onResume();
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            Looper.myQueue().addIdleHandler(new MessageQueue.IdleHandler() {
                @Override
                public boolean queueIdle() {
                    Log.v("wuyue", "textView height2==" + textView.getWidth());
                    return false;
                }
            });
        }
    }
复制代码

很显然,第一种在oncreate方法里获取tv的高度肯定获取不到因为这会还没绘制结束呢。 第二种就可以拿到了,原因前面已经说过了。不多讲。

日志也反应了我们的正确性。

那么有没有更好的方法来证明这个是正确的呢?

可以用android studio的 method trace来看方法的执行轨迹,ddms的 method profiling也可以。这2个工具在这里不多介绍了。 是查卡顿的很重要的方法,各位自行百度谷歌使用方法即可。

除了启动优化以外,我们还可以做些什么?

前面讲述的是activity的启动优化,实际上,我们更希望实时的知道我们app运行的具体情况,比如滑动的时候到底有没有卡顿? 如果有卡顿发生,怎么知道大概在哪里出现了问题以便我们迅速定位到问题代码?

adb shell dumpsys gfxinfo

这个命令大家都很熟悉,可获取最新128帧的绘制信息,详细包括每一帧绘制的Draw,Process,Execute三个过程的耗时,如果这三个时间总和超过16.6ms即认为是发生了卡顿。 但是我们不可能每次到一个页面都去手动执行以下这个命令,太麻烦了,而且 不同的手机还要多次打这个命令,线上实际生产版本也没办法让用户来打这个命令获取结果,所以实际上这个方法并不使用。 还是需要在代码层面下功夫

Looper代码揭秘

ui线程绑定的looper的loop方法 无限循环跑这段代码,执行dispatch方法,注意这个方法的前后都有logging的输出。 那么这2个logging输出的时间差 是不是就可以认为这是我们执行ui线程的时间吗?这个时间长不就代表了ui线程有卡顿现象么?

同时我们到 这个me.mLogging还可以通过public的set方法来设置。

确定思路设计抓取卡顿信息的方案。

通过setMessageLogging方法来设置我们自定义的printer。

自定义的printer 要重写 println 方法,判断如果是dispatch方法前后的日志格式输出,那么就要计算时间戳。

超过这个时间戳就认为卡顿了,输出线程上下文堆栈信息 看看是哪里,哪个方法出现了卡顿。

重要代码

  • 自定义printer
package com.suning.mobile.ebuy;

import android.os.Looper;
import android.util.Printer;


public class CustomPrinterForGetBlockInfo {
    public static void start() {
        Looper.getMainLooper().setMessageLogging(new Printer() {
            //日志输出有很多种格式,我们这里只捕获ui线程中dispatch上下文的日志信息
            //所以这里定义了2个key值,注意不同的手机这2个key值可能不一样,有需要的话这里要做机型适配,
            //否则部分手机这里可能抓取不到日志信息
            private static final String START = ">>>>> Dispatching";
            private static final String END = "<<<<< Finished";
            @Override
            public void println(String x) {
                //这里的思路就是如果发现在打印dispatch方法的 start信息,
                //那么我们就在 “时间戳” 之后 post一个runnable
                if (x.startsWith(START)) {
                    LogMonitor.getInstance().startMonitor();
                }
                //因为我们start 不是立即start runnable 而是在“时间戳” 之后 那么如果在这个时间戳之内
                //dispacth方法执行完毕以后的END到来,那么就会remove掉这个runnable
                //所以 这里就知道 如果dispatch方法执行时间在时间戳之内 那么我们就认为这个ui没卡顿,不输出任何卡顿信息
                //否则就输出卡顿信息 这里卡顿信息主要用StackTraceElement 来输出
                if (x.startsWith(END)) {
                    LogMonitor.getInstance().removeMonitor();
                }
            }
        });
    }
}

复制代码
  • 看看我们的LogMoniter
package com.suning.mobile.ebuy;

import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.util.Log;


public class LogMonitor {
    private static LogMonitor sInstance = new LogMonitor();
    //HandlerThread 这个其实就是一个thread,只不过相对于普通的thread 他对外暴露了一个looper而已。方便
    //我们和handler配合使用
    private HandlerThread mLogThread = new HandlerThread("BLOCKINFO");
    private Handler mIoHandler;
    //这个时间戳的值,通常设置成不超过1000,你可以调低这个数值来优化你的代码。数值越低 暴露的信息就越多
    private static final long TIME_BLOCK = 1000L;

    private LogMonitor() {
        mLogThread.start();
        mIoHandler = new Handler(mLogThread.getLooper());
    }

    private static Runnable mLogRunnable = new Runnable() {
        @Override
        public void run() {
            StringBuilder sb = new StringBuilder();
            //把ui线程的block的堆栈信息都打印出来 方便我们定位问题
            StackTraceElement[] stackTrace = Looper.getMainLooper().getThread().getStackTrace();
            for (StackTraceElement s : stackTrace) {
                sb.append(s.toString() + "\n");
            }
            Log.e("BLOCK", sb.toString());
        }
    };

    public static LogMonitor getInstance() {
        return sInstance;
    }

    public void startMonitor() {
        //在time之后 再启动这个runnable 如果在这个time之前调用了removeMonitor 方法,那这个runnable肯定就无法执行了
        mIoHandler.postDelayed(mLogRunnable, TIME_BLOCK);
    }

    public void removeMonitor() {
        mIoHandler.removeCallbacks(mLogRunnable);
    }
}

复制代码
  • 最后再application中的oncreate方法启动我们的统计函数

基本上就可以了。可以满足我们的卡顿统计需求。

额外奉送,统计帧率的方法。

前面我们分析actiivty页面绘制的时候提到过Choreographer这个类。其实这个类网上资料超多,大家可以自行搜索一下, 这个类的 Choreographer.getInstance().postFrameCallback(this); 是可以统计到帧率的。实时的,很方便。 通过这个我们也可以检测到卡顿现象,和上面的方法其实效果差不多,唯一要注意的,大多数blog的isMonitor 其实都不可用,原因是

注意看这个函数是个hide函数,压根没办法给我们app使用到的。编译是不可能编译通过的。 这里给出正确的写法,其余代码我就不多复述了其实都差不多。搜搜都可以搜到。

public boolean isMonitor() {
        //网上流传的方法多数是这个,但是这个是错的,因为hasCallbacks 是一个hide函数 你压根调用不了的,只能反射调用
        //return mIoHandler.hasCallbacks(mLogRunnable);
        try {
            //通过详细地类名获取到指定的类
            Class<?> handlerClass = Class.forName("android.os.Handler");
            //通过方法名,传入参数获取指定方法
            java.lang.reflect.Method method = handlerClass.getMethod("hasCallbacks", Runnable.class);
            Boolean ret = (Boolean) method.invoke(mIoHandler, mLogRunnable);
            return ret;
        } catch (Exception e) {
        }
        return false;
    }
复制代码

总结

说了这么多,其实本篇文章核心思想就2点,统计activity启动时间,尽可能缩小页面白屏的时间。 统计卡顿的上下文环境,方便我们定位代码问题便于优化。大体的分析问题和解决问题的思路都在这里了。 有兴趣的同学可以自行拓展思路,写出一个个库方便使用。但是核心思想应该就是上述内容。 当然不想重复造轮子的同学也可以使用开源库。在这里我推荐2个个人认为比较好的:

比较小巧精致的库功能不多

这个库就大而全了。全面检测android app性能的工具

关注下面的标签,发现更多相似文章
评论

查看更多 >