1
前言
Android的稳定性是Android性能的一个重要指标,它也是App质量构建体系中最基本和最关键的一环。如果应用经常崩溃率,或者关键功能不可用,那显然会对我们的留存产生重大影响。
为了保障应用的稳定性,我们首先应该树立对稳定性的正确认识,本文主要包括以下内容:
1. 稳定性优化的正确认识
2. Crash处理的一般步骤
3. Crash长效治理
4. 业务高可用方案建设
5. 稳定性优化常见面试题
2
稳定性优化的正确认识
稳定性优化的关键指标
要做稳定性优化,首先一个问题就是,要做成什么效果?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没有崩溃,但是关键功能却不可用,这又怎么算是稳定的呢?
因此应用的稳定性可以分为三个维度,如下所示:
1. Crash纬度:最重要的指标就是应用的Crash率。
2. 性能维度:包括启动速度、内存、绘制等等优化方向,相对于Crash来说是次要的,但也是应用稳定性的一部分。
3. 业务高可用维度:它是非常关键的一步,我们需要采用多种手段来保证我们App的主流程以及核心路径的稳定性。
3
Crash处理的一般步骤
下面我们来看下应该如何处理Crash,即如果应用崩溃了,你应该如何去分析?
主要从崩溃现场和崩溃分析两个角度来分析。
崩溃现场
崩溃现场是我们的“第一案发现场”,它保留着很多有价值的线索。在这里我们挖掘到的信息越多,下一步分析的方向就越清晰,而不是去靠盲目猜测。
接下来我们具体来看看在崩溃现场应该采集哪些信息。
崩溃信息
从崩溃的基本信息,我们可以对崩溃有初步的判断。
• 进程名、线程名。崩溃的进程是前台进程还是后台进程,崩溃是不是发生在 UI 线程。
• 崩溃堆栈和类型。崩溃是属于 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,发生在哪个业务中。
• 关键操作路径。不同于开发过程详细的打点日志,我们可以记录关键的用户操作路径,这对我们复现崩溃会有比较大的帮助。
• 其他自定义信息。不同的应用关心的重点可能不太一样,比如网易云音乐会关注当前播放的音乐,QQ 浏览器会关注当前打开的网址或视频。此外例如运行时间、是否加载了补丁、是否是全新安装或升级等信息也非常重要。
上面介绍了在崩溃现场应该采集的信息,当然开发一个这样的采集平台还是很复杂的,大多数情况我们只需要接入一些第三方的平台比如bugly和Sentry即可。但是通过上述介绍,我们可以知道在分析崩溃的时候应该重点关注哪些信息,同时如果平台能力有缺失,我们也可以添加自定义的上报。
崩溃分析
在崩溃现场上报了足够的信息之后,我们就可以开始分析崩溃了,下面我们介绍崩溃分析“三部曲”。
第一步:确定重点
确认和分析重点,关键在于在日志中找到重要的信息,对问题有一个大致判断。一般来说,我建议在确定重点这一步可以关注以下几点。
1. 确认严重程度与优先级。解决崩溃也要看性价比,我们优先解决 Top 崩溃或者对业务有重大影响,
2. 崩溃基本信息。确定崩溃的类型以及异常描述,对崩溃有大致的判断。一般来说,大部分的简单崩溃经过这一步已经可以得到结论。
• 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 导致卡死
3. Logcat。Logcat 一般会存在一些有价值的线索,日志级别是 Warning、Error 的需要特别注意。从 Logcat 中我们可以看到当时系统的一些行为跟手机的状态,例如出现 ANR 时,会有“am_anr”;App 被杀时,会有“am_kill”。不同的系统、厂商输出的日志有所差别,当从一条崩溃日志中无法看出问题的原因,或者得不到有用信息时,不要放弃,建议查看相同崩溃点下的更多崩溃日志。
4. 各个资源情况。结合崩溃的基本信息,我们接着看看是不是跟 “内存信息” 有关,是不是跟“资源信息”有关。比如是物理内存不足、虚拟内存不足,还是文件句柄 fd 泄漏了。
无论是资源文件还是 Logcat,内存与线程相关的信息都需要特别注意,很多崩溃都是由于它们使用不当造成的。
第二步:查找共性
如果使用了上面的方法还是不能有效定位问题,我们可以尝试查找这类崩溃有没有什么共性。找到了共性,也就可以进一步找到差异,离解决问题也就更进一步。
机型、系统、ROM、厂商、ABI,这些采集到的系统信息都可以作为维度聚合,共性问题例如是不是因为安装了 Xposed,是不是只出现在 x86 的手机,是不是只有三星这款机型,是不是只在 Android 5.0 的系统上。应用信息也可以作为维度来聚合,比如正在打开的链接、正在播放的视频、国家、地区等。找到了共性,可以对你下一步复现问题有更明确的指引。
第三步:尝试复现
如果我们已经大概知道了崩溃的原因,为了进一步确认更多信息,就需要尝试复现崩溃。如果我们对崩溃完全没有头绪,也希望通过用户操作路径来尝试重现,然后再去分析崩溃原因。
“只要能本地复现,我就能解”,相信这是很多开发跟测试说过的话。有这样的底气主要是因为在稳定的复现路径上面,我们可以采用增加日志或使用 Debugger、GDB 等各种各样的手段或工具做进一步分析。
系统崩溃解决
有时有些崩溃并不是我们应用的问题,而是系统的问题,系统崩溃系统崩溃常常令我们感到非常无助,它可能是某个 Android 版本的 bug,也可能是某个厂商修改 ROM 导致。
这种情况下的崩溃堆栈可能完全没有我们自己的代码,很难直接定位问题。
针对这种疑难问题,我们可以尝试通过以下方法解决。
1. 查找可能的原因。通过上面的共性归类,我们先看看是某个系统版本的问题,还是某个厂商特定 ROM 的问题。虽然崩溃日志可能没有我们自己的代码,但通过操作路径和日志,我们可以找到一些怀疑的点。
2. 尝试规避。查看可疑的代码调用,是否使用了不恰当的 API,是否可以更换其他的实现方式规避。
3. 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,我们只需要代理它就可以实现捕获。
4
Crash长效治理
上面介绍了处理线上Crash的一般步骤,但是Crash治理真正重要的阶段在上线之前,我们需要从开发阶段开始,系统性的进行Crash长效治理。
开发阶段
Crash的长效治理需要从开发阶段抓起,从长远来说,更好的代码质量将带来更好的稳定性,我们可以从以下两个角度来提升代码质量。
• 统一编码规范、增强编码功底、技术评审、增强CodeReview机制。
• 架构优化,能力收敛(即将一些常见的操作进行封装),统一容错:如在网络库utils中统一对返回信息进行预校验,如不合法就直接不走接下来的流程。
测试阶段
除了功能测试、自动化测试、回归测试、覆盖安装等常规测试流程之外,还需要针对特殊场景、机型等边界进行测试:如服务端返回异常数据、服务端宕机等情况。
合码阶段
• 在我们的功能开发完毕,即将合并到主分支时,首先要进行编译检测、静态扫描,来发现可能存在的问题。
• 在扫描完成后也不能直接合入,因为多个分支可能会冲突,因此我们先进行一个预编译流程,即合入一个与主分支一样的分支、然后打包进行主流程自动化回归测试,流程通过后再合入主分支。当然这样做可能比较麻烦,但这些步骤应该都是自动化的。
发布阶段
• 在发布阶段,我们应该进行多轮灰度,灰度量级应逐渐由小变大,用最小的代价提前暴露问题。
• 灰度发布同样应该分场景、多纬度全面覆盖,可以针对特别的版本,机型等进行专门的灰度,看下那些更有可能出现问题的用户是否出现问题。
运维阶段
• 在上线之后,稳定性问题同样需要关注,因此特别依赖于APM的灵敏监控,发现问题及时报警。
• 如果出现了异常情况,也需要根据情况进行回滚或者降级策略。
• 如果不能回滚或降级的话,也可以采用热修复的方式来修复、如果热修复也失效的话,只能依赖于本地容灾方案来恢复。
5
业务高可用方案建设
很多人认为稳定性优化就是降低Crash率,但其实稳定性优化还有一个重要的维度就是业务的高可用。
业务的不可用可能不会导致崩溃,但是会降低用户的体验,从而直接影响我们的收入。
业务高可用方案建设
1. 业务高可用不像Crash,需要我们自己打点做数据采集。我们需要梳理项目主流程、核心路径、关键节点,并添加打点。
2. 数据采集我们也可以采用AOP方式采集,减少手动打点的成本。
3. 数据上报之后,我们可以建立数据大盘,统计每个步骤的转化率。
4. 在数据之报之后,我们也可以建立报警策略,比如阈值报警、趋势报警(相比同期减少)、特定指标报警(比如支付失败)。
5. 同时我们可以做一些异常监控的工作,比如Catch住的异常与异常逻辑的上报,这些异常虽然不会崩溃,但也是我们需要关注的。
6. 针对一些难以解决的问题,我们可以针对特定用户采用全量日志回捞的方式来采集更多信息。
7. 在发现了异常之后,我们可以通过一些兜底策略来解决问题,比如支持通过配置中心配置功能开关是否打开,当发现某个新功能有问题时,我们可以直接隐藏该功能,或者通过配置路由的方式跳转到另一个方式。
客户端容灾方案
在性能或者业务异常发生了之后,我们该如何解决呢?传统的流程需要经过用户反馈,重新打包,渠道更新等多个步骤,可以看出其实比较麻烦,对用户的响应度也比较低。
我们可以从以下角度来进行客户端的容灾方案建设。
1. 对于新加的功能或者代码重构,支持通过配置中心配置开关,如果发生问题可以及时关闭。
2. 同时如果我们的App所有的页面都是通过路由跳转的,可以通过动态配置路由的方式跳转到统一错误处理页面,或者跳转到临时h5页面。
3. 通过热修复技术修复BUG,比如接入腾讯的Tinker或者美团的Robust等。
4. 如果你的项目使用了RN或者Weex,可直接实现增量更新。
5. 如果崩溃发生在刚启动APP时,这时候动态更新动态配置就都失效了,这个时候就需要用到安全模式。安全模式根据Crash信息自动恢复,多次启动失败重置应用为安装初始状态。如果是特别严重的Bug,也可以通过阻塞性热修复的方式来解决,即热修成功了才能进入APP。安全模式不仅可以用于APP,也可用于组件,如果某个组件多次报错,就可以进入兜底页面。
6
稳定性优化常见面试题
下面介绍一下稳定性优化的模拟面试题。
你们做了哪些稳定性方面的优化?
参考答案:
随着项目的逐渐成熟,用户基数逐渐增多,DAU持续升高,我们遇到了很多稳定性方面的问题,对于我们技术同学遇到了很多的挑战,用户经常使用我们的App卡顿或者是功能不可用,因此我们就针对稳定性开启了专项的优化,我们主要优化了三项:
1. Crash专项优化
2. 性能稳定性优化
3. 业务稳定性优化
通过这三方面的优化我们搭建了移动端的高可用平台。同时,也做了很多的措施来让App真正地实现了高可用。
性能稳定性是怎么做的?
参考答案:
• 全面的性能优化:启动速度、内存优化、绘制优化
• 线下发现问题、优化为主
• 线上监控为主
• Crash专项优化
我们针对启动速度,内存、布局加载、卡顿、瘦身、流量、电量等多个方面做了多维的优化。
我们的优化主要分为了两个层次,即线上和线下,针对于线下呢,我们侧重于发现问题,直接解决,将问题尽可能在上线之前解决为目的。而真正到了线上呢,我们最主要的目的就是为了监控,对于各个性能纬度的监控呢,可以让我们尽可能早地获取到异常情况的报警。
同时呢,对于线上最严重的性能问题性问题:Crash,我们做了专项的优化,不仅优化了Crash的具体指标,而且也尽可能地获取了Crash发生时的详细信息,结合后端的聚合、报警等功能,便于我们快速地定位问题。
业务稳定性如何保障?
参考答案:
• 数据采集 + 报警
• 需要对项目的主流程与核心路径进行埋点监控
• 同时还需知道每一步发生了多少异常,这样,我们就知道了所有业务流程的转换率以及相应界面的转换率
• 结合大盘,如果转换率低于某个值,进行报警
• 异常监控 + 单点追查
• 兜底策略,如天猫安全模式
移动端业务高可用它侧重于用户功能完整可用,主要是为了解决一些线上一些异常情况导致用户他虽然没有崩溃,也没有性能问题,但是呢,只是单纯的功能不可用的情况,我们需要对项目的主流程、核心路径进行埋点监控,来计算每一步它真实的转换率是多少,同时呢,还需要知道在每一步到底发生了多少异常。这样我们就知道了所有业务流程的转换率以及相应界面的转换率,有了大盘的数据呢,我们就知道了,如果转换率或者是某些监控的成功率低于某个值,那很有可能就是出现了线上异常,结合了相应的报警功能,我们就不需要等用户来反馈了,这个就是业务稳定性保障的基础。
同时呢,对于一些特殊情况,比如说,开发过程当中或代码中出现了一些catch代码块,捕获住了异常,让程序不崩溃,这其实是不合理的,程序虽然没有崩溃,当时程序的功能已经变得不可用,所以呢,这些被catch的异常我们也需要上报上来,这样我们才能知道用户到底出现了什么问题而导致的异常。此外,线上还有一些单点问题,比如说用户点击登录一直进不去,这种就属于单点问题,其实我们是无法找出其和其它问题的共性之处的,所以呢,我们就必须要找到它对应的详细信息。
最后,如果发生了异常情况,我们还采取了一系列措施进行快速止损。
如果发生了异常情况,怎么快速止损?
参考答案:
• 功能开关
• 统跳中心
• 动态修复:热修复、资源包更新
• 自主修复:安全模式
首先,需要让App具备一些高级的能力,我们对于任何要上线的新功能,要加上一个功能的开关,通过配置中心下发的开关呢,来决定是否要显示新功能的入口。如果有异常情况,可以紧急关闭新功能的入口,那就可以让这个App处于可控的状态了。
然后,我们需要给App设立路由跳转,所有的界面跳转都需要通过路由来分发,如果我们匹配到需要跳转到有bug的这样一个新功能时,那我们就不跳转了,或者是跳转到统一的异常正处理中的界面。如果这两种方式都不可以,那就可以考虑通过热修复的方式来动态修复,目前热修复的方案其实已经比较成熟了,我们完全可以低成本地在我们的项目中添加热修复的能力,当然,如果有些功能是由RN或WeeX来实现就更好了,那就可以通过更新资源包的方式来实现动态更新。而这些如果都不可以的话呢,那就可以考虑自己去给应用加上一个自主修复的能力,如果App启动多次的话,那就可以考虑清空所有的缓存数据,将App重置到安装的状态,到了最严重的等级呢,可以阻塞主线程,此时一定要等App热修复成功之后才允许用户进入。
7
总结
本文主要介绍了Android稳定性的正确认识,如何处理Crash,Crash的长效治理,业务高可用方案建设等内容,给大家介绍了一些稳定性优化的思路与方案。
不过大部分内容其实还是理论性的,具体的稳定性优化,Crash治理还是依赖于具体问题的分析与修复,所以本文名为从入门到了解。
后续应该还会给大家带来稳定性优化之ANR处理,OOM处理等内容,敬请期待~
参考资料
深入探索Android稳定性优化
https://juejin.cn/post/6844903972587716621
Android开发高手课之崩溃优化
国内Top团队大牛带你玩转Android性能分析与优化