/   作者简介   /

本篇文章转自程序员江同学的博客,文章主要分享了Android稳定性优化相关的内容,相信会对大家有所帮助!

/   前言   /

Android的稳定性是Android性能的一个重要指标,它也是App质量构建体系中最基本和最关键的一环。如果应用经常崩溃率,或者关键功能不可用,那显然会对我们的留存产生重大影响。

为了保障应用的稳定性,我们首先应该树立对稳定性的正确认识,本文主要包括以下内容:

  1. 稳定性优化的正确认识
  2. Crash处理的一般步骤
  3. Crash长效治理
  4. 业务高可用方案建设
  5. 稳定性优化常见面试题

Android 系统稳定性 android稳定性分析_Android 系统稳定性

/   稳定性优化的正确认识   /

稳定性优化的关键指标

要做稳定性优化,首先一个问题就是,要做成什么效果?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即可。但是通过上述介绍,我们可以知道在分析崩溃的时候应该重点关注哪些信息,同时如果平台能力有缺失,我们也可以添加自定义的上报。

崩溃分析

在崩溃现场上报了足够的信息之后,我们就可以开始分析崩溃了,下面我们介绍崩溃分析“三部曲”。

第一步:确定重点

确认和分析重点,关键在于在日志中找到重要的信息,对问题有一个大致判断。一般来说,我建议在确定重点这一步可以关注以下几点。

  1. 确认严重程度与优先级。解决崩溃也要看性价比,我们优先解决 Top 崩溃或者对业务有重大影响。
  2. 崩溃基本信息。确定崩溃的类型以及异常描述,对崩溃有大致的判断。一般来说,大部分的简单崩溃经过这一步已经可以得到结论。
  1. Java 崩溃。Java 崩溃类型比较明显,比如 NullPointerException 是空指针,OutOfMemoryError 是资源不足,这个时候需要去进一步查看日志中的 “内存信息”和“资源信息”。
  2. Native 崩溃。需要观察 signal、code、fault addr 等内容,以及崩溃时 Java 的堆栈。关于各 signal 含义的介绍,你可以查看崩溃信号介绍。比较常见的是有 SIGSEGV 和 SIGABRT,前者一般是由于空指针、非法指针造成,后者主要因为 ANR 和调用 abort() 退出所导致。
  3. ANR。我的经验是,先看看主线程的堆栈,是否是因为锁等待导致。接着看看 ANR 日志中 iowait、CPU、GC、system server 等信息,进一步确定是 I/O 问题,或是 CPU 竞争问题,还是由于大量 GC 导致卡死。
  1. Logcat。Logcat 一般会存在一些有价值的线索,日志级别是 Warning、Error 的需要特别注意。从 Logcat 中我们可以看到当时系统的一些行为跟手机的状态,例如出现 ANR 时,会有“am_anr”;App 被杀时,会有“am_kill”。不同的系统、厂商输出的日志有所差别,当从一条崩溃日志中无法看出问题的原因,或者得不到有用信息时,不要放弃,建议查看相同崩溃点下的更多崩溃日志。
  2. 各个资源情况。结合崩溃的基本信息,我们接着看看是不是跟 “内存信息” 有关,是不是跟“资源信息”有关。比如是物理内存不足、虚拟内存不足,还是文件句柄 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率,但其实稳定性优化还有一个重要的维度就是业务的高可用。业务的不可用可能不会导致崩溃,但是会降低用户的体验,从而直接影响我们的收入。

业务高可用方案建设

  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,也可用于组件,如果某个组件多次报错,就可以进入兜底页面

/   稳定性优化常见面试题   /

下面介绍一下稳定性优化的模拟面试题。

你们做了哪些稳定性方面的优化? 

参考答案

随着项目的逐渐成熟,用户基数逐渐增多,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处理等内容,敬请期待~