相关阅读:
吊炸天!74款APP完整源码!
[干货]2017已来,最全面试总结——这些Android面试题你一定需要
Android常用开源框架的源码解读套路--教你怎么读开源框架源码
来源:http://blog.csdn.net/megatronkings/article/details/52215803
图片加载性能优化永远是
Android
领域中一个无法绕过的话题,经过数年的发展,涌现了很多成熟的图片加载开源库,比如Fresco、Picasso、UIL等等,使得图片加载不再是一个头疼的问题,并且大幅降低了OOM发生的概率。然而,在图片加载方面我们是否可以就此放松警惕了呢?
开源图片加载库能为我们解决绝大部分有关图片的问题,然而并不是所有!
首先,图片从来源上可以分成三大类:网络图片、手机图片、APK资源图片。网络图片和手机图片都在图片加载库功能的覆盖范围内,基本上不用开发者太操心,但是APK资源图片却不在此范围!
关于APK资源图片有3个特征:
1、资源图片基本都是在xml中引用 ,在Java中也是通过资源ID查找 。
2、资源图片一般不使用异步记载,不会出现loading图这些中间状态。
3、资源图片不会加载失败,如果失败了那么APP也挂掉了。
正是由于这3点特征,所以图片加载库实在鞭长莫及。那么就很容易出现一个问题:图片过大导致OOM!
很多APP为了追求酷炫的效果,热衷于设计绚丽全屏背景页面。既然是为了炫酷,考虑到用户体验,这些全屏背景图自然不能使用网络图片了,所以,这些图片都被放在apk包中作为资源文件直接引用。
使用这些资源图片的方式一般都是:
android:background="@drawable/xxx"
正常情况下,这样使用自然不会出现问题,但是如果APP内存紧张,很容易就出现OOM,尤其是5.0版本以下的手机,经常跑着跑着就Crash了,始作俑者就是这个。
为了解决这种问题,最常用的方式是找设计师压缩图片。而压缩图片有两种方式:缩小尺寸和降低质量。那么,这两种方式是否有效呢?
1、缩小尺寸:
压缩图片的宽度和高度。由于图片的内存占用与宽高成正比,这种方式确实有效,但是图片显示时会被拉伸导致变形,从而失却美感。
2、降低质量:
降低图片的色彩度,像素颜色密度。这其实是一个误区,很多人认为图片的存储占用空间小,图片的内存占用就会小,其实是错误的观点。这是方式并不会影响图片的内存占用,反而由于质量降低(下文具体分析),使得页面缺乏质感。必须记住:
图片的内存占用与图片质量毫无干系!
为了寻求一个合理的解决方案,必须知彼知己。下面,我们来详细分析下资源图片的内存占用的情况!(后文所说的图片,除非特殊指明,否则都默认指APK资源图片)。
1、计算Bitmap的内存占用
我们以一张标准720p的全屏图片为例,宽高比为720×1280,对应的资源文件夹为drawable-xhdpi。同样,设备以标准720p的小米2S手机为例,density=320。
首先,android设备上图片都被处理成Bitmap对象。生成Bitmap有一个非常重要的参数Config,属性值有ALPHA_8、RGB_565、ARGB_4444、ARGB_8888四种。不同的属性值对应的图片每个像素点占用内存大小不同,ALPHA_8每个像素占用1byte,RGB_565和ARGB_4444占用2byte,ARGB_8888占用4byte,其中ARGB_4444在高版本中已经废弃。
那么,资源图片被decode成Bitmap的时候,Config参数值是哪个呢?来看几段代码。
Resources.java
private Drawable loadDrawableForCookie(TypedValue value, int id, Theme theme) {
...
final String file = value.string.toString();
...
final Drawable dr;
if (file.endsWith(".xml")) {
final XmlResourceParser rp = loadXmlResourceParser(file, id, value.assetCookie, "drawable");
dr = Drawable.createFromXml(this, rp, theme);
rp.close();
} else {
final InputStream is = mAssets.openNonAsset(value.assetCookie, file, AssetManager.ACCESS_STREAMING);
dr = Drawable.createFromResourceStream(this, value, is, file, null);
is.close();
}
...
}
Drawable.java
public static Drawable createFromResourceStream(Resources res, TypedValue value,
InputStream is, String srcName, BitmapFactory.Options opts) {
...
if (opts == null) opts = new BitmapFactory.Options();
opts.inScreenDensity = res != null ? res.getDisplayMetrics().noncompatDensityDpi : DisplayMetrics.DENSITY_DEVICE;
Bitmap bm = BitmapFactory.decodeResourceStream(res, value, is, pad, opts);
...
return null;
}
BitmapFactory.java
public static class Options {
...
/**
* Image are loaded with the {@link Bitmap.Config#ARGB_8888} config by default.
*/
public Bitmap.Config inPreferredConfig = Bitmap.Config.ARGB_8888;
...
}
从上面资源文件生成BitmapDrawable的代码可知,Bitmap.Config使用的是默认值ARGB_8888,即图像每个像素点占用内存4byte。
我们图片的尺寸是720×1280,也就是说像素点个数是720×1280=921600,所有像素点占用内存=720x1280x4=3686400 byte=3.515625M,这个大小是图片不做任何处理时占用的内存大小。
另外,不管图片的内容是什么样子,体现在内存中的也仅仅是每个像素点对应的字节的值不同,大小是一样的,即一张720×1280的空白图和一张720×1280的彩色绚图占用内存大小是一致的。
所以说想要降低占用内存,唯有减小宽高尺寸
。
刚刚说过,计算出来的3.515625M大小是图片未作任何处理时的大小,但是系统在将图片处理成Drawable对象的时候是否未作处理呢?答案是:不!
BitmapFactory.java
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);
}
代码中Options有两个非常重要的参数,
inDensity
和
inTargetDensity
,先来解释一下这俩参数的作用。
inDensity表示被设定的图像密度,决定这个值的是图片所放置的文件目录,比如drawable-hdpi、drawable-xhdpi等等,其对应的density如下表:
代码中opts.inDensity 被赋值为 value.density,也就是资源维度对应的密度值。如果图片放在drawable-hdpi下,inDensity=240,如果放在drawable-xhdpi下,inDensity=320。
inTargetDensity表示最终需要适配到的图片密度,这个值由手机设备来决定,上面代码中其值为DisplayMetrics的densityDpi,手机屏幕越高清这个值越大,而我们例子中720p的小米2S对应的densityDpi=320。
如果inDensity的值和inTargetDensity的值不相等,那么图片尺寸就被会缩放,缩放的比例为 inTargetDensity / inDensity。
当然,宽高是需要同时等比缩放的,不然图片就变形了。
前面说过图片占用内存与图片的尺寸有关,如果被尺寸缩放了,内存大小就变了。前面未作任何缩放处理的720×1280图占用内存是3.515625M,假设放在drawable-ldpi目录下inDensity=120,设备inTargetDensity=320,那么最终的占用内存大小将是3.515625Mx(320/120)x(320/120)=25M。
一张图片占用25M大小,很恐怖的一个值,这种情况下,app估计直接挂了,如果放在drawable-hdpi下,占用就是6.25M,drawable-xhdpi下占用是3.515625M。由此可见,图片放置的目录一定要慎重。
最终我们得出一个公式:
资源图片内存大小 = 宽 x 高 x 4 x (设备密度 / 资源维度密度)x(设备密度 / 资源维度密度)
2、图像后门inPurgeable
前面说到,资源图片防止的目录不对会导致内存占用翻倍,但也不是放的密度维度越高越好,毕竟还是要做适配,不然小尺寸图片显示在高清大屏幕上就不好看了。而即使图片放对位置,占用内存大小也是相当惊人的,来个十张大图应用内存就蹭蹭上去了,冷不丁还来个OOM。
相信很多人都找到过解决方案:
inPurgeable
,代码网上一搜一大推:
public static Bitmap readBitmap(Context context, int resId) {
BitmapFactory.Options opt = new BitmapFactory.Options();
opt.inPurgeable = true;
opt.inInputShareable = true;
InputStream is =context.getResources().openRawResource(resId);
return BitmapFactory.decodeStream(is, null, opt);
}
那么,这段代码是否起效果呢?答案是肯定的,以前经常报OOM的现在都好了,而且用AS的内存监视器一看,加载图片时基本上不占内存。不管有没有其它问题,姑且把这个称之为图像后门吧。
下面,我们来看这个后门为什么能起效果!
BitmapFactory.java
public static Bitmap decodeStream(InputStream is, Rect outPadding, Options opts) {
if (is == null) {
return null;
}
Bitmap bm = null;
...
if (is instanceof AssetManager.AssetInputStream) {
...
} else {
bm = decodeStreamInternal(is, outPadding, opts);
}
}
private static Bitmap decodeStreamInternal(InputStream is, Rect outPadding, Options opts) {
byte [] tempStorage = null;
if (opts != null) tempStorage = opts.inTempStorage;
if (tempStorage == null) tempStorage = new byte[DECODE_BUFFER_SIZE];
return nativeDecodeStream(is, tempStorage, outPadding, opts);
}
private static native Bitmap nativeDecodeStream(InputStream is, byte[] storage,
Rect padding, Options opts);
很明显,decodeStream这段代码最终调用的是native层的类库,我们追踪下去查看(下面以JellyBean源码为例)。
BitmapFactory.cpp
static jobject doDecode(JNIEnv* env, SkStream* stream, jobject padding,
jobject options, bool allowPurgeable, bool forcePurgeable = false,
bool applyScale = false, float scale = 1.0f) {
...
if (!isPurgeable) {
decoder->setAllocator(&javaAllocator);
}
...
if (isPurgeable) {
decodeMode = SkImageDecoder::kDecodeBounds_Mode;
}
...
if (isPurgeable) {
pr = installPixelRef(bitmap, stream, sampleSize, doDither);
} else {
pr = bitmap->pixelRef();
}
...
}
static SkPixelRef* installPixelRef(SkBitmap* bitmap, SkStream* stream,
int sampleSize, bool ditherImage) {
SkImageRef* pr;
// only use ashmem for large images, since mmaps come at a price
if (bitmap->getSize() >= 32 * 1024) {
pr = new SkImageRef_ashmem(stream, bitmap->config(), sampleSize);
} else {
pr = new SkImageRef_GlobalPool(stream, bitmap->config(), sampleSize);
}
...
return pr;
}
相关isPurgeable的代码就这么多,最终关于图片的decode逻辑都在installPixelRef中,有一段逻辑值得玩味。如果图片大小(占用内存)大于32×1024=32K,那么就使用Ashmem,否则就就放入一个引用池中。
这个做法也很容易理解,如果图片不大,直接放到native层内存中,读取方便且迅速。如果图片过大,放到native层内存也就不合理了,不然图片一多,native层内存很难管理。但是如果使用Ashmem匿名共享内存方式,写入到设备文件中,需要时再读取就能避免很大的内存消耗了,另外,这块内存是由Linux系统的内存管理来管理的,系统内存不足可以直接回收。而且,由于Ashmem跨进程的特性,同一张图片内存是可以跨进程共享的,这也是inInputShareable属性的由来。
关于Ashmem不明白的,可以参考老罗的博客:
http://blog.csdn.net/luoshengyang/article/details/6664554
由此可见,如果inPurgeable=true,图片所占用的内存就完全与Java Heap无关了,自然就不会有OOM这种烦恼了。
但是,万事有利有弊,一件事情的成功往往是用牺牲其它方面换来的。
前面说过,使用Resources获取图片Drawable的时候,会默认使用inDensity和inTargetDensity属性缩放图片来达到适配不同分辨率屏幕的目的。
但是如果设置了inPurgeable=true来避免在Java Heap中分配内存,inDensity和inTargetDensity这两个属性就不能再使用了,否则即使inPurgeable=true,图片仍然会在Java Heap中分配内存。
关于这一点,从以下代码中可以验证:
BitmapFactory.cpp
static jobject doDecode(JNIEnv* env, SkStream* stream, jobject padding,
jobject options, bool allowPurgeable, bool forcePurgeable = false,
bool applyScale = false, float scale = 1.0f) {
...
bool willScale = applyScale && scale != 1.0f;
bool isPurgeable = !willScale &&
(forcePurgeable || (allowPurgeable && optionsPurgeable(env, options)));
...
}
在doDecode方法中,isPurgeable会重新赋值,首决条件是图片不会缩放(willScale),其次才会判断Options中的isPurgeable属性。很明显,如果inDensity和inTargetDensity两个属性断定图片需要缩放,isPurgeable会被强制设定成false。这么做的原因很简单,Ashmem不可能维护多套不同尺寸的相同图片。
如果要解决这种适配问题,唯一的解决方案就是
图片切成不同的尺寸,放到不同维度的drawable目录下
。这样虽然不能做到精准适配,但是可以做到大体适配。原理就是,不同分辨率的屏幕decode相匹配密度维度目录下的对应尺寸图片。
说完适配的问题,你以为坑就到此结束了?其实不然,真正的大问题不是这个!
我们来看inPurgeable属性的一段官方注释:
While inPurgeable can help avoid big Dalvik heap allocations (from API level 11 onward), it sacrifices performance predictability since any image that the view system tries to draw may incur a decode delay which can lead to dropped frames。
意思就是:虽然inPurgeable能避免在Heap中分配一大段内存,但这个是以牺牲性能为代价的,如果图片要绘制到View上可能出现延时导致掉帧。
前面说过,inPurgeable=true的情况下,大图使用Ashmem共享内存存储图片,但是这部分内存存储的仅仅是解码前的图片数据,我们获取的Bitmap只是一个空包弹,不含任何像素信息。当图片需要渲染的时候,先要对一个个像素点进行解码,这个过程是比较耗时的,而偏偏又发生在UI线程中,必须等图像解码完成,UI线程才能继续渲染。如果图片像素点过多,计算量大,很容易就导致卡帧。最坑爹的是,Ashmem内存是由Linux系统统一管理的,如果系统内存紧张,这块儿图片内存很容易被回收,当图片再次被渲染时,Ashmem设备文件就需要重新映射内存再重新解码。
综上所诉,虽然inPurgeable既能导致适配问题,又可能导致性能问题,那么我们为什么还要使用呢?理由很简单:相对于出现OOM导致Crash,这两点牺牲仍然是值得的!
Facebook出品的大名鼎鼎的图片加载库Fresco中对图片的处理都使用了inPurgeable=true,代码如下 :
BitmapFactory.Options = new BitmapFactory.Options();
options.inPurgeable = true;
Bitmap bitmap = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length, options);
虽然,Fresco和我们所说的资源图片干系并不大,但是很多思想还是值得我们借鉴。另外,关于inPurgeable的问题点以及Fresco为什么仍然继续使用这个属性,在其介绍文章中写得很详细,有兴趣可以去阅读下:
https://code.facebook.com/posts/366199913563917
(需要翻墙)。