优化Android应用内存的若干方法

jopen 10年前

在app开发的各个阶段中要考虑RAM的限制问题, 包括在设计阶段(正式开发之前). 使用下面的不同的方法可以达到很好的效果. 当您在设计和开发Android应用时用下面的方法可以使内存运用最高效.

使用保守的Service

如果你的应用需要使用 service 在后台执行业务功能, 除非是一直在进行活动的工作(比如每隔几秒向服务器端请求数据之类)否则不要让它一直保持在后台运行. 并且, 当你的 service 执行完成但是停止失败时要小心 service 导致的内存泄露问题.

当你启动 service 时, 系统总是优先保持服务的运行. 这会导致内存应用效率非常低, 因为被该服务使用的内存不能做其它事情. 也会减少系统一直保持的LRU缓存处理数目, 使不同的app切换效率降低. 当前所有 service 的运行会导致内存不够不能维持正常系统的运行时, 系统会发生卡顿的现象严重时能导致系统不断重启.

最好的方式是使用 IntentSevice 控制 service 的生命周期, 当使用 intent 开始任务后, 该 service 执行完所有的工作时会自动停止.

在android应用中当不需要使用常驻 service 执行业务功能而去使用一个常驻 service 是最糟糕的内存管理方式之一. 所以不要贪婪的使用 service 使你的应用一直运行状态. 这样不仅使你因为内存的限制提高了应用运行的风险, 也会导致用户发现这些异常行为后而卸载应用.

 

当视图变为隐藏状态后释放内存

当用户跳转到不同的应用并且你的视图不再显示时, 你应该释放应用视图所占的资源. 这时释放所占用的资源能显著的提高系统的缓存处理容量, 并且对用户的体验质量有直接的影响.

当实现当前 Activity 类的 onTrimMemory() 回调方法后, 用户离开视图时会得到通知. 使用该方法可以监听 TRIM_MEMORY_UI_HIDDEN 级别, 当你的视图元素从父视图中处于隐藏状态时释放视图所占用的资源.

注意只有当你应用的所有视图元素变为隐藏状态时你的应用才能收到 onTrimMemory() 回调方法的 TRIM_MEMORY_UI_HIDDEN . 这个和 onStop() 回调方法不同, 该方法只有当 Activity 的实例变为隐藏状态, 或者有用户移动到应用中的另外的 activity 才会引发. 所以说你虽然实现了 onStop() 去释放 activity 的资源例如网络连接或者未注册的广播接收者, 但是应该直到你收到 onTrimMemory(TRIM_MEMORY_UI_HIDDEN)才去释放视图资源否则不应该释放视图所占用的资源. 这里可以确定的是如果用户通过后退键从另外的 activity 进入到你的应用中, 视图资源会一直处于可用的状态可以用来快速的恢复 activity.

 

内存资源紧张时释放内存

在应用生命周期的任何阶段 onTrimMemory() 回调方法都可以告诉你设备的内存越来越低的情况, 你可以根据该方法推送的内存紧张级别来释放资源.

  • TRIM_MEMORY_RUNNING_CRITICAL

应用处于运行状态并且不会被杀掉, 设备使用的内存比较低, 系统级会杀掉一些其它的缓存应用.

  • TRIM_MEMORY_RUNNING_LOW

应用处于运行状态并且不会被杀掉, 设备可以使用的内存非常低, 可以把不用的资源释放一些提高性能(会直接影响程序的性能)

  • TRIM_MEMORY_RUNNING_CRITICAL

应用处于运行状态但是系统已经把大多数缓存应用杀掉了, 你必须释放掉不是非常关键的资源, 如果系统不能回收足够的运行内存, 系统会清除所有缓存应用并且会把正在活动的应用杀掉.

还有, 当你的应用被系统正缓存时, 通过 onTrimMemory() 回调方法可以收到以下几个内存级别:

  • TRIM_MEMORY_BACKGROUND

系统处于低内存的运行状态中并且你的应用处于缓存应用列表的初级阶段.  虽然你的应用不会处于被杀的高风险中, 但是系统已经开始清除缓存列表中的其它应用, 所以你必须释放资源使你的应用继续存留在列表中以便用户再次回到你的应用时能快速恢复进行使用.

  • TRIM_MEMORY_MODERATE

系统处于低内存的运行状态中并且你的应用处于缓存应用列表的中级阶段. 如果系运行内存收到限制, 你的应用有被杀掉的风险.

  • TRIM_MEMORY_COMPLETE

系统处于低内存的运行状态中如果系统现在没有内存回收你的应用将会第一个被杀掉. 你必须释放掉所有非关键的资源从而恢复应用的状态.

因为 onTrimMemory() 是在级别14的android api中加入的, 所以低版本的要使用 onLowMemory() 方法, 该方法大致相当于 TRIM_MEMORY_COMPLETE 事件.

注意: 当系统开始清除缓存应用列表中的应用时, 虽然系统的主要工作机制是自下而上, 但是也会通过杀掉消费大内存的应用从而使系统获得更多的内存, 所以在缓存应用列表中消耗更少的内存将会有更大的机会留存下来以便用户再次使用时进行快速恢复.

检查可以使用多大的内存

前面提到, 不同的android设备系统拥有的运行内存各自都不同, 从而不同的应用堆内存的限制大小也不一样. 你可以通过调用 ActivityManager 中的 getMemoryClass() 函数可以通过以兆为单位获取当前应用可用的内存大小, 如果你想获取超过最大限度的内存则会发生 OutOfMemoryError .

有一个特别的情况, 可以在 manifest 文件中的 <application> 标签中设置 largeHeap 属性的值为 "true"时, 当前应用就可以获取到系统分配的最大堆内存. 如果你设置了该值, 可以通过 ActivityManager 的 getLargeMemoryClass() 函数获取最大的堆内存.

然后, 只有一小部分应用需要消耗大量堆内存(比如大照片编辑应用). 从来不需要使用大量内存仅仅是因为你已经消耗了大量的内存并且必须快速修复它, 你必须使用它是因为你恰好知道所有的内存已经被分配完了而你必须要保留当前应用不会被清除掉. 甚至当你的应用需要消耗大量内存时, 你应该尽可能的避免这种需求. 使用大量内存后, 当你切换不同的应用或者执行其它类似的操作时, 因为长时间的内存回收会导致系统的性能下降从而渐渐的会损害整个系统的用户体验.

另外, 大内存不是所有的设备都相同.  当跑在有运行内存限制的设备上时, 大内存和正常的堆内存是一样的.  所以如果你需要大内存, 你就要调用 getMemoryClass() 函数查看正常的堆内存的大小并且尽可能使内存使用情况维护在正常堆内存之下.

避免在 bitmaps 中浪费内存

当你加载 bitmap 时, 需要根据分辨率来保持它的内存时最大为当前设备的分辨率, 如果下载下来的原图为高分辨率则要拉伸它. 要小心bitmap的分辨率增加后所占用的内存也要进行相应的增加, 因为它是根据x和y的大小来增加内存占用的.

注意: 在 Android 2.3.x(api level 10)以下, 无论图片的分辨率多大 bitmap 对象在内存中始终显示相同大小, 实际的像素数据被存储在底层 native 的内存中(c++内存). 因为内存分析工具无法跟踪 native 的内存状态所有调试 bitmap 内存分配变得非常困难. 然而, 从 Android 3.0(api level 11)开始, bitmap 对象的内存数据开始在应用程序所在Dalvik虚拟机堆内存中进行分配, 提高了回收机率和调试的可能性. 如果你在老版本中发现 bitmap 对象占用的内存大小始终一样时, 切换设备到系统3.0或以上来进行调试.

使用优化后的数据容器

利用 Android 框架优化后的数据容器, 比如 SparseArraySparseBooleanArray 和 LongSparseArray. 传统的 HashMap 在内存上的实现十分的低效因为它需要为 map 中每一项在内存中建立映射关系. 另外, SparseArray类非常高效因为它避免系统中需要自动封箱(autobox)的key和有些值.

知道内存的开销

在你设计应用各个阶段都要很谨慎的考虑所使用的语言和库带来的内存上的成本和开销. 通常情况下, 表面上看起来无害的会带来巨大的开销,  下面在例子说明:

  • 当枚举(enum)成为静态常量时超过正常两倍以上的内存开销, 在 android 中你需要严格避免使用枚举
  • java 中的每个类(包含匿名内部类)大约使用500个字节
  • 每个类实例在运行内存(RAM)中占用12到16个字节
  • 在 hashmap 中放入单项数据时, 需要为额外的其它项分配内存, 总共占用32个字节

使用很多的不必要类和对象时, 增加了分析堆内存问题的复杂度.

当心抽象代码

通常来说, 使用简单的抽象是一种好的编程习惯, 因为一定程度上的抽象可以提供代码的伸缩性和可维护性. 然而抽象会带来非常显著的开销: 需要执行更多的代码, 需要更长时间和更多的运行内存把代码映射到内存中, 所以如果抽象没有带来显著的效果就尽量避免.

使用纳米 Protocol buffers 作为序列化数据

Protocol Buffers 是 Google 公司开发的一种数据描述语言,类似于XML能够将结构化数据序列化. 但是它更小, 更快, 更简单. 如果你决定使用它作为你的数据, 你必须在你的客户端代码中一直使用纳米 protocol buffer, 因为正常的 protocol buffer 会产生极其冗余的代码, 在你的应用生会引起很多问题: 增加了使用的内存, 增加了apk文件的大小, 执行速度较慢以及会快速的把一些限定符号打入 dex 包中.

尽量避免使用依赖注入框架

使用像 Guice 和 RoboGuice 依赖注入框架会有很大的吸引力, 因为它使我们可以写一些更简单的代码和提供自适应的环境用来进行有用的测试和进行其它配置的更改. 然而这些框架通过注解的方式扫描你的代码来执行一系列的初始化, 但是这些也会把一些我们不需要的大量的代码映射到内存中. 被映射后的数据会被分配到干净的内存中, 放入到内存中后很长一段时间都不会使用, 这样造成了内存大量的浪费.

谨慎使用外部依赖库

许多的外部依赖库往往不是在移动环境下写出来的, 这样当在移动使用中使用这些库时就会非常低效. 所以当你决定使用一个外部库时, 你就要承担为优化为移动应用外部库带来的移植问题和维护负担. 在项目计划前期就要分析该类库的授权条件, 代码量, 内存的占用再来决定是否使用该库.

甚至据说专门设计用于 android 的库也有潜在的风险, 因为每个库做的事情都不一样. 例如, 一个库可能使用的是 nano protobuf 另外一个库使用的是 micro protobuf, 现在在你的应用中有两个不同 protobuf 的实现. 这将会有不同的日志, 分析, 图片加载框架, 缓存, 等所有你不可预知的事情的发生. Proguard 不会保存你的这些, 因为所有低级别的 api 依赖需要你依赖的库里所包含的特征. 当你使用从外部库继承的 activity 时尤其会成为一个问题(因为这往往产生大量的依赖). 库要使用反射(这是常见的因为你要花许多时间去调整ProGuard使它工作)等.

也要小心不要陷入使用几十个依赖库去实现一两个特性的陷阱; 不要引入大量不需要使用的代码. 一天结束时, 当你没有发现符合你要求的实现时, 最好的方式是创建一个属于自己的实现.

优化整体性能

除了上述情况外, 还可以优化CPU的性能和用户界面, 也会带动内存的优化

使用代码混淆去掉不需要的代码

代码混淆工具 ProGuard 通过去除没有用的代码和通过语义模糊来重命名类, 字段和方法来缩小, 优化和混淆你的代码. 使用它能使你的代码更简洁, 更少量的RAM映射页.

使用签名工具签名apk文件

如果构建apk后你没有做后续的任何处理(包括根据你的证书进行签名), 你必须运行 zipalign 工具为你的apk重新签名, 如果不这样做会导致你的应用使用更多的内存, 因为像资源这样的东西不会再从apk中进行映射(mmap).

注意:goole play store 不接受没有签名的apk

分析你的内存使用情况

使用adb shell dumpsys meminfo +包名 等工具来分析你的应用在各个生命周期的内存使用情况, 这个后续博文会有所体现.

使用多进程

一种更高级的技术能管理应用中的内存, 分离组件技术能把单进程内存划分为多进程内存. 该技术一定要谨慎的使用并且大多数的应用都不会跑多进程, 因为如果你操作不当反而会浪费更多的内存而不是减少内存. 它主要用于后台和前台能各自负责不同业务的应用程序

当你构建一个音乐播放器应用并且长时间从一个 service 中播放音乐时使用多进程处理对你的应用来说更恰当. 如果整个应用只有一个进程, 当前用户却在另外一个应用或服务中控制播放时, 却为了播放音乐而运行着许多不相关的用户界面会造成许多的内存浪费. 像这样的应用可以分隔为两个进程:一个进程负责 UI 工作, 另外一个则在后台服务中运行其它的工作.

在各个应用的 manifest 文件中为各个组件申明 android:process 属性就可以分隔为不同的进程.例如你可以指定你一运行的服务从主进程中分隔成一个新的进程来并取名为"background"(当然名字可以任意取).

<service android:name=".PlaybackService"           android:process=":background" />

进程名字必须以冒号开头":"以确保该进程属于你应用中的私有进程.

在你决定创建一个新的进程之前必须理解对这样做内存的影响. 为了说明每个进程的影响, 一个基本空进程会占用大约1.4兆的内存, 下面的堆内存信息说明这一点

adb shell dumpsys meminfo com.example.android.apis:empty    ** MEMINFO in pid 10172 [com.example.android.apis:empty] **                  Pss     Pss  Shared Private  Shared Private    Heap    Heap    Heap                Total   Clean   Dirty   Dirty   Clean   Clean    Size   Alloc    Free               ------  ------  ------  ------  ------  ------  ------  ------  ------    Native Heap     0       0       0       0       0       0    1864    1800      63    Dalvik Heap   764       0    5228     316       0       0    5584    5499      85   Dalvik Other   619       0    3784     448       0       0          Stack    28       0       8      28       0       0      Other dev     4       0      12       0       0       4       .so mmap   287       0    2840     212     972       0      .apk mmap    54       0       0       0     136       0      .dex mmap   250     148       0       0    3704     148     Other mmap     8       0       8       8      20       0        Unknown   403       0     600     380       0       0          TOTAL  2417     148   12480    1392    4832     152    7448    7299     148

注意: 上面关键的数据是 private dirty 和 private clean 两项, 第一项主要使用了大约是1.4兆的非分页内存(分布在Dalvik heap, native分配, book-keeping, 和库的加载), 另外执行业务代码使用150kb的内存.

空进程的内存占用是相当显著的, 当你的应用加入了许多业务后会增长得更加迅速. 下面的例子是使用activity显示一些文字, 当前进程的内存使用状况的分析.

** MEMINFO in pid 10226 [com.example.android.helloactivity] **                  Pss     Pss  Shared Private  Shared Private    Heap    Heap    Heap                Total   Clean   Dirty   Dirty   Clean   Clean    Size   Alloc    Free               ------  ------  ------  ------  ------  ------  ------  ------  ------    Native Heap     0       0       0       0       0       0    3000    2951      48    Dalvik Heap  1074       0    4928     776       0       0    5744    5658      86   Dalvik Other   802       0    3612     664       0       0          Stack    28       0       8      28       0       0         Ashmem     6       0      16       0       0       0      Other dev   108       0      24     104       0       4       .so mmap  2166       0    2824    1828    3756       0      .apk mmap    48       0       0       0     632       0      .ttf mmap     3       0       0       0      24       0      .dex mmap   292       4       0       0    5672       4     Other mmap    10       0       8       8      68       0        Unknown   632       0     412     624       0       0          TOTAL  5169       4   11832    4032   10152       8    8744    8609     134

这个比上面多花费了3倍的内存, 只是在 界面 上显示一些简单的文字, 用了大约4兆. 从这里可以得出一个很重要的结论:如果你的想在应用中使用多进程, 只能有一个进程来负责 UI 的工作, 在其它进程中不能出现任何 UI的工作, 否则会迅速提高内存的使用率(尤其当你加载 bitmap 资源和其它资源时). 一旦加入了UI的绘制工作就不可能会减少内存的使用了.

另外, 当你的应用超过一个进程时, 保持代码的紧凑非常重要, 因为现在由相同实现造成的不必要的内存开销会复制到每一个进程中, 会造成内存浪费更严重的状况出现. 例如, 你使用了枚举, 不同的进程在内存中都会创建和初始化这些常量.并且你所有的抽象适配器和其它临时的开销也会和前面一样被复制过来.

另外要关心的问题是多进程之间的依赖关系. 例如, 当应用中运行默认的进程需要为UI进程提供内容, 后台进程的代码为进程本身提供内容还要留在内存中为UI运行提供支持, 如果你的目标是在一个拥有重量级的UI进程的应用里拥有一个独立运行的后台进程, 那么你在UI进程中则不能直接依赖它, 而要在UI进程使用 service 处理它.