作者:程序员江同学
Android
的稳定性是Android
性能的一个重要指标,它也是App质量构建体系中最基本和最关键的一环。如果应用经常崩溃率,或者关键功能不可用,那显然会对我们的留存产生重大影响。
为了保障应用的稳定性,我们首先应该树立对稳定性的正确认识,本文主要包括以下内容:
Crash
处理的一般步骤Crash
长效治理要做稳定性优化,首先一个问题就是,要做成什么效果?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
崩溃率
很多人都会认为稳定性优化就是降低Crash
率,但如果你的APP
没有崩溃,但是关键功能却不可用,这又怎么算是稳定的呢?
因此应用的稳定性可以分为三个纬度,如下所示:
Crash
纬度:最重要的指标就是应用的Crash
率。Crash
来说是次要的,但也是应用稳定性的一部分。App
的主流程以及核心路径的稳定性。Crash
处理的一般步骤下面我们来看下应该如何处理Crash
,即如果应用崩溃了,你应该如何去分析?
主要从崩溃现场和崩溃分析两个角度来分析
崩溃现场是我们的“第一案发现场”,它保留着很多有价值的线索。在这里我们挖掘到的信息越多,下一步分析的方向就越清晰,而不是去靠盲目猜测。
接下来我们具体来看看在崩溃现场应该采集哪些信息。
从崩溃的基本信息,我们可以对崩溃有初步的判断。
Java
崩溃、Native
崩溃,还是 ANR
,对于不同类型的崩溃我们关注的点也不太一样。特别需要看崩溃堆栈的栈顶,看具体崩溃在系统的代码,还是我们自己的代码里面。除了崩溃的信息之外,系统的信息有时候会带有一些关键的线索,对我们解决问题有非常大的帮助。
Logcat
输出。这里包括应用、系统的运行日志。有时从堆栈中看不出什么信息,反而可以从Logcat
中获得意外收获CPU
、ABI
、Linux
版本等。我们会采集多达几十个维度,这对后面讲到寻找共性问题会很有帮助。root
、是否是模拟器。一些问题是由 Xposed
或多开软件造成,对这部分问题我们要区别对待。OOM
、ANR
、虚拟内存耗尽等,很多崩溃都跟内存有直接关系。如果我们把用户的手机内存分为“2GB 以下”和“2GB 以上”两个桶,会发现“2GB 以下”用户的崩溃率是“2GB 以上”用户的几倍。
/proc/meminfo
。当系统可用内存很小(低于 MemTotal
的 10%)时,OOM
、大量 GC
、系统频繁自杀拉起等问题都非常容易出现。Java
内存、RSS
(Resident Set Size
)、PSS
(Proportional Set Size
),我们可以得出应用本身内存的占用大小和分布。/proc/self/status
得到,通过 /proc/self/maps
文件可以得到具体的分布情况。有时候我们一般不太重视虚拟内存,但是很多类似 OOM
、tgkill
等问题都是虚拟内存不足导致的。
Name: com.sample.name // 进程名
FDSize: 800 // 当前进程申请的文件句柄个数
VmPeak: 3004628 kB // 当前进程的虚拟内存峰值大小
VmSize: 2997032 kB // 当前进程的虚拟内存大小
Threads: 600 // 当前进程包含的线程个数
一般来说,对于 32 位进程,如果是 32 位的 CPU
,虚拟内存达到 3GB 就可能会引起内存申请失败的问题。如果是 64 位的 CPU
,虚拟内存一般在 3~4GB 之间。当然如果我们支持 64 位进程,虚拟内存就不会成为问题。因此我们的应用应该尽量适配64位
有的时候我们会发现应用堆内存和设备内存都非常充足,还是会出现内存分配失败的情况,这跟资源泄漏可能有比较大的关系。
fd
。一般单个进程允许打开的最大文件句柄个数为 1024
。但是如果文件句柄超过 800
个就比较危险,需要将所有的 fd
以及对应的文件名输出到日志中,进一步排查是否出现了有文件或者线程的泄漏2MB
的虚拟内存,过多的线程会对虚拟内存和文件句柄带来压力。根据我的经验来说,如果线程数超过 400 个就比较危险。需要将所有的线程 id
以及对应的线程名输出到日志中,进一步排查是否出现了线程相关的问题。除了系统,其实我们的应用更懂自己,可以留下很多相关的信息。
Activity
或 Fragment
,发生在哪个业务中。上面介绍了在崩溃现场应该采集的信息,当然开发一个这样的采集平台还是很复杂的,大多数情况我们只需要接入一些第三方的平台比如bugly
和Sentry
即可。但是通过上述介绍,我们可以知道在分析崩溃的时候应该重点关注哪些信息,同时如果平台能力有缺失,我们也可以添加自定义的上报
在崩溃现场上报了足够的信息之后,我们就可以开始分析崩溃了,下面我们介绍崩溃分析“三部曲”
确认和分析重点,关键在于在日志中找到重要的信息,对问题有一个大致判断。一般来说,我建议在确定重点这一步可以关注以下几点。
确认严重程度与优先级。解决崩溃也要看性价比,我们优先解决 Top
崩溃或者对业务有重大影响,
崩溃基本信息。确定崩溃的类型以及异常描述,对崩溃有大致的判断。一般来说,大部分的简单崩溃经过这一步已经可以得到结论。
Java
崩溃。Java
崩溃类型比较明显,比如 NullPointerException
是空指针,OutOfMemoryError
是资源不足,这个时候需要去进一步查看日志中的 “内存信息”和“资源信息”。Native
崩溃。需要观察 signal
、code
、fault addr
等内容,以及崩溃时 Java
的堆栈。关于各 signal
含义的介绍,你可以查看 崩溃信号介绍 。比较常见的是有 SIGSEGV
和 SIGABRT
,前者一般是由于空指针、非法指针造成,后者主要因为 ANR
和调用 abort()
退出所导致。ANR
。我的经验是,先看看主线程的堆栈,是否是因为锁等待导致。接着看看 ANR
日志中 iowait
、CPU
、GC
、system server
等信息,进一步确定是 I/O
问题,或是 CPU
竞争问题,还是由于大量 GC
导致卡死Logcat
。Logcat
一般会存在一些有价值的线索,日志级别是 Warning
、Error
的需要特别注意。从 Logcat
中我们可以看到当时系统的一些行为跟手机的状态,例如出现 ANR
时,会有“am_anr”;App
被杀时,会有“am_kill”。不同的系统、厂商输出的日志有所差别,当从一条崩溃日志中无法看出问题的原因,或者得不到有用信息时,不要放弃,建议查看相同崩溃点下的更多崩溃日志。
各个资源情况。结合崩溃的基本信息,我们接着看看是不是跟 “内存信息” 有关,是不是跟“资源信息”有关。比如是物理内存不足、虚拟内存不足,还是文件句柄 fd
泄漏了。
无论是资源文件还是 Logcat
,内存与线程相关的信息都需要特别注意,很多崩溃都是由于它们使用不当造成的。
如果使用了上面的方法还是不能有效定位问题,我们可以尝试查找这类崩溃有没有什么共性。找到了共性,也就可以进一步找到差异,离解决问题也就更进一步。
机型、系统、ROM
、厂商、ABI
,这些采集到的系统信息都可以作为维度聚合,共性问题例如是不是因为安装了 Xposed
,是不是只出现在 x86
的手机,是不是只有三星这款机型,是不是只在 Android 5.0
的系统上。应用信息也可以作为维度来聚合,比如正在打开的链接、正在播放的视频、国家、地区等。找到了共性,可以对你下一步复现问题有更明确的指引。
如果我们已经大概知道了崩溃的原因,为了进一步确认更多信息,就需要尝试复现崩溃。如果我们对崩溃完全没有头绪,也希望通过用户操作路径来尝试重现,然后再去分析崩溃原因。
“只要能本地复现,我就能解”,相信这是很多开发跟测试说过的话。有这样的底气主要是因为在稳定的复现路径上面,我们可以采用增加日志或使用 Debugger
、GDB
等各种各样的手段或工具做进一步分析。
有时有些崩溃并不是我们应用的问题,而是系统的问题,系统崩溃系统崩溃常常令我们感到非常无助,它可能是某个 Android
版本的 bug
,也可能是某个厂商修改 ROM
导致。
这种情况下的崩溃堆栈可能完全没有我们自己的代码,很难直接定位问题。
针对这种疑难问题,我们可以尝试通过以下方法解决。
ROM
的问题。虽然崩溃日志可能没有我们自己的代码,但通过操作路径和日志,我们可以找到一些怀疑的点。API
,是否可以更换其他的实现方式规避。Hook
解决。在了解了原因之后,最后可以通过Hook
的方式修改系统代码的逻辑来处理比如我们发现线上出现一个 Toast
相关的系统崩溃,它只出现在 Android 7.0
的系统中,看起来是在 Toast
显示的时候窗口的 token
已经无效了。这有可能出现在 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
,我们只需要代理它就可以实现捕获。
Crash
长效治理上面介绍了处理线上Crash
的一般步骤,但是Crash
治理真正重要的阶段在上线之前,我们需要从开发阶段开始,系统性的进行Crash
长效治理
Crash
的长效治理需要从开发阶段抓起,从长远来说,更好的代码质量将带来更好的稳定性,我们可以从以下两个角度来提升代码质量
CodeReview
机制除了功能测试、自动化测试、回归测试、覆盖安装等常规测试流程之外,还需要针对特殊场景、机型等边界进行测试:如服务端返回异常数据、服务端宕机等情况
APM
的灵敏监控,发现问题及时报警很多人认为稳定性优化就是降低Crash
率,但其实稳定性优化还有一个重要的维度就是业务的高可用。
业务的不可用可能不会导致崩溃,但是会降低用户的体验,从而直接影响我们的收入
Crash
,需要我们自己打点做数据采集。我们需要梳理项目主流程、核心路径、关键节点,并添加打点AOP
方式采集,减少手动打点的成本。Catch
住的异常与异常逻辑的上报,这些异常虽然不会崩溃,但也是我们需要关注的在性能或者业务异常发生了之后,我们该如何解决呢?传统的流程需要经过用户反馈,重新打包,渠道更新等多个步骤,可以看出其实比较麻烦,对用户的响应度也比较低
我们可以从以下角度来进行客户端的容灾方案建设
App
所有的页面都是通过路由跳转的,可以通过动态配置路由的方式跳转到统一错误处理页面,或者跳转到临时h5页面BUG
,比如接入腾讯的Tinker
或者美团的Robust
等RN
或者Weex
,可直接实现增量更新APP
时,这时候动态更新动态配置就都失效了,这个时候就需要用到安全模式。安全模式根据Crash
信息自动恢复,多次启动失败重置应用为安装初始状态。如果是特别严重的Bug
,也可以通过阻塞性热修复的方式来解决,即热修成功了才能进入APP
。安全模式不仅可以用于APP
,也可用于组件,如果某个组件多次报错,就可以进入兜底页面下面介绍一下稳定性优化的模拟面试题
参考答案:
随着项目的逐渐成熟,用户基数逐渐增多,DAU
持续升高,我们遇到了很多稳定性方面的问题,对于我们技术同学遇到了很多的挑战,用户经常使用我们的App
卡顿或者是功能不可用,因此我们就针对稳定性开启了专项的优化,我们主要优化了三项:
Crash
专项优化通过这三方面的优化我们搭建了移动端的高可用平台。同时,也做了很多的措施来让App
真正地实现了高可用。
参考答案:
Crash
专项优化我们针对启动速度,内存、布局加载、卡顿、瘦身、流量、电量等多个方面做了多维的优化。
我们的优化主要分为了两个层次,即线上和线下,针对于线下呢,我们侧重于发现问题,直接解决,将问题尽可能在上线之前解决为目的。而真正到了线上呢,我们最主要的目的就是为了监控,对于各个性能纬度的监控呢,可以让我们尽可能早地获取到异常情况的报警。
同时呢,对于线上最严重的性能问题性问题:Crash
,我们做了专项的优化,不仅优化了Crash
的具体指标,而且也尽可能地获取了Crash
发生时的详细信息,结合后端的聚合、报警等功能,便于我们快速地定位问题。
参考答案:
移动端业务高可用它侧重于用户功能完整可用,主要是为了解决一些线上一些异常情况导致用户他虽然没有崩溃,也没有性能问题,但是呢,只是单纯的功能不可用的情况,我们需要对项目的主流程、核心路径进行埋点监控,来计算每一步它真实的转换率是多少,同时呢,还需要知道在每一步到底发生了多少异常。这样我们就知道了所有业务流程的转换率以及相应界面的转换率,有了大盘的数据呢,我们就知道了,如果转换率或者是某些监控的成功率低于某个值,那很有可能就是出现了线上异常,结合了相应的报警功能,我们就不需要等用户来反馈了,这个就是业务稳定性保障的基础。
同时呢,对于一些特殊情况,比如说,开发过程当中或代码中出现了一些catch
代码块,捕获住了异常,让程序不崩溃,这其实是不合理的,程序虽然没有崩溃,当时程序的功能已经变得不可用,所以呢,这些被catch
的异常我们也需要上报上来,这样我们才能知道用户到底出现了什么问题而导致的异常。此外,线上还有一些单点问题,比如说用户点击登录一直进不去,这种就属于单点问题,其实我们是无法找出其和其它问题的共性之处的,所以呢,我们就必须要找到它对应的详细信息。
最后,如果发生了异常情况,我们还采取了一系列措施进行快速止损。
参考答案:
首先,需要让App
具备一些高级的能力,我们对于任何要上线的新功能,要加上一个功能的开关,通过配置中心下发的开关呢,来决定是否要显示新功能的入口。如果有异常情况,可以紧急关闭新功能的入口,那就可以让这个App
处于可控的状态了。
然后,我们需要给App
设立路由跳转,所有的界面跳转都需要通过路由来分发,如果我们匹配到需要跳转到有bug
的这样一个新功能时,那我们就不跳转了,或者是跳转到统一的异常正处理中的界面。如果这两种方式都不可以,那就可以考虑通过热修复的方式来动态修复,目前热修复的方案其实已经比较成熟了,我们完全可以低成本地在我们的项目中添加热修复的能力,当然,如果有些功能是由RN
或WeeX
来实现就更好了,那就可以通过更新资源包的方式来实现动态更新。而这些如果都不可以的话呢,那就可以考虑自己去给应用加上一个自主修复的能力,如果App
启动多次的话,那就可以考虑清空所有的缓存数据,将App
重置到安装的状态,到了最严重的等级呢,可以阻塞主线程,此时一定要等App
热修复成功之后才允许用户进入。
本文主要介绍了Android
稳定性的正确认识,如何处理Crash
,Crash
的长效治理,业务高可用方案建设等内容,给大家介绍了一些稳定性优化的思路与方案。
当面我们在性能优化与监控时,你会发现中间有不少涉及到 Framework底层相关的知识点,所以我们在学习性能优化和性能监控的同时,也需要对Framework底层原理进行学习了解,可参考:
Android 性能调优学习笔记:https://qr18.cn/FVlo89