APP稳定性问题汇总与KOOM的预研

1. APP稳定性问题汇总

稳定性问题

2.1 卡顿/流畅度

概念与原理

View的绘制帧数保持60fps是最佳,这要求每帧的绘制时间不超过16ms(1000/60),如果安卓不能在16ms内完成界面的渲染,那么就会出现卡顿现象。而UI的绘制在主线程中进行的,因此UI卡顿本质上就是主线程卡顿。

常见原因

  1. 布局Layout过于复杂,无法在16ms内完成渲染。
  2. 过度绘制overDraw,导致像素在同一帧的时间内被绘制多次,使CPU和GPU负载过重。
  3. View频繁的触发measure、layout,导致measure、layout累计耗时过多和整个View频繁的重新渲染。
  4. 同一时间动画执行的次数过多,导致CPU和GPU负载过重。
  5. 频繁的触发GC操作导致线程暂停、内存抖动,会使得安卓系统在16ms内无法完成绘制。
  6. 冗余资源及业务逻辑过于复杂等导致加载和执行缓慢。

常见解决办法

  1. 通过开发者工具检查过度绘制。
  2. 使用include复用布局、使用ViewStub延迟加载布局、使用merge减少代码层级、使用ConstraintLayout/RelativeLayout也能大大减少视图的层级、慎重设置整体背景颜色防止过度绘制。
  3. 使用自定义View取代复杂的View。
  4. 使用TraceView工具检测UI卡顿、方法耗时。
  5. 把耗时操作放在子线程中进行。
  6. 考虑使用享元模式、避免在onDraw方法中创建对象等。
  7. 列表控件滑动卡顿:复用convertView、滑动不进行加载、使用压缩图片、加载缩略图等。
  8. 使用BlockCanary。

UI卡顿最严重的后果是ANR,因此需要在开发中避免和解决卡顿问题。

2.2 ANR

概念与原理

Android中,主线程(UI线程)如果在规定时内没有处理完相应工作,就会出现ANR(Application Not Responding),弹出页面无响应的对话框。其核心监测代码主要在AMS中通过Handler实现。
其中系统的无响应叫做SNR。

ANR分类

  1. Activity的输入事件(按键和触摸事件)5s内没被处理: Input event dispatching timed out
  2. BroadcastReceiver的事件(onReceive方法)在规定时间内没处理完(前台广播为10s,后台广播为60s): Timeout of broadcast BroadcastRecord
  3. Service在规定时间内(前台20s/后台200s)未响应: Timeout executing service
  4. ContentProvider的publish在10s内没进行完: Timeout publishing content provider

ANR的核心原因

  1. 主线程在做一些耗时的工作,例如耗时的逻辑、IO操作(包括磁盘、内存的频繁读写操作)等
  2. 主线程被其他线程锁
  3. cpu被其他进程占用,该进程没被分配到足够的cpu资源

ANR的分析方法(主要是分析是否有死锁、通过调用栈定位耗时操作、系统资源情况)

  1. 从/data/anr/traces.txt中找到ANR反生的信息:可以从log中搜索“ANR in”或“am_anr”,会找到ANR发生的log,该行会包含了ANR的时间、进程、是何种ANR等信息,如果是BroadcastReceiver的ANR可以怀疑BroadCastReceiver.onReceive()的问题,如果的Service或Provider就怀疑是否其onCreate()的问题。
  2. 在该条log之后会有CPU usage的信息,表明了CPU在ANR前后的用量(log会表明截取ANR的时间),从各种CPU Usage信息中大概可以分析如下几点:
    • 如果某些进程的CPU占用百分比较高,几乎占用了所有CPU资源,而发生ANR的进程CPU占用为0%或非常低,则认为CPU资源被占用,进程没有被分配足够的资源,从而发生了ANR。这种情况多数可以认为是系统状态的问题,并不是由本应用造成的。
    • 如果发生ANR的进程CPU占用较高,如到了80%或90%以上,则可以怀疑应用内一些代码不合理消耗掉了CPU资源,如出现了死循环或者后台有许多线程执行任务等等原因,这就要结合trace和ANR前后的log进一步分析了。
    • 如果CPU总用量不高,该进程和其他进程的占用过高,这有一定概率是由于某些主线程的操作就是耗时过长,或者是由于主进程被锁造成的。
  3. 除了上述的情况1以外,分析CPU usage之后,确定问题需要我们进一步分析trace文件。trace文件记录了发生ANR前后该进程的各个线程的stack。对我们分析ANR问题最有价值的就是其中主线程的stack,一般主线程的trace可能有如下几种情况:
    • 主线程是running或者native而对应的栈对应了我们应用中的函数,则很有可能就是执行该函数时候发生了超时。
    • 主线程被block:非常明显的线程被锁,这时候可以看是被哪个线程锁了,可以考虑优化代码。如果是死锁问题,就更需要及时解决了。
    • 由于抓trace的时刻很有可能耗时操作已经执行完了(ANR -> 耗时操作执行完毕 ->系统抓trace)。

如何避免ANR的方法(常见场景)

  1. 主线程避免执行耗时操作(文件操作、IO操作、数据库操作、网络访问等):
  2. Activity、Service(默认情况下)的所有生命周期回调 BroadcastReceiver的onReceive()回调方法 AsyncTask的回调除了doInBackground,其他都是在主线程中 没有使用子线程Looper的Handler的handlerMessage,post(Runnable)都是执行在主线程中
  3. 尽量避免主线程的被锁的情况,在一些同步的操作主线程有可能被锁,需要等待其他线程释放相应锁才能继续执行,这样会有一定的死锁、从而ANR的风险。对于这种情况有时也可以用异步线程来执行相应的逻辑。

2.3 Crash/异常

Android异常体系

Android异常体系
  • 常见的Android崩溃有两类,一类是Java Exception异常,一类是Native Signal异常。我们将围绕这两类异常进行。对于很多基于Unity、Cocos平台的游戏,还会有C#、JavaScript、Lua等的异常,这里不做讨论。
  • Throwable类是所有Java异常和错误的父类,有两个子类Error(错误)和Exception(异常)。
  • Error是程序无法处理的错误,虚拟机一般会选择线程终止。这种错误无法恢复或不可能捕获,将导致应用程序中断,通常应用程序无法处理这些错误,因此应用程序不应该捕获Error对象,也无须在其throws子句中声明该方法抛出任何Error或其子类。
  • Exception是程序本身可以处理的异常,这种异常分两大类运行时异常和非运行时异常。程序中应当尽可能去处理这些异常。
  • 运行时异常都是RuntimeException类及其子类异常,这些异常是编译器不检查的异常,程序中可以选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误引起的,程序应该从逻辑角度尽可能避免这类异常的发生。
  • 非运行时异常是RuntimeException以外的异常,类型上都属于Exception类及其子类。从程序语法角度讲是必须进行处理的异常,如果不处理,程序就不能编译通过。

列举常见的异常

  • 常见的Error:StackOverflowError、OutOfMemoryError、ThreadDeath、ClassFormatError、AbstractMethodError、AssertionError
  • 常见的RuntimeException:NullPointerException、ClassCastException、IllegalArgumentException、ArithmeticException、IndexOutOfBoundsException、SecurityException、NumberFormatException
  • 常见的非RuntimeException:IOException、FileNotFoundException、、一般用户自定义的异常

Android平台的崩溃捕获机制及实现

  • 使用try/catch
  • 使用UncaughtExceptionHandler捕获Uncaught异常。没有捕获住的异常,即Uncaught异常,都会导致应用程序崩溃。那么面对崩溃,我们是否可以做些什么呢?比如程序退出前,弹出个性化对话框,而不是默认的强制关闭对话框,或者弹出一个提示框安慰一下用户,甚至重启应用程序等。其实Java提供了一个接口给我们,可以完成这些,这就是UncaughtExceptionHandler。
  • 对Native代码的崩溃,可以通过调用sigaction()注册信号处理函数来完成捕获。熟悉Linux开发的人都知道,so库一般通过gcc/g++编译,崩溃时会产生信号异常。Android底层是Linux系统,所以so库崩溃时也会产生信号异常。通过调用sigaction()注册信号处理函数可以捕获Android Native崩溃。

2.4 资源问题

常见问题

  • IO/DB/FD等资源未及时释放导致的泄漏问题
  • 内存抖动:指程序短时间内大量创建对象,然后回收的现象
  • 内存泄漏:指程序分配出去的内存不再使用,无法进行回收
  • 内存溢出:指程序在申请内存时,没有足够的空间供其使用
  • 线程使用不当,比如频繁的new Thread()也会导致OOM

解决办法

  • 针对内存抖动,避免频繁创建对象,必要的时候可以复用某些对象
  • 针对内存溢出,获取并且分析堆转储hprof文件,主要关注占用大内存的对象、线程、Activity/Fragment等常见对象的泄漏
  • 针对泄漏问题,资源回收情况分析,及时释放不用的资源
  • 针对线程使用不当的问题,合理使用线程,比如使用线程池

2.5 功耗

基本概念

  • 电量优化是方方面面的,比如说减少内存的开销,减少界面的过度绘制,本身就是一种电量优化。
  • AAF功耗问题主要关注的是待机过程中CPU被唤醒、网络被唤醒的问题

解决办法

  1. 暂无精确定位AAF功耗问题的办法
  2. 一般在使用过程中如果出现发热严重等问题,多数是由死循环、频繁的网络请求、IO操作等导致

2.6 安装包体积

APK文件的组成

直接在Android Studio中打开APK文件,通过APK分析器,可以看到APK文件的组成成分与比例(实际上是调用AAPT工具的功能):

APK组成
  • asserts:存放一些配置文件或资源文件,比如WebView的本地html,React Native的jsbundle等
  • lib:lib目录下会有各种so文件,分析器会检查出项目自己的so和各种库的so。
  • resources.arsc:编译后的二进制资源文件,里面是id-name-value的一个Map。
  • res:res目录存放的是资源文件。包括图片、字符串。raw文件夹下面是音频文件,各种xml文件等等。
  • dex:dex文件是Java代码打包后的字节码,一个dex文件最多只支持65536个方法,开启了dex分包的话会有多个。
  • META-INF:META-INF目录下存放的是签名信息,分别是MANIFEST.MF、CERT.SF、CERT.RSA。用来保证apk包的完整性和系统的安全性,帮助用户避免安装来历不明的盗版APK。
  • AndroidManifest.xml:Android清单文件。

常见APK瘦身方案

  • 优化assets

    • 资源动态下载,字体、js代码这样的资源能动态下载的就做动态下载,虽然复杂度提高,但是实现了动态更新
    • 压缩资源文件,用到的时候再进行解压
    • 删除不必要的字体文件中的字体
    • 减少图标字体(Icon-Font)的使用,多用SVG代替
  • 优化lib

    • 配置abiFilters精简so动态库,而已根据需求保留需要的平台

      defaultConfig {
          //armeabi是必须包含的,v7是一个图形加强版本,x86是英特尔平台的支持库
          ndk {
              abiFilters "armeabi", "armeabi-v7a" ,"x86"
          }
      }
      
    • 统计分析用户手机的cpu类型,排除没有或少量用户才会用到的so

  • 优化resources.arsc

    • 删除不必要的string entry,你可以借助android-arscblamer来检查出可以优化的部分,比如一些空的引用
    • 使用微信的资源混淆工具AndResGuard,它将资源的名称进行了混淆(需要重点配置白名单)
  • 优化META-INF:除了公钥CERT.RSA没有压缩机会外,其余的两个文件都可以通过混淆资源名称的方式进行压缩

  • 优化res

    • 动态下载资源

    • 通过Android Studio的重构工具删除无用资源

    • 打包时剔除无用资源

      release {
              zipAlignEnabled true
              minifyEnabled true  
              shrinkResources true // 是否去除无效的资源文件   
              proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
              signingConfig signingConfigs.release
          }
      
    • 删除无用的语言(排除了所有的依赖库的资源)

      android {
          //...
          defaultConfig {
              resConfigs "zh"
          }
      }
      
    • 控制图片、视频、音频资源的大小,推荐使用有损压缩等其他格式(ogg、svg、webp等)

    • 统一应用风格,减少shape文件

    • 使用toolbar,减少menu文件

    • 通过复用等方式减少layout文件

    • ...

  • 优化dex:

    • 利用工具(dexcount、statistic、apk-method-count、Lint)分析、精简不必要的方法、空行、依赖库等
    • 通过proguard来删除无用代码
    • 剔除无用的测试代码
    • 依赖第三方库的时候,在打包发版中不要将某些不必要的库进行releaseCompile(比如LeakCanary)
    • 使用更小库或合并现有库
    • 减少方法数、使用插件化等方案,不用mulitdex

3.1 细聊OOM问题

Android中常见的OOM问题

OOM、ANR都是线上稳定性的老大难问题,线上采集到的日志难以定位问题。Android中常见的OOM如下:

  • java.lang.StackOverflowError,栈内存溢出
  • java.lang.OutMemoryError,堆内存溢出
  • java.lang.OutMemoryError: xxx thread xxx,线程数过多或者线程过于耗费资源导致的溢出

OOM问题分析与防治

  1. 发生OOM后,冻结APP进程,抓取堆转储hprof文件(目前AAF采用的是这种方案)
  2. 开发人工分析hprof文件,主要关注占用大内存的对象、线程、Activity/Fragment等常见对象的泄漏
  3. 通过LeakCanary的机制(弱引用+主动触发GC),可以及时发现内存泄漏,预防OOM

技术难点和局限性

  1. 主动连续触发GC会造成用户能感知的APP卡顿。(垃圾回收线程在工作,占用CPU资源)
  2. Dump内存镜像造成APP冻结。(dump前,通过ScopedSuspendAll(构造函数中执行SuspendAll)执行了暂停所有java线程的操作,以防止在dump的过程中java堆发生变化,当dump结束后通过ScopedSuspendAll析构函数进行ResumeAll)
  3. hprof往往比较大,不利于上传到服务端;而在Android机器上面直接解析成功率低
  4. 一般的内存泄漏问题,只能定位Activity&Fragment泄漏;无法定位大对象、频繁分配等问题
  5. 需要人工分析、无法对问题归类划分和自动指派,不利于及时修复问题

3.2 快手KOOM

基本介绍

  • KOOM(Kwai OOM, Kill OOM)是快手性能优化团队在处理移动端OOM问题的过程中沉淀出的一套完整解决方案。
  • 其中Android Java内存部分在LeakCanary的基础上进行了大量优化,解决了线上内存监控的性能问题,在不影响用户体验的前提下线上采集内存镜像并解析。从 2020 年春节后在快手主APP上线至今解决了大量OOM问题,其性能和稳定性经受住了海量用户与设备的考验,因此决定开源以回馈社区

KOOM对OOM的治理(优势、解决了那些技术痛点)

  • 解决GC卡顿

    • Java堆内存/线程数/文件描述符数突破阈值触发采集
    • Java堆上涨速度突破阈值触发采集
    • 发生OOM时如果策略1、2未命中 触发采集
    • 泄漏判定延迟至解析时
  • 解决Dump hprof冻结app

    • Dump hprof是通过虚拟机提供的API dumpHprofData实现的,这个过程会“冻结”整个应用进程,造成数秒甚至数十秒内用户无法操作,这也是LeakCanary无法线上部署的最主要原因
    • 利用Linux Copy-on-write机制fork子进程dump hprof,通过欺骗虚拟机解决了dump hprof导致的冻结问题
  • 解决hprof文件过大

    • Hprof文件通常比较大,分析OOM时遇到500M以上的hprof文件并不稀奇,文件的大小,与dump成功率、dump速度、上传成功率负相关,且大文件额外浪费用户大量的磁盘空间和流量。
    • 对hprof进行裁剪,只保留分析OOM必须的数据,另外,裁剪还有数据脱敏的好处,只上传内存中类与对象的组织结构,并不上传真实的业务数据
    • 通过hook dump hprof的写入过程来实现裁剪
  • 解决hprof解析的耗时与OOM

    • LeakCanary发布了新的解析引擎shark,取代了HAHA库
    • 在解析引擎shark的基础上进行一系列的优化,提升了解析性能,包括解析的速度以及解析过程的内存问题
  • BUG分发与跟进

    • 解析结果上传到server以后,还要做反混淆,聚类等工作。
    • 通过关键对象以及引用链,将问题聚合后自动分发给研发同学,分发的原则是引用链中最近提交代码的owner。
    • 注意:KOOM一期只开源了Android端的源码,后台管理平台需要自己搭建

兼容性

  • 支持的Android版本:L-Q(5.0-10.0);风险点:不支持Android R(11.0)
  • 支持的最小minSdkVersion为18
  • 支持的Abi:armeabi、armeabi-v7a、arm64-v8a;不支持x86
  • 只支持Androidx;不支持Android Support Library,需要通过修改源码并且以本地依赖(源码/aar/jar)的方式接入

开源协议

  • KOOM 以 Apache-2.0 证书开源

  • Apache Licence是著名的非盈利开源组织Apache采用的协议。该协议和BSD类似,同样鼓励代码共享和尊重原作者的著作权,同样允许代码修改,再发布(作为开源或商业软件)。需要满足的条件也和BSD类似:

    • 需要给代码的用户一份Apache Licence
    • 如果你修改了代码,需要在被修改的文件中说明。
    • 在延伸的代码中(修改和有源代码衍生的代码中)需要带有原来代码中的协议,商标,专利声明和其他原来作者规定需要包含的说明。
    • 如果再发布的产品中包含一个Notice文件,则在Notice文件中需要带有Apache Licence。你可以在Notice中增加自己的许可,但不可以表现为对Apache Licence构成更改。
  • Apache Licence也是对商业应用友好的许可。使用者也可以在需要的时候修改代码来满足需要并作为开源或商业产品发布/销售。

对安装包体积的影响

APK前后对比
  • 主要是字节码有所增加
  • 但是对最终APK的体积增加在1M以内

最佳做法

  1. 在短视频和资讯项目,拉取分支,接入KOOM

  2. 内测(功能验证和接入后是否会引起稳定性问题)

    • KOOM的内存快照抓取和分析触发包括自动触发和手动触发,触发相关功能后弹出提示或者增加Log,方便调试接入后KOOM的功能是否正常
    • 选择合适场景,通过代码手动触发内存快照抓取和分析,测试验证是否会导致应用卡顿或者冻结
    • 触发AAF,验证接入KOOM后是否会带来新的问题
  3. 后期规划

    • 完成前期的功能内测后,确保引入后不会带来新增问题的前提下
    • 发布到线上环境,收集KOOM生成JSON报告,通过DataService上传到服务端,为后面的报告进一步剪枝、搭建自己的后台管理平台做准备

4 总结

  • 第一部分总结了Android开发中常见的稳定性相关的问题
  • 第二部分简单地认识了OOM的分类以及治理办法,并且介绍了快手KOOM对OOM治理上面做的贡献,以及接入KOOM的一些问题