vlambda博客
学习文章列表

吹爆系列:深入实战Android卡顿优化


本文作者


链接:

https://juejin.im/post/5e49fc29e51d4526d326b056

本文由作者授权发布。


还记得卡顿优化的上篇吗?




主要介绍:


  • 卡顿优化分析方法与工具

  • 自动化卡顿检测方案及优化


本篇,为深入探索Android卡顿优化的下篇。这篇文章包含的主要内容如下所示:


  • ANR分析与实战

  • 卡顿单点问题检测方案

  • 优雅监控耗时盲区

  • 卡顿优化技巧总结

  • 常见卡顿问题解决方案总结

  • 卡顿优化的常见问题


卡顿时间过长,一定会造成应用发生ANR。


下面,我们就来从应用的ANR分析与实战来开始今天的探索之旅。


历史系列:






1
ANR分析与实战


1、ANR介绍与实战


首先,我们再来回顾一下ANR的几种常见的类型,如下所示:


  • KeyDispatchTimeout:按键事件在5s的时间内没有处理完成。

  • BroadcastTimeout:广播接收器在前台10s,后台60s的时间内没有响应完成。

  • ServiceTimeout:服务在前台20s,后台200s的时间内没有处理完成。


具体的时间定义我们可以在AMS(ActivityManagerService)中找到:


// How long we allow a receiver to run before giving up on it.
static final int BROADCAST_FG_TIMEOUT = 10*1000;
static final int BROADCAST_BG_TIMEOUT = 60*1000;

// How long we wait until we timeout on key dispatching.
static final int KEY_DISPATCHING_TIMEOUT = 5*1000;


接下来,我们来看一下ANR的执行流程。


ANR执行流程


  1. 首先,我们的应用发生了ANR。

  2. 然后,我们的进程就会接收到异常终止信息,并开始写入进程ANR信息,也就是当时应用的场景信息,它包含了应用所有的堆栈信息、CPU、IO等使用的情况。

  3. 最后,会弹出一个ANR提示框,看你是要选择继续等待还是退出应用,需要注意这个ANR提示框不一定会弹出,根据不同ROM,它的表现情况也不同。因为有些手机厂商它会默认去掉这个提示框,以避免带来不好的用户体验。


分析完ANR的执行流程之后,我们来分析下怎样去解决ANR,究竟哪里可以作为我们的一个突破点。


在上面我们说过,当应用发生ANR时,会写入当时发生ANR的场景信息到文件中,那么,我们可不可以通过这个文件来判断是否发生了ANR呢?


关于根据ANR log进行ANR问题的排查与解决的方式笔者已经在的第三节ANR优化中讲解过了,这里就不多赘述了。


线上ANR监控方式


在深入探索Android稳定性优化的第三节ANR优化中我说到了使用FileObserver可以监听 /data/anr/traces.txt的变化,利用它可以实现线上ANR的监控,但是它有一个致命的缺点,就是高版本ROM需要root权限,解决方案是只能通过海外Google Play服务、国内Hardcoder的方式去规避。但是,这在国内显然是不现实的,那么,有没有更好的实现方式呢?


那就是ANR-WatchDog,下面我就来详细地介绍一下它。


https://github.com/SalomonBrys/ANR-WatchDog


ANR-WatchDog是一种非侵入式的ANR监控组件,可以用于线上ANR的监控,接下来,我们就使用ANR-WatchDog来监控ANR。


首先,在我们项目的app/build.gradle中添加如下依赖:


 
implementation 'com.github.anrwatchdog:anrwatchdog:1.4.0'


然后,在应用的Application的onCreate方法中添加如下代码启动ANR-WatchDog:


new ANRWatchDog().start();


可以看到,它的初始化方式非常地简单,同时,它内部的实现也非常简单,整个库只有两个类,一个是ANRWatchDog,另一个是ANRError。


接下来我们来看一下ANRWatchDog的实现方式。


/**
* A watchdog timer thread that detects when the UI thread has frozen.
*/

public class ANRWatchDog extends Thread {

可以看到,ANRWatchDog实际上是继承了Thread类,也就是它是一个线程,对于线程来说,最重要的就是其run方法,如下所示:


private static final int DEFAULT_ANR_TIMEOUT = 5000;

private volatile long _tick = 0;
private volatile boolean _reported = false;

private final Runnable _ticker = new Runnable() {
    @Override public void run() {
        _tick = 0;
        _reported = false;
    }
};

@Override
public void run() {
    // 1、首先,将线程命名为|ANR-WatchDog|。
    setName("|ANR-WatchDog|");

    // 2、接着,声明了一个默认的超时间隔时间,默认的值为5000ms。
    long interval = _timeoutInterval;
    // 3、然后,在while循环中通过_uiHandler去post一个_ticker Runnable。
    while (!isInterrupted()) {
        // 3.1 这里的_tick默认是0,所以needPost即为true。
        boolean needPost = _tick == 0;
        // 这里的_tick加上了默认的5000ms
        _tick += interval;
        if (needPost) {
            _uiHandler.post(_ticker);
        }

        // 接下来,线程会sleep一段时间,默认值为5000ms。
        try {
            Thread.sleep(interval);
        } catch (InterruptedException e) {
            _interruptionListener.onInterrupted(e);
            return ;
        }

        // 4、如果主线程没有处理Runnable,即_tick的值没有被赋值为0,则说明发生了ANR,第二个_reported标志位是为了避免重复报道已经处理过的ANR。
        if (_tick != 0 && !_reported) {
            //noinspection ConstantConditions
            if (!_ignoreDebugger && (Debug.isDebuggerConnected() || Debug.waitingForDebugger())) {
                Log.w("ANRWatchdog""An ANR was detected but ignored because the debugger is connected (you can prevent this with setIgnoreDebugger(true))");
                _reported = true;
                continue ;
            }

            interval = _anrInterceptor.intercept(_tick);
            if (interval > 0) {
                continue;
            }

            final ANRError error;
            if (_namePrefix != null) {
                error = ANRError.New(_tick, _namePrefix, _logThreadsWithoutStackTrace);
            } else {
                // 5、如果没有主动给ANR_Watchdog设置线程名,则会默认会使用ANRError的NewMainOnly方法去处理ANR。
                error = ANRError.NewMainOnly(_tick);
            }

           // 6、最后会通过ANRListener调用它的onAppNotResponding方法,其默认的处理会直接抛出当前的ANRError,导致程序崩溃。 _anrListener.onAppNotResponding(error);
            interval = _timeoutInterval;
            _reported = true;
        }
    }
}

首先,在注释1处,我们将线程命名为了|ANR-WatchDog|。


接着,在注释2处,声明了一个默认的超时间隔时间,默认的值为5000ms。然后,注释3处,在while循环中通过_uiHandler去post一个_ticker Runnable。


注意这里的_tick默认是0,所以needPost即为true。接下来,线程会sleep一段时间,默认值为5000ms。


在注释4处,如果主线程没有处理Runnable,即_tick的值没有被赋值为0,则说明发生了ANR,第二个_reported标志位是为了避免重复报道已经处理过的ANR。如果发生了ANR,就会调用接下来的代码,开始会处理debug的情况,然后,我们看到注释5处,如果没有主动给ANR_Watchdog设置线程名,则会默认会使用ANRError的NewMainOnly方法去处理ANR。


ANRError的NewMainOnly方法如下所示:


/**
 * The minimum duration, in ms, for which the main thread has been blocked. May be more.
 */

public final long duration;

static ANRError NewMainOnly(long duration) {
    // 1、获取主线程的堆栈信息
    final Thread mainThread = Looper.getMainLooper().getThread();
    final StackTraceElement[] mainStackTrace = mainThread.getStackTrace();

    // 2、返回一个包含主线程名、主线程堆栈信息以及发生ANR的最小时间值的实例。
    return new ANRError(new $(getThreadTitle(mainThread), mainStackTrace).new _Thread(null), duration);
}

可以看到,在注释1处,首先获了主线程的堆栈信息,然后返回了一个包含主线程名、主线程堆栈信息以及发生ANR的最小时间值的实例。(我们可以改造其源码在此时添加更多的卡顿现场信息,如CPU 使用率和调度信息、内存相关信息、I/O 和网络相关的信息等等)


接下来,我们再回到ANRWatchDog的run方法中的注释6处,最后这里会通过ANRListener调用它的onAppNotResponding方法,其默认的处理会直接抛出当前的ANRError,导致程序崩溃。对应的代码如下所示:


 
private static final ANRListener DEFAULT_ANR_LISTENER = new ANRListener() {
    @Override public void onAppNotResponding(ANRError error) {
        throw error;
    }
};


了解了ANRWatchDog的实现原理之后,我们试一试它的效果如何。首先,我们给MainActivity中的悬浮按钮添加主线程休眠10s的代码,如下所示:


@OnClick({R.id.main_floating_action_btn})
void onClick(View view) {
    switch (view.getId()) {
        case R.id.main_floating_action_btn:
            try {
                // 对应项目中的第170行
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            jumpToTheTop();
            break;
        default:
            break;
    }
}

然后,我们重新安装运行项目,点击悬浮按钮,发现在10s内都不能触发屏幕点击和触摸事件,并且在10s之后,应用直接发生了崩溃。


接着,我们在Logcat过滤栏中输入fatal关键字,找出致命的错误,log如下所示:


2020-01-18 09:55:53.459 29924-29969/? E/AndroidRuntime: FATAL EXCEPTION: |ANR-WatchDog|
Process: json.chao.com.wanandroid, PID: 29924
com.github.anrwatchdog.ANRError: Application Not Responding for at least 5000 ms.
Caused by: com.github.anrwatchdog.ANRError$$$_Thread: main (state = TIMED_WAITING)
    at java.lang.Thread.sleep(Native Method)
    at java.lang.Thread.sleep(Thread.java:373)
    at java.lang.Thread.sleep(Thread.java:314)
    // 1
    at json.chao.com.wanandroid.ui.main.activity.MainActivity.onClick(MainActivity.java:170)
    at json.chao.com.wanandroid.ui.main.activity.MainActivity_ViewBinding$1.doClick(MainActivity_ViewBinding.java:45)
    at butterknife.internal.DebouncingOnClickListener.onClick(DebouncingOnClickListener.java:22)
    at android.view.View.performClick(View.java:6311)
    at android.view.View$PerformClick.run(View.java:24833)
    at android.os.Handler.handleCallback(Handler.java:794)
    at android.os.Handler.dispatchMessage(Handler.java:99)
    at android.os.Looper.loop(Looper.java:173)
    at android.app.ActivityThread.main(ActivityThread.java:6653)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:821)
 Caused by: com.github.anrwatchdog.ANRError$$$_Thread: AndroidFileLogger./storage/emulated/0/Android/data/json.chao.com.wanandroid/log/ (state = RUNNABLE)

可以看到,发生崩溃的线程正是|ANR-WatchDog|。我们重点关注注释1,这里发生崩溃的位置是在MainActivity的onClick方法,对应的行数为170行,从前可知,这里正是线程休眠的地方。

接下来,我们来分析一下ANR-WatchDog的实现原理。


2、ANR-WatchDog原理


  1. 首先,我们调用了ANR-WatchDog的start方法,然后这个线程就会开始工作。

  2. 然后,我们通过主线程的Handler post一个消息将主线程的某个值进行一个加值的操作。

  3. post完成之后呢,我们这个线程就sleep一段时间。

  4. 在sleep之后呢,它就会来检测我们这个值有没有被修改,如果这个值被修改了,那就说明我们在主线程中执行了这个message,即表明主线程没有发生卡顿,否则,则说明主线程发生了卡顿。

  5. 最后,ANR-WatchDog就会判断发生了ANR,抛出一个异常给我们。


ANR-WatchDog的工作流程简图如下所示:



上面我们最后说到,如果检测到主线程发生了卡顿,则会抛出一个ANR异常,这将会导致应用崩溃,显然不能将这种方案带到线上,那么,有什么方式能够自定义最后发生卡顿时的处理过程吗?


其实ANR-WatchDog自身就实现了一个我们自身也可以去实现的ANRListener,通过它,我们就可以对ANR事件去做一个自定义的处理,比如将堆栈信息压缩后保存到本地,并在适当的时间上传到APM后台。


3、小结


ANR-WatchDog是一种非侵入式的ANR监控方案,它能够弥补我们在高版本中没有权限去读取traces.txt文件的问题,需要注意的是,在线上这两种方案我们需要结合使用。


在之前,我们还讲到了AndroidPerformanceMonitor,那么它和ANR-WatchDog有什么区别呢?


对于AndroidPerformanceMonitor来说,它是监控我们主线程中每一个message的执行,它会在主线程的每一个message的前后打印一个时间戳,然后,我们就可以据此计算每一个message的具体执行时间,但是我们需要注意的是一个message的执行时间通常是非常短暂的,也就是很难达到ANR这个级别。


然后我们来看看ANR-WatchDog的原理,它是不管应用是如何执行的,它只会看最终的结果,即sleep 5s之后,我就看主线程的这个值有没有被更改。如果说被改过,就说明没有发生ANR,否则,就表明发生了ANR。


根据这两个库的原理,我们便可以判断出它们分别的适用场景,对于AndroidPerformanceMonitor来说,它适合监控卡顿,因为每一个message它执行的时间并不长。对于ANR-WatchDog来说,它更加适合于ANR监控的补充。


此外,虽然ANR-WatchDog解决了在高版本系统没有权限读取 /data/anr/traces.txt 文件的问题,但是在Java层去获取所有线程堆栈以及各种信息非常耗时,对于卡顿场景不一定合适,它可能会进一步加剧用户的卡顿。


如果是对性能要求比较高的应用,可以通过Hook Native层的方式去获得所有线程的堆栈信息,具体为如下两个步骤:


  • 通过libart.so、dlsym调用ThreadList::ForEach方法,拿到所有的 Native 线程对象。

  • 遍历线程对象列表,调用Thread::DumpState方法。


通过这种方式就大致模拟了系统打印 ANR 日志的流程,但是由于采用的是Hook方式,所以可能会产生一些异常甚至崩溃的情况,这个时候就需要通过 fork 子进程方式去避免这种问题,而且使用 子进程去获取堆栈信息的方式可以做到完全不卡住我们主进程。


但是需要注意的是,fork 进程会导致进程号发生改变,此时需要通过指定 /proc/[父进程 id]的方式重新获取应用主进程的堆栈信息。


通过 Native Hook 的 方式我们实现了一套“无损”获取所有 Java 线程堆栈与详细信息的卡顿监控体系。为了降低上报数据量,建议只有主线程的 Java 线程状态是 WAITING、TIME_WAITING 或者 BLOCKED 的时候,才去使用这套方案。


2
卡顿单点问题检测方案


除了自动化的卡顿与ANR监控之外,我们还需要进行卡顿单点问题的检测,因为上述两种检测方案的并不能满足所有场景的检测要求,这里我举一个小栗子:


比如我有很多的message要执行,但是每一个message的执行时间都不到卡顿的阈值,那自动化卡顿检测方案也就不能够检测出卡顿,但是对用户来说,用户就觉得你的App就是有些卡顿。


除此之外,为了建立体系化的监控解决方案,我们就必须在上线之前将问题尽可能地暴露出来。


1、IPC单点问题检测方案


常见的单点问题有主线程IPC、DB操作等等,这里我就拿主线程IPC来说,因为IPC其实是一个很耗时的操作,但是在实际开发过程中,我们可能对IPC操作没有足够的重视,所以,我们经常在主程序中去做频繁IPC操作,所以说,这种耗时它可能并不到你设定卡顿的一个阈值,接下来,我们看一下,对于IPC问题,我们应该去监测哪些指标。


  • 1、IPC调用类型:如PackageManager、TelephoneManager的调用。

  • 2、每一个的调用次数与耗时。

  • 3、IPC的调用堆栈(表明哪行代码调用的)、发生线程。


常规方案


常规方案就是在IPC的前后加上埋点。但是,这种方式不够优雅,而且,在平常开发过程中我们经常忘记某个埋点的真正用处,同时它的维护成本也非常大。


接下来,我们讲解一下IPC问题监测的技巧。


IPC问题监测技巧


在线下,我们可以通过adb命令的方式来进行监测,如下所示:


 
// 1、首先,对IPC操作开始进行监控
adb shell am trace-ipc start
// 2、然后,结束IPC操作的监控,同时,将监控到的信息存放到指定的文件当中
adb shell am trace-ipc stop -dump-file /data/local/tmp/ipc-trace.txt
// 3、最后,将监控到的ipc-trace导出到电脑查看
adb pull /data/local/tmp/ipc-trace.txt


然后,这里我们介绍一种优雅的实现方案,看过的同学可能知道这里的实现方案无非就是ARTHook或AspectJ这两种方案,这里我们需要去监控IPC操作,那么,我们应该选用哪种方式会更好一些呢?(利用epic实现ARTHook)

https://github.com/tiann/epic


要回答这个问题,就需要我们对ARTHook和AspectJ这两者的思想有足够的认识,对应ARTHook来说,其实我们可以用它来去Hook系统的一些方法,因为对于系统代码来说,我们无法对它进行更改,但是我们可以Hook住它的一个方法,在它的方法体里面去加上自己的一些代码。


但是,对于AspectJ来说,它只能针对于那些非系统方法,也就是我们App自己的源码,或者是我们所引用到的一些jar、aar包。因为AspectJ实际上是往我们的具体方法里面插入相对应的代码,所以说,他不能够针对于我们的系统方法去做操作,在这里,我们就需要采用ARTHook的方式去进行IPC操作的监控。


在使用ARTHook去监控IPC操作之前,我们首先思考一下,哪些操作是IPC操作呢?


比如说,我们通过PackageManager去拿到我们应用的一些信息,或者去拿到设备的DeviceId这样的信息以及AMS相关的信息等等,这些其实都涉及到了IPC的操作,而这些操作都会通过固定的方式进行IPC,并最终会调用到android.os.BinderProxy,接下来,我们来看看它的transact方法,如下所示:


public boolean transact(int code, Parcel data, Parcel reply, int flags) throws RemoteException {

这里我们仅仅关注transact方法的参数即可,第一个参数是一个行动编码,为int类型,它是在FIRST_CALL_TRANSACTION与LAST_CALL_TRANSACTION之间的某个值,第二、三个参数都是Parcel类型的参数,用于获取和回复相应的数据,第四个参数为一个int类型的标记值,为0表示一个正常的IPC调用,否则表明是一个单向的IPC调用。


然后,我们在项目中的Application的onCreate方法中使用ARTHook对android.os.BinderProxy类的transact方法进行Hook,代码如下所示:


try {
    DexposedBridge.findAndHookMethod(Class.forName("android.os.BinderProxy"), "transact",
            int.class, Parcel.class, Parcel.classint.classnew XC_MethodHook() {
                @Override
                protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
                    LogHelper.i( "BinderProxy beforeHookedMethod " + param.thisObject.getClass().getSimpleName()
                            + "\n" + Log.getStackTraceString(new Throwable()));
                    super.beforeHookedMethod(param);
                }
            });
catch (ClassNotFoundException e) {
    e.printStackTrace();
}

重新安装应用,即可看到如下的Log信息:


|:-:|:-:|: │ WanAndroidApp$1.beforeHookedMethod  (WanAndroidApp.java:160)
|:-:|:-:|: │    LogHelper.i  (LogHelper.java:37)
|:-:|:-:|: ├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
|:-:|:-:|: │ [WanAndroidApp.java | 160 | beforeHookedMethod] BinderProxy beforeHookedMethod BinderProxy
|:-:|:-:|: │ java.lang.Throwable
|:-:|:-:|: │    at json.chao.com.wanandroid.app.WanAndroidApp$1.beforeHookedMethod(WanAndroidApp.java:160)
|:-:|:-:|: │    at com.taobao.android.dexposed.DexposedBridge.handleHookedArtMethod(DexposedBridge.java:237)
|:-:|:-:|: │    at me.weishu.epic.art.entry.Entry64.onHookBoolean(Entry64.java:72)
|:-:|:-:|: │    at me.weishu.epic.art.entry.Entry64.referenceBridge(Entry64.java:237)
|:-:|:-:|: │    at me.weishu.epic.art.entry.Entry64.booleanBridge(Entry64.java:86)
|:-:|:-:|: │    at android.os.ServiceManagerProxy.getService(ServiceManagerNative.java:123)
|:-:|:-:|: │    at android.os.ServiceManager.getService(ServiceManager.java:56)
|:-:|:-:|: │    at android.os.ServiceManager.getServiceOrThrow(ServiceManager.java:71)
|:-:|:-:|: │    at android.app.UiModeManager.<init>(UiModeManager.java:127)
|:-:|:-:|: │    at android.app.SystemServiceRegistry$42.createService(SystemServiceRegistry.java:511)
|:-:|:-:|: │    at android.app.SystemServiceRegistry$42.createService(SystemServiceRegistry.java:509)
|:-:|:-:|: │    at android.app.SystemServiceRegistry$CachedServiceFetcher.getService(SystemServiceRegistry.java:970)
|:-:|:-:|: │    at android.app.SystemServiceRegistry.getSystemService(SystemServiceRegistry.java:920)
|:-:|:-:|: │    at android.app.ContextImpl.getSystemService(ContextImpl.java:1677)
|:-:|:-:|: │    at android.view.ContextThemeWrapper.getSystemService(ContextThemeWrapper.java:171)
|:-:|:-:|: │    at android.app.Activity.getSystemService(Activity.java:6003)
|:-:|:-:|: │    at android.support.v7.app.AppCompatDelegateImplV23.<init>(AppCompatDelegateImplV23.java:33)
|:-:|:-:|: │    at android.support.v7.app.AppCompatDelegateImplN.<init>(AppCompatDelegateImplN.java:31)
|:-:|:-:|: │    at android.support.v7.app.AppCompatDelegate.create(AppCompatDelegate.java:198)
|:-:|:-:|: │    at android.support.v7.app.AppCompatDelegate.create(AppCompatDelegate.java:183)
|:-:|:-:|: │    at android.support.v7.app.AppCompatActivity.getDelegate(AppCompatActivity.java:519)
|:-:|:-:|: │    at android.support.v7.app.AppCompatActivity.onCreate(AppCompatActivity.java:70)
|:-:|:-:|: │    at me.yokeyword.fragmentation.SupportActivity.onCreate(SupportActivity.java:38)
|:-:|:-:|: │    at json.chao.com.wanandroid.base.activity.AbstractSimpleActivity.onCreate(AbstractSimpleActivity.java:29)
|:-:|:-:|: │    at json.chao.com.wanandroid.base.activity.BaseActivity.onCreate(BaseActivity.java:37)
|:-:|:-:|: │    at android.app.Activity.performCreate(Activity.java:7098)
|:-:|:-:|: │    at android.app.Activity.performCreate(Activity.java:7089)
|:-:|:-:|: │    at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1215)
|:-:|:-:|: │    at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2770)
|:-:|:-:|: │    at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2895)
|:-:|:-:|: │    at android.app.ActivityThread.-wrap11(Unknown Source:0)
|:-:|:-:|: │    at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1616)
|:-:|:-:|: │    at android.os.Handler.dispatchMessage(Handler.java:106)
|:-:|:-:|: │    at android.os.Looper.loop(Looper.java:173)
|:-:|:-:|: │    at android.app.ActivityThread.main(ActivityThread.java:6653)
|:-:|:-:|: │    at java.lang.reflect.Method.invoke(Native Method)
|:-:|:-:|: │    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
|:-:|:-:|: │    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:821)

可以看出,这里弹出了应用中某一个IPC调用的所有堆栈信息。在这里,具体是在AbstractSimpleActivity的onCreate方法中调用了ServiceManager的getService方法,它是一个IPC调用的方法。这样,应用的IPC调用我们就能很方便地捕获到了。


大家可以看到,通过这种方式我们可以很方便地拿到应用中所有的IPC操作,并可以获得到IPC调用的类型、调用耗时、发生次数、调用的堆栈等等一系列信息。当然,除了IPC调用的问题之外,还有IO、DB、View绘制等一系列单点问题需要去建立与之对应的检测方案。


2、卡顿问题检测方案


对于卡顿问题检测方案的建设,主要是利用ARTHook去完善线下的检测工具,尽可能地去Hook相对应的操作,以暴露、分析问题。这样,才能更好地实现卡顿的体系化解决方案。


3
优雅监控耗时盲区


尽管我们在应用中监控了很多的耗时区间,但是还是有一些耗时区间我们还没有捕捉到,如onResume到列表展示的间隔时间,这些时间在我们的统计过程中很容易被忽视,这里我们举一个小栗子:


 
我们在Activity的生命周期中post了一个message,那这个message很可能其中
执行了一段耗时操作,那你知道这个message它的具体执行时间吗?这个message其实
很有可能在列表展示之前就执行了,如果这个message耗时1s,那么列表的展示
时间就会延迟1s,如果是200ms,那么我们设定的自动化卡顿检测就无法
发现它,那么列表的展示时间就会延迟200ms。

其实这种场景非常常见,接下来,我们就在项目中来进行实战演练。


首先,我们在MainActivity的onCreate中加上post消息的一段代码,其中模拟了延迟1000ms的耗时操作,代码如下所示:


// 以下代码是为了演示Msg导致的主线程卡顿
new Handler().post(() -> {
    LogHelper.i("Msg 执行");
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
});

接着,我们在RecyclerView对应的Adapter中将列表展示的时间打印出来,如下所示:


if (helper.getLayoutPosition() == 1 && !mHasRecorded) {
        mHasRecorded = true;
        helper.getView(R.id.item_search_pager_group).getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
            @Override
            public boolean onPreDraw() {
                helper.getView(R.id.item_search_pager_group).getViewTreeObserver().removeOnPreDrawListener(this);
                LogHelper.i("FeedShow");
                return true;
            }
        });
    }

最后,我们重新运行下项目,看看两者的执行时间,log信息如下:


 
I/WanAndroid-LOG: │ [MainActivity.java | 108 | lambda$initEventAndData$1$MainActivity] Msg 执行
I/WanAndroid-LOG: │ [null | 57 | json_chao_com_wanandroid_aop_ActivityHooker_onWindowFocusChanged] json.chao.com.wanandroid.ui.main.activity.MainActivity onWindowFocusChanged cost 1585
I/WanAndroid-LOG: │ ArticleListAdapter$1.onPreDraw  (ArticleListAdapter.java:93)
I/WanAndroid-LOG: │    LogHelper.i  (LogHelper.java:37)
I/WanAndroid-LOG: ├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
I/WanAndroid-LOG: │ [ArticleListAdapter.java | 93 | onPreDraw] FeedShow


从log信息中可以看到,MAinActivity的onWindowFocusChanged方法延迟了1000ms才被调用,与此同时,列表页时延迟了1000ms才展示出来。


也就是说,post的这个message消息是执行在界面、列表展示之前的。因为任何一个开发都有可能在某一个生命周期或者是某一个阶段以及一些第三方的SDK里面,回去做一些handler post的相关操作,这样,他的handler post的message的执行,很有可能在我们的界面或列表展示之前就被执行,所以说,出现这种耗时的盲区是非常普遍的,而且也不好排查,下面,我们分析下耗时盲区存在的难点。


1、耗时盲区监控难点


首先,我们可以通过细化监控的方式去获取耗时的一些盲区,但是我们却不知道在这个盲区中它执行了什么操作。其次,对于线上的一些耗时盲区,我们是无法进行排查的。


这里,我们先来看看如何建立耗时盲区监控的线下方案。


2、耗时盲区监控线下方案


这里我们直接使用TraceView去检测即可,因为它能够清晰地记录线程在具体的时间内到底做了什么操作,特别适合一段时间内的盲区监控。


然后,我们来看下如何建立耗时盲区监控的线上方案。


3、耗时盲区监控线上方案


我们知道主线程的所有方法都是通过message来执行的,还记得在之前我们学习了一个库:AndroidPerformanceMonitor,我们是否可以通过这个mLogging来做盲区检测呢?


通过这个mLogging确实可以知道我们主线程发生的message,但是通过mLogging无法获取具体的调用栈信息,因为它所获取的调用栈信息都是系统回调回来的,它并不知道当前的message是被谁抛出来的,所以说,这个方案并不够完美。


那么,我们是否可以通过AOP的方式去切Handler方法呢?


比如sendMessage、sendMessageDeleayd方法等等,这样我们就可以知道发生message的一个堆栈,但是这种方案也存在着一个问题,就是它不清楚准确的执行时间,我们切了这个handler的方法,仅仅只知道它具体是在哪个地方被发的和它所对应的堆栈信息,但是无法获取准确的执行时间。如果我们想知道在onResume到列表展示之间执行了哪些message,那么通过AOP的方式也无法实现。


那么,最终的耗时盲区监控的一个线上方案就是使用一个统一的Handler,定制了它的两个方法,一个是sendMessageAtTime,另外一个是dispatchMessage方法。因为对于发送message,不管调用哪个方法最终都会调用到一个是sendMessageAtTime这个方法,而处理message呢,它最终会调用dispatchMessage方法。


然后,我们需要定制一个gradle插件,来实现自动化的接入我们定制好的handler,通过这种方式,我们就能在编译期间去动态地替换所有使用Handler的父类为我们定制好的这个handler。这样,在整个项目中,所有的sendMessage和handleMessage都会经过我们的回调方法。


接下来,我们来进行一下实战演练。


首先,我这里给出定制好的全局Handler类,如下所示:


public class GlobalHandler extends Handler {

    private long mStartTime = System.currentTimeMillis();

    public GlobalHandler() {
        super(Looper.myLooper(), null);
    }

    public GlobalHandler(Callback callback) {
        super(Looper.myLooper(), callback);
    }

    public GlobalHandler(Looper looper, Callback callback) {
        super(looper, callback);
    }

    public GlobalHandler(Looper looper) {
        super(looper);
    }

    @Override
    public boolean sendMessageAtTime(Message msg, long uptimeMillis) {
        boolean send = super.sendMessageAtTime(msg, uptimeMillis);
        // 1
        if (send) {
            GetDetailHandlerHelper.getMsgDetail().put(msg, Log.getStackTraceString(new Throwable()).replace("java.lang.Throwable"""));
        }
        return send;
    }

    @Override
    public void dispatchMessage(Message msg) {
        mStartTime = System.currentTimeMillis();
        super.dispatchMessage(msg);

        if (GetDetailHandlerHelper.getMsgDetail().containsKey(msg)
            && Looper.myLooper() == Looper.getMainLooper()) {
            JSONObject jsonObject = new JSONObject();
            try {
                // 2
                jsonObject.put("Msg_Cost", System.currentTimeMillis() - mStartTime);
                jsonObject.put("MsgTrace", msg.getTarget() + " " + GetDetailHandlerHelper.getMsgDetail().get(msg));

                // 3
                LogHelper.i("MsgDetail " + jsonObject.toString());
                GetDetailHandlerHelper.getMsgDetail().remove(msg);
            } catch (Exception e) {
            }
        }
    }
}

上面的GlobalHandler将会是我们项目中所有Handler的一个父类。在注释1处,我们在sendMessageAtTime这个方法里面判断如果message发送成功,将会把当前message对象对应的调用栈信息都保存到一个ConcurrentHashMap中,GetDetailHandlerHelper类的代码如下所示:


public class GetDetailHandlerHelper {

    private static ConcurrentHashMap<Message, String> sMsgDetail = new ConcurrentHashMap<>();

    public static ConcurrentHashMap<Message, String> getMsgDetail() {
        return sMsgDetail;
    }
}

这样,我们就能够知道这个message它是被谁发送过来的。然后,在dispatchMessage方法里面,我们可以计算拿到其处理消息的一个耗时,并在注释2处将这个耗时保存到一个jsonObject对象中,同时,我们也可以通过GetDetailHandlerHelper类的ConcurrentHashMap对象拿到这个message对应的堆栈信息,并在注释3处将它们输出到log控制台上。


当然,如果是线上监控,则会把这些信息保存到本地,然后选择合适的时间去上传。最后,我们还可以在方法体里面做一个判断,我们设置一个阈值,比如阈值为20ms,超过了20ms就把这些保存好的信息上报到APM后台。


在前面的实战演练中,我们使用了handler post的方式去发送一个消息,通过gradle插件将所有handler的父类替换为我们定制好的GlobalHandler之后,我们就可以优雅地去监控应用中的耗时盲区了。


对于实现全局替换handler的gradle插件,除了使用AspectJ实现之外,这里推荐一个已有的项目:DroidAssist

https://github.com/didi/DroidAssist


然后,重新运行项目,关键的log信息如下所示:


MsgDetail {"Msg_Cost":1001,"MsgTrace":"Handler (com.json.chao.com.wanandroid.performance.handler.GlobalHandler) {b0d4d48} \n\tat 
com.json.chao.com.wanandroid.performance.handler.GlobalHandler.sendMessageAtTime(GlobalHandler.java:36)\n\tat
json.chao.com.wanandroid.ui.main.activity.MainActivity.initEventAndData$__twin__(MainActivity.java:107)\n\tat"


从以上信息我们不仅可以知道message执行的时间,还可以从对应的堆栈信息中得到发送message的位置,这里的位置是MainActivity的107行,也就是new Handler().post()这一行代码。


使用这种方式我们就可以知道在列表展示之前到底执行了哪些自定义的message,我们一眼就可以知道哪些message其实是不符合我们预期的,比如说message的执行时间过长,或者说这个message其实可以延后执行,这个我们都可以根据实际的项目和业务需求进行相应地修改。


4、耗时盲区监控方案总结


耗时盲区监控是我们卡顿监控中不可或缺的一个环节,也是卡顿监控全面性的一个重要保障。而需要注意的是,TraceView仅仅适用于线下的一个场景,同时对于TraceView来说,它可以用于监控我们系统的message。而最后介绍的动态替换的方式其实是适合于线上的,同时,它仅仅监控应用自身的一个message。


4
卡顿优化技巧总结


1、卡顿优化实践经验


如果应用出现了卡顿现象,那么可以考虑以下方式进行优化:


  • 首先,对于耗时的操作,我们可以考虑异步或延迟初始化的方式,这样可以解决大多数的问题。但是,大家一定要注意代码的优雅性。

  • 对于布局加载优化,可以采用AsyncLayoutInflater或者是X2C的方式来优化主线程IO以及反射导致的消耗,同时,需要注意,对于重绘问题,要给与一定的重视。

  • 此外,内存问题也可能会导致应用界面的卡顿,我们可以通过降低内存占用的方式来减少GC的次数以及时间,而GC的次数和时间我们可以通过log查看。


然后,我们来看看卡顿优化的工具建设。


2、卡顿优化工具建设


工具建设这块经常容易被大家所忽视,但是它的收益却非常大,也是卡顿优化的一个重点。首先,对于系统工具而言,我们要有一个认识,同时一定要学会使用它,这里我们再回顾一下。


  • 对于Systrace来说,我们可以很方便地看出来它的CPU使用情况。另外,它的开销也比较小。

  • 对于TraceView来说,我们可以很方便地看出来每一个线程它在特定的时间内做了什么操作,但是TraceView它的开销相对比较大,有时候可能会被带偏优化方向。

  • 同时,需要注意,StrictMode也是一个非常强大的工具。


然后,我们介绍了自动化工具建设以及优化方案。我们介绍了两个工具,AndroidPerformanceMonitor以及ANR-WatchDog。


同时针对于AndroidPerformanceMonitor的问题,我们采用了高频采集,以找出重复率高的堆栈这样一种方式进行优化,在学习的过程中,我们不仅需要学会怎样去使用工具,更要去理解它们的实现原理以及各自的使用场景。


同时,我们对于卡顿优化工具的建设也做了细化,对于单点问题,比如说IPC监控,我们通过Hook的手段来做到尽早的发现问题。对于耗时盲区的监控,我们在线上采用的是替换Handler的方式来监控所有子线程message执行的耗时以及调用堆栈。


最后,我们来看一下卡顿监控的指标。我们会计算应用整体的卡顿率,ANR率、界面秒开率以及交换时间、生命周期时间等等。在上报ANR信息的同时,我们也需要上报环境和场景信息,这样不仅方便我们在不同版本之间进行横向对比,同时,也可以结合我们的报警平台在第一时间感知到异常。


5
卡顿优化的常见问题


1、你是怎么做卡顿优化的?


从项目的初期到壮大期,最后再到成熟期,每一个阶段都针对卡顿优化做了不同的处理。各个阶段所做的事情如下所示:


  • 1、系统工具定位、解决

  • 2、自动化卡顿方案及优化

  • 3、线上监控及线下监测工具的建设


我做卡顿优化也是经历了一些阶段,最初我们的项目当中的一些模块出现了卡顿之后,我是通过系统工具进行了定位,我使用了Systrace,然后看了卡顿周期内的CPU状况,同时结合代码,对这个模块进行了重构,将部分代码进行了异步和延迟,在项目初期就是这样解决了问题。


但是呢,随着我们项目的扩大,线下卡顿的问题也越来越多,同时,在线上,也有卡顿的反馈,但是线上的反馈卡顿,我们在线下难以复现,于是我们开始寻找自动化的卡顿监测方案,其思路是来自于Android的消息处理机制,主线程执行任何代码都会回到Looper.loop方法当中,而这个方法中有一个mLogging对象,它会在每个message的执行前后都会被调用,我们就是利用这个前后处理的时机来做到的自动化监测方案的。


同时,在这个阶段,我们也完善了线上ANR的上报,我们采取的方式就是监控ANR的信息,同时结合了ANR-WatchDog,作为高版本没有文件权限的一个补充方案。


在做完这个卡顿检测方案之后呢,我们还做了线上监控及线下检测工具的建设,最终实现了一整套完善,多维度的解决方案。


2、你是怎么样自动化的获取卡顿信息?


我们的思路是来自于Android的消息处理机制,主线程执行任何代码它都会走到Looper.loop方法当中,而这个函数当中有一个mLogging对象,它会在每个message处理前后都会被调用,而主线程发生了卡顿,那就一定会在dispatchMessage方法中执行了耗时的代码,那我们在这个message执行之前呢,我们可以在子线程当中去postDelayed一个任务,这个Delayed的时间就是我们设定的阈值,如果主线程的messaege在这个阈值之内完成了,那就取消掉这个子线程当中的任务,如果主线程的message在阈值之内没有被完成,那子线程当中的任务就会被执行,它会获取到当前主线程执行的一个堆栈,那我们就可以知道哪里发生了卡顿。


经过实践,我们发现这种方案获取的堆栈信息它不一定是准确的,因为获取到的堆栈信息它很可能是主线程最终执行的一个位置,而真正耗时的地方其实已经执行完成了,于是呢,我们就对这个方案做了一些优化,我们采取了高频采集的方案,也就是在一个周期内我们会多次采集主线程的堆栈信息,如果发生了卡顿,那我们就将这些卡顿信息压缩之后上报给APM后台,然后找出重复的堆栈信息,这些重复发生的堆栈大概率就是卡顿发生的一个位置,这样就提高了获取卡顿信息的一个准确性。


3、卡顿的一整套解决方案是怎么做的?


首先,针对卡顿,我们采用了线上、线下工具相结合的方式,线下工具我们需要尽可能早地去暴露问题,而针对于线上工具呢,我们侧重于监控的全面性、自动化以及异常感知的灵敏度。


同时呢,卡顿问题还有很多的难题。比如说有的代码呢,它不到你卡顿的一个阈值,但是执行过多,或者它错误地执行了很多次,它也会导致用户感官上的一个卡顿,所以我们在线下通过AOP的方式对常见的耗时代码进行了Hook,然后对一段时间内获取到的数据进行分析,我们就可以知道这些耗时的代码发生的时机和次数以及耗时情况。


然后,看它是不是满足我们的一个预期,不满足预期的话,我们就可以直接到线下进行修改。同时,卡顿监控它还有很多容易被忽略的一个盲区,比如说生命周期的一个间隔,那对于这种特定的问题呢,我们就采用了编译时注解的方式修改了项目当中所有Handler的父类,对于其中的两个方法进行了监控,我们就可以知道主线程message的执行时间以及它们的调用堆栈。


对于线上卡顿,我们除了计算App的卡顿率、ANR率等常规指标之外呢,我们还计算了页面的秒开率、生命周期的执行时间等等。而且,在卡顿发生的时刻,我们也尽可能多地保存下来了当前的一个场景信息,这为我们之后解决或者复现这个卡顿留下了依据。


6
总结


恭喜你,如果你看到了这里,你会发现要做好应用的卡顿优化的确不是一件简单的事,它需要你有成体系的知识构建基底。最后,我们再来回顾一下面对卡顿优化,我们已经探索的以下九大主题:


  • 卡顿优化分析方法与工具:背景介绍、卡顿分析方法之使用shell命令分析CPU耗时、卡顿优化工具。

  • 自动化卡顿检测方案及优化:卡顿检测方案原理、AndroidPerformanceMonitor实战及其优化。

  • ANR分析与实战:ANR执行流程、线上ANR监控方式、ANR-WatchDog原理。

  • 卡顿单点问题检测方案:IPC单点问题检测方案、卡顿问题检测方案。

  • 优雅监控耗时盲区:耗时盲区监控难点以及线上与线下的监控方案。

  • 常见卡顿问题解决方案总结

  • 卡顿优化的常见问题


相信看到这里,你一定收获满满,但是要记住,方案再好,也只有自己动手去实践,才能真正地掌握它。只有重视实践,充分运用感性认知潜能,在项目中磨炼自己,才是正确的学习之道。


在实践中,在某些关键动作上刻意练习,也会取得事半功倍的效果。


本文因为篇幅问题,略微有所删减,但不影响阅读,全文可以点击阅读原文查看。


参考链接:


1、国内Top团队大牛带你玩转Android性能分析与优化 第6章 卡顿优化

2、极客时间之Android开发高手课 卡顿优化

3、《Android移动性能实战》第四章 CPU

4、《Android移动性能实战》第七章 流畅度

5、Android dumpsys cpuinfo 信息解读

6、如何清楚易懂的解释“UV和PV"的定义?

7、nanoscope-An extremely accurate Android method tracing tool

8、DroidAssist-A lightweight Android Studio gradle plugin based on Javassist for editing bytecode in Android.

9、lancet-A lightweight and fast AOP framework for Android App and SDK developers

10、MethodTraceMan-用于快速找到高耗时方法,定位解决Android App卡顿问题

11、Linux环境下进程的CPU占用率

12、使用 ftrace

13、profilo-A library for performance traces from production

14、ftrace 简介

15、atrace源码

16、AndroidAdvanceWithGeektime / Chapter06

17、AndroidAdvanceWithGeektime / Chapter06-plus



推荐阅读







如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!