导语

智能手机发展到今天已经有十几个年头,手机的软硬件都已经发生了翻天覆地的变化,特别是Android阵营,从一开始的一两百M到今天动辄4G,6G内存。然而大部分的开发者观看下自己的异常上报系统,还是会发现各种内存问题仍然层出不穷,各种OOM为crash率贡献不少。Android开发发展到今天也是已经比较成熟,各种新框架,新技术也是层出不穷,而内存优化一直都是Android开发过程一个不可避免的话题。 恰好最近做了内存优化相关的工作,这里也对Android内存优化相关的知识做下总结。

在开始文章之前推荐下公司同事翻译整理版本《Android性能优化典范 - 第6季》,因为篇幅有限这里我对一些内容只做简单总结,同时如果有不正确内容也麻烦帮忙指正。

本文将会对Android内存优化相关的知识进行总结以及最后案例分析(一二部分是理论知识总结,你也可以直接跳到第三部分看案例):

一、 Android内存分配回收机制
二 、Android常见内存问题和对应检测,解决方式。
三、 JOOX内存优化案例
四 、总结

工欲善其事必先利其器,想要优化App的内存占用,那么还是需要先了解Android系统的内存分配和回收机制。

一 ,Android内存分配回收机制

参考Android 操作系统的内存回收机制[1],这里简单做下总结:

从宏观角度上来看Android系统可以分为三个层次
1. Application Framework,
2. Dalvik 虚拟机
3. Linux内核。

这三个层次都有各自内存相关工作:

1. Application Framework

Anroid基于进程中运行的组件及其状态规定了默认的五个回收优先级:

  • Empty process(空进程)
  • Background process(后台进程)
  • Service process(服务进程)
  • Visible process(可见进程)
  • Foreground process(前台进程)

系统需要进行内存回收时最先回收空进程,然后是后台进程,以此类推最后才会回收前台进程(一般情况下前台进程就是与用户交互的进程了,如果连前台进程都需要回收那么此时系统几乎不可用了)。

由此也衍生了很多进程保活的方法(提高优先级,互相唤醒,native保活等等),出现了国内各种全家桶,甚至各种杀不死的进程。

Android中由ActivityManagerService 集中管理所有进程的内存资源分配。

2. Linux内核

参考QCon大会上阿里巴巴的Android内存优化分享[2],这里最简单的理解就是ActivityManagerService会对所有进程进行评分(存放在变量adj中),然后再讲这个评分更新到内核,由内核去完成真正的内存回收(lowmemorykiller, Oom_killer)。这里只是大概的流程,中间过程还是很复杂的,有兴趣的同学可以一起研究,代码在系统源码ActivityManagerService.Java中。

3. Dalvik虚拟机

Android进程的内存管理分析[3],对Android中进程内存的管理做了分析。

Android中有Native Heap和Dalvik Heap。Android的Native Heap言理论上可分配的空间取决了硬件RAM,而对于每个进程的Dalvik Heap都是有大小限制的,具体策略可以看看android dalvik heap 浅析[4]。

Android App为什么会OOM呢?其实就是申请的内存超过了Dalvik Heap的最大值。这里也诞生了一些比较”黑科技”的内存优化方案,比如将耗内存的操作放到Native层,或者使用分进程的方式突破每个进程的Dalvik Heap内存限制。

Android Dalvik Heap与原生Java一样,将堆的内存空间分为三个区域,Young Generation,Old Generation, Permanent Generation。

最近分配的对象会存放在Young Generation区域,当这个对象在这个区域停留的时间达到一定程度,它会被移动到Old Generation,最后累积一定时间再移动到Permanent Generation区域。系统会根据内存中不同的内存数据类型分别执行不同的gc操作。

GC发生的时候,所有的线程都是会被暂停的。执行GC所占用的时间和它发生在哪一个Generation也有关系,Young Generation中的每次GC操作时间是最短的,Old Generation其次,Permanent Generation最长。

GC时会导致线程暂停,导致卡顿,Google在新版本的Android中优化了这个问题, 在ART中对GC过程做了优化揭秘 ART 细节 —— Garbage collection[5],据说内存分配的效率提高了10倍,GC的效率提高了2-3倍(可见原来效率有多低),不过主要还是优化中断和阻塞的时间,频繁的GC还是会导致卡顿。

上面就是Android系统内存分配和回收相关知识,回过头来看,现在各种手机厂商鼓吹人工智能手机,号称18个月不卡顿,越用越快,其实很大一部分Android系统的内存优化有关,无非就是利用一些比较成熟的基于统计,机器学习的算法定时清理数据,清理内存,甚至提前加载数据到内存。

二 ,Android常见内存问题和对应检测,解决方式

1. 内存泄露

不止Android程序员,内存泄露应该是大部分程序员都遇到过的问题,可以说大部分的内存问题都是内存泄露导致的,Android里也有一些很常见的内存泄露问题[6],这里简单罗列下:

  • 单例(主要原因还是因为一般情况下单例都是全局的,有时候会引用一些实际生命周期比较短的变量,导致其无法释放)
  • 静态变量(同样也是因为生命周期比较长)
  • Handler内存泄露[7]
  • 匿名内部类(匿名内部类会引用外部类,导致无法释放,比如各种回调)
  • 资源使用完未关闭(BraodcastReceiver,ContentObserver,File,Cursor,Stream,Bitmap)

Android内存泄露业界已经有很多优秀的组件其中LeakCanary最为知名(Square出品,Square可谓Android开源界中的业界良心,开源的项目包括okhttp, retrofit,otto, picasso, Android开发大神Jake Wharton就在Square),其原理是监控每个activity,在activity ondestory后,在后台线程检测引用,然后过一段时间进行gc,gc后如果引用还在,那么dump出内存堆栈,并解析进行可视化显示。使用LeakCanary可以快速地检测出Android中的内存泄露。

正常情况下,解决大部分内存泄露问题后,App稳定性应该会有很大提升,但是有时候App本身就是有一些比较耗内存的功能,比如直播,视频播放,音乐播放,那么我们还有什么能做的可以降低内存使用,减少OOM呢?

2. 图片分辨率相关

分辨率适配问题。很多情况下图片所占的内存在整个App内存占用中会占大部分。我们知道可以通过将图片放到hdpi/xhdpi/xxhdpi等不同文件夹进行适配,通过xml android:background设置背景图片,或者通过BitmapFactory.decodeResource()方法,图片实际上默认情况下是会进行缩放的。在Java层实际调用的函数都是或者通过BitmapFactory里的decodeResourceStream函数

public static Bitmap decodeResourceStream(Resources res, TypedValue value,        InputStream is, Rect pad, Options opts) {    if (opts == null) { opts = new Options(); } if (opts.inDensity == 0 && value != null) { final int density = value.density; if (density == TypedValue.DENSITY_DEFAULT) { opts.inDensity = DisplayMetrics.DENSITY_DEFAULT; } else if (density != TypedValue.DENSITY_NONE) { opts.inDensity = density; } } if (opts.inTargetDensity == 0 && res != null) { opts.inTargetDensity = res.getDisplayMetrics().densityDpi; } return decodeStream(is, pad, opts); }

decodeResource在解析时会对Bitmap根据当前设备屏幕像素密度densityDpi的值进行缩放适配操作,使得解析出来的Bitmap与当前设备的分辨率匹配,达到一个最佳的显示效果,并且Bitmap的大小将比原始的大,可以参考下腾讯Bugly的详细分析Android 开发绕不过的坑:你的 Bitmap 究竟占多大内存?。

关于Density、分辨率、-hdpi等res目录之间的关系:

举个例子,对于一张1280×720的图片,如果放在xhdpi,那么xhdpi的设备拿到的大小还是1280×720而xxhpi的设备拿到的可能是1920×1080,这两种情况在内存里的大小分别为:3.68M和8.29M,相差4.61M,在移动设备来说这几M的差距还是很大的。

尽管现在已经有比较先进的图片加载组件类似Glide,Facebook Freso, 或者老牌Universal-Image-Loader,但是有时就是需要手动拿到一个bitmap或者drawable,特别是在一些可能会频繁调用的场景(比如ListView的getView),怎样尽可能对bitmap进行复用呢?这里首先需要明确的是对同样的图片,要 尽可能复用,我们可以简单自己用WeakReference做一个bitmap缓存池,也可以用类似图片加载库写一个通用的bitmap缓存池,可以参考GlideBitmapPool[8]的实现。

我们也来看看系统是怎么做的,对于类似在xml里面直接通过android:background或者android:src设置的背景图片,以ImageView为例,最终会调用Resource.java里的loadDrawable:

Drawable loadDrawable(TypedValue value, int id, Theme theme) throws NotFoundException {    // Next, check preloaded drawables. These may contain unresolved theme    // attributes.    final ConstantState cs; if (isColorDrawable) { cs = sPreloadedColorDrawables.get(key); } else { cs = sPreloadedDrawables[mConfiguration.getLayoutDirection()].get(key); } Drawable dr; if (cs != null) { dr = cs.newDrawable(this); } else if (isColorDrawable) { dr = new ColorDrawable(value.data); } else { dr = loadDrawableForCookie(value, id, null); } ... return dr; } 

可以看到实际上系统也是有一份全局的缓存,sPreloadedDrawables, 对于不同的drawable,如果图片时一样的,那么最终只会有一份bitmap(享元模式),存放于BitmapState中,获取drawable时,系统会从缓存中取出这个bitmap然后构造drawable。而通过BitmapFactory.decodeResource()则每次都会重新解码返回bitmap。所以其实我们可以通过context.getResources().getDrawable再从drawable里获取bitmap,从而复用bitmap,然而这里也有一些坑,比如我们获取到的这份bitmap,假如我们执行了recycle之类的操作,但是假如在其他地方再使用它是那么就会有”Canvas: trying to use a recycled bitmap android.graphics.Bitmap”异常。

3. 图片压缩

BitmapFactory 在解码图片时,可以带一个Options,有一些比较有用的功能,比如:

  • inTargetDensity 表示要被画出来时的目标像素密度

  • inSampleSize 这个值是一个int,当它小于1的时候,将会被当做1处理,如果大于1,那么就会按照比例(1 / inSampleSize)缩小bitmap的宽和高、降低分辨率,大于1时这个值将会被处置为2的倍数。例如,width=100,height=100,inSampleSize=2,那么就会将bitmap处理为,width=50,height=50,宽高降为1 / 2,像素数降为1 / 4

  • inJustDecodeBounds 字面意思就可以理解就是只解析图片的边界,有时如果只是为了获取图片的大小就可以用这个,而不必直接加载整张图片。

  • inPreferredConfig 默认会使用ARGB_8888,在这个模式下一个像素点将会占用4个byte,而对一些没有透明度要求或者图片质量要求不高的图片,可以使用RGB_565,一个像素只会占用2个byte,一下可以省下50%内存。

  • inPurgeableinInputShareable 这两个需要一起使用,BitmapFactory.java的源码里面有注释,大致意思是表示在系统内存不足时是否可以回收这个bitmap,有点类似软引用,但是实际在5.0以后这两个属性已经被忽略,因为系统认为回收后再解码实际会反而可能导致性能问题

  • inBitmap 官方推荐使用的参数,表示重复利用图片内存,减少内存分配,在4.4以前只有相同大小的图片内存区域可以复用,4.4以后只要原有的图片比将要解码的图片大既可以复用了。

4. 缓存池大小

现在很多图片加载组件都不仅仅是使用软引用或者弱引用了,实际上类似Glide 默认使用的事LruCache,因为软引用 弱引用都比较难以控制,使用LruCache可以实现比较精细的控制,而默认缓存池设置太大了会导致浪费内存,设置小了又会导致图片经常被回收,所以需要根据每个App的情况,以及设备的分辨率,内存计算出一个比较合理的初始值,可以参考Glide的做法。

5. 内存抖动

什么是内存抖动呢?Android里内存抖动是指内存频繁地分配和回收,而频繁的gc会导致卡顿,严重时还会导致OOM。

一个很经典的案例是string拼接创建大量小的对象(比如在一些频繁调用的地方打字符串拼接的log的时候), 见Android优化之String篇[9]。

而内存抖动为什么会引起OOM呢?

主要原因还是有因为大量小的对象频繁创建,导致内存碎片,从而当需要分配内存时,虽然总体上还是有剩余内存可分配,而由于这些内存不连续,导致无法分配,系统直接就返回OOM了。

比如我们坐地铁的时候,假设你没带公交卡去坐地铁,地铁的售票机就只支持5元,10元,而哪怕你这个时候身上有1万张1块的都没用(是不是觉得很反人类..)。当然你可以去兑换5元,10元,而在Android系统里就没那么幸运了,系统会直接拒绝为你分配内存,并扔一个OOM给你(有人说Android系统并不会对Heap中空闲内存区域做碎片整理,待验证)。

其他

常用数据结构优化,ArrayMap及SparseArray是android的系统API,是专门为移动设备而定制的。用于在一定情况下取代HashMap而达到节省内存的目的,具体性能见HashMap,ArrayMap,SparseArray源码分析及性能对比[10],对于key为int的HashMap尽量使用SparceArray替代,大概可以省30%的内存,而对于其他类型,ArrayMap对内存的节省实际并不明显,10%左右,但是数据量在1000以上时,查找速度可能会变慢。

枚举Android平台上枚举是比较争议的,在较早的Android版本,使用枚举会导致包过大,在个例子里面,使用枚举甚至比直接使用int包的size大了10多倍 在stackoverflow上也有很多的讨论, 大致意思是随着虚拟机的优化,目前枚举变量在Android平台性能问题已经不大,而目前Android官方建议,使用枚举变量还是需要谨慎,因为枚举变量可能比直接用int多使用2倍的内存。

ListView复用,这个大家都知道,getView里尽量复用conertView,同时因为getView会频繁调用,要避免频繁地生成对象

谨慎使用多进程,现在很多App都不是单进程,为了保活,或者提高稳定性都会进行一些进程拆分,而实际上即使是空进程也会占用内存(1M左右),对于使用完的进程,服务都要及时进行回收。

尽量使用系统资源,系统组件,图片甚至控件的id

减少view的层级,对于可以 延迟初始化的页面,使用viewstub

数据相关:序列化数据使用protobuf可以比xml省30%内存,慎用shareprefercnce,因为对于同一个sp,会将整个xml文件载入内存,有时候为了读一个配置,就会将几百k的数据读进内存,数据库字段尽量精简,只读取所需字段。

dex优化,代码优化,谨慎使用外部库, 有人觉得代码多少于内存没有关系,实际会有那么点关系,现在稍微大一点的项目动辄就是百万行代码以上,多dex也是常态,不仅占用rom空间,实际上运行的时候需要加载dex也是会占用内存的(几M),有时候为了使用一些库里的某个功能函数就引入了整个庞大的库,此时可以考虑抽取必要部分,开启proguard优化代码,使用Facebook redex使用优化dex(好像有不少坑)。

三 案例

JOOX是IBG一个核心产品,2014年发布以来已经成为5个国家和地区排名第一的音乐App。东南亚是JOOX的主要发行地区,实际上这些地区还是有很多的低端机型,对App的进行内存优化势在必行。

上面介绍了Android系统内存分配和回收机制,同时也列举了常见的内存问题,但是当我们接到一个内存优化的任务时,我们应该从何开始?下面是一次内存优化的分享。

1. 首先是解决大部分内存泄露。

不管目前App内存占用怎样,理论上不需要的东西最好回收,避免浪费用户内存,减少OOM。实际上自JOOX接入LeakCanary后,每个版本都会做内存泄露检测,经过几个版本的迭代,JOOX已经修复了几十处内存泄露。

2. 通过MAT查看内存占用,优化占用内存较大的地方。

JOOX修复了一系列内存泄露后,内存占用还是居高不下,只能通过MAT查看到底是哪里占用了内存。关于MAT的使用,网上教程无数,简单推荐两篇MAT使用教程[11],MAT - Memory Analyzer Tool 使用进阶[12]。

点击Android Studio这里可以dump当前的内存快照,因为直接通过Android Sutdio dump出来的hprof文件与标准hprof文件有些差异,我们需要手动进行转换,利用sdk目录/platform-tools/hprof-conv.exe可以直接进行转换,用法:hprof-conv 原文件.hprof 新文件.hprof。只需要输入原文件名还有目标文件名就可以进行转换,转换完就可以直接用MAT打开。

下面就是JOOX打开App,手动进行多次gc的hprof文件。

这里我们看的是Dominator Tree(即内存里占用内存最多的对象列表)。

  • Shallo Heap:对象本身占用内存的大小,不包含其引用的对象内存。

  • Retained Heap: Retained heap值的计算方式是将retained set中的所有对象大小叠加。或者说,由于X被释放,导致其它所有被释放对象(包括被递归释放的)所占的heap大小。

第一眼看去 居然有3个8M的对象,加起来就是24M啊 这到底是什么鬼?

我们通过List objects->with incoming references查看(这里with incoming references表示查看谁引用了这个对象,with outgoing references表示这个对象引用了谁)

通过这个方式我们看到这三张图分别是闪屏,App主背景,App抽屉背景。

这里其实有两个问题:

  • 这几张图原图实际都是1280x720,而在1080p手机上实测这几张图都缩放到了1920x1080

  • 闪屏页面,其实这张图在闪屏显示过后应该可以回收,但是因为历史原因(和JOOX的退出机制有关),这张图被常驻在后台,导致无谓的内存占用。

优化方式:我们通过将这三张图从xhdpi挪动到xxhdpi(当然这里需要看下图片显示效果有没很大的影响),以及在闪屏显示过后回收闪屏图片。
优化结果:

从原来的8.29x3=24.87M 到 3.68x2=7.36M 优化了17M(有没一种万马奔腾的感觉。。可能有时费大力气优化很多代码也优化不了几百K,所以很多情况下内存优化时优化图片还是比较立竿见影的)。

同样方式我们发现对于一些默认图,实际要求的显示要求并不高(图片相对简单,同时大部分情况下图片加载会成功),比如下面这张banner的背景图:

优化前1.6M左右,优化后700K左右。

同时我们也发现了默认图片一个其他问题,因为历史原因,我们使用的图片加载库,设置默认图片的接口是需要一个bitmap,导致我们原来几乎每个adapter都用BitmapFactory decode了一个bitmap,对同一张默认图片,不但没有复用,还保存了多份,不仅会造成内存浪费,而且导致滑动偶尔会卡顿。这里我们也对默认图片使用全局的bitmap缓存池,App全局只要使用同一张bitmap,都复用了同一份。

另外对于从MAT里看到的图片,有时候因为看不到在项目里面对应的ID,会比较难确认到底是哪一张图,这里stackoverflow上有一种方法,直接用原始数据通过GIM还原这张图片。

这里其实也看到JOOX比较吃亏一个地方,JOOX不少地方都是使用比较复杂的图片,同时有些地方还需要模糊,动画这些都是比较耗内存的操作,Material Design出来后,很多App都遵循MD设计进行改版,通常默认背景,默认图片一般都是纯色,不仅App看起来比较明亮轻快,实际上也省了很多的内存,对此,JOOX后面对低端机型做了对应的优化。

3. 我们也对RDM上的OOM进行了分析,发现其实有些OOM是可以避免的。

下面这个crash就是上面提到的在LsitView的adapter里不停创建bitmap,这个地方是我们的首页banner位,理论上App一打开就会缓存这张默认背景图片了,而实际在使用过一段时间后,才因为为了解码这张背景图而OOM, 改为用全局缓存解决。

下面这个就是传说中的内存抖动

实际代码如下,因为打Log而进行了字符串拼接,一旦这个函数被比较频繁地调用,那么就很有可能会发生内存抖动。这里我们新版本已经改为使用stringbuilder进行优化。

还有一些比较奇怪的情况,这里是我们扫描歌曲文件头的时候发生的,有些文件头居然有几百M大,导致一次申请了过大的内存,直接OOM,这里暂时也无法修复,直接catch住out of memory error。

4. 同时我们对一些逻辑代码进行调整,比如我们的App主页的第三个tab(Live tab)进行了数据延迟加载,和定时回收。

这里因为这个页面除了有大图还有轮播banner,实际强引用的图片会有多张,如果这个时候切到其他页面进行听歌等行为,这个页面一直在后台缓存,实际是很浪费耗内存的,同时为优化体验,我们又不能直接通过设置主页的viewpager的缓存页数,因为这样经常都会回收,导致影响体验,所以我们在页面不可见后过一段时间,清理掉adapter数据(只是清空adapter里的数据,实际从网络加载回来的数据还在,这里只是为了去掉界面对图片的引用),当页面再次显示时再用已经加载的数据显示,即减少了很多情况下图片的引用,也不影响体验。

5. 最后我们也遇到一个比较奇葩的问题,在我们的RDM上报上有这样一条上报

我们在stackoverflow上看到了相关的讨论,大致意思是有些情况下比如息屏,或者一些省电模式下,频繁地调System.gc()可能会因为内核状态切换超时的异常。这个问题貌似没有比较好的解决方法,只能是优化内存,尽量减少手动调用System.gc()

优化结果

我们通过启动App后,切换到我的音乐界面,停留1分钟,多次gc后,获取App内存占用

优化前:

优化后:

多次试验结果都差不多,这里只截取了其中一次,有28M的优化效果。
当然不同的场景内存占用不同,同时上面试验结果是通过多次手动触发gc稳定后的结果。对于使用其他第三方工具不手动gc的情况下,试验结果可能会差异比较大。

对于上面提到的JOOX里各种图片背景等问题,我们做了动态的优化,对不同的机型进行优化,对特别低端的机型设置为纯色背景等方式,最终优化效果如下:

平均内存降低41M。

本次总结主要还是从图片方面下手,还有一点逻辑优化,已经基本达到优化目标。

四 总结

上面写了很多,我们可以简单总结,目前Andorid内存优化还是比较重要一个话题,我们可以通过各种内存泄露检测组件,MAT查看内存占用,Memory Monitor跟踪整个App的内存变化情况, Heap Viewer查看当前内存快照, Allocation Tracker追踪内存对象的来源,以及利用崩溃上报平台从多个方面对App内存进行监控和优化。上面只是列举了一些常见的情况,当然每个App功能,逻辑,架构也都不一样,造成内存问题也是不尽相同,掌握好工具的使用,发现问题所在,才能对症下药。

更多相关文章

  1. 一款霸榜 GitHub 的开源 Linux 资源监视器!
  2. Android组件保活,service保活
  3. Android内存泄露自动检测神器LeakCanary
  4. Android中Gridview和ViewPager显示图片的优化处理(2)
  5. 个推:服务端遇到的一些问题。
  6. Android高手进阶教程(二十八)之---Android(安卓)ViewPager控件的
  7. Android内存优化(使用SparseArray和ArrayMap代替HashMap)
  8. Binder 浅出深入 -- 你真的会用 binder?
  9. Android之本地相册图片选取和拍照以及图片剪辑

随机推荐

  1. OpenCV4Android开发
  2. 自定义控件attrs文件里的属性
  3. Android(安卓)Studio 导入包时报 Duplica
  4. ActionBar神奇魅力2
  5. Android(安卓)Layout xml 中的几个概念
  6. android listview实现表格样式
  7. android中字符替换成表情
  8. Android中的dispatchTouchEvent()、onInt
  9. android ListView 属性
  10. Android(安卓)设置没有 actionBar的 样式