02.崩溃治理优化总结
目录介绍
- 01.前沿概述
- 1.1 背景前沿介绍
- 1.2 思考一些问题
- 1.3 Crash指标
- 1.4 Crash治理原则
- 1.5 崩溃分析三部曲
- 02.Crash常见治理
- 2.1 常见Crash分类
- 2.2 空指针崩溃问题
- 2.3 索引越界异常
- 2.4 核心catch上报
- 2.5 异常捕获准则
- 2.6 Crash长效治理
- 03.系统级别Crash
- 3.1 排查解决思路
- 3.2 解决Toast崩溃
- 3.3 排查so库异常
- 3.3 反编译ROM解决
- 04.OOM导致Crash
- 4.1 内存泄漏问题
- 05.三方库导致Crash
- 5.1 反射修改代码
- 06.AOP增强辅助
- 6.1 AOP是什么
- 6.2 处理Intent异常
- 07.Crash的预防实践
- 7.1 工程架构对Crash率的影响
- 7.2 大图监控
- 7.3 Lint检查
- 7.4 最后的抢救
- 08.Looper拦截崩溃
- 8.1 能否用Looper拦截崩溃
- 8.2 使用Loop拦截崩溃
- 8.4 拦截主线程崩溃
- 8.5 拦截子线程崩溃
- 8.6 最后的总结建议
- 09.Crash监控和止损
- 9.1 Crash的监控
- 9.2 Crash的止损
- 9.3 Crash的自我修复
- 9.4 Crash日志回捞
01.前沿概述
1.1 背景前沿介绍
Crash
率是衡量一个App好坏的重要指标之一- 如果你忽略了它的存在,它就会愈演愈烈,最后造成部分用户的流失,而且影响功能的正常使用,让体验不好。
1.2 思考一些问题
- 思考一些问题,看看怎么做
- 在集成一些第三方库的时候,我们不能修改里面的实现(例如 Gson,FastJson,OkHttp,OkSocket,Glide)等,那么如果他们里面抛出异常如何解决,Glide加载图片之后填充图片时,如果Activity销毁就会造成崩溃,那么我们如何解决?
- 针对公司技术库崩溃
- 比如,外卖app某个用户地图出现了崩溃,当捕获到异常信息之后,这个时候可以采用降级的策略止损容错,然后去分析原因找出真正的问题。
1.3 Crash指标
- 计算Crash率,我们首先应该明白稳定性优化的一些关键指标。UV Crash率与PV Crash率
- PV(Page View)即访问量,UV(Unique Visitor)即独立访客,0 - 24小时内的同一终端只计算一次。
- UV Crash率:针对用户使用量的统计,统计一段时间内所有用户发生崩溃的占比,用于评估Crash率的影响范围。
- PV Crash率:针对用户使用次数的统计,评估相关Crash影响的严重程度。
- 大家可以根据自己的需要选择合适的指标,需要注意的是,需要确保一直使用同一种衡量方式。
- Crash率评价。那么,我们App的Crash率降低多少才能算是一个正常水平或优秀的水平呢?
- Java与Native的总崩溃率必须在千分之二以下。Crash率万分位为优秀。注意,以上说的都是UV崩溃率。
1.4 Crash治理原则
- 由点到面。
- 一个
Crash
发生了,不能只针对这个Crash
的去解决,而要去考虑这一类Crash怎么去解决和预防。只有这样才能使得这一类Crash真正被解决。
- 一个
- 异常不能随便吃掉
- 随意的使用
try-catch
,只会增加业务的分支和隐蔽真正的问题,要了解Crash
的本质原因,根据本质原因去解决。catch的分支,更要根据业务场景去兜底,保证后续的流程正常。
- 随意的使用
- 先止损再根治
- 当
Crash
发生的时候,损失已经造成了,我们再怎么治理也只是减少损失。尽可能的提前预防Crash的发生,可以将Crash消灭在萌芽阶段。
- 当
- 事后复现和总结
- 尽量去找到复现问题的链路,方便排查问题,修改后是否再次出现。有些bug如果找不到,那么思考能否上传info日志,通过技术埋点去排查崩溃链路问题。
1.5 崩溃分析三部曲
- 第一步:确定重点
- 确认和分析重点,关键在于在日志中找到重要的信息,对问题有一个大致判断。一般来说,我建议在确定重点这一步可以关注以下几点。
- 1.确认严重程度。解决崩溃也要看性价比,我们优先解决 Top 崩溃或者对业务有重大影响,例如启动、支付过程的崩溃。
- 2.崩溃基本信息。确定崩溃的类型以及异常描述,对崩溃有大致的判断。
- 3.各个资源情况。结合崩溃的基本信息,接着看看是不是跟 “内存信息” 有关,是不是跟“资源信息”有关。比如是物理内存不足、虚拟内存不足,还是文件句柄 fd 泄漏了。
- 第二步:查找共性
- 如果使用了上面的方法还是不能有效定位问题,我们可以尝试查找这类崩溃有没有什么共性。找到了共性,也就可以进一步找到差异,离解决问题也就更进一步 机型、系统、ROM、厂商、ABI,这些采集到的系统信息都可以作为维度聚合,找到了共性,可以对你下一步复现问题有更明确的指引。
- 第三步:尝试复现
- “只要能本地复现,我就能解”,相信这是很多开发跟测试说过的话。有这样的底气主要是因为在稳定的复现路径上面,可以采用增加日志或使用 Debugger、GDB 等各种各样的手段或工具做进一步分析。
02.Crash常见治理
2.1 常见Crash分类
- 常规
Crash
发生的原因主要是由于开发人员编写代码不小心导致的。- 解决这类
Crash
需要由点到面,根据Crash
引发的原因和业务本身,统一集中解决。
- 解决这类
- 常见的
Crash
类型包括:- 空节点、角标越界、类型转换异常、实体对象没有序列化、数字转换异常、Activity或Service找不到等。
- 这类Crash是App中最为常见的Crash,也是最容易反复出现的。在获取Crash堆栈信息后,解决这类Crash一般比较简单,更多考虑的应该是如何避免。
2.2 空指针崩溃问题
NullPointerException
是遇到最频繁的,造成这种Crash一般有两种情况:- 对象本身没有进行初始化就进行操作;对象已经初始化过,但是被回收或者手动置为null,然后对其进行操作。
- 针对第一种情况导致的原因有很多,可能是开发人员的失误、API返回数据解析异常、进程被杀死后静态变量没初始化导致,我们可以做的有:
- 对可能为空的对象做判空处理;养成使用@NonNull和@Nullable注解的习惯;如果是kotlin避免使用!!;尽量不使用静态变量存储跨页面数据。
- 针对第二种情况大部分是由于Activity/Fragment销毁或被移除后,在Message、Runnable、网络等回调中执行了一些代码导致的,可以做的有:
- 1.
Message
、Runnable
回调时,判断Activity/Fragment是否销毁或被移除;加try-catch保护;Activity/Fragment销毁时移除所有已发送的Runnable。 - 2.封装
LifecycleMessage
/Runnable
基础组件,并自定义Lint检查,提示使用封装好的基础组件。在BaseActivity、BaseFragment的onDestroy()里把当前Activity所发的所有请求取消掉。 - 3.针对Fragment中onActivityResult回调,处理data偶发空指针(原因是activity异常生命周期导致data可能为空),增加非空判断。
- 1.
2.3 索引越界异常
- 这类
Crash
常见于对RecyclerView
的操作和多线程下对容器的操作。- 针对
RecyclerView
中造成的IndexOutOfBoundsException
,经常是因为外部也持有了Adapter里数据的引用(如在Adapter的构造函数里直接赋值),这时如果外部引用对数据更改了,但没有及时调用notifyDataSetChanged()
,则有可能造成Crash。 - 对此我们封装了一个BaseAdapter,数据统一由Adapter自己维护通知,这两类Crash目前得到了统一的解决。
- 针对
- 注意有些容器是线程不安全的
- 所以如果在多线程下对其操作就容易引发
IndexOutOfBoundsException
。常用的如JDK里的ArrayList和Android里的SparseArray、ArrayMap,同时也要注意有一些类的内部实现也是用的线程不安全的容器,如Bundle里用的就是ArrayMap。
- 所以如果在多线程下对其操作就容易引发
2.4 核心catch上报
- 核心
try-catch
建议增加APM异常上报- 对于一些特殊情况,比如说,开发过程当中或代码中出现了一些catch代码块,捕获住了异常,让程序不崩溃,这其实是不合理的,程序虽然没有崩溃,当时程序的功能已经变得不可用。
- 所以,这些被
catch
的异常我们也需要上报上来,这样我们才能知道用户到底出现了什么问题而导致的异常。
- 如何捕获下层基础库异常,上报到APM?
- 举个例子,比如你是在app壳module集成APM,但是现在你想统计一些基础lib库module(被app依赖)的异常。显然没办法直接做到……可以间接通过接口回调来上报异常。具体参考:EventUploadLib
- 举一个实际的例子捕获catch中异常
- 你抛出的异常越具体、越明确越好。使用者可以根据具体的异常进行不同的补救措施。因此,你需要确保提供尽可能多的信息,这会使得你的 API 更易于理解。
try { xxxxs } catch (IOException e) { // 上报后可在异常平台上查询到相关信息,这个地方注意使用具体的异常,比如NullPointerException ExceptionHelper.report("video player set data error : " , e); }
2.5 异常捕获准则
- catch 时请分清稳定代码和非稳定代码,稳定代码指的是无论如何不会出错的代码。
- 对于非稳定代码的 catch 尽可能进行区分异常类型,再做对应的异常处理。说明:对大段代码进行 try-catch,使程序无法根据不同的异常做出正确的应激反应,也不利于定位问题,这是一种不负责任的表现。
- 正例:用户注册的场景中,如果用户输入非法字符,或用户名称已存在,或用户输入密码过于简单,在程序上作出分门别类的判断,并提示给用户。
- 不要在 finally 块中使用 return
- finally 块中的 return 返回后方法结束执行,不会再返回 try 块中的 return 语句的结果,即返回值被 finally 的返回值覆盖;
- catch 只处理应用能处理的异常,不要捕获 Throwable
- Throwable 是所有 Exceptions 和 Errors 的父类。如果使用了 Throwable ,它将不仅捕获所有异常,还会捕获所有错误。这些错误是由 JVM 抛出的,用来表明不打算由应用处理的严重错误。
2.6 Crash长效治理
- 开发阶段
Crash
的长效治理需要从开发阶段抓起,从长远来说,更好的代码质量将带来更好的稳定性,我们可以从以下两个角度来提升代码质量。- 统一编码规范、增强编码功底、技术评审、增强
CodeReview
机制;架构优化,能力收敛(即将一些常见的操作进行封装),统一容错:如在网络库utils中统一对返回信息进行预校验,如不合法就直接不走接下来的流程。
- 测试阶段
- 除了功能测试、自动化测试、回归测试、覆盖安装等常规测试流程之外,还需要针对特殊场景、机型等边界进行测试:如服务端返回异常数据、服务端宕机等情况。
- 合码阶段
- 在我们的功能开发完毕,即将合并到主分支时,首先要进行编译检测、静态扫描,来发现可能存在的问题。在扫描完成后也不能直接合入,因为多个分支可能会冲突,因此我们先进行一个预编译流程,即合入一个与主分支一样的分支、然后打包进行主流程自动化回归测试,流程通过后再合入主分支。当然这样做可能比较麻烦,但这些步骤应该都是自动化的。
- 发布阶段
- 在发布阶段,我们应该进行多轮灰度,灰度量级应逐渐由小变大,用最小的代价提前暴露问题。灰度发布同样应该分场景、多纬度全面覆盖,可以针对特别的版本,机型等进行专门的灰度,看下那些更有可能出现问题的用户是否出现问题
- 运维阶段
- 在上线之后,稳定性问题同样需要关注,因此特别依赖于APM的灵敏监控,发现问题及时报警。如果出现了异常情况,也需要根据情况进行回滚或者降级策略
03.系统级别Crash
3.1 排查解决思路
- eg举个例子,看下下面这个崩溃:
java.util.concurrent.TimeoutException: android.os.BinderProxy.finalize() timed out after 10 seconds at android.os.BinderProxy.destroy(Native Method) at android.os.BinderProxy.finalize(Binder.java:459)
- 众所周知,
Android
的机型众多,碎片化严重,各个硬件厂商可能会定制自己的ROM,更改系统方法,导致特定机型的崩溃。发现这类Crash
,主要靠云测平台配合自动化测试,以及线上监控,这种情况下的Crash堆栈信息很难直接定位问题。下面是常见的解决思路:- 查找可能的原因。(但通过操作路径和日志,我们可以找到一些怀疑的点)
- 尝试规避(查看可疑的代码调用,是否使用了不恰当的 API,是否可以更换其他的实现方式规避,或者尝试修改代码逻辑来进行规避)
- Hook 解决( Java Hook 和 Native Hook)。Java Hook主要靠反射或者动态代理来更改相应API的行为,需要尝试找到可以Hook的点,一般Hook的点多为静态变量,同时需要注意Android不同版本的API,类名、方法名和成员变量名都可能不一样,所以要做好兼容工作;Native Hook原理上是用更改后方法把旧方法在内存地址上进行替换,需要考虑到Dalvik和ART的差异;相对来说
Native Hook
的兼容性更差一点,所以用Native Hook的时候需要配合降级策略。
- 如果通过前两种方式都无法解决怎么办
- 那就只能尝试反编译ROM,寻找解决的办法。这个比较难,耗时大……
3.2 解决Toast崩溃
- 举一个比较实际的案例
android.view.WindowManager$BadTokenException: at android.view.ViewRootImpl.setView(ViewRootImpl.java) at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java) at android.view.WindowManagerImpl.addView(WindowManagerImpl.java4) at android.widget.Toast$TN.handleShow(Toast.java)
- 为什么
Android 8.0
的系统不会有这个问题?在查看Android
8.0 的源码后我们发现有以下修改:try { mWM.addView(mView, mParams); trySendAccessibilityEvent(); } catch (WindowManager.BadTokenException e) { /* ignore */ }
- 考虑再三,决定参考 Android 8.0 的做法,直接 catch 住这个异常。这里的关键在于寻找 Hook 点,这个案例算是相对比较简单的。Toast 里面有一个变量叫 mTN,它的类型为 handler,我们只需要代理它就可以实现捕获。
- 分析流程
- Hook 解决,这个比较难。以最近解决的一个系统崩溃为例,发现线上出现一个 Toast 相关的系统崩溃,它只出现在 Android 7.0 的系统中,看起来是在 Toast 显示的时候窗口的 token 已经无效了。这有可能出现在 Toast 需要显示时,窗口已经销毁了。
- 什么是hook
- hook,钩子. 安卓中的hook技术,其实是一个抽象概念:对系统源码的代码逻辑进行"劫持",插入自己的逻辑,然后放行。注意:hook可能频繁使用java反射机制···
3.3 排查so库异常
3.4 反编译ROM解决
- 举一个定制系统ROM导致Crash的例子,根据
Crash
平台统计数据发现该Crash只发生在vivo V3Max这类机型上,Crash堆栈如下:java.lang.RuntimeException: An error occured while executing doInBackground() at android.os.AsyncTask$3.done(AsyncTask.java:304) at java.util.concurrent.FutureTask.finishCompletion(FutureTask.java:355) at java.util.concurrent.FutureTask.setException(FutureTask.java:222) at java.util.concurrent.FutureTask.run(FutureTask.java:242) at android.os.AsyncTask$SerialExecutor$1.run(AsyncTask.java:231) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1112) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:587) at java.lang.Thread.run(Thread.java:818) Caused by: java.lang.NullPointerException: Attempt to invoke interface method 'int java.util.List.size()' on a null object reference at android.widget.AbsListView$UpdateBottomFlagTask.isSuperFloatViewServiceRunning(AbsListView.java:7689) at android.widget.AbsListView$UpdateBottomFlagTask.doInBackground(AbsListView.java:7665) at android.os.AsyncTask$2.call(AsyncTask.java:292) at java.util.concurrent.FutureTask.run(FutureTask.java:237) ... 4 more
- 发现原生系统上对应系统版本的
AbsListView
里并没有UpdateBottomFlagTask
类,因此可以断定是vivo该版本定制的ROM修改了系统的实现。我们在定位这个Crash的可疑点无果后决定通过Hook的方式解决,通过源码发现AsyncTask$SerialExecutor
是静态变量,是一个很好的Hook的点,通过反射添加try-catch解决。- 因为修改的是
final
对象所以需要先反射修改accessFlags
,需要注意ART和Dalvik下对应的Class
不同,代码如下:
public static void setFinalStatic(Field field, Object newValue) throws Exception { field.setAccessible(true); Field artField = Field.class.getDeclaredField("artField"); artField.setAccessible(true); Object artFieldValue = artField.get(field); Field accessFlagsFiled = artFieldValue.getClass().getDeclaredField("accessFlags"); accessFlagsFiled.setAccessible(true); accessFlagsFiled.setInt(artFieldValue, field.getModifiers() & ~Modifier.FINAL); field.set(null, newValue); } private void initVivoV3MaxCrashHander() { if (!isVivoV3() && version==xxx) { return; } try { setFinalStatic(AsyncTask.class.getDeclaredField("SERIAL_EXECUTOR"), new SafeSerialExecutor()); Field defaultfield = AsyncTask.class.getDeclaredField("sDefaultExecutor"); defaultfield.setAccessible(true); defaultfield.set(null, AsyncTask.SERIAL_EXECUTOR); } catch (Exception e) { L.e(e); } }
- 因为修改的是
App
用上述方法解决了对应的Crash,但是有时候因为平台的限制无法通过这种方式,于是我们尝试反编译ROM。- Android ROM编译时会将framework、app、bin等目录打入system.img中,system.img是Android系统中用来存放系统文件的镜像 (image),文件格式一般为yaffs2或ext。
- 但Android 5.0开始支持dm-verity后,system.img不再提供,而是提供了三个文件system.new.dat,system.patch.dat,system.transfer.list,因此我们首先需要通过上述的三个文件得到system.img。
- 但我们将vivo ROM解压后发现厂商将system.new.dat进行了分片。经过对system.transfer.list中的信息和system.new.dat 1 2 3 … 文件大小对比研究,发现一些共同点,system.transfer.list中的每一个block数*4KB 与对应的分片文件的大小大致相同,故大胆猜测,vivo ROM对system.patch.dat分片也只是单纯的按block先后顺序进行了分片处理。所以我们只需要在转化img前将这些分片文件合成一个system.patch.dat文件就可以了。最后根据system.img的文件系统格式进行解包,拿到framework目录,其中有framework.jar和boot.oat等文件,因为Android4.4之后引入了ART虚拟机,会预先把system/framework中的一些jar包转换为oat格式,所以我们还需要将对应的oat文件通过ota2dex将其解包获得dex文件,之后通过dex2jar和jd-gui查看源码。
04.OOM导致Crash
OOM
是OutOfMemoryError
的简称,在常见的Crash
疑难排行榜上,OOM
绝对可以名列前茅并且经久不衰。- 因为它发生时的
Crash
堆栈信息往往不是导致问题的根本原因,而只是压死骆驼的最后一根稻草。
- 因为它发生时的
- 导致
OOM
的原因大部分如下:- 第一是内存泄漏,大量无用对象没有被及时回收导致后续申请内存失败;
- 第二是大内存对象过多,最常见的大对象就是
Bitmap
,几个大图同时加载很容易触发OOM。
4.1 内存泄漏问题
- 内存泄漏指系统未能及时释放已经不再使用的内存对象,一般是由错误的程序代码逻辑引起的。
- 在Android平台上,最常见也是最严重的内存泄漏就是
Activity
对象泄漏。Activity
承载了App的整个界面功能,Activity
的泄漏同时也意味着它持有的大量资源对象都无法被回收,极其容易造成OOM。
- 在Android平台上,最常见也是最严重的内存泄漏就是
- 常见的可能会造成
Activity
泄漏的原因有:- 匿名内部类实现
Handler
处理消息,可能导致隐式持有的Activity
对象无法回收。 Activity
和Context
对象被混淆和滥用,在许多只需要Application Context而不需要使用Activity对象的地方使用了Activity对象,比如注册各类Receiver、计算屏幕密度等等。View
对象处理不当,使用Activity
的LayoutInflater
创建的View自身持有的Context对象其实就是Activity,这点经常被忽略,在自己实现View重用等场景下也会导致Activity泄漏。
- 匿名内部类实现
- 对于Activity泄漏,目前已经有了一个非常好用的检测工具:
- LeakCanary,它可以自动检测到所有Activity的泄漏情况,并且在发生泄漏时给出十分友好的界面提示,同时为了防止开发人员的疏漏,我们也会将其上报到服务器,统一检查解决。
4.2 大对象
- 在Android平台上,我们分析任一应用的内存信息,几乎都可以得出同样的结论:
- 占用内存最多的对象大都是
Bitmap
对象。随着手机屏幕尺寸越来越大,屏幕分辨率也越来越高,1080p和更高的2k屏已经占了大半份额,为了达到更好的视觉效果,往往需要使用大量高清图片,同时也为OOM埋下了祸根。
- 占用内存最多的对象大都是
- 对于图片内存优化,我们有几个常用的思路:
- 尽量使用成熟的图片库,比如Glide,图片库会提供很多通用方面的保障,减少不必要的人为失误。
- 根据实际需要,也就是View尺寸来加载图片,可以在分辨率较低的机型上尽可能少地占用内存。除了常用的
BitmapFactory.Options#inSampleSize
和Glide提供的BitmapRequestBuilder#override
之外,图片CDN服务器也支持图片的实时缩放,可以在服务端进行图片缩放处理,从而减轻客户端的内存压力。 - 分析App内存的详细情况是解决问题的第一步,我们需要对App运行时到底占用了多少内存、哪些类型的对象有多少个有大致了解,并根据实际情况做出预测,这样才能在分析时做到有的放矢。
- Android Studio也提供了非常好用的Memory Profiler,堆转储和分配跟踪器功能可以帮我们迅速定位问题。
05.三方库导致Crash
App
经常会依赖很多AAR- 每个AAR可能有多个版本,打包时Gradle会根据规则确定使用的最终版本号(默认选择最高版本或者强制指定的版本),而其他版本的AAR将被丢弃。
- 如果互相依赖的AAR中有不兼容的版本,存在的问题在打包时是不能发现的,只有在相关代码执行时才会出现,会造成
NoClassDefFoundError
、NoSuchFieldError
、NoSuchMethodError
等异常。
- Defensor在编译时通过DexTask获取到所有的输入文件(也就是被编译过的class文件),然后检查每个文件里引用的类、字段、方法等是否存在。
06.AOP增强辅助
6.1 AOP是什么
- AOP是面向切面编程的简称
- 在Android的Gradle插件1.5.0中新增了
Transform
API之后,编译时修改字节码来实现AOP也因为有了官方支持而变得非常方便。
- 在Android的Gradle插件1.5.0中新增了
- 在一些特定情况下,可以通过AOP的方式自动处理未捕获的异常:
- 抛异常的方法非常明确,调用方式比较固定;异常处理方式比较统一。
- 和业务逻辑无关,即自动处理异常后不会影响正常的业务逻辑。典型的例子有读取
Intent Extras
参数、读取SharedPreferences
、解析颜色字符串值和显示隐藏Window
等等。
6.2 处理Intent异常
- 这类问题的解决原理大致相同
- 以Intent Extras为例详细介绍一下。读取Intent Extras的问题在于我们非常常用的方法 Intent#getStringExtra 在代码逻辑出错或者恶意攻击的情况下可能会抛出ClassNotFoundException异常,而我们平时在写代码时又不太可能给所有调用都加上try-catch语句,于是一个更安全的Intent工具类应运而生,理论上只要所有人都使用这个工具类来访问Intent Extras参数就可以防止此类型的Crash。
- 但是面对庞大的旧代码仓库和诸多的业务部门,修改现有代码需要极大成本,还有更多的外部依赖SDK基本不可能使用我们自己的工具类,此时就需要AOP大展身手了。
- 我们专门制作了一个
Gradle
插件,只需要配置一下参数就可以将某个特定方法的调用替换成另一个方法:WaimaiBytecodeManipulator { replacements( "android/content/Intent.getIntExtra(Ljava/lang/String;I)I=com/waimai/IntentUtil.getInt(Landroid/content/Intent;Ljava/lang/String;I)I", "android/content/Intent.getStringExtra(Ljava/lang/String;)Ljava/lang/String;=com/waimai/IntentUtil.getString(Landroid/content/Intent;Ljava/lang/String;)Ljava/lang/String;", "android/content/Intent.getBooleanExtra(Ljava/lang/String;Z)Z=com/waimai/IntentUtil.getBoolean(Landroid/content/Intent;Ljava/lang/String;Z)Z", ...) } }
- 上面的配置就可以将App代码(包括第三方库)里所有的Intent.getXXXExtra调用替换成IntentUtil类中的安全版实现。
- 当然,并不是所有的异常都只需要catch住就万事大吉,如果真的有逻辑错误肯定需要在开发和测试阶段及时暴露出来,所以在IntentUtil中会对App的运行环境做判断,Debug下会将异常直接抛出,开发同学可以根据Crash堆栈分析问题,Release环境下则在捕获到异常时返回对应的默认值然后将异常上报到服务器。
07.Crash的预防实践
- 单纯的靠约定或规范去减少
Crash
的发生是不现实的。- 约定和规范受限于组织架构和具体执行的个人,很容易被忽略,只有靠工程架构和工具才能保证Crash的预防长久的执行下去。
7.1 工程架构对Crash率的影响
- 在治理Crash的实践中,往往忽略了工程架构对Crash率的影响。
- Crash的发生大部分原因是源于程序员的不合理的代码,而程序员工作中最直接的接触的就是工程架构。
- 对于一个层级混乱的架构,是更加容易写出引起Crash的代码。在这样的架构里面,即使程序员意识到导致某种写法存在问题,想要去改善这样不合理的代码,也是非常困难的。相反,一个层级清晰,边界明确的架构,是能够大大减少Crash发生的概率,治理和预防Crash也是相对更容易。
- 业务模块的划分
- 原来Crash基本上都是由个别同学关注解决的,团队里的每个同学都会提交可能引起Crash的代码,如果负责Crash的同学因为某些事情,暂时没有关注App的Crash率,那么造成Crash的同学也不会知道他的代码引起了Crash。
- 对于这个问题,我们的做法是App的业务模块化。业务模块化后,每个业务都有都有唯一包名和对应的负责人。当某个模块发生了Crash,可以根据包名提交问题给这个模块的负责人,让他第一时间进行处理。业务模块化本身也是工程架构优先需要考虑的事情之一。
- 页面跳转路由统一处理页面跳转
- 将页面的跳转,都通过我们封装的
scheme
路由去分发。这样的好处是,通过scheme路由,在工程架构上所有业务都是解耦,模块间不需要相互依赖就可以实现页面的跳转和基本类型参数的传递;同时,由于所有的页面跳转都会走scheme路由,我们只需要在scheme路由里一处加上ActivityNotFoundException异常捕获即可解决这种类型的Crash。路由设计示意图如下:
- 将页面的跳转,都通过我们封装的
- 网络层统一处理
API
脏数据- 客户端的很大一部分的
Crash
是因为API
返回的脏数据。比如当API返回空值、空数组或返回不是约定类型的数据,App收到这些数据,就极有可能发生空指针、数组越界和类型转换错误等Crash。而且这样的脏数据,特别容易引起线上大面积的崩溃。 - 网络层只承担了请求网络的职责,没有承担数据解析的职责,数据解析的职责交给了页面去处理。这样使得我们一旦发现脏数据导致的Crash,就只能在网络请求的回调里面增加各种判断去兼容脏数据。
- 如果存在脏数据的话,在网络层就会发现问题,不会影响到UI层,返回给UI层的都是校验成功的数据。这样改造后,发现这类的Crash率有了极大的改善。
- 客户端的很大一部分的
7.2 大图监控
- 大对象是导致
OOM
的主要原因之一Bitmap
是App里最常见的大对象类型,因此对占用内存过大的Bitmap对象的监控就很有必要了。
- 用AOP方式Hook了三种常见图片库的加载图片回调方法,同时监控图片库加载图片时的两个维度:
- 加载图片使用的URL。App中除静态资源外,所有图片都要求发布到专用的图片CDN服务器上,加载图片时使用正则表达式匹配URL,除了限定CDN域名之外还要求所有图片加载时都要添加对应的动态缩放参数。
- 最终加载出的图片结果(也就是Bitmap对象)。我们知道
Bitmap
对象所占内存和其分辨率大小成正比,而一般情况下在ImageView
上设置超过自身尺寸的图片是没有意义的,所以要求显示在ImageView
中的Bitmap分辨率不允许超过View自身的尺寸(为了降低误报率也可以设定一个报警阈值)。
- 开发过程中,在App里检测到不合规的图片时会立即高亮出错的
ImageView
所在的位置并弹出对话框提示ImageView
所在的Activity
加载图片使用的URL等信息,辅助开发同学定位并解决问题。在Release环境下可以将报警信息上报到服务器,实时观察数据,有问题及时处理。
7.3 Lint检查
- 发现线上的很多
Crash
其实可以在开发过程中通过Lint
检查来避免。- Lint是Google提供的Android静态代码检查工具,可以扫描并发现代码中潜在的问题,提醒开发人员及早修正,提高代码质量。
- 但是Android原生提供的Lint规则(如是否使用了高版本API)远远不够,缺少一些我们认为有必要的检测,也不能检查代码规范。
- 因此我们开始开发自定义Lint,目前我们通过自定义Lint规则已经实现了Crash预防、Bug预防、提升性能/安全和代码规范检查这些功能。
- 如检查实现了
Serializable
接口的类,其成员变量(包括从父类继承的)所声明的类型都要实现Serializable接口,可以有效的避免NotSerializableException
;强制使用封装好的工具类如ColorUtil、WindowUtil等可以有效的避免因为参数不正确产生的IllegalArgumentException和因为Activity
已经finish
导致的BadTokenException
。
- Lint检查可以在多个阶段执行,包括在本地手动检查、编码实时检查、编译时检查、commit时检查,以及在CI系统中提Pull Request时检查、打包时检查等。
7.4 最后的抢救
- 举一个场景例子
- 比如在onCreate方法中,虽然我们捕获住了异常,但程序的执行也被中断了,界面的绘制可能无法完成,点击事件的设置也没有生效。
- 这就导致了 app 虽然没有退出,但用户却无法操作的问题,这种情况似乎还不如直接 Crash 了呢。
- 因此安全气囊应该支持动态配置(接口下发或者阿波罗配置),注意:只处理那些非主流程的操作,比如点击按钮触发的崩溃,或者一些打点等对用户无感知操作造成的崩溃。
- 如何做一些崩溃异常后的补救
- 第一步:当异常发生时捕获异常。匹配异常堆栈是否符合配置(堆栈的集合配置),如果符合则捕获,否则交给默认处理器处理。
- 第二步:判断异常发生时是否是主线程,如果是则重启Looper。主要是使用while循环开发loop操作。
- Android Crash 最后的抢救
- https://mp.weixin.qq.com/s/91CsBU57BubJtRUdfmkPgw
08.Looper拦截崩溃
8.1 能否用Looper拦截崩溃
- 问题思考一下。
- 能否基于
Handler
和Looper
拦截全局崩溃(主线程),避免 APP 退出。能否基于 Handler 和 Looper 实现 ANR 监控。
- 能否基于
- 今天分析的问题有:
- 如何拦截全局崩溃,避免APP退出。如何实现 ANR 监控。拦截到了之后可以做什么处理,如何优化?
8.2 使用Loop拦截崩溃
- 先从APP启动开始分析,APP的启动方法是在ActivityThread中,在main方法中创建了主线程的Looper,也就是当前进程创建。
- 在main方法的最后调用了 Looper.loop(),在这个方法中处理主线程的任务调度,一旦执行完这个方法就意味着APP被退出了。
- 如果我们要避免APP被退出,就必须让APP持续执行Looper.loop()。注意这句话非常重要!!!
- 查看相关源码得出结论
- Looper 是用来循环遍历消息队列的,一旦消息队列中存在消息,那么就会执行里面的操作。整个 Android 系统就是基于事件驱动的,而事件主要就是基于 Looper 来获取的。
- 如果这里一旦出现 crash,那么就直接会跳出整个 main 方法,自然 loop 循环也就跳出了,那么自然而然事件也就接收不到,更没法处理,所以整个 App 就会卡死在这里。
- 如何让app崩溃后不会退出
- 如果主线程发生了异常,就会退出循环,意味着APP崩溃,所以我们我们需要进行try-catch,避免APP退出,我们可以在主线程再启动一个 Looper.loop() 去执行主线程任务,然后try-catch这个Looper.loop()方法,就不会退出。
8.4 拦截主线程崩溃
- 拦截主进程崩溃其实也有一定的弊端,因为给用户的感觉是点击没有反应,因为崩溃已经被拦截了。如果是Activity.create崩溃,会出现黑屏问题,所以如果Activity.create崩溃,必须杀死进程,让APP重启,避免出现改问题。
- 有没有其他办法可以保证 App 在抛出异常不 crash 的情况下,又能保证不会卡死呢?looper 是查询事件的核心类,那么我们是否可以不让跳出 loop 循环呢。
- 可以给消息队列发送一个 loop 循环,然后给这个 loop 做一个 try-catch ,一旦外层的 loop 检测到这个事件,就会执行我们自己创建的 loop 循环,这样以后 App 内的所有事件都会在我们自己的 loop 循环中处理。
- 一旦抛出异常,跳出 loop 循环以后,我们也可以在 loop 外层套一层 while 循环,让自己的 loop 再次工作。
public class MyApplication extends Application { @Override protected void attachBaseContext(Context base) { super.attachBaseContext(base); new Handler(getMainLooper()).post(new Runnable() { @Override public void run() { while (true) { try { Looper.loop(); } catch (Throwable e) { e.printStackTrace(); // TODO 需要手动上报错误到异常管理平台,比如bugly,及时追踪问题所在。 if (e.getMessage() != null && e.getMessage().startsWith("Unable to start activity")) { // 如果打开Activity崩溃,就杀死进程,让APP重启。 android.os.Process.killProcess(android.os.Process.myPid()); break; } } } } }); } }
8.5 拦截子线程崩溃
- 主线程的Looper 发送 loop 循环都是主线程操作的,那么子线程如果抛出异常怎么办呢,这么处理应该也是会 crash ,该怎么处理呢?处理代码如下所示:
public class MyApplication extends Application { @Override protected void attachBaseContext(Context base) { super.attachBaseContext(base); new Handler(getMainLooper()).post(new Runnable() { @Override public void run() { while (true) { try { Looper.loop(); } catch (Throwable e) { e.printStackTrace(); // TODO 需要手动上报错误到异常管理平台,比如bugly,及时追踪问题所在。 if (e.getMessage() != null && e.getMessage().startsWith("Unable to start activity")) { // 如果打开Activity崩溃,就杀死进程,让APP重启。 android.os.Process.killProcess(android.os.Process.myPid()); break; } } } } }); } } Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { @Override public void uncaughtException(Thread t, Throwable e) { e.printStackTrace(); Log.e("Application-----","uncaughtException---异步线程崩溃,自行上报崩溃信息"); } });
- 然后分析一下为何添加了setDefaultUncaughtExceptionHandler方法后就会避免异步线程崩溃
- Thread.UncaughtExceptionHandler 接口代码如下所示
@FunctionalInterface public interface UncaughtExceptionHandler { void uncaughtException(Thread t, Throwable e); }
- UncaughtExceptionHandler 未捕获异常处理接口,当一个线程由于一个未捕获异常即将崩溃时,JVM 将会通过 getUncaughtExceptionHandler() 方法获取该线程的 UncaughtExceptionHandler,并将该线程和异常作为参数传给 uncaughtException()方法。
- 如果没有显式设置线程的 UncaughtExceptionHandler,那么会将其 ThreadGroup 对象会作为 UncaughtExceptionHandler。
- 如果其 ThreadGroup 对象没有特殊的处理异常的需求,那么就会调 getDefaultUncaughtExceptionHandler() 方法获取默认的 UncaughtExceptionHandler 来处理异常。
- 难道要为每一个线程创建UncaughtExceptionHandler吗?
- 应用程序通常都会创建很多线程,如果为每一个线程都设置一次 UncaughtExceptionHandler 未免太过麻烦。
- 既然出现未处理异常后 JVM 最终都会调 getDefaultUncaughtExceptionHandler(),那么我们可以在应用启动时设置一个默认的未捕获异常处理器。即调用Thread.setDefaultUncaughtExceptionHandler(handler)
- setDefaultUncaughtExceptionHandler被调用多次如何理解?
- Thread.setDefaultUncaughtExceptionHandler(handler) 方法如果被多次调用的话,会以最后一次传递的 handler 为准,所以如果用了第三方的统计模块,可能会出现失灵的情况。对于这种情况,在设置默认 hander 之前,可以先通过 getDefaultUncaughtExceptionHandler() 方法获取并保留旧的 hander,然后在默认 handler 的uncaughtException 方法中调用其他 handler 的 uncaughtException 方法,保证都会收到异常信息。
- Thread.UncaughtExceptionHandler 接口代码如下所示
8.6 最后的总结建议
- 虽然说looper可以拦截主线程,子线程崩溃,还有ANR。但是有一个最大的问题是,让本该出现异常的地方,触发后没有反应,这样可能会导致后续出现大问题。
- 不过还是看到网上有方案说,有一些源码层级崩溃异常,比如startActivity出现异常,还有开启activity需要flag兼容,以及弹窗show出现崩溃。这种则可以反射处理,在activity生命周期使用handler发送消息导致异常的时候【极少数偶发】,关闭页面。
09.Crash监控和止损
9.1 Crash的监控
- 在经过前面提到的各种检查和测试之后,应用便开始发布了。建立了如下监控流程,来保证异常发生时能够及时得到反馈并处理。
- 首先是灰度监控,灰度阶段是增量Crash最容易暴露的阶段,如果这个阶段没有很好的把握住,会使得增量变存量,从而导致Crash率上升。如果条件允许的话,可以在灰度期间制定一些灰度策略去提高这个阶段Crash的暴露。例如分渠道灰度、分城市灰度、分业务场景灰度、新装用户的灰度等等,尽量覆盖所有的分支。
- 灰度结束之后便开始全量,在全量的过程中我们还需要一些日常Crash监控和Crash率的异常报警来防止突发情况的发生,例如因为后台上线或者运营配置错误导致的线上Crash。
- 除此之外还需要一些其他的监控,例如,之前提到的大图监控,来避免因为大图导致的OOM。具体的输出形式主要有邮件通知、IM通知、报表。
9.2 Crash的止损
- 在前面做了那么多,但是Crash还是无法避免的
- 例如,在灰度阶段因为量级不够,有些Crash没有被暴露出来;又或者某些功能客户端比后台更早上线,而这些功能在灰度阶段没有被覆盖到;这些情况下,如果出现问题就需要考虑如何止损了。
- 问题发生时首先需要评估重要性
- 如果问题不是很严重而且修复成本较高可以考虑在下个版本再修复,相反如果问题比较严重,对用户体验或下单有影响时就必须要修复。
- 修复时首先考虑业务降级,主要看该部分异常的业务是否有兜底或者A/B策略,这样是最稳妥也是最有效的方式。
- 如果问题发生非要发版本,就只能强制用户升级。强制升级因为覆盖周期长,同时影响用户的体验,只在万不得已的情况下才会使用。
9.3 Crash的自我修复
- 在做新技术选型时除了要考虑是否能满足业务需求、是否比现有技术更优秀和团队学习成本等因素之外,兼容性和稳定性也非常重要。但面对国内非富多彩的Android系统环境,在体量百万级以上的的App中几乎不可能实现毫无瑕疵的技术方案和组件,所以一般情况下如果某个技术实现方案可以达到0.01‰以下的崩溃率,而其他方案也没有更好的表现,我们就认为它是可以接受的。但是哪怕仅仅十万分之一的崩溃率,也代表还有用户受到影响,而我们认为Crash对用户来说是最糟糕的体验,尤其是涉及到交易的场景,所以我们必须本着每一单都很重要的原则,尽最大努力保证用户顺利执行流程。
- 实际情况中有一些技术方案在兼容性和稳定性上做了一定妥协的场景,往往是因为考虑到性能或扩展性等方面的优势。这种情况下我们其实可以再多做一些,进一步提高App的可用性。就像很多操作系统都有“兼容模式”或者“安全模式”,很多自动化机械机器都配套有手动操作模式一样,App里也可以实现备用的降级方案,然后设置特定条件的触发策略,从而达到自动修复Crash的目的。
- 举例来讲,Android 3.0中引入了硬件加速机制,虽然可以提高绘制帧率并且降低CPU占用率,但是在某些机型上还是会有绘制错乱甚至Crash的情况,这时我们就可以在App中记录硬件加速相关的Crash问题或者使用检测代码主动检测硬件加速功能是否正常工作,然后主动选择是否开启硬件加速,这样既可以让绝大部分用户享受硬件加速带来的优势,也可以保障硬件加速功能不完善的机型不受影响。
- 还有一些类似的可以做自动降级的场景,比如:
- 部分使用JNI实现的模块,在SO加载失败或者运行时发生异常则可以降级为Java版实现。
- RenderScript实现的图片模糊效果,也可以在失败后降级为普通的Java版高斯模糊算法。
- 在使用Retrofit网络库时发现OkHttp3或者HttpURLConnection网络通道失败率高,可以主动切换到另一种通道。
- 这类问题都需要根据具体情况具体分析,如果可以找到准确的判定条件和稳定的修复方案,就可以让App稳定性再上一个台阶。
9.4 Crash日志回捞
- 在开发时使用各种工具、措施来避免Crash的发生,但Crash还是不可避免。
- 线上某些怪异的Crash发生后,我们除了分析Crash堆栈信息之外,还可以使用离线日志回捞、下发动态日志等工具来还原Crash发生时的场景,帮助开发同学定位问题,但是这两种方式都有它们各自的问题。
- 离线日志顾名思义,它的内容都是预先记录好的,有时候可能会漏掉一些关键信息,因为在代码中加日志一般只是在业务关键点,在大量的普通方法中不可能都加上日志。
- 动态日志(Holmes)存在的问题是每次下发只能针对已知UUID的一个用户的一台设备,对于大量线上Crash的情况这种操作并不合适,因为我们并不能知道哪个发生Crash的用户还会再次复现这次操作,下发配置充满了不确定性。
- 可以改造Holmes使其支持批量甚至全量下发动态日志,记录的日志等到发生特定类型的Crash时才上报,这样一来可以减少日志服务器压力,同时也可以极大提高定位问题的效率,因为我们可以确定上报日志的设备最后都真正发生了该类型Crash,再来分析日志就可以做到事半功倍。