人间观察
步入社会后,你会发现,老人说的话都是对的。
前面讲了些Android的jni知识和bitmap的实践,接下来几篇应该都是Android中jni的一些实践。这篇我们对Android中图片在jni层利用libjpeg-turbo
进行大小压缩,并且压缩后不失真,清晰度和原图基本无差别。
背景
libjpeg
开源的JPEG图像库,它使用非常广泛,Android也依赖libjpeg
来压缩图片,但是Android不是直接使用libjpeg
,而是基于一个叫Skia
的开源项目来作为的图像处理引擎,Skia
对libjpeg
进行了良好的封装。libjpeg
在压缩图像时,有一个参数叫optimize_coding
,这个参数的设置直接影响图片的质量和大小。
如果设置optimize_coding
为true,将会使得压缩图像过程中基于图像数据计算哈弗曼表(关于图片压缩中的哈弗曼表,可以百度下查阅相关资料),由于这个计算会显著消耗空间和时间,默认值被设置为false。采用默认哈夫曼表进行计算。optimize_coding
在Skia
中默认值也是false。
随着时间的推移现在 Android 手机性能越来越好,Google 在Android 7.0后已经设置为true了。如下代码可以看到。
源码地址:
http://androidos.net.cn/androidossearch?query=SkImageDecoder_libjpeg.cpp
>=android 7.0 后的源码
// ...省略其它代码
// Tells libjpeg-turbo to compute optimal Huffman coding tables
// for the image. This improves compression at the cost of
// slower encode performance.
cinfo.optimize_coding = TRUE;
jpeg_set_quality(&cinfo, quality, TRUE /* limit to baseline-JPEG values */);
// ...省略其它代码
也就是说在Android 7.0前中无论你怎么压缩(尺寸压缩,质量压缩,Matrix 矩阵变换)它都会导致图片质量变差,而且在app应用层是无法修改的。但是我们可以自己编译libjpeg
来设置这个参数为true,既然Android 7.0 后已经是optimize_coding = TRUE;
还有必要自己编译libjpeg
来设置这个参数为true 吗? 不过没关系,我们就拿这个来学习jni也是挺好的。这个库也支持解压缩(有兴趣的可以研究下),接下来我们看下如何实现压缩。
在有ugc功能的app中,拍照上传图片的时候基本都会进行压缩。
但是我看了下快手,微信,抖音的apk里。 快手里有用这个压缩库,微信抖音好像并使用libjpeg,也可能是改了so的名字,也可能出于7.0 后已经为true的考虑没必要处理7.0之前的版本了,也可能是用的Android 系统提供的API,也可能直接上传的原图云端进行的压缩。
压缩效果对比
下图是一张2.4MB的原始图片采用30%的压缩后是632KB,4倍还是可以的。清晰度对比如下,几乎看不出来差别,但是如果压缩到10%,图片有稍微的清晰度降低,真实项目可以权衡下。说明效果远比Android 内置的好。
(图片拍摄于2020-11-16号北京西北旺下班回家的公交站~,留下纪念,说不定哪天就不在北京了。)
libjpeg-turbo在Android环境下的编译
开源libjpeg-turbo源码
这个确实不好编译,有点坑。。。我编译的时候在网上也百度了下,大部分的文章都是几年前的,大部分在linux环境下编译的,提供了一些脚本,但都不是Android平台下的,也不是基于libjpeg-turbo
最新的代码进行,最后尝试都编不过。哭唧唧,只能自己看文档了最后折腾ok。
编译步骤大概如下
- 下载
libjpeg-turbo
源码 - 编写脚本,结合
libjpeg-turbo
目录下BUILDING.md
文件,使用Android ndk提供的cmake
自带交叉编译工具链编译 - 跑脚本,最后生成Android下个平台的so和需要的头文件
备注,我用的是最新的ndk 21.1.6352462(最新)编译的,它已经不支持生成armeabi平台的so了,有点奇怪。
下面是完整的编译build.sh
脚本,如果你要编译需要把build.sh
放在与下载后的源码命令同级下执行sh build.sh
。
同时把脚本的CMAKE_PATH
和NDK_PATH
改为自己电脑的路径即可。
build.sh
也放到了这个压缩demo的工程里了。
#编译参考了https://www.jianshu.com/p/20902ca448ae?utm_source=oschina-app
# lib-name
MY_LIBS_NAME=libjpeg-turbo
MY_SOURCE_DIR=$(pwd)/libjpeg-turbo
MY_BUILD_DIR=binary
CMAKE_PATH=/Users/guxiuzhong/Library/Android/sdk/cmake/3.10.2.4988404
export PATH=${CMAKE_PATH}/bin:$PATH
NDK_PATH=/Users/guxiuzhong/Library/Android/sdk/ndk/21.1.6352462
BUILD_PLATFORM=linux-x86_64
TOOLCHAIN_VERSION=4.9
ANDROID_VERSION=24
ANDROID_ARMV5_CFLAGS="-march=armv5te"
ANDROID_ARMV7_CFLAGS="-march=armv7-a -mfloat-abi=softfp -mfpu=neon" # -mfpu=vfpv3-d16 -fexceptions -frtti
ANDROID_ARMV8_CFLAGS="-march=armv8-a " # -mfloat-abi=softfp -mfpu=neon -fexceptions -frtti
ANDROID_X86_CFLAGS="-march=i386 -mtune=intel -mssse3 -mfpmath=sse -m32"
ANDROID_X86_64_CFLAGS="-march=x86-64 -msse4.2 -mpopcnt -m64 -mtune=intel"
# params($1:arch,$2:arch_abi,$3:host,$4:compiler,$5:cflags,$6:processor)
build_bin() {
echo "-------------------start build $1-------------------------"
ANDROID_ARCH_ABI=$1 # armeabi armeabi-v7a x86 mips
CFALGS="$2"
PREFIX=$(pwd)/dist/${MY_LIBS_NAME}/${ANDROID_ARCH_ABI}/
# build 中间件
BUILD_DIR=./${MY_BUILD_DIR}/${MY_LIBS_NAME}/${ANDROID_ARCH_ABI}
echo "path==>$PATH"
echo "build_dir==>$BUILD_DIR"
echo "ANDROID_ARCH_ABI==>$ANDROID_ARCH_ABI"
echo "CFALGS==>$CFALGS"
mkdir -p ${BUILD_DIR}
cd ${BUILD_DIR}
# -DCMAKE_MAKE_PROGRAM=${NDK_PATH}/prebuilt/${BUILD_PLATFORM}/bin/make \
# -DCMAKE_ASM_COMPILER=${NDK_PATH}/prebuilt/${BUILD_PLATFORM}/bin/yasm \
cmake -G"Unix Makefiles" \
-DANDROID_ABI=${ANDROID_ARCH_ABI} \
-DANDROID_PLATFORM=android-${ANDROID_VERSION} \
-DCMAKE_BUILD_TYPE=Release \
-DANDROID_NDK=${NDK_PATH} \
-DCMAKE_TOOLCHAIN_FILE=${NDK_PATH}/build/cmake/android.toolchain.cmake \
-DCMAKE_POSITION_INDEPENDENT_CODE=1 \
-DCMAKE_INSTALL_PREFIX=${PREFIX} \
-DANDROID_ARM_NEON=TRUE \
-DANDROID_TOOLCHAIN=clang \
-DANDROID_STL=c++_static \
-DCMAKE_C_FLAGS="${CFALGS} -Os -Wall -pipe -fPIC" \
-DCMAKE_CXX_FLAGS="${CFALGS} -Os -Wall -pipe -fPIC" \
-DANDROID_CPP_FEATURES=rtti exceptions \
-DWITH_JPEG8=1 \
${MY_SOURCE_DIR}
make clean
make
make install
cd ../../../
echo "-------------------$1 build end-------------------------"
}
# build armeabi
build_bin armeabi "$ANDROID_ARMV5_CFLAGS"
#build armeabi-v7a
build_bin armeabi-v7a "$ANDROID_ARMV7_CFLAGS"
#build arm64-v8a
build_bin arm64-v8a "$ANDROID_ARMV8_CFLAGS"
#build x86
build_bin x86 "$ANDROID_X86_CFLAGS"
#build x86_64
build_bin x86_64 "$ANDROID_X86_64_CFLAGS"
编译结构&编译成功后会生成Android下个平台的so和需要的头文件 如下:
压缩
编译后,把生成的so和头文件拷贝到Android工程中,同时修改CMakeLists.txt
文件,指定头文件,查找so的路径,以及该jni工程生成的so需要链接的so:libjpeg.so
和libturbojpeg.so
怎么压缩呢? 其实很简单的,整体分如下几步。
- 获取图片Bitmap的像素。
- 取出每个像素的argb通道,alpha通道丢弃,把bitmap的rgb像素转为一维数组进行保存(格式是R,G,B,R,G,B,R,G,B,...)。
- 用libjpeg进行压缩。
- 释放资源
获取Bitmap的像素
这个在上一篇文章有介绍,这里就不多介绍了。
Android-JNI开发系列《九》实战-Bitmap处理实现底片灰度化黑白化暖冷色调等效果
获取每个像素取出ARGB通道
只要拿到了这个就可以对图片进行任何处理了(包含上篇文章对图片的特效处理)。libjpeg-turbo
这个开源库也不例外。
因为我们把它压缩为jepg格式的图片,alpha通道是可以丢弃的。有一个特别注意的点就是:在jni层中,Bitmap像素点的值是ABGR,而不是ARGB,也就是说,高端到低端:A,B,G,R 。这个我们在上一篇文章也验证过。 因为libjpeg-turbo
压缩的时候需要的格式是R,G,B,R,G,B,R,G,B,...
也就是一维数组。 也就是把图片的二位像素转为一维数组,很简单,赋值然后指针++处理就行了。部分代码:
int i = 0, j = 0;
BYTE r, g, b;
//存储RGB所有像素点
BYTE *data = (BYTE *) malloc(w * h * 3);
// 临时保存指向像素内存的首地址
BYTE *tempData = data;
uint32_t color;
for (i = 0; i < h; i++) {
for (j = 0; j < w; j++) {
// 取出一个像素 去调了alpha,然后保存到data中,对应指针++
color = *((uint32_t *) pixelsColor);
// 在jni层中,Bitmap像素点的值是ABGR,而不是ARGB,也就是说,高端到低端:A,B,G,R
b = ((color & 0x00FF0000) >> 16);
g = ((color & 0x0000FF00) >> 8);
r = ((color & 0x000000FF));
// jpeg压缩需要的是rgb
// for example, R,G,B,R,G,B,R,G,B,... for 24-bit RGB color.
*data = r;
*(data + 1) = g;
*(data + 2) = b;
data += 3;
pixelsColor += 4;
}
}
简单吧, BYTE就类似java的byte,typedef uint8_t BYTE;
别名,无符号8位。
用libjpeg进行压缩
这一步最关键,我们拿到了图片bitmap的原始的像素就可以做处理。怎么使用libjpeg-turbo
这个开源库提供的压缩方法呢,其实你下载后在源码的目录下有一个example.txt
文件,这里有很清晰的使用方法,还有详细的注释。
大概分为7步。
- 初始化压缩对象。
jpeg_create_compress
- 设置压缩后的数据的输出形式
jpeg_stdio_dest
,比如输出到文件 - 设置压缩的参数
jpeg_set_defaults
。 这里最重要,也就是我们需要把optimize_coding
设置为true。 因为默认是false。 - 开始压缩
jpeg_start_compress
- 按行循环写入。
jpeg_write_scanlines
- 结束压缩。
jpeg_finish_compress
- 释放压缩对象。
jpeg_destroy_compress
代码中有比较详细的注释。按照它提供的example.txt
文件中的示例写就行,压缩方法如下:
int write_JPEG_file(BYTE *data, int w, int h, int quality,
const char *outFilename, jboolean optimize) {
//jpeg的结构体,保存的比如宽、高、位深、图片格式等信息
struct jpeg_compress_struct cinfo;
/* Step 1: allocate and initialize JPEG compression object */
/* We set up the normal JPEG error routines, then override error_exit. */
struct my_error_mgr jem;
cinfo.err = jpeg_std_error(&jem.pub);
jem.pub.error_exit = my_error_exit;
/* Establish the setjmp return context for my_error_exit to use. */
if (setjmp(jem.setjmp_buffer)) {
/* If we get here, the JPEG code has signaled an error.
and return.
*/
return -1;
}
jpeg_create_compress(&cinfo);
/* Step 2: specify data destination (eg, a file) */
FILE *outfile = fopen(outFilename, "wb");
if (outfile == nullptr) {
LOGE("can't open %s", outFilename);
return -1;
}
jpeg_stdio_dest(&cinfo, outfile);
/* Step 3: set parameters for compression */
cinfo.image_width = w; /* image width and height, in pixels */
cinfo.image_height = h;
cinfo.input_components = 3; /* # of color components per pixel */
cinfo.in_color_space = JCS_RGB; /* colorspace of input image */
//是否采用哈弗曼表数据计算
cinfo.optimize_coding = optimize;
//设置哈夫曼编码,TRUE=arithmetic coding, FALSE=Huffman
if (optimize) {
cinfo.arith_code = false;
} else {
cinfo.arith_code = true;
}
// 其它参数 全部设置默认参数
jpeg_set_defaults(&cinfo);
//设置质量
jpeg_set_quality(&cinfo, quality, TRUE /* limit to baseline-JPEG values */);
/* Step 4: Start compressor */
jpeg_start_compress(&cinfo, TRUE);
/* Step 5: while (scan lines remain to be written) */
/* jpeg_write_scanlines(...); */
JSAMPROW row_pointer[1];
int row_stride;
//一行的RGB数量
row_stride = cinfo.image_width * 3; /* JSAMPLEs per row in image_buffer */
//一行一行遍历
while (cinfo.next_scanline < cinfo.image_height) {
//得到一行的首地址
row_pointer[0] = &data[cinfo.next_scanline * row_stride];
//此方法会将jcs.next_scanline加1
jpeg_write_scanlines(&cinfo, row_pointer, 1);//row_pointer就是一行的首地址,1:写入的行数
}
/* Step 6: Finish compression */
jpeg_finish_compress(&cinfo);
/* After finish_compress, we can close the output file. */
fclose(outfile);
outfile = nullptr;
/* Step 7: release JPEG compression object */
/* This is an important step since it will release a good deal of memory. */
jpeg_destroy_compress(&cinfo);
/* And we're done! */
return 0;
}
我这里是直接同步压缩的,压缩是个耗时的操作(上面的效果图大概360毫秒左右),你可以在jni层中开启线程,然后压缩成功/失败通过回调到java层中,当然也可以在java层开启线程,都差不多。这里demo就直接int返回了,成功0,失败-1.
压缩失败的处理,在压缩的步骤1中进行设置,在jpeg_compress_struct
的err
字段,err
字段是一个jpeg_error_mgr
的结构体,该结构体描述压缩失败的信息,比如错误信息,错误码,有几个函数指针,比如error_exit
, emit_message
,output_message
等。如果赋值的话当压缩失败的时候会回调你的方法。
释放资源
最后记得文件该close
的fclose
,内存该free
的free
即可,jni中的也该释放的释放,jpeg的用完调用 jpeg_destroy_compress
。避免内存泄漏问题。
最后上源码:
demo是读取sd卡的图片压缩后写到了sd卡里,记得添加读写sd卡的权限。
https://github.com/ta893115871/JNIBitmapCompress
备注
本文也是为实践jni,学习jpeg压缩。其中编译libjpeg参考了网上的libjpeg-turbo的编译,有一篇不错。
https://www.jianshu.com/p/20902ca448ae?utm_source=oschina-app