Android中图片是以bitmap形式存在的,那么bitmap所占内存,直接影响到了应用所占内存大小,首先要知道bitmap所占内存大小计算方式:图片宽度 x 图片高度 x 单位像素占用的字节数
以下是图片的色彩模式:其中,A代表透明度;R代表红色;G代表绿色;B代表蓝色。
表示8位Alpha位图,即A=8,一个像素点占用1个字节,它没有颜色,只有透明度
表示16位ARGB位图,即A=4,R=4,G=4,B=4,一个像素点占4+4+4+4=16位,2个字节
表示32位ARGB位图,即A=8,R=8,G=8,B=8,一个像素点占8+8+8+8=32位,4个字节
表示16位RGB位图,即R=5,G=6,B=5,它没有透明度,一个像素点占5+6+5=16位,2个字节
表示64位RGB位图,8个字节
一般情况下我们使用的色彩模式为ARGB_8888,这种模式下一个像素所占的大小为4字节,一个像素的位数总和越高,图像也就越逼真。假设有一张480x800的图片,在色彩模式为ARGB_8888的情况下,会占用 480*800*4/1024KB=1500KB 的内存;而在RGB_565的情况下,占用的内存为:480*800*2/1024KB=750KB。
无损压缩图片格式,支持Alpha(透明)通道,Android切图素材多采用此格式
有损压缩图片格式,不支持背景透明,适用于照片等色彩丰富的大图压缩,不适合logo,但是文件尺寸较小,下载速度快,更适合网络传输
是一种同时提供了有损压缩和无损压缩的图片格式,派生自视频编码格式VP8,从谷歌官网来看,无损webp平均比png小26%,有损的webp平均比jpeg小25%~34%,无损webp支持Alpha通道,有损webp在一定的条件下同样支持,有损webp在Android4.0(API 14)之后支持,无损和透明在Android4.3(API18)之后支持
Android图片上传是开发中最常见的应用场景之一,但是现在的手机摄像头像素都非常高,随便拍一张照片都在3~6M之间,分辨率也都在3000x4000左右,如此大的照片如果直接显示或者上传到服务器,体验都是非常差的,所以图片在上传之前,一般都要对图片做压缩处理。常用的压缩方法很多,但是归根结底原理上都是围绕 图片宽度 x 图片高度 x 单位像素占用的字节数 这个公式展开的,下面一一展开介绍。
(1)先讲一下图片存在的几种形式:
(2)再简单说下位深与色深
图片在内存中和在磁盘上的两种不同的表示形式:前者为Bitmap,后者为各种压缩格式。这里介绍一下位深与色深的概念:
①色深
色深指的是每一个像素点用多少bit来存储ARGB值,属于图片自身的一种属性。色深可以用来衡量一张图片的色彩处理能力(即色彩丰富程度)。典型的色深是8-bit、16-bit、24-bit和32-bit等。上述的Bitmap.Config参数的值指的就是色深。比如ARGB_8888方式的色深为32位,RGB_565方式的色深是16位。
②位深
位深指的是在对Bitmap进行压缩存储时存储每个像素所用的bit数,主要用于存储。由于是“压缩”存储,所以位深一般小于或等于色深 。举个例子:
某张图片100像素*100像素 色深32位(ARGB_8888),保存时位深度为24位,那么:
该图片在内存中所占大小为:100 * 100 * (32 / 8) Byte。
在文件中所占大小为 100 * 100 * ( 24/ 8 ) * 压缩率 Byte
(3)然后整理下Android中图片读取,显示,修改,保存涉及到的类大致如下图所示:
质量压缩并不会改变图片在内存中的大小,仅仅会减小图片所占用的磁盘空间的大小(File文件的大小),因为质量压缩不会改变图片的分辨率,而图片在内存中的大小是根据width*height*单像素的所占用的字节数计算的,宽高没变,在内存中占用的大小自然不会变,质量压缩的原理是通过改变图片的位深和透明度来减小图片占用的磁盘空间大小,所以不适合作为缩略图,可以用于想保持图片质量的同时减小图片所占用的磁盘空间大小。另外,由于png是无损压缩,所以设置quality无效。
可以通过下面的方法测试验证:
注意:图片在内存中的大小,即Bitmap的大小,计算方式是:宽*高*单像素字节数。它与图片文件在磁盘中的存在形式,即File的大小是两个概念(它和图片流的大小是一致的),是不一样的,不能直接拿他们两比较大小。图片在磁盘中的大小跟图片格式和压缩率有关。图片在内存中的大小只和分辨率宽,高,以及单像素字节数有关。二者之间没有关联。测试方法:设置compress方法的quality值为100,不压缩,格式为JPEG,然后比较压缩前后的磁盘中图片大小,以及下面方法中Bitmap内存中占用内存大小。
//(1)测试最初加载到内存中的Bitmap大小
/** 测算Bitmap压缩前在内存中的大小,与预算结果一致,计算方法为:Width * Height * 4 / 1024 KB */
/** {@link android.graphics.Bitmap.Config#ARGB_8888},Android默认色彩模式,单位像素占用字节数为4 */
LogUtils.e("Bitmap.Size.Original:" + bitmap.getAllocationByteCount() / 1024 + "KB" + "\tWidth:" + bitmap.getWidth() + "\tHeight:" + bitmap.getHeight());
//(2)获取Bitmap通过质量压缩后输出流的大小
//注意:这里即便quality设置为100,比起(1)的大小也会被压缩变小,因为本质上是一种压缩存储。不过这
//两个大小没有可比性,一个是占内存大小,一个是占磁盘存储大小
//所以关于参数quality的认知,100意义上是不压缩
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.JPEG, 50, byteArrayOutputStream);
LogUtils.e("Bitmap.Size.Compressed.Stream:" + byteArrayOutputStream.toByteArray().length / 1024 + "KB");
//(3)这里再加载到内存中的大小和(1)测试的大小一致,表明质量压缩并不会改变图片在内存中的大小
Bitmap testBitmap = BitmapFactory.decodeByteArray(byteArrayOutputStream.toByteArray(), 0, byteArrayOutputStream.toByteArray().length);
LogUtils.e("Bitmap.Size.testBitmap:" + testBitmap.getAllocationByteCount() / 1024 + "KB");
//(4)将内存中的Bitmap通过流输出保存到本地文件
//这里quality也取50,打印出压缩并转换后的File大小,与(2)中流的大小完全一致
//最后查看本地文件的大小与(2)中输出流大小基本一致,但是会大一些,原因暂未深究
File file = new File(fileName);
FileOutputStream outputStream = new FileOutputStream(file);
convertResult = bitmap.compress(Bitmap.CompressFormat.JPEG, 50, outputStream);
LogUtils.e("Bitmap.Size.Compressed.File:" + file.length() / 1024 + "KB");
//(5)如果这里的压缩格式改成PNG格式,则无论quality取值为何值,压缩后的大小不变,因为它是无损的
//压缩为PNG可以理解为图片编辑另存为PNG格式,是会改变图片编码结构的,不同于改后缀名
//而且PNG格式的图片文件大小是会比JPEG大一些的,接近一倍(简单测试了几组,参考即可)
//bitmap.compress(Bitmap.CompressFormat.PNG, 50, byteArrayOutputStream);
使用场景:将压缩后的图片上传到服务器,或者保存到本地。核心方法是:
bitmap.compress(format,quality, outputstream);
//参数说明:
//1.使用此方法压缩bitmap以后,图片的宽高大小都不会变化,每个像素大小也不会变化,所以图片在内存中的实际大小不会变化
//2.第一个参数Bitmap.CompressFormat format :是图像的压缩格式,可以理解为图片编辑后另存为的格式
//如果是Bitmap.CompressFormat.PNG,那不管第二个值如何变化,图片大小都不会变化,不支持png图片的压缩
//3.第二个参数是压缩比重,图片存储在磁盘上的大小会根据这个值变化,值越小存储在磁盘的图片文件越小
//关于参数quality的认知,100意义上是不压缩
具体实现如下:
PS:一般图片压缩通用流程:先进行尺寸压缩,然后在进行质量压缩,然后看照片是否有旋转角度,如果有,旋转一下,最后返回处理后的照片路径,在进行上传或者保存。
/**
* 图片压缩监听接口
*/
public interface ImageCompressListener {
void onSuccess(File file);
void onError(String msg);
}
/**
* 图片压缩-Android原生只质量压缩
*
* @param imgPath 图片路径
* @param compressFileName 压缩后的文件名,不用带目录和后缀(如.png或者.jpg)
* @param targetSize 要求压缩后的图片大小,单位:KB
* @param listener 压缩结果监听
*/
public static void qualityCompress(String imgPath, String compressFileName, int targetSize, ImageCompressListener listener) {
Bitmap bitmap = null;
try {
//避免图片加载占用过大内存导致崩溃
bitmap = BitmapFactory.decodeFile(imgPath);
bitmap = rotatePicByDegree(bitmap, getPictureDegree(imgPath));
} catch (OutOfMemoryError e) {
listener.onError("The bitmap has out of memory:" + e.getMessage());
return;
}
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutputStream);
int quality = 90;
//这里也可以设置为quality >= 0,这样可以压到最小,但是也不一定就能满足targetSize
while (byteArrayOutputStream.toByteArray().length / 1024 > targetSize && quality > 0) {
byteArrayOutputStream.reset();
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, byteArrayOutputStream);
Log.d("Bitmap.Size.Comp.Str:", byteArrayOutputStream.toByteArray().length / 1024 + "KB" + "\t" + "quality = " + quality);
quality -= 10;
}
FileUtils.makeDirs(IMAGE_SAVE_DIR);
String child = compressFileName + ".jpg";
File file = new File(IMAGE_SAVE_DIR, child);
try {
FileOutputStream fileOutputStream = new FileOutputStream(file);
byteArrayOutputStream.writeTo(fileOutputStream);
// fileOutputStream.write(byteArrayOutputStream.toByteArray());//或者這樣
fileOutputStream.flush();
fileOutputStream.close();
byteArrayOutputStream.close();
Log.e("Bitmap.Size.Compressed:", file.length() / 1024 + "KB");
listener.onSuccess(file);
} catch (IOException e) {
e.printStackTrace();
listener.onError(e.getMessage());
}
bitmapRecycle(bitmap);
}
采样率压缩的原理是,通过设置BitmapFactory.Options.inSampleSize的值,请求解码器对原始图像进行二次采样图像,返回较小的图像以节省内存。 如果设置为大于1的值,则 样本大小是任一维度中与已解码位图中单个像素相对应的像素数。 例如,inSampleSize == 4,则返回图像的宽度和高度,是原始图像的1/4,然后总像素数目变为原来的1/16。 任何小于等于1的值都与1相同。注意:解码器使用基于2的幂的最终值,任何其他值将向下四舍五入为最接近的2的幂。如设置inSampleSize为7,则实际上取值为4。此处为了绝对保证压缩后的图片绝对小于或者等于设置的宽和高,直接取了最精确的inSampleSize值,而没有通过当前宽除以要求宽的方式来计算nSampleSize值,主要也是为了避免四舍五入后带来的误差。
/**
* 图片压缩-Android原生只尺寸压缩
* 但是实际上{@link #bitmapToFile 方法中还是有调用compress的质量压缩,只是取了最高quality:100}
* 一般通用完整流程:先进行尺寸压缩,然后在进行质量压缩,然后看照片是否有旋转角度,如果有,rotate一下,最后返回处理后的照片路径
*
* @param imgPath 图片路径
* @param compressFileName 压缩后的文件名,不用带目录和后缀(如.png或者.jpg)
* @param reqWidth 要求压缩后的图片宽度
* @param reqHeight 要求压缩后的图片高度
* @param listener 压缩结果监听
*/
public static void sizeCompress(String imgPath, String compressFileName, int reqWidth, int reqHeight, ImageCompressListener listener) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;//开始读入图片
Bitmap bitmap = BitmapFactory.decodeFile(imgPath, options);//此时返回bitmap为空
int width = options.outWidth;
int height = options.outHeight;
options.inJustDecodeBounds = false;
//计算采样率
int sampleSize = 1;//压缩比例
while ((width / sampleSize > reqWidth) || (height / sampleSize > reqHeight)) {
sampleSize *= 2;
}
options.inSampleSize = sampleSize;
try {
bitmap = BitmapFactory.decodeFile(imgPath, options);//加载采样率压缩后的图片到内存
} catch (OutOfMemoryError e) {
listener.onError("The bitmap has out of memory:" + e.getMessage());
return;
}
bitmap = rotatePicByDegree(bitmap, getPictureDegree(imgPath));//将图片旋转为正常角度
//保存压缩后的图片到本地
FileUtils.makeDirs(IMAGE_SAVE_DIR);
String child = compressFileName + ".jpg";
if (bitmapToFile(bitmap, child)) {
listener.onSuccess(new File(IMAGE_SAVE_DIR, child));
} else {
listener.onError("image_compress_failed");
}
bitmapRecycle(bitmap);
}
顾名思义,其实原理就是通过矩阵变换将Bitmap的长宽精确缩放到你设置的尺寸。下面做图片旋转的方法本质也是通过矩阵变换来实现的。
/**
* android.graphics.Bitmap的原生方法
* 参数二是你要设置的宽度,参数三是你要设置的高度
* 参数四,设置为true,则会在你设置的尺寸附近,如果牺牲很小的尺寸代价可以得到更好的图片质量,
* 系统就会默认选择最好的质量;设置为false,则严格按照你设置的宽高来,不考虑图片质量,压缩更快。
*/
public static Bitmap createScaledBitmap(@NonNull Bitmap src, int dstWidth, int dstHeight,
boolean filter) {
Matrix m = new Matrix();
final int width = src.getWidth();
final int height = src.getHeight();
if (width != dstWidth || height != dstHeight) {
final float sx = dstWidth / (float) width;
final float sy = dstHeight / (float) height;
m.setScale(sx, sy);
}
return Bitmap.createBitmap(src, 0, 0, width, height, m, filter);
}
原理就是通过修改图片的色彩模式来改变单像素占用内存字节数,进而实现压缩图片的方法。不同图片色彩模式占用的字节大小在第一部分已有讲解。原理比较简单,可参考采样率压缩方法,这里只列出来重要的,不再赘述。
BitmapFactory.Options options = new BitmapFactory.Options();
options.inPreferredConfig = Config.RGB_565;
Bitmap bitmap = BitmapFactory.decodeFile(imgPath, options);
RGB_565压缩是通过改用内存占用更小的编码格式来达到压缩的效果。Android默认的颜色模式为ARGB_8888,这个颜色模式色彩最细腻,显示质量最高。一般不建议使用ARGB_4444,因为画质实在是不好,如果对透明度没有要求,建议可以改成RGB_565,相比ARGB_8888将节省一半的内存开销。
/**
* 导入依赖,https://github.com/Curzibn/Luban
*/
implementation 'top.zibin:Luban:1.1.8'
/**
* 图片压缩-Luban压缩
*
* @param context
* @param imgPath 图片本地路径
* @param listener
*/
public static void lubanCompress(Context context, @NonNull String imgPath, ImageCompressListener listener) {
FileUtils.makeDirs(IMAGE_SAVE_DIR);
File file = new File(imgPath);
if (!file.exists()) {
listener.onError(context.getResources().getString(R.string.file_not_exist));
return;
}
Luban.with(context)
.load(file)
.ignoreBy(300)
.setTargetDir(IMAGE_SAVE_DIR)
.setCompressListener(new OnCompressListener() {
@Override
public void onStart() {
}
@Override
public void onSuccess(File file) {
listener.onSuccess(file);
}
@Override
public void onError(Throwable e) {
listener.onError(e.getMessage());
}
})
.launch();
}
先尺寸压缩,再质量压缩:
/**
* 图片压缩-Android原生
* 先进行尺寸压缩,加载到内存中后看照片是否有旋转角度,进行旋转,再进行质量压缩,最后保存本地
*
* @param imgPath 图片路径
* @param compressFileName 压缩后的文件名,不用带目录和后缀(如.png或者.jpg)
* @param reqWidth 要求压缩后的图片宽度
* @param reqHeight 要求压缩后的图片高度
* @param targetSize 要求压缩后的图片大小,单位:KB
* @param listener 压缩结果监听
*/
public static void commonCompress(@NonNull String imgPath, @NonNull String compressFileName, @NonNull int reqWidth, @NonNull int reqHeight,
@NonNull int targetSize, @NonNull ImageCompressListener listener) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
Bitmap bitmap = BitmapFactory.decodeFile(imgPath);
int width = bitmap.getWidth();
int height = bitmap.getHeight();
options.inJustDecodeBounds = false;
int sampleSize = 1;
while ((width / sampleSize > reqWidth) || (height / sampleSize > reqHeight)) {
sampleSize *= 2;
}
options.inSampleSize = sampleSize;
try {
bitmap = BitmapFactory.decodeFile(imgPath, options);
} catch (OutOfMemoryError e) {
listener.onError("The bitmap has out of memory:" + e.getMessage());
return;
}
//角度旋转
bitmap = rotatePicByDegree(bitmap, getPictureDegree(imgPath));
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutputStream);
int quality = 90;
while (byteArrayOutputStream.toByteArray().length / 1024 > targetSize && quality >= 0) {
byteArrayOutputStream.reset();//重置
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, byteArrayOutputStream);
quality -= 10;
}
String child = compressFileName + ".jpg";
File file = new File(IMAGE_SAVE_DIR, child);
try {
FileOutputStream outputStream = new FileOutputStream(file);
byteArrayOutputStream.writeTo(outputStream);
// outputStream.write(byteArrayOutputStream.toByteArray());//或者这样
outputStream.flush();
outputStream.close();
byteArrayOutputStream.close();
Log.e("Bitmap.Size.Compressed:", file.length() / 1024 + "KB");
listener.onSuccess(file);
} catch (IOException e) {
e.printStackTrace();
listener.onError(e.getMessage());
}
bitmapRecycle(bitmap);
}
下面放上其余的通用方法:图片旋转,获取图片旋转角度,Bitmap转文件保存本地,Bitmap内存回收
/**
* 将图片按照某个角度进行旋转
*
* @param bitmap 待旋转图片
* @param degree 旋转角度
* @return 旋转后的图片
*/
public static Bitmap rotatePicByDegree(Bitmap bitmap, int degree) {
Bitmap rotateBitmap = null;
//根据旋转角度,生成旋转矩阵
Matrix matrix = new Matrix();
matrix.postRotate(degree);
try {
// 将原始图片按照旋转矩阵进行旋转,并得到新的图片
rotateBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
} catch (OutOfMemoryError e) {
e.printStackTrace();
}
if (rotateBitmap == null) {
rotateBitmap = bitmap;
}
//注意:如果你给的宽高和oldBitmap一样,newBitmap就是oldBitmap,调用recycle()之后
//你的newBitmap 也就被recycle()而不能用了。
if (bitmap != rotateBitmap) {
bitmap.recycle();
bitmap = null;
}
return rotateBitmap;
}
/**
* 获取图片的旋转角度
* 手机拍照的图片,本地查看正常的照片,传到服务器发现照片旋转了90°或者270°,这是因为有些手机摄像头的参数原因,拍出来的照片是自带旋转角度的
*
* @param imgPath 本地图片路径
* @return 图片旋转角度
*/
public static int getPictureDegree(String imgPath) {
int degree = 0;
try {
//获取图片的exif信息,exif是照片的一些头部信息,如拍照时间,相机品牌,型号,色彩编码,旋转角度等
ExifInterface exifInterface = new ExifInterface(imgPath);
int orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
switch (orientation) {
case ExifInterface.ORIENTATION_ROTATE_90:
degree = 90;
break;
case ExifInterface.ORIENTATION_ROTATE_180:
degree = 180;
break;
case ExifInterface.ORIENTATION_ROTATE_270:
degree = 270;
break;
}
} catch (IOException e) {
e.printStackTrace();
}
return degree;
}
/**
* bitmap格式转文件
* 存在质量压缩
*
* @param bitmap 待转换bitmap
* @param fileName 本地文件名,默认不带后缀,需要调用者自己加
* @return true 转换成功
*/
public static boolean bitmapToFile(Bitmap bitmap, String fileName) {
if (bitmap == null || bitmap.isRecycled()) {
return false;
}
if (TextUtils.isEmpty(fileName)) {
return false;
}
boolean convertResult = false;
FileUtils.makeDirs(IMAGE_SAVE_DIR);
File file = new File(IMAGE_SAVE_DIR, fileName);
FileOutputStream outputStream = null;
try {
/** 测算Bitmap压缩前在内存中的大小,与预算结果一致,计算方法为:Width * Height * 4 / 1024 KB */
/** {@link android.graphics.Bitmap.Config#ARGB_8888},Android默认色彩模式,单位像素占用字节数为4 */
Log.e("Bitmap.Size.Original:", bitmap.getAllocationByteCount() / 1024 + "KB" + "\tWidth:" + bitmap.getWidth() + "\tHeight:" + bitmap.getHeight());
outputStream = new FileOutputStream(file);
convertResult = bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream);
Log.e("Bitmap.Size.Compressed:", file.length() / 1024 + "KB");
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
try {
if (outputStream != null) {
outputStream.flush();
outputStream.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
return convertResult;
}
/**
* Bitmap内存回收
*
* @param bitmaps 准备回收内存的Bitmaps
*/
public static void bitmapRecycle(Bitmap... bitmaps) {
if (bitmaps == null) {
return;
}
for (Bitmap bitmap : bitmaps) {
if (bitmap != null && !bitmap.isRecycled()) {
bitmap.recycle();
}
}
}
/**
* 创建文件目录
*/
public static boolean makeDirs(String dir){
File file = new File(dir);
if (!file.exists()) {
return file.mkdirs();
}
return true;
}
我们在做App内存优化的时候,一般可以从两个方面入手,一个内存泄漏,另外一个是压缩且只使用一次的Bitmap,要及时做好recycle的处理。这里就简单说说BItmap的内存回收。
附:Android 图片格式JPEG与PNG
Android中一张图片占用的内存大小