文章目录
前言
在Bitmap的内存管理中,介绍了Bitmap的压缩、缓存、复用等问题。其中的压缩方案是采样率压缩,这里再介绍几种其它的压缩方案,首先是原生的质量压缩,尺寸压缩,其次是哈夫曼压缩,也叫鲁班压缩或微信压缩。只有先知道原生压缩的原理,我们才能更好的优化改良,以及更好的理解哈夫曼压缩,而这种方案也是本文的分析重点。
1.基础知识
1.1色彩模式
ARGB:指的是一种色彩模式,里面A代表Alpha,R表示red, G表示green,B表示blue
自然界中所有的可见颜色都是由红、绿、蓝组成的,所以红、绿、蓝又称三原色,每个原色都存储着所表示颜色的信息值
A->alpht(透明度),R->red(红色),G->green(绿色),B-blue(蓝色)
1.2四种模式的区别
bitmap在内存中存在四种色彩的存储模式,它们的本质区别体现在每种模式下的bitmap的每个像素点,在内存中大小和组成成分的区别
1.3具体对比
- ALPHA_8: A ->8bit->一个字节,即8bit,一个像素总共占一个字节
- ARGB_8888: A->8bit->一个字节,R->8bit->一个字节,G->8bit->一个字节,B->8bit->一个字节,一个像素总共占用四个字节,8 + 8+ 8 + 8 = 32bit = 4byte
- ARGB_4444: A->4bit,R->4bit,G->4bit,B->4bit,一个像素总共占用两个字节,4 + 4 + 4 + 4 = 16bit = 2byte
- RGB_565: R->5bit,G->6bit,B->5bit,一个像素总共占用两个字节,5+ 6 + 5 = 16bit = 2byte
1.4bitmap内存占用大小计算方式
一张bitmap内存占用大小 = 像素点数 * 每个像素点内存占用大小 = width * height * 每个像素点占用内存大小
tips
- 我们知道了决定了bitmap内存占用的因素,只有改变这些因素才能改变bitmap的内存占用大小,Bitmap的压缩方案也是基于改变这些因素,来减小内存的占用
- bitmap的内存占用大小与在本地磁盘大小是不同的概念
1.5图片存在的形式
- 以File的形式存在于SD卡/磁盘中
- 以Stream的形式存在于内存中
- 以Bitmap的形式存在于内存中
1.6BitampFactory加载Bitmap对象的方式
- decodeFile 从文件中加载Bitmap对象
- decodeResource从资源中加载Bitmap对象
- decodeStream从输入流加载Bitmap对象
- decodeByteArray 从字节数组中加载Bitmap对象
2.压缩方案
2.1采样率压缩
/**
* 采样率压缩
* @param bitmap
* @param file
*/
public void compressInSampleSize(Bitmap bitmap, File file) {
BitmapFactory.Options options = new BitmapFactory.Options();
//正常的做法是通过ImageView的宽高,和图片自身的宽高计算采样率
//inJustDecodeBounds为true的时候不会真正的加载图片,只是解析图片原始宽高信息并不会去真正加载图片,轻量级操作
//options.inJustDecodeBounds = true;
//BitmapFactory.decodeFile(stringName, options);
//通过计算获取采样率inSampleSize
//options.inSampleSize = calculateInSampleSize(options, 150, 100);
//这里只是为了为了通过采用率压缩图片,不需要根据ImageView的宽高,和图片自身的宽高计算采样率
//直接赋值采样率为8,获得采样率后,再设置inJustDecodeBounds为false,真正的加载图片
//采样率数值越高,图片像素越低
int inSampleSize = 8;
options.inSampleSize = inSampleSize;
options.inJustDecodeBounds = false;
BitmapFactory.decodeFile(stringName, options);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
// 把压缩后的数据保存到baos中
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, baos);// 不进行质量压缩
try {
if (file.exists()) {
file.delete();
} else {
file.createNewFile();
}
FileOutputStream fos = new FileOutputStream(file);
fos.write(baos.toByteArray());
fos.flush();
fos.close();
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 获取采样率
* @param options
* @param reqWidth 需要显示/控件定义的宽度
* @param reqHeight 需要显示/控件定义的高度
* @return
*/
public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
int width = options.outWidth;
int height = options.outHeight;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
int halfWidth = width / 2;
int halfHeight = height / 2;
while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *=2;
}
}
return inSampleSize;
}
2.2尺寸压缩
/**
* 2. 尺寸压缩
* 通过减少单位尺寸的像素值,真正意义上的降低像素
* 通过缩放图片像素来达到减少图片占用内存大小的效果
* 使用场景:缩略图(头像)
* @param bmp
* @param file
*/
public void compressSize(Bitmap bmp, File file){
// 尺寸压缩倍数,值越大,图片尺寸越小
int ratio = 6;
// 压缩Bitmap到对应尺寸
Bitmap result = Bitmap.createBitmap(bmp.getWidth() / ratio, bmp.getHeight() / ratio, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(result);
Rect rect = new Rect(0, 0, bmp.getWidth() / ratio, bmp.getHeight() / ratio);
//将原图画在缩放之后的矩形上
canvas.drawBitmap(bmp, null, rect, null);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
// 把压缩后的数据存放到baos中
result.compress(Bitmap.CompressFormat.JPEG, 100 ,baos);
try {
FileOutputStream fos = new FileOutputStream(file);
fos.write(baos.toByteArray());
fos.flush();
fos.close();
} catch (Exception e) {
e.printStackTrace();
}
}
2.3质量压缩
/**
* 质量压缩
* 设置bitmap options属性,降低图片的质量,像素不会减少
* 第一个参数为需要压缩的bitmap图片对象,第二个参数为压缩后图片保存的位置
* 设置options 属性0-100,来实现压缩
* 原理:通过算法扣掉(同化)了图片中的一些某个点附近相近的像素,达到降低质量减少文件大小的目的减小了图片质量
* 注意:它其实只能实现对file的影响,对加载这个图片出来的bitmap内存是无法节省的,还是那么大因为bitmap
* 在内存中的大小是按照像素计算的,也就是width*height,对于质量压缩,并不会改变图片的真实像素(像素大小不会变)
* 使用场景:将图片压缩后保存到本地,或者将图片上传到服务器,根据实际需求来使用。
*
* 质量压缩并不会改变图片在内存中的大小,仅仅会减小图片所占用的磁盘空间的大小,
* 因为质量压缩不会改变图片的分辨率,而图片在内存中的大小是根据width*height*一个
* 像素的所占用的字节数计算的,宽高没变,在内存中占用的大小自然不会变,质量压缩的
* 原理是通过改变图片的位深和透明度来减小图片占用的磁盘空间大小,所以不适合作为缩
* 略图,可以用于想保持图片质量的同时减小图片所占用的磁盘空间大小。另外,由于png是
* 无损压缩,所以设置quality无效。
*
* 一张分辨率为1024*1024的图片
* RGB_565 单个像素占2个字节 占用内存1024*1024*2=2M
* ARGB_8888 单个像素占4个字节 占用内存1024*1024*4=4M
* @param bmp
* @param file
*/
public void compressQuality(Bitmap bmp,File file) {
// 0-100 100为不压缩
int options = 10;
Canvas canvas;
ByteArrayOutputStream baos = new ByteArrayOutputStream();
// 把压缩后的数据存放到baos中
bmp.compress(Bitmap.CompressFormat.JPEG, 20, baos);
try {
FileOutputStream fos = new FileOutputStream(file);
fos.write(baos.toByteArray());
fos.flush();
fos.close();
} catch (Exception e) {
e.printStackTrace();
}
}
2.4哈夫曼压缩
2.4.1背景介绍
原生压缩最终都是调用Bitmap.compress()方法来进行的,那么接下来看看源码流程。首先是Bitmap.java的compress方法:
public boolean compress(CompressFormat format, int quality, OutputStream stream) {
checkRecycled("Can't compress a recycled bitmap");
// do explicit check before calling the native method
if (stream == null) {
throw new NullPointerException();
}
if (quality < 0 || quality > 100) {
throw new IllegalArgumentException("quality must be 0..100");
}
StrictMode.noteSlowCall("Compression of a bitmap is slow");
Trace.traceBegin(Trace.TRACE_TAG_RESOURCES, "Bitmap.compress");
boolean result = nativeCompress(mNativePtr, format.nativeInt,
quality, stream, new byte[WORKING_COMPRESS_STORAGE]);
Trace.traceEnd(Trace.TRACE_TAG_RESOURCES);
return result;
}
核心代码是调用了nativeCompress方法,继续走下去我们发现是调用的native方法nativeCompress
private static native boolean nativeCompress(long nativeBitmap, int format,
int quality, OutputStream stream,
byte[] tempStorage);
通常java层的代码追踪就到这里了,如果想要进一步了解,就要往系统源码查看了,
frameworks/base/core/jni/android/graphics/Bitmap.cpp
static const JNINativeMethod gBitmapMethods[] = {
{ "nativeCreate", "([IIIIIIZ[FLandroid/graphics/ColorSpace$Rgb$TransferParameters;)Landroid/graphics/Bitmap;",
(void*)Bitmap_creator },
{ "nativeCopy", "(JIZ)Landroid/graphics/Bitmap;",
(void*)Bitmap_copy },
{ "nativeCopyAshmem", "(J)Landroid/graphics/Bitmap;",
(void*)Bitmap_copyAshmem },
{ "nativeCopyAshmemConfig", "(JI)Landroid/graphics/Bitmap;",
(void*)Bitmap_copyAshmemConfig },
{ "nativeGetNativeFinalizer", "()J", (void*)Bitmap_getNativeFinalizer },
{ "nativeRecycle", "(J)Z", (void*)Bitmap_recycle },
{ "nativeReconfigure", "(JIIIZ)V", (void*)Bitmap_reconfigure },
{ "nativeCompress", "(JIILjava/io/OutputStream;[B)Z",
(void*)Bitmap_compress },
...
接着查看Bitmap_compress是如何调用的
static jboolean Bitmap_compress(JNIEnv* env, jobject clazz, jlong bitmapHandle,
jint format, jint quality,
jobject jstream, jbyteArray jstorage) {
SkEncodedImageFormat fm;
switch (format) {
//处理JPEG格式图片
case kJPEG_JavaEncodeFormat:
fm = SkEncodedImageFormat::kJPEG;
break;
//处理png格式图片
case kPNG_JavaEncodeFormat:
fm = SkEncodedImageFormat::kPNG;
break;
//处理webp格式图片
case kWEBP_JavaEncodeFormat:
fm = SkEncodedImageFormat::kWEBP;
break;
default:
return JNI_FALSE;
}
LocalScopedBitmap bitmap(bitmapHandle);
if (!bitmap.valid()) {
return JNI_FALSE;
}
std::unique_ptr<SkWStream> strm(CreateJavaOutputStreamAdaptor(env, jstream, jstorage));
if (!strm.get()) {
return JNI_FALSE;
}
SkBitmap skbitmap;
bitmap->getSkBitmap(&skbitmap);
return SkEncodeImage(strm.get(), skbitmap, fm, quality) ? JNI_TRUE : JNI_FALSE;
}
我们可以看到,bitmap_compress方法中传入了代表图片格式的format,以及代表图片质量的quality。以及switch语句中针对不同格式的图片进行处理,再看最后的一行代码
SkEncodeImage(strm.get(), skbitmap, fm, quality) ? JNI_TRUE : JNI_FALSE;我们发现是交给SKBitmap处理的,也就是说Bitmap对象相当于摆渡者而已,只是提供了native方法罢了,在native层是由SKBitmap进行处理。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TqJtStb5-1582794045626)(C:\Users\dell\Pictures\Camera Roll\Android基础\bitmap\bitmap摆渡者.png)]
到这里就引入了Skia(2007年)图像处理引擎,其实它是基于JPEG(1995年)图像处理引擎的二次封装,它们的关系好比Retrofit与OkHttp的关系,都是在后者的基础上做了一层封装,而Skia默认不支持哈夫曼压缩(鲁班压缩),而JPEG图像处理引擎是支持的,Skia也被戏称为JPEG的阉割版。之所以去除哈夫曼算法,是因为哈夫曼编码即变长编码需要事先知道元素的频率,就需要先遍历扫描图片的每个像素的argb,这在早期的手机性能有限的情况下,是十分吃CPU的。出于性能方面的考虑,被迫去掉了哈夫曼算法,现在手机性能不断提升,理论上是不需要屏蔽哈夫曼算法了,也许某一天Skia图像处理引擎就可以直接通过java调用哈夫曼来处理图片压缩了。
2.4.2代码逻辑
哈夫曼压缩的代码逻辑在native-lib.cpp文件中,流程式的编码,几乎是固有的写法,概况起来大致包含以下几个步骤:
- 申请一块内存用来存储像素的rgb信息
- 循环遍历取出图片中每一个像素的rgb信息
- 将获取的图片像素信息写入到一个新文件中(压缩后的文件名)
- 创建压缩对象
- 设置错误信息处理
- 指定存储文件(根据存储路径)
- 设置压缩参数(宽、高)
- 开启哈夫曼压缩功能(打开开关,Skia默认关闭)
- 开始压缩
#include <jni.h>
#include <string>
#include <android/bitmap.h>
#include <malloc.h>
#include <jpeglib.h>
extern "C"
{
#include "jpeglib.h"
}
void write_JPEG_file(uint8_t *data, int w, int h, const char *path) {
//创建jpeg压缩对象
jpeg_compress_struct jcs;
//设置错误处理信息
jpeg_error_mgr error;
jcs.err = jpeg_std_error(&error);
//类比Bitmap.create();
//给结构体分配内存
jpeg_create_compress(&jcs);
//压缩bitmap---->file
//指定存储文件
FILE *file = fopen(path, "wb");
jpeg_stdio_dest(&jcs, file);
//设置压缩参数
jcs.image_width = w;
jcs.image_height = h;
//开启哈夫曼功能
jcs.arith_code = FALSE;
//优化编码
jcs.optimize_coding = TRUE;
//rgb, JCS_RGB 不要A
jcs.in_color_space = JCS_RGB;
jcs.input_components = 3;
//其它参数设置为默认
jpeg_set_defaults(&jcs);
//设置质量压缩比例
jpeg_set_quality(&jcs, 20, true);
//开始压缩
jpeg_start_compress(&jcs, 1);
//循环写入每一行数
int row_stride = w * 3;//一行的字节数
JSAMPROW row[1];
while (jcs.next_scanline < jcs.image_height) {
//取一行数据
uint8_t *pixels = data + jcs.next_scanline * row_stride;
row[0]=pixels;
jpeg_write_scanlines(&jcs,row,1);
}
//压缩完成
jpeg_finish_compress(&jcs);
//释放jpeg对象
fclose(file);
jpeg_destroy_compress(&jcs);
}
extern "C"
JNIEXPORT void JNICALL
Java_com_bitmap_compress_MainActivity_compress(JNIEnv *env, jobject thiz, jobject bitmap,
jstring path_) {
const char *path = env->GetStringUTFChars(path_, 0);
//从bitmap获取argb数据
AndroidBitmapInfo info;//info=new 对象();
//获取里面的信息
AndroidBitmap_getInfo(env, bitmap, &info);// void method(list)
//得到图片中的像素信息
uint8_t *pixels;//uint8_t char java byte *pixels可以当byte[]
AndroidBitmap_lockPixels(env, bitmap, (void **) &pixels);
//jpeg argb中去掉他的a ===>rgb
int w = info.width;
int h = info.height;
int color;
//开一块内存用来存入rgb信息
uint8_t* data = (uint8_t *) malloc(w * h * 3);//data中可以存放图片的所有内容
uint8_t* temp = data;
uint8_t r, g, b;//byte
//循环取图片的每一个像素
for (int i = 0; i < h; i++) {
for (int j = 0; j < w; j++) {
color = *(int *) pixels;//0-3字节 color4 个字节 一个点
//颜色值为16进制表示,通过运算取出rgb
r = (color >> 16) & 0xFF;// #00rrggbb 16 0000rr 8 00rrgg
g = (color >> 8) & 0xFF;
b = color & 0xFF;
//存放,以前的主流格式jpeg bgr
*data = b;
*(data + 1) = g;
*(data + 2) = r;
data += 3;
//指针跳过4个字节
pixels += 4;
}
}
//把得到的新的图片的信息存入一个新文件 中
write_JPEG_file(temp, w, h, path);
//释放内存
free(temp);
AndroidBitmap_unlockPixels(env, bitmap);
env->ReleaseStringUTFChars(path_, path);
}
2.4.3压缩原理
如果是初次接触Bitmap的哈夫曼压缩,即使看了代码也似懂非懂,当然笔者也是如此。了解了哈夫曼树和哈夫曼编码,再多温习几次,就比较容易理解了。如果对哈夫曼编码还不太熟悉,可以查阅
数据结构与算法:哈夫曼树与哈夫曼编码,对哈夫曼编码有一个简单的认识,有利于理解哈夫曼编码来压缩bitmap的原理。
我们知道R、G、B三个颜色通道的变化以及它们相互之间的的叠加来得到各色各样的颜色,而图片的颜色就是由它们决定的。RGB各有256级亮度,用数字表示为从0、1、2…直到255。假设现在有一张红色图片,没有G、B这两种颜色的像素点,全部由6种不同亮度级别红色的像素组成,而且它们的频率(即像素点个数)分别为:
红色亮度1:1500个像素点
红色亮度2:5000个像素点
红色亮度3:1400个像素点
红色亮度4:2000个像素点
红色亮度5:100个像素点
图(1)为构造哈夫曼树的过程的权值显示,图(2)为将权值左分支(孩子)改为0,右分支(孩子)改为1后的哈夫曼树
此时我们对这5种不同颜色亮度的颜色信息从树根(根结点)到叶子所经过的路径0或1来编码,可以得到下面这样的定义
红色颜色亮度值 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
二进制字符 | 011 | 1 | 0101 | 00 | 0100 |
解码图片的时候就是根据这样对应的表格信息,将二进制字符转换为对象的像素的颜色信息。我们知道如果存储图片的像素,即使一个像素占用一个字节,也就是8位,但是如果存的是二进制数据,011代表的就是1个像素,只占3位,节约了将近一半的存储空间。以此类推,大大的节约了资源,从哈夫曼编码树可知,如果图片像素的颜色值越多,分布的越均匀,即各个颜色值数量相当,那么树的层级就越深,每编码一个像素的二进制字符位数就越长,压缩效果就越差。所以颜色值越集中,即数的层级越浅,压缩效果就越好。
为什么说哈夫曼是无损压缩,相信看到这里,应该就能理解了,压缩存储的是二进制字符数据,并非具体的像素。解码图片时,根据相对应的编码规则,还原得到具体像素信息。采用哈夫曼压缩需先遍历扫描每个像素的颜色信息,这个过程是非常消耗CPU的,在早期手机性能有限的情况下,谷歌处于性能方面的考虑,所以Skia图像处理引擎就屏蔽了哈夫曼编码,现在的手机性能已今非昔比,完全能够承受哈夫曼编码的消耗,也许未来某一天就会开启哈夫曼编码的开关,直接就可以调用相关API。以上内容仅为个人学习总结,如有错误或不足之处,欢迎批评指正。
demo链接:https://github.com/mitufengyun/BitmapCompress