(转)Android开发:性能最佳实践-管理应用内存

时间:2022-05-04 16:26:59

翻自:http://developer.android.com/training/articles/memory.html
在任何软件开发环境中,RAM都是宝贵的资源,但在移动操作系统中更加珍贵。尽管Dalvik虚拟机有垃圾回收机制,也不要忽略分配和释放内存。
为了让GC回收内存,你要避免内存泄漏(通常因为全局成员变量引用对象引起),并且在适当的时候释放对象引用。对大多数app来说,垃圾回收负责剩下的:当相应的对象离开app活动线程范围时,系统回收内存分配。为了让GC回收内存,你要避免内存泄漏(通常因为全局成员变量引用对象引起),并且在适当的时候释放对象引用。对大多数app来说,垃圾回收负责剩下的:当相应的对象离开app活动线程范围时,系统回收内存分配。
这篇文档解释了Android如何管理app处理内存分配,你如何主动减少内存占用。更多用java编程时清理资源的信息,可以参考关于管理资源引用的文档和书籍。如果你在找分析应用的内存占用的信息,阅读:Investigating Your RAM Usage 以后会翻译

Android如何管理内存

Android不提供交换空间,而是使用页和内存映射来管理内存。这意味着任何内存的修改,不论是分配给新对象还是映射内存页,都在内存中,无法被移出.所以,完成释放内存的唯一方法是释放对象引用,把这块内存交给GC。这里有个例外,任何映射过来过来没有修改的,例如代码,如果系统要使用内存可以调用。

共享内存

为了适应内存中的所有需要,Android会跨进程共享内存页.可以用下面的方式来实现:

  • 每个app进程都是从Zygote进程fork出来的。Zygote进程在系统启动并且载入通用框架代码和资源时启动(像activity主题),为了启动一个app进程,系统从Zygote进程里fork出一个进程来载入运行app的代码。这使得大多数为Android 框架代码和资源的内存分配可以跨所有app进程共享
  • 大多数静态的数据是被映射到一个进程里.这不仅允许相同的数据在进程间被共享,同时也允许在需要的时候移出。静态数据例子:Dalvik代码(在直接映射的.odex文件中),app资源(通过设计一个资源表的结构,使得可以被映射和调整),native代码的so文件
  • 在很多地方,android通过显示地分配共享内存区域,来跨进程共享动态内存。举个例子,窗口surface在app和screen compositor间共享内存,cursor在Content Provider和客户端间共享内存

由于大量共享内存,确定你的应用用了多少内存需要当心。http://developer.android.com/tools/debugging/debugging-memory.html 从技术上讨论了判断app内存使用。

分配和回收应用内存

下面是Android如何分配和回收内存的几个事实:

  • 每个进程的Dalvik堆局限于一个单一的虚拟内存区域。这里定义了可以根据需要自动增长的逻辑堆大小(但只有达到系统给每个app定义的上限时)
  • 堆的逻辑大小,跟堆使用的物理内存大小不同。当检查应用的堆时,Android计算一个叫PPS(比例设置大小)的值,该值记录了与其他进程共享的dirty和clean页,但只有多少应用共享内存的一个比例 。这个值就是系统认为了你的物理内存足迹,更多关于pps的信息,参考Investigating Your RAM Usage guide
  • Dalvik堆并不会整理碎片,Android只会在无用的空间处理堆的结尾时收缩堆的size.但这并不意味着堆用的物理内存不能被收缩。在GC后,Dalvik找到堆里无用的页,并且用madvise返回给内核.所以,分配和释放大块内存成对出现,就会释放几乎所有的物理内存。然而,小块分配内存的释放没有那么明显的效果,因为这块内存可能仍然被其他没有释放的模块共享

限制app内存

为了支持多任务,Android给每个app限制了堆大小。具体限制大小根据设备不同以及内存大小不同而变动。如果你的app达到了这个限制还是尝试分配更多内存,就会报OutOfMemoryError.
因为某些原因,你可能需要通过查询系统判断当前设备上堆限制的大小,比如,判断缓存里放多少数据比较安全。你可以通过调用getMemoryClass()来得到这个数字。它返回一个整数表示你的app的堆可用大小(M).这将在下面作进一步讨论,参见 判断你应该使用多少内存。

切换App

当用户切换App时,Android并没有把之前的App放到交换空间里,而是把所有非前台的应用组件放到一个LRU缓存里。举例来说,用户第一次运行app时,为它创建了一个进程,但当用户离开这个app时,进程并没有退出。系统缓存了这个进程,所以当用户回到这个应用时,进程可以重用以达到快速切回。
当你的应用缓存了进程,并且保留当前没有用的内存时,即使用户没有在用,也限制了系统的整体性能。所以,当系统内存较低时,它会清理LRU缓存里最近最少用的那一部分进程,但也会考虑哪个进程最需要内存。为了让你的进程缓存得尽可能久,遵从下面几条关于何时释放引用的建议。
更多关于进程在没有运行在前台时如何被缓存以及Android决定杀死哪个进程的信息,参考 Processes and Threads guide

你的App该如何管理内存

在整个开发阶段,你都应该考虑到内存的限制,包括app设计(开发前).这里有几条让你的设计和代码更高效的建议,通过再三应用聚合同样的技术。
你在设计和实现app时应该遵循以下技巧以实现高效的内存利用。

谨慎使用Service

如果你的应用需要一个Service在后台执行任务,在没有任何执行时,不要让它运行。同样不要忘记在它结束工作时关闭Service.
当你启动一个Service时,系统更倾向于保持这个进程以让Service继续运行。这让进程变得非常昂贵,因为被它使用的内存无法被系统回收。这减少了系统可以缓存在LRU里进程的数量。让App切换效能差一些。它甚至可能会导致系统在可用内存紧张,无法维持足够的进程供所有当前运行的Service时超负荷运转。
限制Service生命期限的最佳方法是使用IntentService.它会在处理完启动它的Intent后,自动杀死自己。更多信息,阅读Running in a Background Service

在不需要的时候还让Service运行是最糟糕的内存管理之一。所以,不要贪婪地想用保持Service运行来保持App运行。这不仅会增加在达到内存限制时风险,用户也会因为这样的作弊行为而卸载它。

当用户界面隐藏时,释放内存

当用户导航到其他app,你的ui不再可见时,你应该释放那些只有UI使用的资源。在这个时候释放资源可以大大增加系统缓存进程的能力,这跟用户体验直接相关。
当用户退出ui时,在Activity里实现onTrimMemory()回调,你应该在这个方法里监听TRIM_MEMORY_UI_HIDDEN,它表示你的UI从视图中隐藏了,你需要释放只有UI使用的资源。
注意,只有当应用的所有ui都隐藏时,才会收到onTrimMemory()回调。这跟onStop()回调不同,它在Activity隐藏时就会回调,包括切换到同一个app的不同Activity.所以,尽管你应该实现onStop()释放activity的资源,像网络连接,或者解注册广播接收器,你通常不应该释放你的UI资源,直到收到onTrimMemory(TRIM_MEMORY_UI_HIDDEN)回调。这确保了当用户导致到应用内的其他activity再回来时,ui资源仍旧可用并能快速恢复.

当内存紧张时释放资源

在App生命周期的任何一个阶段,onTrimMemory()同样告诉你什么时候整个设备的可用内存变低。此时,你应该根据onTrimMemory传入的几个内存级别释放内存:

  • TRIM_MEMORY_RUNNING_MODERATE
    你的应用正在运行,并且不会被杀死,但设备已经处于低内存状态,并且开始杀死LRU缓存里的内存。
  • TRIM_MEMORY_RUNNING_LOW
    你的应用正在运行,并且不会被杀死,但设备处于内存更低的状态,所以你应该释放无用资源以提高系统性能(直接影响app性能)
  • TRIM_MEMORY_RUNNING_CRITICAL
    你的应用还在运行,但系统已经杀死了LRU缓存里的大多数进程,所以你应该在此时释放所有非关键的资源。如果系统无法回收足够的内存,它会清理掉所有LRU缓存,并且开始杀死之前优先保持的进程,像那些运行着service的。同时,当你的app进程当前被缓存,你可能会从onTrimMemory()收到下面的几种level.
  • TRIM_MEMORY_BACKGROUND
    系统运行在低内存状态,并且你的进程已经接近LRU列表的顶端(即将被清理).虽然你的app进程还没有很高的被杀死风险,系统可能已经清理LRU里的进程,你应该释放那些容易被恢复的资源,如此可以让你的进程留在缓存里,并且当用户回到app时快速恢复.
  • TRIM_MEMORY_MODERATE
    系统运行在低内存状态,你的进程在LRU列表中间附近。如果系统变得内存紧张,可能会导致你的进程被杀死。
  • TRIM_MEMORY_COMPLETE
    系统运行在低内存状态,如果系统没有恢复内存,你的进程是首先被杀死的进程之一。你应该释放所有不重要的资源来恢复你的app状态。
    因为onTrimMemory()是在API 14里添加的,你可以在老版本里使用onLowMemory()回调,大致跟TRIM_MEMORY_COMPLETE事件相同。

提示:当系统开始杀死LRU缓存里的进程时,尽管它主要从下往上工作,它同时也考虑了哪些进程消耗更多的内存,如果杀死它们,系统会得到更多的可用内存。所以,在LRU整个列表中,你消耗越少的内存,留在列表里的机会就更大。

确定你可以用多少内存

之前提过,每个Android设备有不同的可用内存,因此给每个app提供不同的堆限制。你可以调用getMemoryClass()来确定你的app能用多少M堆大小。如果你的app尝试分配更多的内存,会收到OutOfMemoryError
在非常特殊的情况下,你可以请求一个较大的堆,通过在Androidmanifest.xml的application标签里设置largeHeap属性为true。如果你这么干,你可以调用 getLargeMemoryClass()来得到较大堆的大小.
然而,这个特性只是给一小部分需要消耗大量内存的app(像大图片编辑app)准备的。不要仅仅因为你的应用报了OOM,需要快速修复而设置large heap,你应该在知道所有的内存用在哪,为什么需要保留时才设置.即使你很自信能证明你的app需要这么多内存,你也应该尽可能避免。使用额外的内存,会影响用户体验,因为垃圾收回需要更长的时间,当切换任务或执行其他普通操作时,系统性能也会变慢。

另外large heap的大小并不是在所有机器上都一样,并且,当运行在限制RAM的设备上时,large heap大小可能会跟正常的heap大小相同。所以,即使你请求了large heap,也应该调用 getMemoryClass()来得到heap大小,并努力让内存在这个限制之内.

避免在图片上浪费内存

载入图片时,应该仅仅在内存中保留适应当前设备屏幕大小的图片。如果原图分辨率高,做下缩小。谨记,图片分辨率的增加伴随着内存占用的增加,因为x,y的值都增加。

提示:在Android 2.3.x(API level 10)或更低版本,bitmap对象总是跟app的堆大小相同,而不管分辨率图片真实分辨率(实际像素数据分开保存在native内存中,非dalvik)。这使得调试图片内存分配更加困难,因为大多数heap分析工具,看不到native的内存分配。
然而,从Android 3.0 (API level 11)开始,图片像素数据在app的Dalvik heap里分配,改善了垃圾回收和可调试性。所以,如果你的app使用图片,在老设备上要找到app使用内存有些困难时,你可以切换到高版本的设备来调试.
更多关于位图的提示,阅读 Managing Bitmap Memory.

使用优化的数据容器

利用Android框架里的优化过的容器,像SparseArray, SparseBooleanArray, 和 LongSparseArray.一般的HashMap实现内存方面效率较差,因为它需要为每个映射分开对象条目。另外,SparseArray更高效因为它们避免了系统对key(value有时也行)做自动封装,如果有意义,不要担心使用原始的数组。

注意内存开销

了解你用的使用和库的成本和开销,在设计app时谨记,从开始到结束。经常,表面上看起来无害的东西会带来庞大的开销。例子包括:
枚举比静态常量需要大于两倍的内存,在Android里,应该严格避免使用数据。
Java里每个类(包括匿名和内部类)使用大约500的字节的代码。
每个类实例有12-16字节的开销.
放单一的条目到HashMap,需要创建额外的entry对象,消费32字节(参考上一节 使用优化的数据容器)
积少成多,app设计会被这些开销所影响。在内存里的一大堆小对象里分析,找到问题,并非易事。

小心使用代码抽象

经常,开发者们使用抽象,仅仅是因为”良好的编程实践”,因为抽象可以提高代码可扩展性,可维护性。然后,抽象意味着成本:通常它们需要一定数量的额外代码,需要更多的时间,更多的内存把那部分代码映射到内存。所以,如果你的抽象没有实现的作用,你应该避免使用。

使用nano protobufs序列化数据

Protocol buffers是一个由google设计的语言中立,平台中立,可扩展机制,用来序列化结构数据.像xml,但更小,更快,更简单.如果你决定使用protobufs,你应该在客户端代码使用nano protobufs。普通的protobufs生成非常冗长的代码,可能会给app带来各自问题:增加内存占用,apk体积增长,执行慢,并且很快会达到dex文件的限制。
更多信息,参见 protobuf readme里的Nano version一节。

避免依赖注入框架

使用像Guice或RoboGuice的注入框架,可能很吸引人,因为可以简化你写的代码,提供一个用于测试或其他配置的有用的可适配的环境。然而,这些框架倾向于在初始化时扫描注解执行一大堆的方法,这意味着大量的代码被映射到内存包括你不需要的。这些映射页被分配到clean内存里,android可以扔掉他们,但在一个很长的周期内不会被移除。

当心使用外部库

外部库通常不是为移动环境写的,在移动客户端上使用可能会导致效能低。至少,当你决定要用一个外部库时,你应该承担起移植维护并为移动优化的工作。在决定使用前,为这些工作作计划,分析库大小,内存占用。

即使为Android设计的库,也可能会带来风险,因为每个库做不同的事情。比如,一个库使用nano protobufs另一个库使用micro protobufs。现在你有两种不同的protobuf实现。这些是不可预料的。ProGuard救不了你,因为这些都是你需要的库的特性需要的低级别的依赖。当你从库中使用一个Activity的子类(会有大片的依赖)或使用反射或其他,更容易出问题。

也要小心不要掉入为几十个特性中的一两个特性使用一个共享库的陷阱。你不需要增加一大堆你不会用的代码开销。找了很久也没找到一个与你的需求非常符合的现有实现,自己创建实现也许是最好的。

优化整体性能

Best Practices for Performance里列出了各种各样的关于优化整体性能方法。很多这些文档包括了cpu性能优化建议,但很多建议同时对优化内存也有帮助,像减少UI的layout对象数量。
同时,你也应该阅读关于用布局调试工具优化UI,利用lint提供的优化建议.

使用ProGuard除去无用的代码

ProGuard通过移除无用的代码,语义无关地重命名类,字段,方法来收缩,优化,混淆你的代码。使用ProGuard能使你的代码更加紧凑,减少映射的ram页。

在最终的apk上使用zipalign

如果你对系统构建的apk(包括用最终产品签名的)做后处理,你必须运行zipalign,让其重新对齐。否则会导致你的app需要更多的内存,因为像资源这些东西可能不必从apk映射。

提示:Google Play商店不接受没有zipaligne过的APK

分析你的内存使用

一旦你实现了一个相对稳定的版本,就要开始分析贯穿整个生命周期的内存占用。关于如何分析app,阅读Investigating Your RAM Usage

使用多进程

如果合适,一项可以帮助你管理应用内存的高级技术是拆分你的应用组件到多个进程。这项技术使用时必须小心,并且大多数应用不应该使用,因为如果错误使用,它很容易造成内存占用大幅增长。它主要适用于那些在后台执行跟前台一样的重要工作,并且能分开管理这些操作的app。

一个适用多进程的例子是,构建一个音乐播放器,有一个Service长期在后台播放音乐。如果整个app运行在一个进程,为Activity UI分配的很多内存必须保持跟播放音乐一样长的时间,即使当前用户已经跳到另一个app,但Service仍在播放。一个类似这样的app,可以拆分成两个进程,一个用作UI,一个用于后台服务.

你可以通过声明android:process属性给每个组件指定一个单独的进程。比如,你可以指定service运行在一个与app主进程不同的叫”background”(随你喜欢,任意)的进程.

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

你的进程名应该以冒号开始,来确保这个进程对app私有。
在你决定创建一个新进程时,你必须知道对内存的影响。为了展示每个进程的影响,给大家展示下dump出来的内存信息,一个空的基本上不做什么的进程要消耗额外的1.4M内存,

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

提示:更多关于如何阅读输出,参见Investigating Your RAM Usage,这里的关键数据是Private Dirty和Private Clean内存,展示了这个进程正在使用大约1.4M不可分页的内存(分布在Dalvik堆,Native分配,预订保留,库加载),另一个150K的内存用来映射执行。

内存印迹对一个空进程来说是相当重要的,当这个进程开始工作时,会快速增长。如,这是一个仅仅用来在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

简单地在UI上显示一些文本,进程占的内存几乎达到了3倍,4MB。这引出了一个重要的结论:如果你想要拆分你的app到多个进程,应该只有一个负责UI,其他进程应该避免UI操作,因为这会进程需要的内存快速增长(特别是你开始加载图片或其他资源时)。一旦UI被画出来,要减少内存使用就很难或几乎不可能。

此外,当运行多个进程时,尽可能保持代码精简就更加重要。因为在进程里会有一些没必要重复的内存开销。如,如果你用枚举(尽管你不应该用枚举),每个进程都需要创建和初始化这些常量。其他适配器和临时变量里的抽象或其他开销也会重复。

另一个需要关心的多进程问题是他们之间存在的依赖。如,如果你的app有一个在默认进程里的Content Provider,这个进程同时也处理UI,后台进程里的代码要访问Content Provider就需要UI进程也留在内存里。如果你的目标是让后台进程可以独立于重量级的UI进程,它就不能依赖UI进程里的Content Provider或Service.