一、从相册加载一张图片
我们先从简单的入手,看看从手机相册加载一张图片到ImageView的正确方式。
【图】
我们就以上图为列,这张图片在我手机里的信息如下:
可以看到,图片大小不足1M。那么把他加载到手机内存中时又会发生什么呢?
【图】
1.1 打开相册加载图片
/**
* 打开手机相册
*/
private void selectFromGalley() {
Intent intent = new Intent();
intent.setType("image/*");
intent.setAction(Intent.ACTION_GET_CONTENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
startActivityForResult(intent, REQUEST_CODE_PICK_FROM_GALLEY);
}
在Android 中打开相册是一件非常方便的事情,选择好图片之后就可以在onActivityResult中接收这张图片
if (resultCode == Activity.RESULT_OK) {
Uri uri = data.getData();
if (uri != null) {
ProcessResult(uri);
}
}
1.2 根据Uri得到Bitmap
-
onActivityResult 方法中返回的Intent返回的图片地址是一个Uri类型,包含具体协议
- 为了方便使用BitmapFactory的decode方法,需要将这个个Uri类型的地址转换为普通的地址,stripFileProtocol具体实现可参考源码
-
showBitmapInfos 这个方法就是很简单,就是获取一下所要加载图片的信息
- inJustDecodeBounds 这个参数,当此参数为true时,BitmapFactory 只会解析图片的原始宽/高信息,并不会去真正的加载图片
关于getByteCount和getAllocationByteCount的区别,这里暂时不讨论,只要知道他们都可以获取Bitmap占用内存大小
@TargetApi(Build.VERSION_CODES.KITKAT)
private void ProcessResult(Uri destUrl) {
String pathName = FileHelper.stripFileProtocol(destUrl.toString());
showBitmapInfos(pathName);
Bitmap bitmap = BitmapFactory.decodeFile(pathName);//【!!!】
if (bitmap != null) {
mImageView.setImageBitmap(bitmap);
float count = bitmap.getByteCount() / M_RATE;
float all = bitmap.getAllocationByteCount() / M_RATE;
String result = "这张图片占用内存大小:\n" +
"bitmap.getByteCount()== " + count + "M\n" +
"bitmap.getAllocationByteCount()= " + all + "M";
info.setText(result);
Log.e(TAG, result);
bitmap = null;
} else {
T.showLToast(mContext, "fail");
}
}
/**
* 获取Bitmap的信息
* @param pathName
*/
private void showBitmapInfos(String pathName) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;//【!!!】
BitmapFactory.decodeFile(pathName, options);//【!!!】
int width = options.outWidth;
int height = options.outHeight;
Log.e(TAG, "showBitmapInfos: \n" +
"width=: " + width + "\n" +
"height=: " + height);
options.inJustDecodeBounds = false;
}
我们看一下输出日志及内存变化:
- 由于这张图片是放在手机内部SD卡上,所以showBitmapInfos 解析后获取的图片宽高信息和之前是一致的,宽x高为 2160x1920
- 看到所占用的内存 15M,是不是有点意外,一张658KB 的加载后居然要占这么大的内存
再看一下monitor检测的内存变化,在20s后选择图片后,占用内存有了一个明显的上升。
二、Bitmap 内存计算方式
Bitmap 在内存当中占用的大小其实取决于:
- 色彩格式
- 前面我们已经提到,如果是 ARGB8888 那么就是一个像素4个字节,如果是 RGB565 那就是2个字节
- 密度转换
- 这个计算仅针对于drawable文件夹的图片来说**,而对于一个file或者stream那么inDensity和inTargetDensity是不考虑的!他们默认就是0
- 原始文件存放的资源目录(是 hdpi 还是 xxhdpi 可不能傻傻分不清楚哈)
- 目标屏幕的密度(所以同等条件下,红米在资源方面消耗的内存肯定是要小于三星S6的)
- 固定的精度转换公式
- 采样转换
2.1 density 和 densityDpi
本文涉及到屏幕密度的讨论,这里先要搞清楚 DisplayMetrics 的两个变量,简单来说,可以理解为
- density 的数值是 1dp=density px;
- densityDpi 是屏幕每英寸对应多少个点(不是像素点)
在 DisplayMetrics 当中,这两个的关系是线性的:
density | 1 | 1.5 | 2 | 3 | 3.5 | 4 |
---|---|---|---|---|---|---|
densityDpi | 160 | 240 | 320 | 480 | 560 | 640 |
为了不引起混淆,本文所有提到的密度除非特别说明,都指的是 densityDpi,当然如果你愿意,也可以用 density 来说明问题。
2.2 getByteCount处理格式字节
通过getByteCount
这个方法,我们就可以获取到一张 Bitmap 在运行时到底占用多大内存了
举个例子:一张 522x686 的 PNG 图片,我把它放到 drawable-xxhdpi 目录下,在三星s6上加载,占用内存2547360B,就可以用这个方法获取到。
public final int getByteCount() {
// int result permits bitmaps up to 46,340 x 46,340
return getRowBytes() * getHeight();
}
我们来看下这个函数的实现,getHeight 就是图片的高度(单位:px),getRowBytes 是什么?
public final int getrowBytes() {
if (mRecycled) {
Log.w(TAG, "Called getRowBytes() on a recycle()'d bitmap! This is undefined behavior!");
}
return nativeRowBytes(mFinalizer.mNativeBitmap);
}
进入了jni代码,与 nativeRowBytes 对应的函数如下:
Bitmap.cpp
static jint Bitmap_rowBytes(JNIEnv* env, jobject, jlong bitmapHandle) {
SkBitmap* bitmap = reinterpret_cast<SkBitmap*>(bitmapHandle)
return static_cast<jint>(bitmap->rowBytes());
}
Bitmap 本质上就是一个 SkBitmap,而这个 SkBitmap 也是大有来头
//SkBitmap.h
/** Return the number of bytes between subsequent rows of the bitmap. */
size_t rowBytes() const { return fRowBytes; }
//SkBitmap.cpp
size_t SkBitmap::ComputeRowBytes(Config c, int width) {
return SkColorTypeMinRowBytes(SkBitmapConfigToColorType(c), width);
}
SkImageInfo.h
static int SkColorTypeBytesPerPixel(SkColorType ct) {
static const uint8_t gSize[] = {
0, // Unknown
1, // Alpha_8
2, // RGB_565
2, // ARGB_4444
4, // RGBA_8888
4, // BGRA_8888
1, // kIndex_8
};
SK_COMPILE_ASSERT(SK_ARRAY_COUNT(gSize) == (size_t)(kLastEnum_SkColorType + 1),
size_mismatch_with_SkColorType_enum);
SkASSERT((size_t)ct < SK_ARRAY_COUNT(gSize));
return gSize[ct];
}
static inline size_t SkColorTypeMinRowBytes(SkColorType ct, int width) {
return width * SkColorTypeBytesPerPixel(ct);
}
我们发现 ARGB_8888(也就是我们最常用的 Bitmap 的格式)的一个像素占用 4byte,那么 rowBytes 实际上就是 4*width bytes
那么结论出来了,一张 ARGB_8888 的 Bitmap 占用内存的计算公式:
*bitmapInRam = bitmapWidth*bitmapHeight 4 bytes
说到这儿你以为故事就结束了么?有本事你拿去试,算出来的和你获取到的总是会差个倍数,为啥呢?
还记得我们最开始给出的那个例子么?
一张 522x686 的 PNG 图片,我把它放到 drawable-xxhdpi 目录下,在三星s6上加载,占用内存2547360B,然而公式计算出来的可是1432368B。。。
2.3 Density处理转换映射字节
知道我为什么在举例的时候那么费劲的说放到xxx目录下,还要说用xxx手机么?你以为 Bitmap 加载只跟宽高有关么?Naive
还是先看代码,我们读取的是 drawable 目录下面的图片,用的是 decodeResource 方法,该方法本质上就两步:
- 读取原始资源,这个调用了 Resource.openRawResource 方法,这个方法调用完成之后会对 TypedValue 进行赋值,其中包含了原始资源的 density 等信息;
- 调用 decodeResourceStream 对原始资源进行解码和适配。这个过程实际上就是原始资源的 density 到屏幕 density 的一个映射。
- 原始资源的 density 其实取决于资源存放的目录(比如 xxhdpi 对应的是480)
- 屏幕 density 的赋值是一个固态值
public static Bitmap decodeResource(Resources res, int id, Options opts) {
Bitmap bm = null;
InputStream is = null;
try {
final TypedValue value = new TypedValue();
is = res.openRawResource(id, value);
bm = decodeResourceStream(res, value, is, null, opts);
} catch (Exception e) {
/* do nothing.
If the exception happened on open, bm will be null.
If it happened on close, bm is still valid.
*/
} finally {
try {
if (is != null) is.close();
} catch (IOException e) {
// Ignore
}
}
if (bm == null && opts != null && opts.inBitmap != null) {
throw new IllegalArgumentException("Problem decoding into existing bitmap");
}
return bm;
}
public static Bitmap decodeResourceStream(Resources res, TypedValue value,
InputStream is, Rect pad, Options opts) {
//【1】实际上,我们这里的opts是null的,所以在这里初始化。
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; //【2】这里density的值如果对应资源目录为hdpi的话,就是240
}
}
if (opts.inTargetDensity == 0 && res != null) {
//【3】请注意,inTargetDensity就是当前的显示密度,比如三星s6时就是640
opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
}
return decodeStream(is, pad, opts);
}
public Options() {
inDither = false;
inScaled = true;
inPremultiplied = true;
}
我们需要知道的就是:inDensity 就是原始资源的 density,inTargetDensity 就是屏幕的 density
- ldpi -----> 120
- mdpi -----> 160
- hdpi -----> 240
- xhdpi -----> 320
- xxhdpi -----> 480
- xxxhdpi -----> 640
public static Bitmap decodeStream(InputStream is, Rect outPadding, Options opts) {
// we don't throw in this case, thus allowing the caller to only check
// the cache, and not force the image to be decoded.
if (is == null) {
return null;
}
Bitmap bm = null;
Trace.traceBegin(Trace.TRACE_TAG_GRAPHICS, "decodeBitmap");
try {
if (is instanceof AssetManager.AssetInputStream) {
final long asset = ((AssetManager.AssetInputStream) is).getNativeAsset();
bm = nativeDecodeAsset(asset, outPadding, opts);
} else {
bm = decodeStreamInternal(is, outPadding, opts);
}
if (bm == null && opts != null && opts.inBitmap != null) {
throw new IllegalArgumentException("Problem decoding into existing bitmap");
}
setDensityFromOptions(bm, opts);
} finally {
Trace.traceEnd(Trace.TRACE_TAG_GRAPHICS);
}
return bm;
}
private static Bitmap decodeStreamInternal(InputStream is, Rect outPadding, Options opts) {
// ASSERT(is != null);
byte [] tempStorage = null;
if (opts != null) tempStorage = opts.inTempStorage;
if (tempStorage == null) tempStorage = new byte[DECODE_BUFFER_SIZE];
return nativeDecodeStream(is, tempStorage, outPadding, opts);
}
紧接着,用到了 nativeDecodeStream 方法,不重要的代码直接略过,直接给出最关键的 doDecode 函数的代码:
BitmapFactory.cpp
static jobject doDecode(JNIEnv* env, SkStreamRewindable* stream, jobject padding, jobject options) {
......
if (env->GetBooleanField(options, gOptions_scaledFieldID)) {
const int density = env->GetIntField(options, gOptions_densityFieldID);//对应hdpi的时候,是240
const int targetDensity = env->GetIntField(options, gOptions_targetDensityFieldID);//三星s6的为640
const int screenDensity = env->GetIntField(options, gOptions_screenDensityFieldID);
if (density != 0 && targetDensity != 0 && density != screenDensity) {
scale = (float) targetDensity / density;
}
}
}
const bool willScale = scale != 1.0f;
......
SkBitmap decodingBitmap;
if (!decoder->decode(stream, &decodingBitmap, prefColorType,decodeMode)) {
return nullObjectReturn("decoder->decode returned false");
}
//【注意】这里这个deodingBitmap就是解码出来的bitmap,大小是图片原始的大小
int scaledWidth = decodingBitmap.width();
int scaledHeight = decodingBitmap.height();
//【注意】 此处处理映射转换和精度转换
if (willScale && decodeMode != SkImageDecoder::kDecodeBounds_Mode) {
scaledWidth = int(scaledWidth * scale + 0.5f);
scaledHeight = int(scaledHeight * scale + 0.5f);
}
if (willScale) {
const float sx = scaledWidth / float(decodingBitmap.width());
const float sy = scaledHeight / float(decodingBitmap.height());
// TODO: avoid copying when scaled size equals decodingBitmap size
SkColorType colorType = colorTypeForScaledOutput(decodingBitmap.colorType());
// FIXME: If the alphaType is kUnpremul and the image has alpha, the
// colors may not be correct, since Skia does not yet support drawing
// to/from unpremultiplied bitmaps.
outputBitmap->setInfo(SkImageInfo::Make(scaledWidth, scaledHeight,
colorType, decodingBitmap.alphaType()));
if (!outputBitmap->allocPixels(outputAllocator, NULL)) {
return nullObjectReturn("allocation failed for scaled bitmap");
}
// If outputBitmap's pixels are newly allocated by Java, there is no need
// to erase to 0, since the pixels were initialized to 0.
if (outputAllocator != &javaAllocator) {
outputBitmap->eraseColor(0);
}
SkPaint paint;
paint.setFilterLevel(SkPaint::kLow_FilterLevel);
SkCanvas canvas(*outputBitmap);
canvas.scale(sx, sy);
canvas.drawBitmap(decodingBitmap, 0.0f, 0.0f, &paint);
}
......
}
- density 是指decodingBitmap 的 density,这个值跟这张图片的放置的目录有关(比如 hdpi 是240,xxhdpi 是480)
- targetDensity 是指实际上是我们加载图片的目标 density,这个值的来源我们已经在前面给出了,就是 DisplayMetrics 的 densityDpi,如果是三星s6那么这个数值就是640
sx 和sy 实际上是约等于 scale 的,因为 scaledWidth 和 scaledHeight 是由 width 和 height 乘以 scale 得到的。我们看到 Canvas 放大了 scale 倍,然后又把读到内存的这张 bitmap 画上去,相当于把这张 bitmap 放大了 scale 倍。
输出图片的宽高= 原图片的宽高 / inSampleSize * (inTargetDensity / inDensity)
注意:
- inSampleSize代表采样率,只能是2的幂,如不是2的幂下转到最大的2的幂,而且inSampleSize>=1)
- 这个计算仅针对于drawable文件夹的图片来说,而对于一个file或者stream那么inDensity和inTargetDensity是不考虑的!他们默认就是0
一张522*686的PNG 图片,我把它放到 drawable-xxhdpi 目录下,在三星s6上加载,占用内存2547360B,其中 density 对应 xxhdpi 为480,targetDensity 对应三星s6的密度为640:
522/480 * 640 * 686/480 *640 * 4 = 2546432B
2.4 精度调整
越来越有趣了是不是,你肯定会发现我们这么细致的计算还是跟获取到的数值 还是不一样
为什么呢?由于结果已经非常接近,我们很自然地想到精度问题。来,再把上面这段代码中的一句拿出来看看:
outputBitmap->setInfo(SkImageInfo::Make(scaledWidth, scaledHeight,
colorType, decodingBitmap.alphaType()));
//我们看到最终输出的 outputBitmap 的大小是scaledWidth*scaledHeight,我们把这两个变量计算的片段拿出来给大家一看就明白了:
if (willScale && decodeMode != SkImageDecoder::kDecodeBounds_Mode) {
scaledWidth = int(scaledWidth * scale + 0.5f);
scaledHeight = int(scaledHeight * scale + 0.5f);
}
在我们的例子中,
- scaledWidth = int( 522 * 640 / 480f + 0.5) = int(696.5) = 696
- scaledHeight = int( 686 * 640 / 480f + 0.5) = int(915.16666…) = 915
下面就是见证奇迹的时刻:
915 * 696 * 4 = 2547360
三、想办法减少 Bitmap 内存占用
我们知道在读入bitmap时,占用这么大的内存,显然是不好的,如何解决这种问题呢?
3.1 Jpg 和 Png
说到这里,肯定会有人会说,我们用 jpg 吧,jpg 格式的图片不应该比 png 小么?
确实是个好问题,因为同样一张图片,jpg 确实比 png 会多少小一些(甚至很多),原因很简单,jpg 是一种有损压缩的图片存储格式,而 png 则是 无损压缩的图片存储格式,显而易见,jpg 会比 png 小,代价也是显而易见的。
可是,这说的是文件存储范畴的事情,它们只存在于文件系统,而非内存或者显存。
说得简单一点儿,我有一个极品飞车的免安装硬盘版的压缩包放在我的磁盘里面,这个游戏是不能玩的,我需要先解压,才能玩------jpg 也好,png 也好就是个压缩包的概念,而我们讨论的内存占用则是从使用角度来讨论的。
所以,jpg 格式的图片与 png 格式的图片在内存当中不应该有什么不同,休想通过这个方法来减少内存占用
肯定有人有意见,jpg 图片读到内存就是会小,还会给我拿出例子。当然,他说的不一定是错的。因为 jpg 的图片没有 alpha 通道!!所以读到内存的时候如果用 RGB565的格式存到内存,这下大小只有 ARGB8888的一半,能不小么
如果仅仅是为了 Bitmap 读到内存中的大小而考虑的话,jpg 也好 png 也好,没有什么实质的差别;二者的差别主要体现在:
- alpha 你是否真的需要?如果需要 alpha 通道,那么没有别的选择,用 png。
- 你的图色值丰富还是单调?就像刚才提到的,如果色值丰富,那么用jpg,如果作为按钮的背景,请用 png。
- 对安装包大小的要求是否非常严格?如果你的 app 资源很少,安装包大小问题不是很凸显,看情况选择 jpg 或者 png(不过,我想现在对资源文件没有苛求的应用会很少吧。。)
- 目标用户的 cpu 是否强劲?jpg 的图像压缩算法比 png 耗时。这方面还是要酌情选择,前几年做了一段时间 Cocos2dx,由于资源非常多,项目组要求统一使用 png,可能就是出于这方面的考虑。
3.2 压缩图片方案一(Compress)压缩文件
因为我们要处理的是Bitmap,首先从他自带的方法出发,果然找到了一个compress方法。
private Bitmap getCompressedBitmap(Bitmap bitmap) {
try {
//创建一个用于存储压缩后Bitmap的文件
File compressedFile = FileHelper.createFileByType(mContext, destType, "compressed");
Uri uri = Uri.fromFile(compressedFile);
OutputStream os = getContentResolver().openOutputStream(uri);
Bitmap.CompressFormat format = destType == FileHelper.JPEG ?
Bitmap.CompressFormat.JPEG : Bitmap.CompressFormat.PNG;
boolean success = bitmap.compress(format, compressRate, os);//【核心】
if (success) {
T.showLToast(mContext, "success");
}
final String pathName = FileHelper.stripFileProtocol(uri.toString());
showBitmapInfos(pathName);
bitmap = BitmapFactory.decodeFile(pathName);
os.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return bitmap;
}
bitmap.compress(format, compressRate, os) 会按照指定的格式和压缩比例将压缩后的bitmap写入到os 所对应的文件中。compressRate的取值在0-100之间,0表示压缩到最小尺寸。
在ProcessResult方法中,我们获取bitmap后,首先通过上述方法将bitmap压缩,然后在显示到ImageView中。我们看一下,压缩过后的情况。
【 压缩图片方案一(Compress)】
- 第一个showBitmapInfos 显示的是选择的图片通过BitmapFactory解析后的信息,第二个showBitmapInfos
- 显示的压缩后图片的宽高信息,最后很意外,我们的压缩方法似乎没起到作用,占用的内存没有任何变化,依旧是15M。
难道是compress方法没生效吗?其实不然,至少从UI上看compress的确生效了, 当compressRate=0时,懒羊羊的图片显示到ImageView上时已经非常不清晰了,失真非常严重。那么到底是为什么呢?
这里就得从概念上说起:
- 一开始我们提到了这张懒羊羊的图片大小时658KB,这是它在手机存储空间所占的大小,而当我们在选择这张图片,并解析为Bitmap时,他所站的15MB是在内存中所占的大小
- compress方法只能压缩前一种大小,也就是所使用Bitmap的compress方法只是压缩他在存储空间的大小,结果就是导致图片失真;而不能改变他在内存中所占用的大小
在上一环节我们已经说过了,决定一张图片所占内存大小的因素是图片的宽高和Bitmap的格式,我们先来从宽高处理
3.3 压缩图片方案二 (Crop)裁剪部分
private void CropTheImage(Uri imageUrl) {
Intent cropIntent = new Intent("com.android.camera.action.CROP");
cropIntent.setDataAndType(imageUrl, "image/*");
cropIntent.putExtra("cropWidth", "true");
cropIntent.putExtra("outputX", cropTargetWidth);
cropIntent.putExtra("outputY", cropTargetHeight);
File copyFile = FileHelper.createFileByType(mContext, destType, String.valueOf(System.currentTimeMillis()));
copyUrl = Uri.fromFile(copyFile);
cropIntent.putExtra("output", copyUrl);
startActivityForResult(cropIntent, REQUEST_CODE_CROP_PIC);
}
这里调用了系统自带的图片裁剪控件,并创建了一个copyFile 的文件,裁剪过后的图片的地址指向就是这个文件所对应的地址。
当cropTargetWidth=1080,cropTargetHeight=920时,我们看一下日志:
【压缩图片方案二 (Crop)】
可以看到,Bitmap所占用的内存终于变小了,而且由于在裁剪时宽高各缩小了1/2,整个内存的占用也是缩小了1/4,变成了3.9M左右。同时图片在手机存储空间也变小了
当然,这里要注意的是,com.android.camera.action.CROP 中两个参数 “outputX” 和"outputY",决定了压缩后图片的大小,因此当这两个值的大小超过原始图片的大小时,内存占用反而会增加,这一点应该很好理解,所以需确保传递合适的值,否则会适得其反。
有同学指出,裁剪导致了图片缺失,起不到压缩的作用。其实这里我们给出了一种 长图的压缩方案:们可以把一张长图横向切割成多张,然后放在多个ImageView里显示,ImageView在ScrollView中显示时才加载bitmap
3.4 图片压缩方案三 (Sample )采样模糊
采用Sample,也就是是采样的方式压缩图片之前,我们首先需要了解一下inSampleSize 这个参数。
inSampleSize 是BitmapFactory.Options 的一个参数,当他为1时,采样后的图片大小为图片原始大小;当inSampleSize 为2时,那么采样后的图片其宽/高均为原图大小的1/2,而像素数为原图的1/4,其占有的内存大小也为原图的1/4。inSampleSize 的取值应该是2的指数。
if (needSample) {
//给一个200*200的图片
bitmap = getRealCompressedBitmap(pathName, 200, 200);
}
private Bitmap getRealCompressedBitmap(String pathName, int reqWidth, int reqHeight) {
Bitmap bitmap;
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(pathName, options);
int width = options.outWidth / 2;
int height = options.outHeight / 2;
int inSampleSize = 1;
while (width / inSampleSize >= reqWidth && height / inSampleSize >= reqHeight) {
inSampleSize = inSampleSize * 2;
}
options.inSampleSize = inSampleSize;
options.inJustDecodeBounds = false;
bitmap = BitmapFactory.decodeFile(pathName, options);
showBitmapInfos(pathName);
return bitmap;
}
我们希望将2160x1920像素的原图压缩到200x200 像素的大小,因此在getRealCompressedBitmap方法中,通过while循环inSampleSize的值最终为8,因此内存占用率将变为原来的1/64,这是一个很大的降幅。我们看一下日志,看看到底是否能够如我们所愿:
【图片压缩方案三 (Sample )】
可以看到,使用这种方法进行图片压缩后,增加的内存只有0.24M,几乎可以忽略不计了。
当然前提是我们要使用的图片的确不需要很大,比如这里,需要用这张图片作为用户头像的话,那么将原图缩略成200x200 px的大小是没有问题的。 否则会导致图片模糊不清晰,相当于强行放大的效果。
上面提到的三种压缩方案,通过对比可以发现
- 第一种方案适用于进行纯粹的文件压缩,而不适用进行图像处理压缩;
- 第二种方案压缩方案适用于进行图像编辑时的压缩,就像手机自带相册的编辑功能,可以随着裁剪区域的大小进行最终的压缩;
- 第三种方案相对来说,适应性较强,各种场景都会符合。
3.5 使用矩阵Matrix 小图形放大绘制
Bitmap 的像素点阵,还不就是个矩阵
大图小用用采样,小图大用用矩阵
还是用前面模糊图片的例子,我们不是采样了么?内存是小了,可是图的尺寸也小了啊,我要用 Canvas 绘制这张图可怎么办?当然是用矩阵了:
Matrix matrix = new Matrix();
matrix.preScale(2, 2, 0, 0);
canvas.drawBitmap(bitmap, matrix, paint);
这样,绘制出来的图就是放大以后的效果了,不过占用的内存却仍然是我们采样出来的大小
如果我要把图片放到 ImageView 当中呢?一样可以,请看:
Matrix matrix = new Matrix();
matrix.postScale(2, 2, 0, 0);
imageView.setImageMatrix(matrix);
imageView.setScaleType(ScaleType.MATRIX);
imageView.setImageBitmap(bitmap);
3.6 合理选择Bitmap的像素格式
其实前面我们已经多次提到这个问题。ARGB8888格式的图片,每像素占用 4 Byte,而 RGB565则是 2 Byte。我们先看下有多少种格式可选:
格式 | 描述 |
---|---|
ALPHA_8 | 只有一个alpha通道 |
ARGB_4444 | 这个从API 13开始不建议使用,因为质量太差 |
ARGB_8888 | ARGB四个通道,每个通道8bit |
RGB_565 | 每个像素占2Byte,其中红色占5bit,绿色占6bit,蓝色占5bit |
- ALPHA8 没必要用,因为我们随便用个颜色就可以搞定的。
- ARGB4444 虽然占用内存只有 ARGB8888 的一半,不过已经被官方嫌弃,失宠了。。『又要占省内存,又要看着爽,臣妾做不到啊T T』。
- ARGB8888 是最常用的,大家应该最熟悉了。
- RGB565 看到这个,我就看到了资源优化配置无处不在,其实如果不需要 alpha 通道,特别是资源本身为 jpg 格式的情况下,用这个格式比较理想。
3.7 高能:索引位图(Indexed Bitmap)
索引位图,每个像素只占 1 Byte,不仅支持 RGB,还支持 alpha,而且看上去效果还不错!等等,请收起你的口水,Android 官方并不支持这个。是的,你没看错,官方并不支持。
public enum Config {
// these native values must match up with the enum in SkBitmap.h
ALPHA_8 (2),
RGB_565 (4),
ARGB_4444 (5),
ARGB_8888 (6);
final int nativeInt;
}
不过,Skia 引擎是支持的,不信你再看:
enum Config {
kNo_Config, //!< bitmap has not been configured
kA8_Config, //!< 8-bits per pixel, with only alpha specified (0 is transparent, 0xFF is opaque)
//看这里看这里!!↓↓↓↓↓
kIndex8_Config, //!< 8-bits per pixel, using SkColorTable to specify the colors
kRGB_565_Config, //!< 16-bits per pixel, (see SkColorPriv.h for packing)
kARGB_4444_Config, //!< 16-bits per pixel, (see SkColorPriv.h for packing)
kARGB_8888_Config, //!< 32-bits per pixel, (see SkColorPriv.h for packing)
kRLE_Index8_Config,
kConfigCount
};
其实 Java 层的枚举变量的 nativeInt 对应的就是 Skia 库当中枚举的索引值,所以,如果我们能够拿到这个索引是不是就可以了?对不起,拿不到。
不过呢,在 png 的解码库里面有这么一段代码:
bool SkPNGImageDecoder::getBitmapColorType(png_structp png_ptr, png_infop info_ptr,
SkColorType* colorTypep,
bool* hasAlphap,
SkPMColor* SK_RESTRICT theTranspColorp) {
png_uint_32 origWidth, origHeight;
int bitDepth, colorType;
png_get_IHDR(png_ptr, info_ptr, &origWidth, &origHeight, &bitDepth,
&colorType, int_p_NULL, int_p_NULL, int_p_NULL);
#ifdef PNG_sBIT_SUPPORTED
// check for sBIT chunk data, in case we should disable dithering because
// our data is not truely 8bits per component
png_color_8p sig_bit;
if (this->getDitherImage() && png_get_sBIT(png_ptr, info_ptr, &sig_bit)) {
#if 0
SkDebugf("----- sBIT %d %d %d %d\n", sig_bit->red, sig_bit->green,
sig_bit->blue, sig_bit->alpha);
#endif
// 0 seems to indicate no information available
if (pos_le(sig_bit->red, SK_R16_BITS) &&
pos_le(sig_bit->green, SK_G16_BITS) &&
pos_le(sig_bit->blue, SK_B16_BITS)) {
this->setDitherImage(false);
}
}
#endif
if (colorType == PNG_COLOR_TYPE_PALETTE) {
bool paletteHasAlpha = hasTransparencyInPalette(png_ptr, info_ptr);
*colorTypep = this->getPrefColorType(kIndex_SrcDepth, paletteHasAlpha);
// now see if we can upscale to their requested colortype
//【这段代码,如果返回false,那么colorType就被置为索引了,那么我们看看如何返回false】
if (!canUpscalePaletteToConfig(*colorTypep, paletteHasAlpha)) {
*colorTypep = kIndex_8_SkColorType;
}
} else {
......
}
return true;
}
//canUpscalePaletteToConfig函数如果返回false,那么colorType就被置为kIndex_8_SkColorType了。
static bool canUpscalePaletteToConfig(SkColorType dstColorType, bool srcHasAlpha) {
switch (dstColorType) {
case kN32_SkColorType:
case kARGB_4444_SkColorType:
return true;
case kRGB_565_SkColorType:
// only return true if the src is opaque (since 565 is opaque)
return !srcHasAlpha;
default:
return false;
}
}
如果传入的 dstColorType
是 kRGB_565_SkColorType
,同时图片还有 alpha 通道
,那么返回 false~~咳咳,那么问题来了,这个dstColorType 是哪儿来的??就是我们在 decode 的时候,传入的Options
的 inPreferredConfig
。
下面我们做个实验: 在 assets 目录当中放了一个叫 index.png 的文件,大小192*192,这个文件是通过 PhotoShop 编辑之后生成的索引格式的图片。
try {
Options options = new Options();
options.inPreferredConfig = Config.RGB_565;
Bitmap bitmap = BitmapFactory.decodeStream(getResources().getAssets().open("index.png"), null, options);
Log.d(TAG, "bitmap.getConfig() = " + bitmap.getConfig());
Log.d(TAG, "scaled bitmap.getByteCount() = " + bitmap.getByteCount());
imageView.setImageBitmap(bitmap);
} catch (IOException e) {
e.printStackTrace();
}
程序运行在 Nexus6上,由于从 assets 中读取不涉及前面讨论到的 scale 的问题,所以这张图片读到内存以后的大小理论值(ARGB8888):*192 * 192 4=147456
好,运行我们的代码,看输出的 Config 和 ByteCount:
D/MainActivity: bitmap.getConfig() = null
D/MainActivity: scaled bitmap.getByteCount() = 36864
- 先说大小为什么只有 36864,我们知道如果前面的讨论是没有问题的话,那么这次解码出来的 Bitmap 应该是索引格式,那么占用的内存只有 ARGB 8888 的1/4是意料之中的;
- 再说 Config 为什么为 null。官方说:Config没的类型就是黑户,返回null
public final Bitmap.Config getConfig ()
Added in API level 1
If the bitmap’s internal config is in one of the public formats, return that config, otherwise return null.
3.8 巨图加载
巨图加载,当然不能使用常规方法,必OOM。 原理比较简单,系统中有一个类BitmapRegionDecoder:
public static BitmapRegionDecoder newInstance(byte[] data, int offset, int length, boolean isShareable) throws IOException {
}
public static BitmapRegionDecoder newInstance( FileDescriptor fd, boolean isShareable) throws IOException {
}
public static BitmapRegionDecoder newInstance(InputStream is, boolean isShareable) throws IOException {
}
public static BitmapRegionDecoder newInstance(String pathName, boolean isShareable) throws IOException {
}
可以按区域加载:
public Bitmap decodeRegion(Rect rect, BitmapFactory.Options options) {
}
微博的大图浏览也是通过这个BitmapRegionDecoder实现的,具体可自行查阅。
其他
Bitmap为什么要调用recycle()方法来显示释放内存
itmap类有一个方法recycle(),从方法名可以看出意思是回收。这里就有疑问了,Android系统有自己的垃圾回收机制,可以不定期的回收掉不使用的内存空间,当然也包括Bitmap的空间。那为什么还需要这个方法呢?
Bitmap类的构造方法都是私有的,所以开发者不能直接new出一个Bitmap对象,只能通过BitmapFactory类的各种静态方法来实例化一个Bitmap。仔细查看BitmapFactory的源代码可以看到,生成Bitmap对象最终都是通过JNI调用方式实现的。
所以,加载Bitmap到内存里以后,是包含两部分内存区域的。简单的说,一部分是Java部分的,一部分是C部分的。
这个Bitmap对象是由Java部分分配的,不用的时候系统就会自动回收了,但是那个对应的C可用的内存区域,虚拟机是不能直接回收的,这个只能调用底层的功能释放。所以需要调用recycle()方法来释放C部分的内存。从Bitmap类的源代码也可以看到,recycle()方法里也的确是调用了JNI方法了的。
8.0bitmap已经不是在java堆了,在native直接分配内存,不需要自己回收
附录
try {
Bitmap bitmap = ((BitmapDrawable)bitmImage.getDrawable()).getBitmap();
int M_RATE = 1024 * 1024;
float count = bitmap.getByteCount() / M_RATE;
float all = 0;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) {
all = bitmap.getAllocationByteCount() / M_RATE;
}
DisplayMetrics dm = new DisplayMetrics();
getActivity().getWindowManager().getDefaultDisplay().getMetrics(dm);
float width = bitmap.getWidth()/dm.density;
float hight = bitmap.getHeight()/dm.density;
double size = ( width * dm.density + 0.5) * ( hight * dm.density + 0.5) * 4 / M_RATE;
String me =
"DisplayMetrics.density== " + dm.density + "\n" + "DisplayMetrics.densityDpi== " + dm.densityDpi + "\n" +
"这张图片占用内存大小:\n" +
"原始宽== " + width+ "px\n" +
"原始高== " + hight+ "px\n" +
"计算公式== " + size+ "M\n" +
"bitmap.getByteCount()== " + count + "M\n" +
"bitmap.getAllocationByteCount()= " + all + "M";
Log.e("yhf", me);
}catch (Exception e){
}
参考文献
- Android Bitmap 初探
- Android 开发绕不过的坑:你的 Bitmap 究竟占多大内存?
- decode图片时BitmapFactory.Options中的inDensity和inTargetDensity
- Android Bitmap 常见的几个操作:缩放,裁剪,旋转,偏移
- [Android] Bitmap OOM解决办法二
- [Android] Android开发优化之——对Bitmap的内存优化
- 关于android 使用bitmap的OOM心得和解决方案
- Android Bitmap 那些事
- Android Bitmap 面面观
- Android Bitmap最全面详解
- Android加载大图——BitmapRegionDecoder
- 超大图片的显示:BitmapRegionDecoder