如何定义 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 性能的工具
来源: https://juejin.im/post/5a6fd7b86fb9a01ca47ac6e8