一、背景介绍
ANR(Application Not Response)指应用程序无响应,通常出现在主线程被阻塞时,并伴随ANR弹窗出现。ANR发生时要么关闭当前app,要么等待,等待的结果大概率还是继续ANR,最终需要杀掉应用进程。ANR的治理难点是不像Crash一样有崩溃日志,定位问题比较困难,但是ANR带来的用户体验是极差的,是必须要解决的问题。
本文将着眼于三个方面,ANR的统计、ANR的定位,以及线上ANR治理的几个case介绍。
二、 ANR的统计
2.1 ANR初步挖掘
目前判定发生ANR的原理,和大部分卡顿判定、FPS监控的原理是一致的,即为主线程Looper设置一个LooperPrinter,根据回传信息头区分消息执行开始与结束,计算Message耗时。原理如下:
public static void loop() {
...
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
...
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " +
msg.callback);
}
}
自定义LooperPrinter如下:
class LooperPrinter implements Printer {
@Override
public void println(String x) {
...
if (isValid) {
<!--区分开始结束,计算消息耗时-->
dispatch(x.charAt(0) == '>', x);
}
}
利用回调日志中的参数">>>>"
与"<<<<"
,即可诊断出Message执行耗时,原则上UI线程所有的消息都应该保持轻量级,任何消息超时都应当算作异常行为。从而确定单位时间内的掉帧数,达到间接计算FPS的目的;若执行耗时更长一些,则认为发生了一次卡顿;若耗时超过了5秒,就可以认为发生了ANR。
目前团队在用的ANR监控源码已经公开,具体代码可以见源码中ANRMonitorRunnable
的实现,在实现中可以看到,在监控到日志中的开始标记">>>>"
时,便使用mANRHandler#postDelayed
一个延迟5秒的ANRMonitorRunnable
,并在Runnable中维护invalid
字段,用于在接收到结束标记"<<<<"
时阻止ANR的判定。最后,在确定发生ANR时,将发生ANR的Activity信息进行上传。
2.2 初版本上线效果
自上线以来,我们监测到了线上存在的ANR情况,包括每日ANR数量以及在哪些页面发生,量级如下图所示:
我们开心的发现我们的APP中确实存在大量的ANR,但是我们好像并没有定位到具体堆栈的方法:
-
通过对相关Activity中的代码进行Review去主动发现ANR的方式,显然太过于笨拙,只能靠猜。一段时间过去了,ANR数量和上线初期相比并无明显改善。根本原因在于,缺乏ANR的具体堆栈定位以及上报的机制,防止大海捞针的情况出现;
-
同时,通过研究源码发现,线上的ANR监测库在原理层面存在一些先天不足,其无法监控
IdleHandler
卡顿、以及View#TouchEvent
卡顿 (下文将详细说明)。
2.3 ANR监控的完善
2.3.1 已有框架的监控原理 & 漏洞
如上图所示,已有监控框架是通过计算执行dispatchMessage()
方法之前和之后打印字符串的时间差来获得该方法执行的时间,并实现卡顿的监控;
然而,在处理消息之前需要获取到消息,MessageQueue#next()
本身可能会阻塞导致ANR,显然是无法被监控到的。
2.3.2 对监控框架进行完善
完善监控框架的第一反应是,直接监控两次dispatchMessage()
方法之前的时间差,这样就可以把next()
方法的耗时也计算在内。不幸的是,主线程空闲时,也会阻塞在MessageQueue#next()
方法中,我们很难区分究竟是发生了卡顿还是主线程空闲。
因此我们只能深入研究可能会引起queue.next()
阻塞的原因。通过研究MessageQueue#next()
的源码发现,有两个重要的case可能会引起next()
阻塞的情况:
IdleHandler
处理,通常用于在主线程空闲时候的业务处理;View#TouchEvent
,通常用于自定义View中的一些坐标记录。
2.3.3 关于IdleHandler的耗时监控
首先对于第一种情况,IdleHandler耗时的监控是比较重要的,因为涉及到的业务较多。我们发现MessageQueue
中的mIdleHandlers
是可以被反射的,这个变量保存了所有将要执行的IdleHandler,我们只需要把ArrayList类型的mIdleHandlers
,通过反射替换为MyArrayList
,在我们自定义的MyArrayList
中重写add()
方法,再将我们自定义的MyIdleHandler
添加到MyArrayList
中,即可完成对queueIdle()
方法的耗时监控。实现源码可以参考Matrix的实现,源码在这里。
- 原理图如下所示:
2.3.4 关于View#TouchEvent的耗时监控
接着,对于第二种View#TouchEvent
的监控。Touch事件是通过server端的inputDispatcher线程传递给Client端的UI线程的,并且使用的是一对Socket进行通讯。我们可以通过PLT Hook
,将libinput.so
中的recvfrom
和sendto
方法进行替换。并计算两者的时间差来验证产生了一次Touch事件的卡顿。这种方案只能说理论上可行,但是实操有一定的兼容性风险,建议还是通过自行Review自定义View中TouchEvent
的代码实现进行问题的检查。
- 原理图如下所示:
三、 ANR堆栈的定位
3.1 尝试将ANR堆栈上报到APM
当定位到发生ANR时,我们一定是希望获得主线程目前被卡在哪里的。首先想到的就是在子线程中去dump主线程的堆栈信息,并上传堆栈到APM平台。
//在子线程中获取主线程当前的堆栈信息
StackTraceElement[] stackTrace = Looper.getMainLooper().getThread().getStackTrace();
流程图如下所示:
然而实际操作下来,发现一个非常麻烦的问题,就是堆栈聚合问题。我们自己的APM平台本质上就是一个数据库,对上传的信息进行存储和展示:
可以看出,APM上的堆栈无法聚合,每天数万条的堆栈,根本无法人肉去处理;因此无法使用APM进行堆栈上传和分析。
3.2 尝试使用Firebase平台的堆栈聚合能力
我们想到,我们日常使用的Crash监控平台,本身是有堆栈聚合能力的,我们希望可以利用这一点,完成我们自己ANR堆栈信息的上报。通过阅读Firebase SDK的源码,发现其私有API是可以支持自定义上报堆栈的,我们通过反射的方式进行调用。
- 流程图如下所示:
- 源码如下所示:
StackTraceElement[] stackTrace = Looper.getMainLooper().getThread().getStackTrace();
FirebaseCrashlytics instance = FirebaseCrashlytics.getInstance();
CrashlyticsCore core = (CrashlyticsCore) ReflectUtil.reflectObject(instance, "core");
Object controller = ReflectUtil.reflectObject(core, "controller");
Method method = ReflectUtil.reflectMethod(controller, "writeNonFatalException", Thread.class, Throwable.class);
Throwable throwable = new Throwable("ANR异常 " + activity.getClass().getSimpleName());
throwable.setStackTrace(stackTrace);
method.invoke(controller, Looper.getMainLooper().getThread(), throwable);
class ReflectUtil {
public static Method reflectMethod(Object instance, String name, Class<?>... argTypes) {
try {
Method method = instance.getClass().getDeclaredMethod(name, argTypes);
method.setAccessible(true);
return method;
} catch (Exception e) {
}
return null;
}
public static <T> T reflectObject(Object instance, String name) {
try {
java.lang.reflect.Field field = instance.getClass().getDeclaredField(name);
field.setAccessible(true);
return (T) field.get(instance);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
通过测试,发现确实可以完成目标。可以从下图看出,堆栈信息明确且已聚合,真正定位到了引起ANR的具体代码位置。
四、 线上ANR治理的一些case分享
Case1: String Format性能问题导致ANR
- 上述代码一开始是死活想不对会有什么问题,但是经过分析堆栈,发现只有在倒计时View模块的setText会发生ANR,猜测是频繁调用导致性能问题,
- 继续分析测试,在频繁调用的情况下,
String.format()
的耗时比简单的字符串+号拼接慢40-60倍。
Case2: 网络诊断库netanalysis里的一个隐蔽ANR
- 问题1(BUG) :每个页面Created时机都会开启一个30秒的循环去上报当前的网络状态,这显然是不合理的,因为开启N个页面就是开启N个循环,导致获取时机越来越繁杂,算是发现了一个隐藏BUG;
- 问题2(性能):系统服务在主线程调用,可优化到子线程中去开启循环。
Case3: ROM信息获取<兼容问题>导致ANR
- 在判断是不是小米Rom的时候,使用了
Runtime.getRuntime().exex()
的方式,这种方式是阻塞调用,且会在某些机型上导致卡死。 - 判断小米Rom的方式替换为官网推荐方式:
Build.MANUFACTURER.contains("Xiaomi");
五、 线上ANR治理总结