App性能优化之稳定性优化;十分钟带你了解Crash治理
一、Android碎片化的痛点
说到Android系统手机,大部分人的印象是用了一段时间就变得有点卡顿,有些程序在运行期间莫名其妙的出现崩溃,打开系统文件夹一看发现多了很多文件,然后用手机管家APP不断地进行清理优化,才感觉运行速度稍微提高了点,就算手机在各种性能跑分软件面前分数遥遥领先,还是感觉无论有多大的内存空间都远远不够用。相信每个使用Android系统的用户都有过以上类似经历,Android系统在流畅性方面不如iOS系统,明明在看手机硬件配置上时,Android设备都不会输于iOS设备,甚至都强于它,关键是在于软件上。
造成这种现象的原因是多方面的,简单罗列几点如下:
1、其实近年来,随着Android版本不断迭代,Google提供的Android系统已经越来越流畅,但是在国内大部分用户用的Android手机系是各大厂商定制过的版本,往往不是最新的原生系统内核,可能绝大多数还停留在较老的Android版本,更新存在延迟性。
2、由于Android系统源码是开放的,每个人只要遵从相应的协议,就可以对源码进行修改,那么国内各个厂商就把基于Android源码改造成自己对外发布的系统,比如我们熟悉的小米MIUI系统、华为EMUI系统、oppo的ColorOS系统等。由于每个厂商都修改过Android原生系统源码,这里面就会引发一个问题,那就是著名的Android碎片化问题,本质就是不同Android系统的应用兼容性不同,达不到一致性。
3、由于存在着各种Android碎片化和兼容性问题,导致Android开发者在开发应用时需要对不同系统进行适配,同时每个Android开发者的开发水平参差不齐,写出来的应用性能也都存在不同类型的问题,导致用户在使用过程中用户体验感受不同,那么有些问题用户就会转化为Android系统问题,进而影响对Android手机的评价。
二、App稳定性
Android 应用的稳定性定义很宽泛,影响稳定性的原因很多,比如内存使用不合理、代码异常场景考虑不周全、代码逻辑不合理等,都会对应用的稳定性造成影响。其中最常见的两个场景是:Crash 和 ANR,这两个错误将会使得程序无法使用,比较常用的解决方式如下:
提高代码质量。比如开发期间的代码审核,看些代码设计逻辑,业务合理性等。
代码静态扫描工具。常见工具有Android Lint、Findbugs、Checkstyle、PMD等等。
Crash监控和上传。把一些崩溃的信息,异常信息及时地记录下来,以便后续分析解决。在Crash后,尽量先保存日志到本地,然后等下一次网络正常时再上传日志信息。
本文重点说说Crash相关的东西,包括Crash对App的影响、造成Crash的因素和优化/降低Crash率的方法论。
三、什么是Crash
Crash崩溃也叫闪退,指用户在操作手机App时,突然退出App的现象,伴随着可能会弹出停止运行的对话框或者自动重启App。
四、Crash的原理
JavaWeb程序在运行的过程中,只要还有其他线程运行,JVM虚拟机就不会关闭,进程就不会结束。但是在Android App的运行过程中,不论是主线程还是子线程,或是三方库的子线程,只要发生异常,就会引起应用崩溃。原因是JavaWeb程序中没有设置默认的线程异常处理器,而Android系统为每一个Android App进程都设置了默认的线程异常处理器。
五、Crash的影响
Crash率是衡量一个App口碑好坏的重要指标之一。如果忽略了它的存在,它就会得寸进尺,愈演愈烈,最后造成大量用户的流失,进而会带来无法估量的损失。
六、Crash治理原则
对于Crash的治理,我们尽量遵守以下三点原则:
1、由点到面。一个Crash发生了,我们不能只针对这个Crash的去解决,而要去考虑这一类Crash怎么去解决和预防。只有这样才能使得这一类Crash真正被解决。
2、异常不能随便吃掉。随意的使用try-catch,只会增加业务的分支和隐蔽真正的问题,要了解Crash的本质原因,根据本质原因去解决。catch的分支,更要根据业务场景去兜底,保证后续的流程正常。
3、预防胜于治理。当Crash发生的时候,损失已经造成了,我们再怎么治理也只是减少损失。尽可能的提前预防Crash的发生,可以将Crash消灭在萌芽阶段。
七、Crash治理分类
(一) 常规的Crash治理
常规Crash发生的原因主要是由于开发人员编写代码不小心导致的。解决这类Crash需要由点到面,根据Crash引发的原因和业务本身,统一集中解决。常见的Crash类型包括:空节点、角标越界、类型转换异常、实体对象没有序列化、数字转换异常、Activity或Service找不到等。这类Crash是App中最为常见的Crash,也是最容易反复出现的。在获取Crash堆栈信息后,解决这类Crash一般比较简单,更多考虑的应该是如何避免。下面介绍两个我们治理的量比较大的Crash。
NullPointerException空指针异常
1、NullPointerException是我们遇到最频繁的,造成这种Crash一般有两种情况:
- 对象本身没有进行初始化就进行操作。
- 对象已经初始化过,但是被回收或者手动置为null,然后对其进行操作。
2、两种情况对应的解决方法:
针对第一种情况导致的原因有很多,可能是开发人员的失误、API返回数据解析异常、进程被杀死后静态变量没初始化导致,我们可以做的有:
- (1)对可能为空的对象做判空处理。
- (2)养成使用@NonNull和@Nullable注解的习惯。
- (3)尽量不使用静态变量,万不得已使用SharedPreferences来存储。
- (4)考虑使用Kotlin语言。
针对第二种情况大部分是由于Activity/Fragment销毁或被移除后,在Message、Runnable、网络等回调中执行了一些代码导致的,我们可以做的有:
- (1)Message、Runnable回调时,判断Activity/Fragment是否销毁或被移除;加try-catch保护;Activity/Fragment销毁时移除所有已发送的Runnable。
- (2)封装LifecycleMessage/Runnable基础组件,并自定义Lint检查,提示使用封装好的基础组件。
- (3)在BaseActivity、BaseFragment的onDestory()里把当前Activity所发的所有请求取消掉。
IndexOutOfBoundsException角标越界异常
这类Crash常见于对ListView的操作和多线程下对容器的操作。
1、针对ListView中造成的IndexOutOfBoundsException,经常是因为外部也持有了Adapter里数据的引用(如在Adapter的构造函数里直接赋值),这时如果外部引用对数据更改了,但没有及时调用notifyDataSetChanged(),则有可能造成Crash,对此我们封装了一个BaseAdapter,数据统一由Adapter自己维护通知, 同时也极大的避免了The content of the adapter has changed but ListView did not receive a notification,这两类Crash目前得到了统一的解决。
2、很多容器是线程不安全的,所以如果在多线程下对其操作就容易引发IndexOutOfBoundsException。常用的如JDK里的ArrayList和Android里的SparseArray、ArrayMap,同时也要注意有一些类的内部实现也是用的线程不安全的容器,如Bundle里用的就是ArrayMap。
(二) 系统级Crash治理/特定机型的崩溃治理
众所周知,Android的机型众多,碎片化严重,各个硬件厂商可能会定制自己的ROM,更改系统方法,导致特定机型的崩溃。发现这类Crash,主要靠云测平台配合自动化测试,以及线上监控,这种情况下的Crash堆栈信息很难直接定位问题。下面是常见的解决思路:
1、尝试找到造成Crash的可疑代码,看是否有特异的API或者调用方式不当导致的,尝试修改代码逻辑来进行规避。
2、通过Hook来解决,Hook分为Java Hook和Native Hook: Java Hook主要靠反射或者动态代理来更改相应API的行为,需要尝试找到可以Hook的点,一般Hook的点多为静态变量,同时需要注意Android不同版本的API,类名、方法名和成员变量名都可能不一样,所以要做好兼容工作; Native Hook原理上是用更改后方法把旧方法在内存地址上进行替换,需要考虑到Dalvik和ART的差异;相对来说Native Hook的兼容性更差一点,所以用Native Hook的时候需要配合降级策略。
3、如果通过前两种方式都无法解决的话,我们只能尝试反编译ROM,寻找解决的办法。
(三) OOM
OOM是OutOfMemoryError的简称,在常见的Crash疑难排行榜上,OOM绝对可以名列前茅并且经久不衰。因为它发生时的Crash堆栈信息往往不是导致问题的根本原因,而只是压死骆驼的最后一根稻草。
导致OOM的原因大部分如下:
1、内存泄漏,大量无用对象没有被及时回收导致后续申请内存失败。
2、大内存对象过多,最常见的大对象就是Bitmap,几个大图同时加载很容易触发OOM。
进行内存优化方案主要包括:
- 避免内存泄露
- 避免内存抖动:避免频繁创建大量、临时的、小的局部对象
- 图片Bitmap相关:释放资源、适配屏幕、解码方式、图片缓存
- 提高代码质量 & 减少代码数量
- 日常不正确使用:ListView的缓存复用、尽量少用多进程、依赖注入框架等
(四) 依赖库的问题(第三方SDK或者服务的问题)
Android App经常会依赖很多AAR,每个AAR可能有多个版本,打包时Gradle会根据规则确定使用的最终版本号(默认选择最高版本或者强制指定的版本),而其他版本的AAR将被丢弃。如果互相依赖的AAR中有不兼容的版本,存在的问题在打包时是不能发现的,只有在相关代码执行时才会出现,会造成NoClassDefFoundError、NoSuchFieldError、NoSuchMethodError等异常。
A和B两个业务库都依赖了map.aar,一个是1.0版本,一个是2.0版本,默认最终打进APK的只有map.aar 2.0版本,这时如果A库里用到的map库里的某个类/方法,但在2.0版本中被删除了,运行时就可能发生异常,虽然SDK在升级时会尽量做到向下兼容,但很多时候尤其是第三方SDK是没法得到保证的。
在这问题在HXB5.0 App里出现过,梆梆加固升级和蚂蚁金服SDK出现兼容问题,导致App闪退。也不明白为什么要用梆梆加固,对APK加固有用吗?我觉得是更是对安全问题的一个甩锅,哈哈哈。
八、Crash的预防实践&止损
(一) 预防实践
单纯的靠约定或规范去减少Crash的发生是不现实的。约定和规范受限于组织架构和具体执行的个人,很容易被忽略,只有靠工程架构和工具才能保证Crash的预防长久的执行下去。
1、工程架构对Crash率的影响
- 业务模块的划分
- 页面跳转路由统一处理页面跳转
- 网络层统一处理API脏数据
2、大图监控
3、Lint检查
4、资源重复检查
5、高效的监控流程
(二) 止损
尽管我们在前面做了那么多,但是Crash还是无法避免的,例如:在灰度阶段因为量级不够,有些Crash没有被暴露出来;又或者某些功能客户端比后台更早上线,而这些功能在灰度阶段没有被覆盖到;这些情况下,如果出现问题就需要考虑如何止损了。
问题发生时首先需要评估重要性,如果问题不是很严重而且修复成本较高可以考虑在下个版本再修复,相反如果问题比较严重,对用户体验或下单有影响时就必须要修复。修复时首先考虑业务降级,主要看该部分异常的业务是否有兜底或者A/B策略,这样是最稳妥也是最有效的方式。
如果业务不能降级就需要考虑热修复了,如果问题发生在热修复无法覆盖的场景,就只能强制用户升级。强制升级因为覆盖周期长,同时影响用户的体验,只在万不得已的情况下才会使用。