管理APP内存的最佳实践
在任何软件开发环境中,RAM都是昂贵的资源,对手机设备来说更是如此,因为手机操作系统的物理内存通常都很有限。尽管安卓DVM执行常规的垃圾收集工作,但是这并不是说可以不考虑app何时何处分配和释放内存。
为了使垃圾回收器从你的app中回收内存,你需要避免内存泄露(这通常是因为在全局成员中持有对象的引用导致的),并且在合适的时间(比如生命周期方法回调时)释放引用的对象。对多数app来说,Dalvik垃圾收集器关注这样的事情:系统在相应的对象离开app活动线程的范围时,回收内存的分配。
一、Android如何管理内存
Android不提供内存交换区,但是它确实使用paging和memory-mapping去管理内存。这意味着任何你修改(不论是通过new出对象或是触发内存映射页面)的内存都驻留在RAM中,并且不能被置换出来。因此,从app中完全释放内存的唯一方法是释放你可能持有的对象的引用,从而让内存对垃圾收集器可用。这里有个例外,任何被映射进内存的没有被修改的文件,例如代码,可被置换出RAM——如果系统想在其他地方使用内存的话。
1. 共享内存
为了符合RAM的需要,安卓试图跨进程共享内存页面。有以下几种实现方式:
- 每个APP进程从一个现有的叫Zygote的进程fork而来,在系统boot并且加载common框架代码和资源(如activity themes)的时候,Zygote进程开始启动。为了开启一个新的APP进程,系统fork Zygote进程,然后在新进程中加载、运行APP代码。这使得分配给框架代码和资源的大部分RAM页面可以跨所有APP进程共享。
- 大部分静态数据映射到进程中,这不仅允许同样的数据可跨进程共享,而且还可在需要的时候被置换出来。静态数据包括:Dalvik代码、APP资源、传统的工程元素,如.so文件中的本地代码等。
- 在很多地方,安卓跨进程共享同样的动态RAM,这些进程使用显式分配的共享内存区域(ashmem或gralloc)。例如,window surface在APP和屏幕图像合成器间共享内存,cursor buffer在content provider和client之间共享内存。
2. 分配和回收APP内存
每个进程的Davlik堆受限于单个的虚拟内存范围。这定义了逻辑堆大小,逻辑堆随需要可以增长,但是只能增大到系统为每个APP定义的大小。
3. 限制APP内存
为了维持良好的多任务环境,安卓对堆设置了严格的限制。准确的堆的大小随设备RAM可用的多少而变化。如果APP已经达到了堆的最大容量,这时再分配更多内存的话,它将会收到一个OutOfMomoryError的错误。有些情况下,你可能需要检查系统多少堆空间可供你用,例如检查缓存多少数据是安全的。你可以调用getMemoryClass()来检查。它返回一个整数值,标志APP堆有多少百万字节可用。
4. 切换APP
代替使用内存交换区,用户切换APP的时候,安卓在LRU缓存中保持一个非前台(用户可见)的APP组件。例如,当用户第一次启动APP时,一个进程就创建了,但是当用户离开APP时,进程并没有退出。系统保持进程被缓存,因此如果用户等会儿回到APP,这个进程就会被复用——为了更快地转换APP。
如果APP有一个缓存进行,并且它保持当前不需要的内存,那么你的APP就约束了系统整体的性能。因此,当系统在低内存运行时,它可能杀死LRU中最久未使用缓存的进程,当然它也会考虑哪些对内存对敏感的进程。
二、APP应该如何管理内存?
你应该贯穿整个开发阶段考虑RAM的约束,包括APP设计阶段。有很多方法可让你更高效地设计、写代码。
1. 保守地使用service
如果你的APP需要一个service执行后台任务,不要保持它一直运行除非它正在执行任务。也要注意,不要让它因工作完成却无法停止而发生泄漏。
最好的限制生命周期跨度的办法是使用IntentService,它会在处理完启动它的Intent之后就结束自己。
2. 当你的用户界面变隐藏的时候,释放内存
当用户切换到不同的APP,并且你的UI不再可见的时候,你应该释放那些只被你的UI使用的资源。这时候释放资源能明显提高系统缓存进程的能力,这直接影响到用户体验的质量。
为了得到用户退出UI的通知,需要在Activity类中实现onTrimMemory()回调方法。你可以使用这个方法监听TRIM_MEMORY_UI_HIDDEN级别,它指示你的UI当前在view上是隐藏的,你应该释放仅被你UI使用的资源。
注意只有当所有的UI组件对用户变得隐藏的时候,你的APP才回收到带TRIM_MEMORY_UI_HIDDEN级别的onTrimMemory()回调。这与onStop()回调方法不同,onStop()在Activity实例变隐藏的时候被回调,这发生在用户在你的APP终转换到另一个Activity的时候发生。因此,尽管你应该实现onStop()方法来释放activity资源(如网络连接或者解除broadcast receiver的注册),但是你不应释放你的UI资源,直到你收到onTrimMemory(TRIM_MEMORY_UI_HIDDEN)回调。这确保了如果用户从你APP另一个activity切换回来,你的UI资源仍是可用的,以便较快地恢复activity。
3. 当内存变得紧俏时释放内存
在你APP生命周期的任何阶段,onTrimMemory()回调方法都可以告诉你什么时候总体设备内存变少。基于下面的传递给onTrimMemory()方法的内存级别 你应该进一步释放资源。
- TRIM_MEMORY_RUNNING_MODERATE
你的APP正在运行,并且不被认为是可以杀掉的,但是设备正在较低内存状况下运行,因此你应该释放无用的内存来提升系统性能。
- TRIM_MEMORY_RUNNING_CRITICAL
你的APP正在运行,但是系统已经杀掉LRU cache中大部分进程,因此你应该释放所有非临界资源。如果系统不能回收足够量的RAM,它将清除所有LRU cache,并且开始杀掉那些系统希望保持存活的进程,比如那些持有一个运行中的service的进程。
此外,当你的APP进程当前被缓存的时候,你可以收到下面几个与onTrimMemory()有关的级别:
- TRIM_MEMORY_BACKGROUND
系统正在低内存状态下运行,并且你的进程在LRU列表的开始。尽管你的APP进程不处在被杀掉的高风险状态,系统仍可能杀掉LRU缓存中的进程。你应该释放哪些容易恢复的资源,以便你的进程仍在列表中,当用户切回APP时,可以快速恢复。 - TRIM_MEMORY_MODERATE
系统正在低内存状态下运行,并且你的进程在LRU列表的中间。如果系统内存进一步吃紧,你的进程将有机会被杀掉。 - TRIM_MEMORY_COMPLETE
系统正在低内存状态下运行,并且你的进程是首要被杀掉的进程之一,你应该释放掉对恢复APP状态不是很敏感的资源。
因为onTrimMemory()回调在API 14之后增加的,你可以在旧版API中使用onLowMemory()回调,它大致相当于TRIM_MEMORY_COMPLETE事件。
注意:当系统开始杀LRU缓存中的进程的时候,尽管它基本上自底向上,但是它会考虑哪个进程消耗更多内存,如果杀掉将会给系统提供多少内存。因此LRU列表中内存消耗越少的进程,越有机会存留在列表中,这样它可以较快地恢复(resume)。
4. 检查你应该使用多少内存
前面已经提到,每个安卓设备有不同数量的RAM可用量,因此对每个APP提供了不同大小的堆的限制。你可以调用getMemoryClass()获取你APP可用的堆的大小。如果你的APP试图分配多于可用的更多的内存,你将收到OutOfMemoryError的错误。
在非常特殊的情况下,你可以在标签中设置largeHeap为true来请求更大的堆内存大小。如果你这样做的话,你可以调getLargeMemoryClas()来获取大堆大小的估计值。
然而,请求大堆的能力的目的在于小部分APP可以调整消耗更多RAM的需要(例如大型图片编辑APP)。不要仅仅因为你遇到OOM就请求大堆。OOM需要修复它,你应该只在你知道内存如何在哪分配、为什么必须保留的情况下使用大堆。尽管,你确信你的APP使用大堆是正确的,你也应该避免请求大堆。额外使用内存将会有损总体用户体验,因为垃圾回收将花更多时间,在任务切换或者执行其他操作时系统性能会减慢。
此外,大堆的大小在不同设备上是不同的,当运行在有限的RAM的设备中,大堆大小可能与普通堆大小相等。因此,尽管你请求了大堆,你也应该调用getMemoryClass()来检查常规堆大小,争取保持低于这个限制。
5. 避免bitmap浪费内存
当你加载bitmap时,以你当前屏幕分辨率的需要在RAM中保持图片。如果原始bitmap是高分辨率的bitmap,就按比例来裁剪它。记住bitmap像素的增加导致相应内存需要的增加(increase2),因为X和Y都增加了。
6. 使用优化的数据容器
充分利用Android Framework优化的数据容器,如SparseArray,SparseBooleanArray,和LongSparseArray。通用的HashMap的实现可能不是内存高效的,因为它需要一个独立的entry对象来进行每一个映射。此外,SparseArray因为避免了key自动装箱的需要而更高效。
7. 注意内存开销
了解语言和库的内存的开销,当你设计APP的时候注意这些。有时,有些事情表面上看是无害的,但是实际上可能有大量的内存开销。例如:
- 枚举通常比静态常量需要两倍多的内存。在安卓中,你应该严格避免使用枚举。
- Java中每个类(包括匿名内部类)使用大约500个字节。
- 每个类的实例有12-16个字节的RAM开销。
- 在HashMap中放置一个entry需要额外地分配entry对象32个字节。
8. 谨慎使用第三方libraries
不要陷入为了一两个功能而进入整个Library的陷阱。如果没有合适的库与你的需求相吻合,你应该考虑自己去实现,而不是导入一个大而全的解决方案。
9. 小心代码抽象
开发者经常使用抽象作为“好的编程实践”,因为抽象可以提高代码灵活性和可维护性。然而,抽象导致明显的开销:一般地,抽象需要相当数量的代码去执行,需要更多的时间和更多的RAM将代码映射进内存。因此,如果你的抽象没有带来明显的好处,那你应该避免抽象。
10. 使用ProGuard剔除不需要的代码
ProGuard能够通过移除不需要的代码,重命名类,字段和方法等对代码进压缩,优化与混淆。使用ProGuard可以使得你的代码更加紧凑,这样能使用更少mapped代码所需要的RAM。
11. 对最终APK使用zipalign
在编写完所以代码,并通过编译系统生成APK之后,你需要使用zipalign对APK进行重新校准。如果不做这个步骤,会导致你的APK需要更多的APK,因为一些类似图片资源的东西不能被映射。
12. 分析你的RAM使用
一旦你获得了一个相对稳定的build,开始分析你的APP使用多少RAM在整个生命周期阶段。
13. 使用多进程
如果合适的话,一项高级技术可能帮助你管理APP内存:通过把你的APP组件分成多个组件,运行在不同的进程中。这项技术必须小心使用,多数APP不应运行在多进程中,因为如果不正确使用这很容易增加内存占用。
多进程何时是合适的,有个例子:后台播放音乐的音乐播放器应用。如果整个APP运行在一个进程中,当后台播放的时候,前台那些UI资源没法得到释放。像这样的APP就应分成两个进程,一个负责UI,另一个负责后台工作的持续进行。
你可以通过声明APP的组件来指定一个分开的进程(andriod:process),例如,你可以指定service运行在主进程之外的一个叫background新进程。
在决定创建新进程之前,你需要理解内存的隐式占用。为了说明每个进程创建的后果,考虑创建一个几乎说明都不做的空进程,即使这样它也占用大约1.4MB的内存。内存信息如下: