Android性能优化之OOM

前言

OOM全称为Out Of Memory,内存溢出,指的是请求的内存超过了剩余内存的容量。在Android中,App中已经使用的内存加上新申请的内存大于Android系统分配给App进程的最大内存时,就会发生OOM,导致程序直接崩溃,使得我们的App的用户体验非常糟糕。因此如何避免OOM就成了Android内存优化中比较关键的一点, 这篇文章就是关于内存优化中如何避免OOM的总结性概要文章。

5R法则

在网上看了一篇腾讯大佬写的关于Android中的内存优化的文章,文章中根据他的经验总结出了一个内存优化的“5R法则”,如下所示:

内存优化5R法则

这个5R法则标明了内存优化需要注意的5种情况,下面就这5种情况来进行分析。

Reduce

缩减,就是要减小对象的内存占用,避免OOM的第一步就是要尽量减少新分配出来的对象占用内存的大小,尽量使用更加轻量的对象。

使用更小的资源图片

在设计给到资源图片的时候,我们需要特别留意这张图片是否存在可以压缩的空间,是否可以使用一张更小的图片。尽量使用更小的图片不仅仅可以减少内存的使用,还可以避免出现大量的InflationException。假设有一张很大的图片被XML文件直接引用,很有可能在初始化视图的时候就会因为内存不足而发生InflationException,这个问题的根本原因其实是发生了OOM。

资源按需加载

使用按需加载的方式来加载资源是内存优化中很重要的一环,例如我们常用的ListView中,当数据量特别大的时候,保证只有显示区域的item被加载到内存中,移出显示区域的item被销毁是非常重要的。

对Bitmap对象进行内存优化

Bitmap是一个极容易消耗内存的大胖子,减小创建出来的Bitmap的内存占用是很重要的,通常来说有下面2个措施:

  • inSampleSize:缩放比例,在把图片载入内存之前,我们需要先计算出一个合适的缩放比例,避免不必要的大图载入。
  • decode format:解码格式,选择ARGB_8888/RBG_565/ARGB_4444/ALPHA_8,存在很大差异。

避免使用枚举类型

枚举是我比较喜欢使用的一种类型,枚举的使用方便、可读性强,无需考虑线程安全的问题而且还能自定义方法。不得不说枚举确实好用,但是如果你看过枚举类型字节码反编译出的代码的话,就知道其实枚举就是一个普通的类。那么我们使用枚举类型的时候需要将枚举类加载到内存中,这个过程至少会占用24字节的内存。而我们使用 public static final int 来定义一个整型的话,它只占用整型的4字节内存。Android官方提到过一句话:

Enums often require more than twice as much memory as static constants. You should strictly avoid using enums on Android.

因此我们应该避免在Android里面使用到枚举。

使用更加轻量级的数据结构

在Java中,当需要存放键值对象的时候,我们通常会想到HashMap。HashMap底层数据结构基于哈希数组和单链表,并且在必要的时候单链表可以转化为红黑树来提高性能,这就使得HashMap在插入和查询时的性能表现相当优异。

但是HashMap也有不少的缺点,例如当Key或Value为基本类型时,由于Java中泛型的特点,使得HashMap在存储过程中需要频繁的进行装箱和拆箱操作,非常浪费性能。而在Android中使用最大的问题就是HashMap的内存浪费问题。HashMap默认数组大小为16,若为空,那么这个HashMap就占用了16个Entry对象的内存。HashMap每次扩容都是2倍,当数据越来越多的时候,每次扩若所产生的unused对象就越多,导致HashMap占用大量内存。

为了解决HashMap自动装拆箱的问题,Android提供了性能更强的SparseArray。SparseArray将Map中的key值直接规定为整型,这样的话直接就解决了Key为Integer时的装拆箱开销。SparseArray内部采用两个数组分别存储key和Value的形式存储数组,同时为了优化性能,它内部对数据还采取了压缩的方式来表示稀疏数组的数据。

关于稀疏数组中的压缩,借用网上的一张图片:

稀疏数组的压缩策略

从图中可以看出,SparseArray使用两个数组的方式,由原来的63缩小18,极大的减少了内存的开销。

而为了解决HashMap更占内存的弊端,Android也提供了内存效率更高的ArrayMap。ArrayMap的内部也是使用两个数组进行工作的,其中一个数组记录Key hash过后的顺序列表,另外一个数组按key的顺序记录Key-Value,那么首先我们就想到ArrayMap是有序的。ArrayMap支持缩容,当元素被移除时以0.5被进行缩容。同时其内部维护了一些小数组,当元素特别少的场景时,无需额外申请内存空间,直接复用这些小数组,采用池的思想解决了内存浪费的问题。

ArrayMap在查找时,会先计算输入key的hash值,然后对key数组进行二分查找寻找对应的index,然后通过这个index可以直接访问到另一个数组中需要的键值对(当然也会存在hash碰撞)。这跟HashMap不同,HashMap在获取到key的hash值后,先要获取通过(hash&n-1)的方式到对应数组中的index,然后再进行链表或者红黑树的遍历。这样来看ArrayMap的查找性能虽然不差,但还是远不如HashMap的,而且当数据越多,hash冲突越频繁时,其性能还会大幅度下降。

那么在删除和插入的时候呢?其实这个问题不用想就知道是肯定比HashMap差远了,毕竟使用的是两个数组。Google官方这样描述ArrayMap的删除和插入操作:

ArrayMap的删除和插入

很明显,ArrayMap的插入与删除的效率是不够高的,它其实是在内存和效率之间做的一个平衡。但是如果数组的列表只是在一百这个数量级上,则完全不用担心这些插入与删除的效率问题。

那么如何使用“合适”的数据结构呢?这里我画了一张图:

使用合适的数据结构

按照图中的方法去选择合适的数据结构,避免在代码阶段就隐藏了OOM的风险。

Reuse

大多数对象的复用,最终实施的方案都是利用对象池技术,要么是在编写代码的时候显式的在程序里面去创建对象池,然后处理好复用的实现逻辑,要么就是利用系统框架既有的某些复用特性达到减少对象的重复创建,从而减少内存的分配与回收。

复用系统自带资源

Android系统本身内置了很多的资源,例如字符串/颜色/图片/动画/样式以及简单布局等等,这些资源都可以在应用程序中直接引用。这样做不仅仅可以减少应用程序的自身负重,减小APK的大小,另外还可以一定程度上减少内存的开销,复用性更好。但是也有必要留意Android系统的版本差异性,对那些不同系统版本上表现存在很大差异,不符合需求的情况,还是需要应用程序自身内置进去。

在ListView/GridView中对ConvertView复用

复用ConvertView

Bitmap对象的缓存和复用

在ListView与RecyclerView等显示大量图片的控件里面需要使用LRU的机制来缓存处理好的Bitmap,也就是我们常说的图片三级缓存。

避免在onDraw方法里面创建对象

类似onDraw等频繁调用的方法,一定需要注意避免在这里做创建对象的操作,因为他会迅速增加内存的使用,而且很容易引起频繁的gc,甚至是内存抖动。

Recycle

内存对象的泄漏,会导致一些不再使用的对象无法及时释放,这样一方面占用了宝贵的内存空间,很容易导致后续需要分配内存的时候,空闲空间不足而出现OOM。显然,这还使得每级Generation的内存区域可用空间变小,gc就会更容易被触发,容易出现内存抖动,从而引起性能问题。

监控内存泄漏常用的工具有LeakCanary、MAT和Android 3.0 新出的 Android Profiler。

注意Activity的泄露

通常来说,Activity的泄漏是内存泄漏里面最严重的问题,它占用的内存多,影响面广,我们需要特别注意以下两种情况导致的Activity泄漏:

内部类引用导致Activity的泄露

最典型的就是Handler了,如果Handler中有延迟的任务或者是等待执行的任务队列过长,都有可能因为Handler继续执行而导致Activity发生泄漏。此时的引用关系链是Looper -> MessageQueue -> Message -> Handler -> Activity。为了解决这个问题,可以在UI退出之前,执行remove Handler消息队列中的消息与runnable对象。或者是使用Static + WeakReference的方式来达到断开Handler与Activity之间存在引用关系的目的。

Activity Context被传递到其他实例中,这可能导致自身被引用而发生泄漏。

内部类引起的泄漏不仅仅会发生在Activity上,其他任何内部类出现的地方,都需要特别留意。我们可以考虑尽量使用static类型的内部类,同时使用WeakReference的机制来避免因为互相引用而出现的泄露。

Context引发的内存泄漏

典型的Context引发的内存泄漏就是单例中传入了Activity Context,使得Activity被引用到全局的单例中无法得到回收。

对于大部分非必须使用Activity Context的情况(Dialog的Context就必须是Activity Context),我们都可以考虑使用Application Context而不是Activity的Context,这样可以避免不经意的Activity泄露。

集合类中的内存泄漏

有时候,我们为了提高对象的复用性把某些对象放到缓存容器中,可是如果这些对象没有及时从容器中清除,也是有可能导致内存泄漏的。

及时回收临时的Bitmap对象

虽然在大多数情况下,我们会对Bitmap增加缓存机制,但是在某些时候,部分Bitmap是需要及时回收的。例如临时创建的某个相对比较大的bitmap对象,在经过变换得到新的bitmap对象之后,应该尽快回收原始的bitmap,这样能够更快释放原始bitmap所占用的空间。

及时注销监听器

在Android程序里面存在很多需要register与unregister的监听器,我们需要确保在合适的时候及时unregister那些监听器。自己手动add的listener,需要记得及时remove这个listener。

及时关闭资源对象

对于使用了BraodcastReceiver,ContentObserver,File,游标 Cursor,Stream,Bitmap等资源的使用,应该在Activity销毁时及时关闭或者注销。

Refactor

当我们需要对整个App进行内存优化分析时,需要在不同的角度思考如何对代码重构达到优化的效果。

使用优化后的数据结构

这个上面已经说过了,根据需求选择ArrayMap和SparseArray来代替HashMap,同时也有注意尽量少使用枚举类型。

优化布局,减少内存消耗

过渡绘制的界面不仅非常占内存,还会导致界面卡顿,考虑使用merge、include、viewStub来优化布局。

Revalue

重审是我们思考对App中使用一些技术是否有必要、是否权衡了利弊的过程。

谨慎使用large heap

Android设备根据硬件与软件的设置差异而存在不同大小的内存空间,他们为应用程序设置了不同大小的Heap限制阈值。你可以通过调用 getMemoryClass() 来获取应用的可用Heap大小。在一些特殊的情景下,你可以通过在 manifest 的 application 标签下添加 largetHeap = true 的属性来为应用声明一个更大的heap空间。然后,你可以通过 getLargeMemoryClass() 来获取到这个更大的heap size阈值。然而,声明得到更大Heap阈值的本意是为了一小部分会消耗大量RAM的应用(例如一个大图片的编辑应用)。不要轻易的因为你需要使用更多的内存而去请求一个大的Heap Size。只有当你清楚的知道哪里会使用大量的内存并且知道为什么这些内存必须被保留时才去使用large heap。因此请谨慎使用large heap属性。使用额外的内存空间会影响系统整体的用户体验,并且会使得每次gc的运行时间更长。在任务切换时,系统的性能会大打折扣。另外, large heap并不一定能够获取到更大的heap。在某些有严格限制的机器上,large heap的大小和通常的heap size是一样的。因此即使你申请了large heap,你还是应该通过执行 getMemoryClass() 来检查实际获取到的heap大小。

谨慎使用多进程

使用多进程可以把应用中的部分组件运行在单独的进程当中,这样可以扩大应用的内存占用范围,但是这个技术必须谨慎使用,绝大多数应用都不应该贸然使用多进程,一方面是因为使用多进程会使得代码逻辑更加复杂,另外如果使用不当,它可能反而会导致显著增加内存。当你的应用需要运行一个常驻后台的任务,而且这个任务并不轻量,可以考虑使用这个技术。

一个典型的例子是创建一个可以长时间后台播放的Music Player。如果整个应用都运行在一个进程中,当后台播放的时候,前台的那些UI资源也没有办法得到释放。类似这样的应用可以切分成2个进程:一个用来操作UI,另外一个给后台的Service。

谨慎使用第三方库

很多开源的library代码都不是为移动网络环境而编写的,如果运用在移动设备上,并不一定适合。即使是针对Android而设计的library,也需要特别谨慎,特别是在你不知道引入的library具体做了什么事情的时候。例如,其中一个library使用的是nano protobufs, 而另外一个使用的是micro protobufs。这样一来,在你的应用里面就有2种protobuf的实现方式。这样类似的冲突还可能发生在输出日志,加载图片,缓存等等模块里面。另外不要为了1个或者2个功能而导入整个library,如果没有一个合适的库与你的需求相吻合,你应该考虑自己去实现,而不是导入一个大而全的解决方案。

选择合适的文件夹存放资源文件

我们知道 hdpi/xhdpi/xxhdpi 等等不同dpi的文件夹下的图片在不同的设备上会经过scale的处理。例如我们只在hdpi的目录下放置了一张100x100的图片,那么根据换算关系,xxhdpi的手机去引用那张图片就会被拉伸到200x200。需要注意到在这种情况下,内存占用是会显著提高的。对于不希望被拉伸的图片,需要放到assets或者nodpi的目录下。

More

关于更多性能优化的知识推荐Google官方系列视频《Android Performance Patterns》,地址贴在下方。

Youtube:Android Performance Patterns 系列视频

参考了胡凯大佬的博客,学到了很多: 胡凯