您可以捐助,支持我们的公益事业。

1元 10元 50元





认证码:  验证码,看不清楚?请点击刷新验证码 必填



  求知 文章 文库 Lib 视频 Code iProcess 课程 认证 咨询 工具 火云堂 讲座吧   成长之路  
会员   
 
   
 
  
每天15篇文章
不仅获得谋生技能
更可以追随信仰
 
 
     
   
 订阅
  捐助
Android 内存优化实践与总结
 
来源于:极客头条 发布于: 2017-4-11
624 次浏览     评价:      
 

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

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

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

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

一、Android 内存分配回收机制

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

从宏观角度上来看 Android 系统可以分为三个层次:

1.Application Framework

2.Dalvik 虚拟机

3.Linux 内核。

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

1. Application Framework

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

1.Empty process(空进程)

2.Background process(后台进程)

3.Service process(服务进程)

4.Visible process(可见进程)

5.Foreground process(前台进程)

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

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

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

2. Linux 内核

参考阿里巴巴的 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%内存。

inPurgeable和inInputShareable 这两个需要一起使用,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 呢?