当前位置:   article > 正文

深入探索Android稳定性优化(详细解析)_android coffeecatch

android coffeecatch

前言:成为一名优秀的Android开发,需要一份完备的知识体系,在这里,让我们一起成长为自己所想的那样。

众所周知,移动开发已经来到了后半场,为了能够在众多开发者中脱颖而出,我们需要对某一个领域有深入地研究与心得,对于Android开发者来说,目前,有几个好的细分领域值得我们去建立自己的技术壁垒,如下所示:

  • 1、性能优化专家:具备深度性能优化与体系化APM建设的能力

  • 2、架构师:具有丰富的应用架构设计经验与心得,对Android Framework层与热门三方库的实现原理与架构设计了如指掌。

  • 3、音视频/图像处理专家:毫无疑问,掌握NDK,深入音视频与图像处理领域能让我们在未来几年大放异彩。

  • 4、大前端专家:深入掌握Flutter及其设计原理与思想,可以让我们具有快速学习前端知识的能力。

在上述几个细分领域中,最难也最具技术壁垒的莫过于性能优化,要想成为一个顶尖的性能优化专家,需要对许多领域的深度知识及广度知识有深入的了解与研究,其中不乏需要掌握架构师、NDK、Flutter所涉及的众多技能。从这篇文章开始,笔者将会带领大家一步一步深入探索Android的性能优化。

为了能够全面地了解Android的性能优化知识体系,我们先看看我总结的下面这张图,如下所示: 

要做好应用的性能优化,我们需要建立一套成体系的性能优化方案,这套方案被业界称为APM(Application Performance Manange),为了让大家快速了解APM涉及的相关知识,笔者已经将其总结成图,如下所示:

 在建设APM和对App进行性能优化的过程中,我们必须首先解决的是App的稳定性问题,现在,让我们搭乘航班,来深入探索Android稳定性优化的疆域。

一、正确认识

首先,我们必须对App的稳定性有正确的认识,它是App质量构建体系中最基本和最关键的一环。如果我们的App不稳定,并且经常不能正常地提供服务,那么用户大概率会卸载掉它。所以稳定性很重要,并且Crash是P0优先级,需要优先解决。

而且,稳定性可优化的面很广,它不仅仅只包含Crash这一部分,也包括卡顿、耗电等优化范畴。

1,稳定性纬度

应用的稳定性可以分为三个纬度,如下所示:

  • 1、Crash纬度:最重要的指标就是应用的Crash率。

  • 2、性能纬度:包括启动速度、内存、绘制等等优化方向,相对于Crash来说是次要的,在做应用性能体系化建设之前,我们必须要确保应用的功能稳定可用。

  • 3、业务高可用纬度:它是非常关键的一步,我们需要采用多种手段来保证我们App的主流程以及核心路径的稳定性,只有用户经常使用我们的App,它才有可能发现别的方面的问题。

2、稳定性优化注意事项

我们在做应用的稳定性优化的时候,需要注意三个要点,如下所示:

(1)重在预防、监控必不可少

对于稳定性来说,如果App已经到了线上才发现异常,那其实已经造成了损失,所以,对于稳定性的优化,其重点在于预防。从开发同学的编码环节,到测试同学的测试环节,以及到上线前的发布环节、上线后的运维环节,这些环节都需要来预防异常情况的发生。如果异常真的发生了,也需要将想方设法将损失降到最低,争取用最小的代价来暴露尽可能多的问题。

此外,监控也是必不可少的一步,预防做的再好,到了线上,总会有各种各样的异常发生。所以,无论如何,我们都需要有全面的监控手段来更加灵敏地发现问题。

(2)思考更深一层、重视隐含信息:如解决Crash问题时思考是否会引发同一类问题

当我们看到了一个Crash的时候,不能简单地只处理这一个Crash,而是需要思考更深一层,要考虑会不会在其它地方会有一样的Crash类型发生。如果有这样的情况,我们必须对其统一处理和预防。

此外,我们还要关注Crash相关的隐含信息,比如,在面试过程当中,面试官问你,你们应用的Crash率是多少,这个问题表明上问的是Crash率,但是实际上它是问你一些隐含信息的,过高的Crash率就代表开发人员的水平不行,leader的架构能力不行,项目的各个阶段中优化的空间非常大,这样一来,面试官对你的印象和评价也不会好。

(3)长效保持需要科学流程

应用稳定性的建设过程是一个细活,所以很容易出现这个版本优化好了,但是在接下来的版本中如果我们不管它,它就会发生持续恶化的情况,因此,我们必须从项目研发的每一个流程入手,建立科学完善的相关规范,才能保证长效的优化效果。

3、Crash相关指标

要对应用的稳定性进行优化,我们就必须先了解与Crash相关的一些指标。

(1)UV、PV

  • PV(Page View):访问量

  • UV(Unique Visitor):独立访客,0 - 24小时内的同一终端只计算一次

(2)UV、PV、启动、增量、存量 Crash率

  • UV Crash率(Crash UV / DAU):针对用户使用量的统计,统计一段时间内所有用户发生崩溃的占比,用于评估Crash率的影响范围,结合PV。需要注意的是,需要确保一直使用同一种衡量方式。

  • PV Crash率:评估相关Crash影响的严重程度。

  • 启动Crash率:启动阶段,用户还没有完全打开App而发生的Crash,它是影响最严重的Crash,对用户伤害最大,无法通过热修复拯救,需结合客户端容灾,以进行App的自主修复。(这块后面会讲)

  • 增量、存量Crash率:增量Crash是指的新增的Crash,而存量Crash则表示一些历史遗留bug。增量Crash是新版本重点,存量Crash是需要持续啃的硬骨头,我们需要优先解决增量、持续跟进存量问题。

(3)Crash率评价

那么,我们App的Crash率降低多少才能算是一个正常水平或优秀的水平呢?

  • Java与Native的总崩溃率必须在千分之二以下。

  • Crash率万分位为优秀:需要注意90%的Crash都是比较容易解决的,但是要解决最后的10%需要付出巨大的努力。

(4)Crash关键问题

这里我们还需要关注Crash相关的关键问题,如果应用发生了Crash,我们应该尽可能还原Crash现场。因此,我们需要全面地采集应用发生Crash时的相关信息,如下所示:

  • 堆栈、设备、OS版本、进程、线程名、Logcat

  • 前后台、使用时长、App版本、小版本、渠道

  • CPU架构、内存信息、线程数、资源包信息、用户行为日志

接着,采集完上述信息并上报到后台后,我们会在APM后台进行聚合展示,具体的展示信息如下所示:

  • Crash现场信息

  • Crash Top机型、OS版本、分布版本、区域

  • Crash起始版本、上报趋势、是否新增、持续、量级

最后,我们可以根据以上信息决定Crash是否需要立马解决以及在哪个版本进行解决,关于APM聚合展示这块可以参考 Bugly平台 的APM后台聚合展示。

然后,我们再来看看与Crash相关的整体架构。

(5)APM Crash部分整体架构

APM Crash部分的整体架构从上之下分为采集层、处理层、展示层、报警层。下面,我们来详细讲解一下每一层所做的处理。

采集层

首先,我们需要在采集层这一层去获取足够多的Crash相关信息,以确保能够精确定位到问题。需要采集的信息主要为如下几种:

  • 错误堆栈

  • 设备信息

  • 行为日志

  • 其它信息

处理层

然后,在处理层,我们会对App采集到的数据进行处理

  • 数据清洗:将一些不符合条件的数据过滤掉,比如说,因为一些特殊情况,一些App采集到的数据不完整,或者由于上传数据失败而导致的数据不完整,这些数据在APM平台上肯定是无法全面地展示的,所以,首先我们需要把这些信息进行过滤。

  • 数据聚合:在这一层,我们会把Crash相关的数据进行聚合。

  • 纬度分类:如Top机型下的Crash、用户Crash率的前10%等等维度。

  • 趋势对比

展示层

经过处理层之后,就会来到展示层,展示的信息为如下几类:

  • 数据还原

  • 纬度信息

  • 起始版本

  • 其它信息

报警层

最后,就会来到报警层,当发生严重异常的时候,会通知相关的同学进行紧急处理。报警的规则我们可以自定义,例如整体的Crash率,其环比(与上一期进行对比)或同比(如本月10号与上月10号)抖动超过5%,或者是单个Crash突然间激增。报警的方式可以通过 邮件、IM、电话、短信 等等方式。

(6)责任归属

最后,我们来看下Crash相关的非技术问题,需要注意的是,我们要解决的是如何长期保持较低的Crash率这个问题。我们需要保证能够迅速找到相关bug的相关责任人并让开发同学能够及时地处理线上的bug。具体的解决方法为如下几种:

设立专项小组轮值:成立一个虚拟的专项小组,来专门跟踪每个版本线上的Crash率,组内的成员可以轮流跟踪线上的Crash,这样,就可以从源头来保证所有Crash一定会有人跟进。

自动匹配责任人:将APM平台与bug单系统打通,这样APM后台一旦发现紧急bug就能第一时间下发到bug单系统给相关责任人发提醒。

处理流程全纪录:我们需要记录Crash处理流程的每一步,确保紧急Crash的处理不会被延误。

二、Crash优化

1、单个Crash处理方案

对与单个Crash的处理方案我们可以按如下三个步骤来进行解决处理。

  • (1)根据堆栈及现场信息找答案

解决90%问题

解决完后需考虑产生Crash深层次的原因

  • (2)找共性:机型、OS、实验开关、资源包,考虑影响范围

  • (3)线下复现、远程调试

2、Crash率治理方案

要对应用的Crash率进行治理,一般需要对以下三种类型的Crash进行对应的处理,如下所示:

  • 1、解决线上常规Crash

  • 2、系统级Crash尝试Hook绕过

  • 3、疑难Crash重点突破或更换方案

3、Java Crash

出现未捕获异常,导致出现异常退出

Thread.setDefaultUncaughtExceptionHandler();

我们通过设置自定义的UncaughtExceptionHandler,就可以在崩溃发生的时候获取到现场信息。注意,这个钩子是针对单个进程而言的,在多进程的APP中,监控哪个进程,就需要在哪个进程中设置一遍ExceptionHandler。

获取主线程的堆栈信息:

Looper.getMainLooper().getThread().getStackTrace();

获取当前线程的堆栈信息:

Thread.currentThread().getStackTrace();

获取全部线程的堆栈信息:

Thread.getAllStackTraces();

第三方Crash监控工具如Fabric、腾讯Bugly,都是以字符串拼接的方式将数组StackTraceElement[]转换成字符串形式,进行保存、上报或者展示。

那么,我们如何反混淆上传的堆栈信息?

对此,我们一般有两种可选的处理方案,如下所示:

  • 1、每次打包生成混淆APK的时候,需要把Mapping文件保存并上传到监控后台。

  • 2、Android原生的反混淆的工具包是retrace.jar,在监控后台用来实时解析每个上报的崩溃时。它会将Mapping文件进行文本解析和对象实例化,这个过程比较耗时。因此可以将Mapping对象实例进行内存缓存,但为了防止内存泄露和内存过多占用,需要增加定期自动回收的逻辑

如何获取logcat方法?

logcat日志流程是这样的,应用层 --> liblog.so --> logd,底层使用ring buffer来存储数据。获取的方式有以下三种:

1、通过logcat命令获取。

优点:非常简单,兼容性好。

缺点:整个链路比较长,可控性差,失败率高,特别是堆破坏或者堆内存不足时,基本会失败。

2、hook liblog.so实现

通过hook liblog.so 中的 __android_log_buf_write 方法,将内容重定向到自己的buffer中。

优点:简单,兼容性相对还好。

缺点:要一直打开。

3、自定义获取代码。通过移植底层获取logcat的实现,通过socket直接跟logd交互。

  • 优点:比较灵活,预先分配好资源,成功率也比较高。

  • 缺点:实现非常复杂

如何获取Java 堆栈?

当发生native崩溃时,我们通过unwind只能拿到Native堆栈。我们希望可以拿到当时各个线程的Java堆栈。对于这个问题,目前有两种处理方式,分别如下所示:

1、Thread.getAllStackTraces()。

优点

简单,兼容性好。

缺点

  • 成功率不高,依靠系统接口在极端情况也会失败。

  • 7.0之后这个接口是没有主线程堆栈。

  • 使用Java层的接口需要暂停线程。

2、hook libart.so

通过hook ThreadList和Thread 的函数,获得跟ANR一样的堆栈。为了稳定性,需要在fork的子进程中执行。

优点:信息很全,基本跟ANR的日志一样,有native线程状态,锁信息等等。

缺点:黑科技的兼容性问题,失败时我们可以使用Thread.getAllStackTraces()兜底。

4、Java Crash处理流程

讲解了Java Crash相关的知识后,我们就可以去了解下Java Crash的处理流程,这里借用Gityuan流程图进行讲解,如下图所示:

 

1、首先发生crash所在进程,在创建之初便准备好了defaultUncaughtHandler,用来来处理Uncaught Exception,并输出当前crash基本信息;

2、调用当前进程中的AMP.handleApplicationCrash;经过binder ipc机制,传递到system_server进程;

3、接下来,进入system_server进程,调用binder服务端执行AMS.handleApplicationCrash;

4、从mProcessNames查找到目标进程的ProcessRecord对象;并将进程crash信息输出到目录/data/system/dropbox;

5、执行makeAppCrashingLocked:

  • 创建当前用户下的crash应用的error receiver,并忽略当前应用的广播;

  • 停止当前进程中所有activity中的WMS的冻结屏幕消息,并执行相关一些屏幕相关操作;

6、再执行handleAppCrashLocked方法:

当1分钟内同一进程连续crash两次时,且非persistent进程,则直接结束该应用所有activity,并杀死该进程以及同一个进程组下的所有进程。然后再恢复栈顶第一个非finishing状态的activity;

当1分钟内同一进程连续crash两次时,且persistent进程,,则只执行恢复栈顶第一个非finishing状态的activity;

当1分钟内同一进程未发生连续crash两次时,则执行结束栈顶正在运行activity的流程。

7、通过mUiHandler发送消息SHOW_ERROR_MSG,弹出crash对话框;

8、到此,system_server进程执行完成。回到crash进程开始执行杀掉当前进程的操作;

9、当crash进程被杀,通过binder死亡通知,告知system_server进程来执行appDiedLocked();

10、最后,执行清理应用相关的activity/service/ContentProvider/receiver组件信息。

5、Native Crash

特点:

  • 访问非法地址

  • 地址对齐出错

  • 发送程序主动abort

上述都会产生相应的signal信号,导致程序异常退出。

(1)合格的异常捕获组件

一个合格的异常捕获组件需要包含以下功能:

  • 支持在crash时进行更多扩张操作

  • 打印logcat和日志

  • 上报crash次数

  • 对不同crash做不同恢复措施

  • 可以针对业务不断改进的适应

2、现有方案

(1)Google Breakpad

  • 优点:权威、跨平台

  • 缺点:代码体量较大

(2)Logcat

  • 优点:利用安卓系统实现

  • 缺点:需要在crash时启动新进程过滤logcat日志,不可靠

(3)coffeecatch

  • 优点:实现简洁、改动容易

  • 缺点:有兼容性问题

3、Native崩溃捕获流程

Native崩溃捕获的过程涉及到三端,这里我们分别来了解下其对应的处理。

(1)编译端

编译C/C++需将带符号信息的文件保留下来。

(2)客户端

捕获到崩溃时,将收集到尽可能多的有用信息写入日志文件,然后选择合适的时机上传到服务器。

(3)服务端

读取客户端上报的日志文件,寻找合适的符号文件,生成可读的C/C++调用栈。

4、Native崩溃捕获的难点

核心:如何确保客户端在各种极端情况下依然可以生成崩溃日志。

核心:如何确保客户端在各种极端情况下依然可以生成崩溃日志。

(1)文件句柄泄漏,导致创建日志文件失败?

提前申请文件句柄fd预留。

(2)栈溢出导致日志生成失败?

  • 使用额外的栈空间signalstack,避免栈溢出导致进程没有空间创建调用栈执行处理函数。(signalstack:系统会在危险情况下把栈指针指向这个地方,使得可以在一个新的栈上运行信号处理函数)

  • 特殊请求需直接替换当前栈,所以应在堆中预留部分空间。

(3)堆内存耗尽导致日志生产失败?

参考Breakpad重新封装Linux Syscall Support的做法以避免直接调用libc去分配堆内存。

(4)堆破坏或二次崩溃导致日志生成失败?

Breakpad使用了fork子进程甚至孙进程的方式去收集崩溃现场,即便出现二次崩溃,也只是这部分信息丢失。

这里说下Breakpad缺点:

  • 生成的minidump文件时二进制的,包含过多不重要的信息,导致文件数MB。但minidump可以使用gdb调试、看到传入参数。

需要了解的是,未来Chromium会使用Crashpad替代Breakpad

(5)想要遵循Android的文本格式并添加更多重要的信息?

改造Breakpad,增加Logcat信息,Java调用栈信息、其它有用信息。

5、Native崩溃捕获注册

一个Native Crash log信息如下:

堆栈信息中 pc 后面跟的内存地址,就是当前函数的栈地址,我们可以通过下面的命令行得出出错的代码行数

arm-linux-androideabi-addr2line -e 内存地址

 下面列出全部的信号量以及所代表的含义:

  1. #define SIGHUP 1 // 终端连接结束时发出(不管正常或非正常)
  2. #define SIGINT 2 // 程序终止(例如Ctrl-C)
  3. #define SIGQUIT 3 // 程序退出(Ctrl-\)
  4. #define SIGILL 4 // 执行了非法指令,或者试图执行数据段,堆栈溢出
  5. #define SIGTRAP 5 // 断点时产生,由debugger使用
  6. #define SIGABRT 6 // 调用abort函数生成的信号,表示程序异常
  7. #define SIGIOT 6 // 同上,更全,IO异常也会发出
  8. #define SIGBUS 7 // 非法地址,包括内存地址对齐出错,比如访问一个4字节的整数, 但其地址不是4的倍数
  9. #define SIGFPE 8 // 计算错误,比如除0、溢出
  10. #define SIGKILL 9 // 强制结束程序,具有最高优先级,本信号不能被阻塞、处理和忽略
  11. #define SIGUSR1 10 // 未使用,保留
  12. #define SIGSEGV 11 // 非法内存操作,与 SIGBUS不同,他是对合法地址的非法访问, 比如访问没有读权限的内存,向没有写权限的地址写数据
  13. #define SIGUSR2 12 // 未使用,保留
  14. #define SIGPIPE 13 // 管道破裂,通常在进程间通信产生
  15. #define SIGALRM 14 // 定时信号,
  16. #define SIGTERM 15 // 结束程序,类似温和的 SIGKILL,可被阻塞和处理。通常程序如 果终止不了,才会尝试SIGKILL
  17. #define SIGSTKFLT 16 // 协处理器堆栈错误
  18. #define SIGCHLD 17 // 子进程结束时, 父进程会收到这个信号。
  19. #define SIGCONT 18 // 让一个停止的进程继续执行
  20. #define SIGSTOP 19 // 停止进程,本信号不能被阻塞,处理或忽略
  21. #define SIGTSTP 20 // 停止进程,但该信号可以被处理和忽略
  22. #define SIGTTIN 21 // 当后台作业要从用户终端读数据时, 该作业中的所有进程会收到SIGTTIN信号
  23. #define SIGTTOU 22 // 类似于SIGTTIN, 但在写终端时收到
  24. #define SIGURG 23 // 有紧急数据或out-of-band数据到达socket时产生
  25. #define SIGXCPU 24 // 超过CPU时间资源限制时发出
  26. #define SIGXFSZ 25 // 当进程企图扩大文件以至于超过文件大小资源限制
  27. #define SIGVTALRM 26 // 虚拟时钟信号. 类似于SIGALRM, 但是计算的是该进程占用的CPU时间.
  28. #define SIGPROF 27 // 类似于SIGALRM/SIGVTALRM, 但包括该进程用的CPU时间以及系统调用的时间
  29. #define SIGWINCH 28 // 窗口大小改变时发出
  30. #define SIGIO 29 // 文件描述符准备就绪, 可以开始进行输入/输出操作
  31. #define SIGPOLL SIGIO // 同上,别称
  32. #define SIGPWR 30 // 电源异常
  33. #define SIGSYS 31 // 非法的系统调用

一般关注SIGILL, SIGABRT, SIGBUS, SIGFPE, SIGSEGV, SIGSTKFLT, SIGSYS即可。

要订阅异常发生的信号,最简单的做法就是直接用一个循环遍历所有要订阅的信号,对每个信号调用sigaction()。

注意

  • JNI_OnLoad是最适合安装信号初识函数的地方。

  • 建议在上报时调用Java层的方法统一上报。Native崩溃捕获注册。

声明:本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:【wpsshop博客】
推荐阅读
相关标签
  

闽ICP备14008679号