Android 性能优化(四)之内存优化实战

3,457 阅读16分钟

在上一篇《Android性能优化(三)之内存管理》中我们对Android的内存管理有了一定的认识,本篇文章从实际出发对内存进行优化,主要包含以下部分:

1. Memory Leak

内存泄漏:对于Java来说,就是new出来的Object 放在Heap上无法被GC回收(内存中存在无法被回收的对象);内存泄漏发生时的主要表现为内存抖动,可用内存慢慢变少。

1.1 Memory Monitor

AndroidStudio自带的Memory Monitor可以方便的观察堆内存的分配情况,并且可以粗略的观察有没有Memory Leak。

频繁的内存抖动,可能存在内存泄漏

  • A:initiate GC 手动触发GC操作;
  • B:Dump Java Heap 获取当前的堆栈信息,生成一个.hprof文件,AndroidStudip会自动使用HeapViewer打开;一般用于操作之后检测内存泄漏的情况;
  • C:Start Allocation Tracking 内存分配追踪工具,用于追踪一段时间的内存分配使用情况,能够知道执行一些列操作后,有哪些对象被分配空间。一般用于追踪某项操作之后的内存分配,调整相关的方法调用来优化app性能与内存使用;
  • D:剩余可用内存;
  • E:已经使用的内存。

点击Memory Monitor的Dump Java Heap,会生成一个.hprof文件,AndroidStudio会自动使用HeapViewer打开。

Hprof Viewer打开.hprof文件

左面板说明:

  • Total Count 该类的实例个数
  • Heap Count 选定的Heap中实例的个数
  • Sizeof 每个实例占用的内存大小
  • Shallow Size 所有该类的实例占用的内存大小
  • Retained Size 该类的所有实例可支配的内存大小

右面板说明:

  • Instance 该类的所有实例对象(左侧Total Count为15,此处就有15个对象)
  • Depth 深度, GC Root点到该实例的最短链路数
  • Dominating Size 该实例可支配的内存大小

此处可以看出MainActivity存在了15个示例对象,怀疑此处有问题。

1.2 MAT

上述只是可以粗略的看出是不是有问题,而要知道问题出在哪里就需要借助MAT了。将生成的.hprof文件进行转换,然后使用MAT打开;

格式转换命令:hprof-conv 原文件路径 转换后文件路径

MAT打开.hprof

注意下面的Actions:

  • Histogram可以列出内存中每个对象的名字、数量以及大小。
  • Dominator Tree会将所有内存中的对象按大小进行排序,并且我们可以分析对象之间的引用结构。
    一般使用最多的也是这两个功能。

Retained Heap表示这个对象以及它所持有的其它引用(包括直接和间接)所占的总内存

  • 使用Histogram:
  1. 点击Histogram并在顶部的Regex中输入MainActivity会进行正则匹配,会将包含“MainActivity”的所有对象全部列出了出来,其中第一行就是MainActivity的实例。
  2. 对着想查看的对象点击右键 -> List objects -> with incoming references 查看具体MainActivity实例。
  3. 对想要查看的对象实例点击右键-> Path To Gc Roots -> exclude weak reference(排除掉软引用)。

注意:
this$0前面的图标的左下角有个圆圈,这代表这个引用可以被Gc Roots引用到,由于MainActivity$LeakClass能被GC Roots访问到导致其不能被回收,从而它所持有的其它引用也无法被回收了,包括MainActivity,也包括MainActivity中所包含的其它资源。
此时我们就找到了内存泄漏的原因。

  • 使用Dominator Tree


使用上面Histogram的操作方式也可以找到泄漏的具体原因,此处不再累述。
注意:每个对象前的图标的圆圈,并不代表一定是导致内存泄漏的原因,有些对象就是需要在内存中存活的,需要区别对待。

1.3 LeakCanary

LeakCanary是square出品的一个检测内存泄漏的库,集成到App之后便无需关心,在发生内存泄漏之后会Toast、通知栏弹出等方式提示,可以指出泄漏的引用路径,而且可以抓取当前的堆栈信息供详细分析。

2. Out Of Memory

2.1 Android OOM

Android系统的每个进程都有一个最大内存限制,如果申请的内存资源超过这个限制,系统就会抛出OOM错误。

  • Android 2.x系统,当dalvik allocated + external allocated + 新分配的大小 >= dalvik heap 最大值时候就会发生OOM。其中bitmap是放于external中 。
  • Android 4.x系统,废除了external的计数器,类似bitmap的分配改到dalvik的java heap中申请,只要allocated + 新分配的内存 >= dalvik heap 最大值的时候就会发生OOM(art运行环境的统计规则还是和dalvik保持一致)

内存溢出是程序运行到某一阶段的最终结果,直接原因是剩余的内存不能满足内存的申请,但是再分析间接原因内存为什么没有了:

  • 内存泄漏的存在可能导致可用内存越来越少;
  • 内存申请的峰值超过了系统时间点剩余的内存;(例如:某手机单个进程可用最大内存为192M,目前分配内存80M,此时申请5M内存,但是当前时间点整个系统可用内存只有3M,此时没有超出单个进程可用最大内存,但是OOM也会发生)

2.2 Avoid Android OOM

除了避免内存泄漏之外,根据《Manage Your App's Memory》,我们可以对内存的状态进行监听,在Activity中覆写此方法,根据不同的case进行不同的处理:

    @Override
    public void onTrimMemory(int level) {
        super.onTrimMemory(level);
    }

TRIM_MEMORY_RUNNING_MODERATE:你的应用正在运行并且不会被列为可杀死的。但是设备此时正运行于低内存状态下,系统开始触发杀死LRU Cache中的Process的机制。
TRIM_MEMORY_RUNNING_LOW:你的应用正在运行且没有被列为可杀死的。但是设备正运行于更低内存的状态下,你应该释放不用的资源用来提升系统性能。
TRIM_MEMORY_RUNNING_CRITICAL:你的应用仍在运行,但是系统已经把LRU Cache中的大多数进程都已经杀死,因此你应该立即释放所有非必须的资源。如果系统不能回收到足够的RAM数量,系统将会清除所有的LRU缓存中的进程,并且开始杀死那些之前被认为不应该杀死的进程,例如那个包含了一个运行态Service的进程。
当应用进程退到后台正在被Cached的时候,可能会接收到从onTrimMemory()中返回的下面的值之一:
TRIM_MEMORY_BACKGROUND: 系统正运行于低内存状态并且你的进程正处于LRU缓存名单中最不容易杀掉的位置。尽管你的应用进程并不是处于被杀掉的高危险状态,系统可能已经开始杀掉LRU缓存中的其他进程了。你应该释放那些容易恢复的资源,以便于你的进程可以保留下来,这样当用户回退到你的应用的时候才能够迅速恢复。
TRIM_MEMORY_MODERATE: 系统正运行于低内存状态并且你的进程已经已经接近LRU名单的中部位置。如果系统开始变得更加内存紧张,你的进程是有可能被杀死的。
TRIM_MEMORY_COMPLETE: 系统正运行于低内存的状态并且你的进程正处于LRU名单中最容易被杀掉的位置。你应该释放任何不影响你的应用恢复状态的资源。

3. Memory Churn

Memory Churn内存抖动:大量的对象被创建又在短时间内马上被释放。
瞬间产生大量的对象会严重占用Young Generation的内存区域,当达到阀值,剩余空间不够的时候,也会触发GC。系统花费在GC上的时间越多,进行界面绘制或流音频处理的时间就越短。即使每次分配的对象占用了很少的内存,但是他们叠加在一起会增加Heap的压力,从而触发更多其他类型的GC。这个操作有可能会影响到帧率,并使得用户感知到性能问题。

Drop Frame Occur

常见的可能引发内存抖动的情形:

  • 循环中创建临时对象;
  • onDraw中创建Paint或Bitmap对象等;

例如之前使用过的有些下拉刷新控件的实现方式,在onDraw中创建Bitmap等多个临时大对象会导致内存抖动。

4. Bitmap

Bitmap的处理也是Android中的一个难点,当然使用第三方框架的话就屏蔽掉了这个难点。

  • Bitmap的内存模型
  • Bitmap的加载、压缩、缓存等策略
  • 版本的兼容等

关于Bitmap之后会写专门的一篇文章来介绍,此处可以参考《Handling Bitmaps》

5. Program Advice

5.1 节制地使用Service

内存管理最大的错误之一就是让Service一直运行。在后台使用service时,除非它需要被触发并执行一个任务,否则其他时候Service都应该是停止状态。另外需要注意Service工作完毕之后需要被停止,以免造成内存泄漏。

系统会倾向于保留有Service所在的进程,这使得进程的运行代价很高,因为系统没有办法把Service所占用的RAM空间腾出来让给其他组件,另外Service还不能被Paged out。这减少了系统能够存放到LRU缓存当中的进程数量,它会影响应用之间的切换效率,甚至会导致系统内存使用不稳定,从而无法继续保持住所有目前正在运行的service。

建议使用JobScheduler,而尽量避免使用持久性的Service。还有建议使用IntentService,它会在处理完交代给它的任务之后尽快结束自己。

5.2 使用优化过的集合

Android API当中提供了一些优化过后的数据集合工具类,如SparseArray,SparseBooleanArray,以及LongSparseArray等,使用这些API可以让我们的程序更加高效。传统Java API中提供的HashMap工具类会相对比较低效,因为它需要为每一个键值对都提供一个对象入口,而SparseArray就避免掉了基本数据类型转换成对象数据类型的时间。

5.3 谨慎对待面向抽象

开发者经常把抽象作为好的编程实践,因为抽象能够提升代码的灵活性与可维护性。然而,抽象会导致一个显著的开销:面向抽象需要额外的代码(不会被执行到),同样会被咨映射到内存中,耗费了更多的时间以及内存空间。因此如果面向抽象对你的代码没有显著的收益,那你应该避免使用。

例如:使用枚举通常会比使用静态常量要消耗两倍以上的内存,在Android开发当中我们应当尽可能地不使用枚举。

5.4 使用nano protobufs序列化数据

Protocol buffers是Google为序列化数据设计的一种语言无关、平台无关、具有良好扩展性的数据描述语言,与XML类似,但是更加轻量、快速、简单。如果使用protobufs来实现数据的序列化及反序列化,建议在客户端使用nano protobufs,因为通常的protobufs会生成冗余代码,会导致可用内存减少,Apk体积变大,运行速度减慢。

5.5 避免内存抖动

垃圾回收通常不会影响应用的表现,但是短时间内多次的垃圾回收会消耗掉界面绘制的时间。系统花费在GC上的时间越多,进行界面绘制或流音频处理的时间就越短。通常内存抖动会导致多次的GC,实践中内存抖动代表了一段时间内分配了临时对象。

例如:在For循环中分配了多个临时对象,或在onDraw()方法中创建了Paint、Bitmap对象,应用产生了大量的对象;这会很快耗尽young generation的可用内存,导致GC发生。

使用Analyze your RAM usage中的工具找出代码里内存抖动的地方。考虑把操作移出内部循环,或者将其移动到基于工厂的分配结构中。

5.6 移除消耗内存的库、缩减Apk的大小

查看Apk的大小,包括三方库和内嵌的资源,这些都会影响应用消耗的内存。通过减少冗余、非必须或大的组件、库、图片、资源、动画等,都可以改善应用的内存消耗。

5.7 使用Dagger 2进行依赖注入

如果您打算在应用程序中使用依赖注入框架,请考虑使用Dagger 2。 Dagger不使用反射来扫描应用程序的代码。 Dagger的编译时注解技术实现意味着它不需要不必要的运行时成本。而使用反射的其它依赖注入框架通常通过扫描代码来初始化过程。 此过程可能需要显着更多的CPU周期和RAM,并可能导致应用程序启动时明显的卡顿。

备注:之前的文档是不建议使用依赖注入框架,因为实现原理是使用反射,而进化为编译时注解之后,就不再有反射带来的影响了。

5.8 谨慎使用第三方库

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

6. Other

6.1 谨慎使用LargeHeap属性

可以通过在manifest的application标签下添加largeHeap=true的属性来为应用声明一个更大的heap空间(可以通过getLargeMemoryClass()来获取到这个更大的heap size阈值)。然而,声明得到更大Heap阈值的本意是为了一小部分会消耗大量RAM的应用(例如一个大图片的编辑应用)。不要轻易的因为你需要使用更多的内存而去请求一个大的Heap Size。只有当你清楚的知道哪里会使用大量的内存并且知道为什么这些内存必须被保留时才去使用large heap,使用额外的内存空间会影响系统整体的用户体验,并且会使得每次gc的运行时间更长。在任务切换时,系统的性能会大打折扣。另外, large heap并不一定能够获取到更大的heap。在某些有严格限制的机器上,large heap的大小和通常的heap size是一样的。

6.2 谨慎使用多进程

多进程确实是一种可以帮助我们节省和管理内存的高级技巧。如果你要使用它的话一定要谨慎使用,因为绝大多数的应用程序都不应该在多个进程当中运行的,一旦使用不当,它甚至会增加额外的内存而不是帮我们节省内存;同时需要知晓多进程带来的缺点。这个技巧比较适用于那些需要在后台去完成一项独立的任务,和前台的功能是可以完全区分开的场景。

这里举一个比较适合去使用多进程技巧的场景,比如说我们正在做一个音乐播放器软件,其中播放音乐的功能应该是一个独立的功能,它不需要和UI方面有任何关系,即使软件已经关闭了也应该可以正常播放音乐。如果此时我们只使用一个进程,那么即使用户关闭了软件,已经完全由Service来控制音乐播放了,系统仍然会将许多UI方面的内存进行保留。在这种场景下就非常适合使用两个进程,一个用于UI展示,另一个则用于在后台持续地播放音乐。

6.3 实现方式可能存在的问题:例如启动页闪屏图,show完毕之后应该释放掉Bitmap。

一些实现方式看起来没有问题实现了功能但是实际上可能对内存造成了影响。我在使用Heap Viewer查看Bitmap对象时发现了一张只需下载不应该被加载的图。

使用HeapViewer可直接查看Bitmap

内存中出现的不应该被加载的图

通过查阅代码,发现问题出在:此处下载图片作为另一个模块的使用图,但是下载的方法竟然是使用图片加载器加载出来Bitmap然后再保存到本地;而且保存之后也没有将Bitmap对象释放掉。

与之类似的还有:首页闪屏图展示之后,Bitmap对象应该及时释放掉。

6.4 使用try catch进行捕获

对高风险OOM代码块如展示高清大图等进行try catch,在catch块加载非高清的图片并做相应内存回收的处理。注意OOM是OutOfMemoryError,不能使用Exception进行捕获。

7. Summary

内存优化的套路:

  1. 解决所有的内存泄漏

    • 集成LeakCanary,可以方便的定位出90%的内存泄漏问题;
    • 通过反复进出可疑界面,观察内存增减的情况,Dump Java Heap获取当前堆栈信息使用MAT进行分析。
    • 内存泄漏的常见情形可参照《Android 内存泄漏分析心得》
  2. 避免内存抖动

    • 避免在循环中创建临时对象;
    • 避免在onDraw中创建Paint、Bitmap对象等。
  3. Bitmap的使用

    • 使用三方库加载图片一般不会出内存问题,但是需要注意图片使用完毕的释放,而不是被动等待释放。
  4. 使用优化过的数据结构

  5. 使用onTrimMemory根据不同的内存状态做相应处理
  6. Library的使用
    • 去掉无用的Library,对生成的Apk进行反编译查看使用到的Library,避免出现无用的Lib仍然被打进Apk;
    • 避免引入巨大的Library;
    • 使用Proguard进行混淆、压缩。

参考:

欢迎关注微信公众号:定期分享Java、Android干货!

欢迎关注