导语

智能手机发展到今天已经有十几个年头,手机的软硬件都已经发生了翻天覆地的变化,特别是 Android 阵营,从一开始的一两百 M 到今天动辄 4G,6G 内存。然而大部分的开发者观看下自己的异常上报系统,还是会发现各种内存问题仍然层出不穷,各种 OOM 为 crash 率贡献不少。Android 开发发展到今天也是已经比较成熟,各种新框架,新技术也是层出不穷,而内存优化一直都是 Android 开发过程一个不可避免的话题。 恰好最近做了内存优化相关的工作,这里也对 Android 内存优化相关的知识做下总结。

在开始文章之前推荐下公司同事翻译整理版本《Android 性能优化典范 - 第 6 季》,因为篇幅有限这里我对一些内容只做简单总结,同时如果有不正确内容也麻烦帮忙指正。

本文将会对 Android 内存优化相关的知识进行总结以及最后案例分析(一二部分是理论知识总结,你也可以直接跳到第三部分看案例):

一、Android 内存分配回收机制
  二、Android 常见内存问题和对应检测,解决方式。
  三、JOOX 内存优化案例
  四、总结

工欲善其事必先利其器,想要优化 App 的内存占用,那么还是需要先了解 Android 系统的内存分配和回收机制。

一、Android 内存分配回收机制

参考 Android 操作系统的内存回收机制[1],这里简单做下总结:

从宏观角度上来看 Android 系统可以分为三个层次:
Application Framework
Dalvik 虚拟机
Linux 内核。

这三个层次都有各自内存相关工作:

1. Application Framework

Anroid 基于进程中运行的组件及其状态规定了默认的五个回收优先级:

Empty process(空进程)
Background process(后台进程)
Service process(服务进程)
Visible process(可见进程)
Foreground process(前台进程)

系统需要进行内存回收时最先回收空进程,然后是后台进程,以此类推最后才会回收前台进程(一般情况下前台进程就是与用户交互的进程了,如果连前台进程都需要回收那么此时系统几乎不可用了)。

由此也衍生了很多进程保活的方法(提高优先级,互相唤醒,native 保活等等),出现了国内各种全家桶,甚至各种杀不死的进程。

Android 中由 ActivityManagerService 集中管理所有进程的内存资源分配。

2. Linux 内核

 

参考阿里巴巴的 Android 内存优化分享[2],这里最简单的理解就是ActivityManagerService会对所有进程进行评分(存放在变量 adj 中),然后再讲这个评分更新到内核,由内核去完成真正的内存回收(lowmemorykiller, Oom_killer)。这里只是大概的流程,中间过程还是很复杂的,有兴趣的同学可以一起研究,代码在系统源码ActivityManagerService.java中。

3. Dalvik 虚拟机

Android 进程的内存管理分析[3],对 Android 中进程内存的管理做了分析。

Android 中有 Native Heap 和 Dalvik Heap。Android 的 Native Heap 言理论上可分配的空间取决了硬件 RAM,而对于每个进程的 Dalvik Heap 都是有大小限制的,具体策略可以看看 android dalvik heap 浅析[4]。

Android App 为什么会 OOM 呢?其实就是申请的内存超过了 Dalvik Heap 的最大值。这里也诞生了一些比较”黑科技”的内存优化方案,比如将耗内存的操作放到 Native 层,或者使用分进程的方式突破每个进程的 Dalvik Heap 内存限制。

Android Dalvik Heap 与原生 Java 一样,将堆的内存空间分为三个区域,Young Generation,Old Generation, Permanent Generation。

 

最近分配的对象会存放在 Young Generation 区域,当这个对象在这个区域停留的时间达到一定程度,它会被移动到 Old Generation,最后累积一定时间再移动到 Permanent Generation 区域。系统会根据内存中不同的内存数据类型分别执行不同的 gc 操作。

GC 发生的时候,所有的线程都是会被暂停的。执行 GC 所占用的时间和它发生在哪一个 Generation 也有关系,Young Generation 中的每次 GC 操作时间是最短的,Old Generation 其次,Permanent Generation 最长。

GC 时会导致线程暂停,导致卡顿,Google 在新版本的 Android 中优化了这个问题, 在 ART 中对 GC 过程做了优化揭秘 ART 细节 —— Garbage collection[5],据说内存分配的效率提高了 10 倍,GC 的效率提高了 2-3 倍(可见原来效率有多低),不过主要还是优化中断和阻塞的时间,频繁的 GC 还是会导致卡顿。

上面就是 Android 系统内存分配和回收相关知识,回过头来看,现在各种手机厂商鼓吹人工智能手机,号称 18 个月不卡顿,越用越快,其实很大一部分 Android 系统的内存优化有关,无非就是利用一些比较成熟的基于统计,机器学习的算法定时清理数据,清理内存,甚至提前加载数据到内存。

二、Android 常见内存问题和对应检测,解决方式

1. 内存泄露

不止 Android 程序员,内存泄露应该是大部分程序员都遇到过的问题,可以说大部分的内存问题都是内存泄露导致的,Android 里也有一些很常见的内存泄露问题[6],这里简单罗列下:
单例(主要原因还是因为一般情况下单例都是全局的,有时候会引用一些实际生命周期比较短的变量,导致其无法释放)
静态变量(同样也是因为生命周期比较长)
Handler 内存泄露[7]
匿名内部类(匿名内部类会引用外部类,导致无法释放,比如各种回调)
资源使用完未关闭(BraodcastReceiver,ContentObserver,File,Cursor,Stream,Bitmap)

对 Android 内存泄露业界已经有很多优秀的组件其中 LeakCanary 最为知名(Square 出品,Square 可谓 Android 开源界中的业界良心,开源的项目包括 okhttp, retrofit,otto, picasso, Android 开发大神 Jake Wharton 就在 Square),其原理是监控每个 activity,在 activity ondestory 后,在后台线程检测引用,然后过一段时间进行 gc,gc 后如果引用还在,那么 dump 出内存堆栈,并解析进行可视化显示。使用 LeakCanary 可以快速地检测出 Android 中的内存泄露。

正常情况下,解决大部分内存泄露问题后,App 稳定性应该会有很大提升,但是有时候 App 本身就是有一些比较耗内存的功能,比如直播,视频播放,音乐播放,那么我们还有什么能做的可以降低内存使用,减少 OOM 呢?

2. 图片分辨率相关

分辨率适配问题。很多情况下图片所占的内存在整个 App 内存占用中会占大部分。我们知道可以通过将图片放到 hdpi/xhdpi/xxhdpi 等不同文件夹进行适配,通过 xml android:background 设置背景图片,或者通过 BitmapFactory.decodeResource()方法,图片实际上默认情况下是会进行缩放的。在 Java 层实际调用的函数都是或者通过 BitmapFactory 里的 decodeResourceStream 函数

public static Bitmap decodeResourceStream(Resources res, TypedValue value,
         InputStream is, Rect pad, Options opts) {
     if (opts == null) {
         opts = new Options();
     }
     if (opts.inDensity == 0 && value != null) {
         final int density = value.density;
         if (density == TypedValue.DENSITY_DEFAULT) {
             opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;
         } else if (density != TypedValue.DENSITY_NONE) {
             opts.inDensity = density;
         }
     }
     if (opts.inTargetDensity == 0 && res != null) {
         opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
     }
     return decodeStream(is, pad, opts);
 }


decodeResource 在解析时会对 Bitmap 根据当前设备屏幕像素密度 densityDpi 的值进行缩放适配操作,使得解析出来的 Bitmap 与当前设备的分辨率匹配,达到一个最佳的显示效果,并且 Bitmap 的大小将比原始的大,可以参考下腾讯 Bugly 的详细分析 Android 开发绕不过的坑:你的 Bitmap 究竟占多大内存?。

关于 Density、分辨率、-hdpi 等 res 目录之间的关系:

 

举个例子,对于一张 1280×720 的图片,如果放在 xhdpi,那么 xhdpi 的设备拿到的大小还是 1280×720 而 xxhpi 的设备拿到的可能是 1920×1080,这两种情况在内存里的大小分别为:3.68M 和 8.29M,相差 4.61M,在移动设备来说这几 M 的差距还是很大的。

尽管现在已经有比较先进的图片加载组件类似 Glide,Facebook Freso, 或者老牌 Universal-Image-Loader,但是有时就是需要手动拿到一个 bitmap 或者 drawable,特别是在一些可能会频繁调用的场景(比如 ListView 的 getView),怎样尽可能对 bitmap 进行复用呢?这里首先需要明确的是对同样的图片,要 尽可能复用,我们可以简单自己用 WeakReference 做一个 bitmap 缓存池,也可以用类似图片加载库写一个通用的 bitmap 缓存池,可以参考 GlideBitmapPool[8]的实现。

我们也来看看系统是怎么做的,对于类似在 xml 里面直接通过 android:background 或者 android:src 设置的背景图片,以 ImageView 为例,最终会调用 Resource.java 里的 loadDrawable:

Drawable loadDrawable(TypedValue value, int id, Theme theme) throws NotFoundException {
    // Next, check preloaded drawables. These may contain unresolved theme
     // attributes.
     final ConstantState cs;
     if (isColorDrawable) {
         cs = sPreloadedColorDrawables.get(key);
     } else {
         cs = sPreloadedDrawables[mConfiguration.getLayoutDirection()].get(key);
     }    Drawable dr;
     if (cs != null) {
         dr = cs.newDrawable(this);
     } else if (isColorDrawable) {
         dr = new ColorDrawable(value.data);
     } else {
         dr = loadDrawableForCookie(value, id, null);
     }
     ...
     return dr;
 }


可以看到实际上系统也是有一份全局的缓存,sPreloadedDrawables, 对于不同的 drawable,如果图片时一样的,那么最终只会有一份 bitmap(享元模式),存放于 BitmapState 中,获取 drawable 时,系统会从缓存中取出这个 bitmap 然后构造 drawable。而通过 BitmapFactory.decodeResource()则每次都会重新解码返回 bitmap。所以其实我们可以通过 context.getResources().getDrawable 再从 drawable 里获取 bitmap,从而复用 bitmap,然而这里也有一些坑,比如我们获取到的这份 bitmap,假如我们执行了 recycle 之类的操作,但是假如在其他地方再使用它是那么就会有”Canvas: trying to use a recycled bitmap android.graphics.Bitmap”异常。

3. 图片压缩

BitmapFactory 在解码图片时,可以带一个 Options,有一些比较有用的功能,比如:

inTargetDensity 表示要被画出来时的目标像素密度

inSampleSize 这个值是一个 int,当它小于 1 的时候,将会被当做 1 处理,如果大于 1,那么就会按照比例(1 / inSampleSize)缩小 bitmap 的宽和高、降低分辨率,大于 1 时这个值将会被处置为 2 的倍数。例如,width=100,height=100,inSampleSize=2,那么就会将 bitmap 处理为,width=50,height=50,宽高降为 1 / 2,像素数降为 1 / 4

inJustDecodeBounds 字面意思就可以理解就是只解析图片的边界,有时如果只是为了获取图片的大小就可以用这个,而不必直接加载整张图片。

inPreferredConfig 默认会使用 ARGB_8888,在这个模式下一个像素点将会占用 4 个 byte,而对一些没有透明度要求或者图片质量要求不高的图片,可以使用 RGB_565,一个像素只会占用 2 个 byte,一下可以省下 50%内存。

inPurgeable和inInputShareable 这两个需要一起使用,BitmapFactory.java 的源码里面有注释,大致意思是表示在系统内存不足时是否可以回收这个 bitmap,有点类似软引用,但是实际在 5.0 以后这两个属性已经被忽略,因为系统认为回收后再解码实际会反而可能导致性能问题

inBitmap 官方推荐使用的参数,表示重复利用图片内存,减少内存分配,在 4.4 以前只有相同大小的图片内存区域可以复用,4.4 以后只要原有的图片比将要解码的图片大既可以复用了。

4. 缓存池大小

现在很多图片加载组件都不仅仅是使用软引用或者弱引用了,实际上类似 Glide 默认使用的事 LruCache,因为软引用 弱引用都比较难以控制,使用 LruCache 可以实现比较精细的控制,而默认缓存池设置太大了会导致浪费内存,设置小了又会导致图片经常被回收,所以需要根据每个 App 的情况,以及设备的分辨率,内存计算出一个比较合理的初始值,可以参考 Glide 的做法。

5. 内存抖动

什么是内存抖动呢?Android 里内存抖动是指内存频繁地分配和回收,而频繁的 gc 会导致卡顿,严重时还会导致 OOM。

 

一个很经典的案例是 string 拼接创建大量小的对象(比如在一些频繁调用的地方打字符串拼接的 log 的时候), 见 Android 优化之 String 篇[9]。

而内存抖动为什么会引起 OOM 呢?

主要原因还是有因为大量小的对象频繁创建,导致内存碎片,从而当需要分配内存时,虽然总体上还是有剩余内存可分配,而由于这些内存不连续,导致无法分配,系统直接就返回 OOM 了。

比如我们坐地铁的时候,假设你没带公交卡去坐地铁,地铁的售票机就只支持 5 元,10 元,而哪怕你这个时候身上有 1 万张 1 块的都没用(是不是觉得很反人类..)。当然你可以去兑换 5 元,10 元,而在 Android 系统里就没那么幸运了,系统会直接拒绝为你分配内存,并扔一个 OOM 给你(有人说 Android 系统并不会对 Heap 中空闲内存区域做碎片整理,待验证)。

其他

常用数据结构优化,ArrayMap 及 SparseArray 是 android 的系统 API,是专门为移动设备而定制的。用于在一定情况下取代 HashMap 而达到节省内存的目的,具体性能见 HashMap,ArrayMap,SparseArray 源码分析及性能对比[10],对于 key 为 int 的 HashMap 尽量使用 SparceArray 替代,大概可以省 30%的内存,而对于其他类型,ArrayMap 对内存的节省实际并不明显,10%左右,但是数据量在 1000 以上时,查找速度可能会变慢。

枚举,Android 平台上枚举是比较争议的,在较早的 Android 版本,使用枚举会导致包过大,在个例子里面,使用枚举甚至比直接使用 int 包的 size 大了 10 多倍 在 stackoverflow 上也有很多的讨论, 大致意思是随着虚拟机的优化,目前枚举变量在 Android 平台性能问题已经不大,而目前 Android 官方建议,使用枚举变量还是需要谨慎,因为枚举变量可能比直接用 int 多使用 2 倍的内存。

ListView 复用,这个大家都知道,getView 里尽量复用 conertView,同时因为 getView 会频繁调用,要避免频繁地生成对象

谨慎使用多进程,现在很多 App 都不是单进程,为了保活,或者提高稳定性都会进行一些进程拆分,而实际上即使是空进程也会占用内存(1M 左右),对于使用完的进程,服务都要及时进行回收。

尽量使用系统资源,系统组件,图片甚至控件的 id

减少 view 的层级,对于可以 延迟初始化的页面,使用 viewstub

数据相关:序列化数据使用 protobuf 可以比 xml 省 30%内存,慎用 shareprefercnce,因为对于同一个 sp,会将整个 xml 文件载入内存,有时候为了读一个配置,就会将几百 k 的数据读进内存,数据库字段尽量精简,只读取所需字段。

dex 优化,代码优化,谨慎使用外部库, 有人觉得代码多少于内存没有关系,实际会有那么点关系,现在稍微大一点的项目动辄就是百万行代码以上,多 dex 也是常态,不仅占用 rom 空间,实际上运行的时候需要加载 dex 也是会占用内存的(几 M),有时候为了使用一些库里的某个功能函数就引入了整个庞大的库,此时可以考虑抽取必要部分,开启 proguard 优化代码,使用 Facebook redex 使用优化 dex(好像有不少坑)。

三、案例

JOOX 是 IBG 一个核心产品,2014 年发布以来已经成为 5 个国家和地区排名第一的音乐 App。东南亚是 JOOX 的主要发行地区,实际上这些地区还是有很多的低端机型,对 App 的进行内存优化势在必行。

上面介绍了 Android 系统内存分配和回收机制,同时也列举了常见的内存问题,但是当我们接到一个内存优化的任务时,我们应该从何开始?下面是一次内存优化的分享。

1. 首先是解决大部分内存泄露。

不管目前 App 内存占用怎样,理论上不需要的东西最好回收,避免浪费用户内存,减少 OOM。实际上自 JOOX 接入 LeakCanary 后,每个版本都会做内存泄露检测,经过几个版本的迭代,JOOX 已经修复了几十处内存泄露。

 

2. 通过 MAT 查看内存占用,优化占用内存较大的地方。

JOOX 修复了一系列内存泄露后,内存占用还是居高不下,只能通过 MAT 查看到底是哪里占用了内存。关于 MAT 的使用,网上教程无数,简单推荐两篇 MAT 使用教程[11],MAT - Memory Analyzer Tool 使用进阶[12]。

点击 Android Studio 这里可以 dump 当前的内存快照,因为直接通过 Android Sutdio dump 出来的 hprof 文件与标准 hprof 文件有些差异,我们需要手动进行转换,利用 sdk 目录/platform-tools/hprof-conv.exe 可以直接进行转换,用法:hprof-conv 原文件.hprof 新文件.hprof。只需要输入原文件名还有目标文件名就可以进行转换,转换完就可以直接用 MAT 打开。

 

下面就是 JOOX 打开 App,手动进行多次 gc 的 hprof 文件。

这里我们看的是 Dominator Tree(即内存里占用内存最多的对象列表)。

 

Shallo Heap:对象本身占用内存的大小,不包含其引用的对象内存。

Retained Heap: Retained heap 值的计算方式是将 retained set 中的所有对象大小叠加。或者说,由于 X 被释放,导致其它所有被释放对象(包括被递归释放的)所占的 heap 大小。

第一眼看去 居然有 3 个 8M 的对象,加起来就是 24M 啊 这到底是什么鬼?

 

我们通过 List objects->with incoming references 查看(这里 with incoming references 表示查看谁引用了这个对象,with outgoing references 表示这个对象引用了谁)

 

通过这个方式我们看到这三张图分别是闪屏,App 主背景,App 抽屉背景。

 

这里其实有两个问题:

这几张图原图实际都是 1280x720,而在 1080p 手机上实测这几张图都缩放到了 1920x1080

闪屏页面,其实这张图在闪屏显示过后应该可以回收,但是因为历史原因(和 JOOX 的退出机制有关),这张图被常驻在后台,导致无谓的内存占用。

优化方式:我们通过将这三张图从 xhdpi 挪动到 xxhdpi(当然这里需要看下图片显示效果有没很大的影响),以及在闪屏显示过后回收闪屏图片。
优化结果:

 

从原来的 8.29x3=24.87M 到 3.68x2=7.36M 优化了 17M(有没一种万马奔腾的感觉。。可能有时费大力气优化很多代码也优化不了几百 K,所以很多情况下内存优化时优化图片还是比较立竿见影的)。

同样方式我们发现对于一些默认图,实际要求的显示要求并不高(图片相对简单,同时大部分情况下图片加载会成功),比如下面这张 banner 的背景图:

 

优化前 1.6M 左右,优化后 700K 左右。

同时我们也发现了默认图片一个其他问题,因为历史原因,我们使用的图片加载库,设置默认图片的接口是需要一个 bitmap,导致我们原来几乎每个 adapter 都用 BitmapFactory decode 了一个 bitmap,对同一张默认图片,不但没有复用,还保存了多份,不仅会造成内存浪费,而且导致滑动偶尔会卡顿。这里我们也对默认图片使用全局的 bitmap 缓存池,App 全局只要使用同一张 bitmap,都复用了同一份。

另外对于从 MAT 里看到的图片,有时候因为看不到在项目里面对应的 ID,会比较难确认到底是哪一张图,这里 stackoverflow 上有一种方法,直接用原始数据通过 GIM 还原这张图片。

这里其实也看到 JOOX 比较吃亏一个地方,JOOX 不少地方都是使用比较复杂的图片,同时有些地方还需要模糊,动画这些都是比较耗内存的操作,Material Design 出来后,很多 App 都遵循 MD 设计进行改版,通常默认背景,默认图片一般都是纯色,不仅 App 看起来比较明亮轻快,实际上也省了很多的内存,对此,JOOX 后面对低端机型做了对应的优化。

3. 我们也对 RDM 上的 OOM 进行了分析,发现其实有些 OOM 是可以避免的。

下面这个 crash 就是上面提到的在 LsitView 的 adapter 里不停创建 bitmap,这个地方是我们的首页 banner 位,理论上 App 一打开就会缓存这张默认背景图片了,而实际在使用过一段时间后,才因为为了解码这张背景图而 OOM, 改为用全局缓存解决。

 

下面这个就是传说中的内存抖动:

 

实际代码如下,因为打 Log 而进行了字符串拼接,一旦这个函数被比较频繁地调用,那么就很有可能会发生内存抖动。这里我们新版本已经改为使用 stringbuilder 进行优化。

 

还有一些比较奇怪的情况,这里是我们扫描歌曲文件头的时候发生的,有些文件头居然有几百 M 大,导致一次申请了过大的内存,直接 OOM,这里暂时也无法修复,直接 catch 住 out of memory error。

 

4. 同时我们对一些逻辑代码进行调整,比如我们的 App 主页的第三个 tab(Live tab)进行了数据延迟加载,和定时回收。

 

这里因为这个页面除了有大图还有轮播 banner,实际强引用的图片会有多张,如果这个时候切到其他页面进行听歌等行为,这个页面一直在后台缓存,实际是很浪费耗内存的,同时为优化体验,我们又不能直接通过设置主页的 viewpager 的缓存页数,因为这样经常都会回收,导致影响体验,所以我们在页面不可见后过一段时间,清理掉 adapter 数据(只是清空 adapter 里的数据,实际从网络加载回来的数据还在,这里只是为了去掉界面对图片的引用),当页面再次显示时再用已经加载的数据显示,即减少了很多情况下图片的引用,也不影响体验。

5. 最后我们也遇到一个比较奇葩的问题,在我们的 RDM 上报上有这样一条上报

 

我们在 stackoverflow 上看到了相关的讨论,大致意思是有些情况下比如息屏,或者一些省电模式下,频繁地调 System.gc()可能会因为内核状态切换超时的异常。这个问题貌似没有比较好的解决方法,只能是优化内存,尽量减少手动调用 System.gc()

优化结果

我们通过启动 App 后,切换到我的音乐界面,停留 1 分钟,多次 gc 后,获取 App 内存占用

优化前:

 

优化后:

 

多次试验结果都差不多,这里只截取了其中一次,有 28M 的优化效果。
当然不同的场景内存占用不同,同时上面试验结果是通过多次手动触发 gc 稳定后的结果。对于使用其他第三方工具不手动 gc 的情况下,试验结果可能会差异比较大。

对于上面提到的 JOOX 里各种图片背景等问题,我们做了动态的优化,对不同的机型进行优化,对特别低端的机型设置为纯色背景等方式,最终优化效果如下:

 

 

平均内存降低 41M。

本次总结主要还是从图片方面下手,还有一点逻辑优化,已经基本达到优化目标。

四、总结

上面写了很多,我们可以简单总结,目前 Andorid 内存优化还是比较重要一个话题,我们可以通过各种内存泄露检测组件,MAT 查看内存占用,Memory Monitor 跟踪整个 App 的内存变化情况, Heap Viewer 查看当前内存快照, Allocation Tracker 追踪内存对象的来源,以及利用崩溃上报平台从多个方面对 App 内存进行监控和优化。上面只是列举了一些常见的情况,当然每个 App 功能,逻辑,架构也都不一样,造成内存问题也是不尽相同,掌握好工具的使用,发现问题所在,才能对症下药。