ANR(Application Not Response)指应用程序无响应,通常出现在主线程被阻塞时,并伴随ANR弹窗出现。ANR发生时要么关闭当前app,要么等待,然而等待的结果大概率还是继续ANR,最终需要杀掉应用进程。ANR的治理难点是不像Crash一样有崩溃日志,定位问题比较困难;同时ANR带来的用户体验是极差的,是必须要解决的问题。
本文将着眼于Android端的ANR问题,从三个方面展开,分别为ANR的统计、ANR的定位以及线上ANR的治理情况总结。
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信息进行上传。
自上线以来,我们监测到了线上存在的ANR情况,包括每日ANR数量以及在哪些页面发生,量级如下图所示。
我们开心地发现在APP中确实存在大量的ANR,也清楚了其分布在哪些页面以及大概的数量级。但是当我们想去治理这些问题时,却发现根本无从下手,因为我们并没有定位到ANR发生时的具体堆栈Trace。
如果单纯通过对相关Activity中的代码进行review,从而去主动发现导致ANR的代码,这种方式显然太过于笨拙,只能盲目的猜测、上线、验证。经过一段时间的治理,ANR数量和初期相比并无明显改善。根本原因在于需要一种策略,可以将引发ANR的具体代码堆栈进行定位和上报,即可避免人工review代码这样的低效解法;
同时,通过研究源码发现,线上的ANR监测库在原理层面存在一些先天不足,存在一些监控盲区,其无法监控IdleHandler卡顿、以及View#TouchEvent卡顿 (下文将详细说明),需要对监控体系进行完善。
然而,在处理消息之前需要先获取到消息实例,MessageQueue#next()本身可能会阻塞导致ANR,这显然属于我们的监控盲区范围。
根据上述原理,如果想要完善监控框架,第一反应的解法就是,直接监控两次dispatchMessage方法之前的时间差,这样就可以把next()方法的耗时也计算在内。不幸的是,主线程空闲时,也会阻塞在MessageQueue#next()方法中,我们很难区分究竟是发生了卡顿还是主线程空闲。
因此我们只能深入研究可能会引起queue.next()阻塞的原因。通过研究MessageQueue#next()的源码发现,有两个重要的case可能会引起next()阻塞的情况:
IdleHandler处理,通常用于在主线程空闲时候的业务处理;
View#TouchEvent,通常用于自定义View中的一些坐标记录。
首先对于第一种情况,IdleHandler耗时的监控是比较重要的,因为涉及到的业务较多。我们发现MessageQueue中的mIdleHandlers是可以被反射的,这个变量保存了所有将要执行的IdleHandler,我们只需要把ArrayList类型的mIdleHandlers,通过反射替换为MyArrayList,在我们自定义的MyArrayList中重写add()方法,再将我们自定义的MyIdleHandler添加到MyArrayList中,即可完成对queueIdle()方法的耗时监控。原理图如下所示:
接着,对于第二种View#TouchEvent的监控。Touch事件是通过server端的InputDispatcher线程传递给Client端的UI线程的,并且使用的是一对Socket进行通信。我们可以通过PLT Hook,将libinput.so中的关键方法进行替换,即可验证是否产生了一次Touch事件的卡顿。这种方案只能说理论上可行,原理图如下所示。但是实操有一定的兼容性风险,再加上实际工程中重写自定义View中TouchEvent的代码量较少,建议还是通过review的方式对相关代码进行可能引发ANR问题的检查。
当定位到发生ANR时,我们一定是希望获得主线程目前被卡在哪里的。虽然ANR问题在一些场景下是历史消息耗时较长并不断累加导致,但是通过将现场的主线程堆栈信息上报并做计数统计,并通过大量数据统计,若某个堆栈信息被统计到的次数较多,那么当前ANR是其导致的可能性就越大。
我们首先想到的就是在ANR发生时(如主线程消息队列的卡顿时间超过了5秒),开启子线程并dump当前主线程的堆栈信息,并上传堆栈到APM监控平台。
//在子线程中获取主线程当前的堆栈信息
StackTraceElement[] stackTrace = Looper.getMainLooper().getThread().getStackTrace();
然而实际操作下来,发现一个非常麻烦的问题,就是堆栈聚合去重问题。我们自己的APM平台本质上就是一个数据库,对上传的信息进行存储和展示:
可以看出,APM上的堆栈无法聚合,每天数万条的堆栈,根本无法人肉去处理;因此无法使用APM进行堆栈上传和分析。
我们想到,我们日常使用的Crash监控平台,本身是有堆栈聚合能力的,我们希望可以利用这一点,避免付出额外的研发成本去单独实现,从而完成堆栈信息的上报与聚合。通过阅读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) {
e.printStackTrace();
}
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,如果没有堆栈定位的能力,单纯靠代码Review,是很难发现这些会导致卡顿的细节性能问题的。
上述代码一开始是死活想不到会有什么问题,只是单纯的一个setText和String.format(),但是经过分析堆栈,发现只有在倒计时相关模块调用才会发生ANR,猜测是频繁调用的条件下触发了某个性能问题。
继续分析测试,在频繁调用的情况下,String.format()的耗时比简单的字符串+号拼接慢40-60倍,因此对该问题进行优化处理。
问题1(BUG) :发现在历史代码中,每个页面Created时机都会开启一个30秒的循环去上报当前的网络状态,这显然是不合理的,开启N个页面就是开启N个循环,导致系统服务的获取时机越来越繁杂。
问题2(性能):系统服务的调用发生在主线程,可优化到子线程中去开启循环调用。
在获取虚拟导航栏信息的代码中,有关于Rom类型的判断逻辑。其中,在判断是不是小米Rom的时候,使用了Runtime.getRuntime().exex(),这种方式是阻塞调用,且会在某些机型上很容易导致卡死。
判断小米Rom的方式替换为官网推荐方式:Build.MANUFACTURER.contains("Xiaomi");
4.4 线上ANR治理总结
通过对线上ANR问题的监控,并根据上报的高频次堆栈信息对ANR问题进行定位,在一系列的case修复之后,线上ANR问题治理情况如下图所示,线上ANR的出现次数同比降低了73.8%,大幅降低了用户在使用APP时出现无响应的可能性,提升了APP的用户使用体验。
本文介绍了Android端ANR问题监控体系的实现原理,指出了监控体系中存在的一些盲区,并针对性的给出解决方案予以完善。在确认发生ANR问题时,可以将现场堆栈信息上报并完成聚合工作,切实的辅助开发人员发现隐藏在工程中导致ANR问题的根源,通过治理大幅降低了线上ANR问题出现的频次。
后续的工作将根据ANR问题的特性,针对ANR Trace堆栈在某些情况下并不是RootCase的情况,尽可能的获取在ANR发生之前的消息调度耗时并记录,同时结合系统负载情况,从更多的维度、更加精准的定位ANR问题,从而进一步提升APP的用户体验。
轻量级Android性能监测工具:https://github.com/happylishang/Collie
本文由作者授权严选技术团队发布