/ 作者简介 /
本篇文章转自程序员江同学的博客,文章主要分享了Android稳定性优化相关的内容,相信会对大家有所帮助!
/ 前言 /
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没有崩溃,但是关键功能却不可用,这又怎么算是稳定的呢?
因此应用的稳定性可以分为三个纬度,如下所示:
1、Crash纬度:最重要的指标就是应用的Crash率。
2、性能纬度:包括启动速度、内存、绘制等等优化方向,相对于Crash来说是次要的,但也是应用稳定性的一部分。
3、业务高可用纬度:它是非常关键的一步,我们需要采用多种手段来保证我们App的主流程以及核心路径的稳定性。
/ 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即可。但是通过上述介绍,我们可以知道在分析崩溃的时候应该重点关注哪些信息,同时如果平台能力有缺失,我们也可以添加自定义的上报。
崩溃分析
在崩溃现场上报了足够的信息之后,我们就可以开始分析崩溃了,下面我们介绍崩溃分析“三部曲”。
第一步:确定重点
确认和分析重点,关键在于在日志中找到重要的信息,对问题有一个大致判断。一般来说,我建议在确定重点这一步可以关注以下几点。
- 确认严重程度与优先级。解决崩溃也要看性价比,我们优先解决 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机制
- 架构优化,能力收敛(即将一些常见的操作进行封装),统一容错:如在网络库utils中统一对返回信息进行预校验,如不合法就直接不走接下来的流程。
测试阶段
除了功能测试、自动化测试、回归测试、覆盖安装等常规测试流程之外,还需要针对特殊场景、机型等边界进行测试:如服务端返回异常数据、服务端宕机等情况。
合码阶段
- 在我们的功能开发完毕,即将合并到主分支时,首先要进行编译检测、静态扫描,来发现可能存在的问题。
- 在扫描完成后也不能直接合入,因为多个分支可能会冲突,因此我们先进行一个预编译流程,即合入一个与主分支一样的分支、然后打包进行主流程自动化回归测试,流程通过后再合入主分支。当然这样做可能比较麻烦,但这些步骤应该都是自动化的。
发布阶段
- 在发布阶段,我们应该进行多轮灰度,灰度量级应逐渐由小变大,用最小的代价提前暴露问题
- 灰度发布同样应该分场景、多纬度全面覆盖,可以针对特别的版本,机型等进行专门的灰度,看下那些更有可能出现问题的用户是否出现问题
运维阶段
- 在上线之后,稳定性问题同样需要关注,因此特别依赖于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的长效治理,业务高可用方案建设等内容,给大家介绍了一些稳定性优化的思路与方案。
不过大部分内容其实还是理论性的,具体的稳定性优化,Crash治理还是依赖于具体问题的分析与修复,所以本文名为从入门到了解。
后续应该还会给大家带来稳定性优化之ANR处理,OOM处理等内容,敬请期待~