存档计划注:原文于 2021.07.27 发布于 CSDN ;在搬运回博客时略做修改。
前言
最近在解决一个遗留已久的BUG时,发现调用 Bitmap 的 compress 方法将 bitmap 导出到文件流时,如果导出的 bitmap 特别大且导出编码为 Bitmap.CompressFormat.JPEG 的话该方法会直接返回 false 而没有抛出任何错误。
而对于同一个 bitmap ,改用 Bitmap.CompressFormat.PNG 就不会返回 false 而是能正常导出。
原因
懒得看分析过程的可以直接看这里。
经过我的分析,导致 compress 方法返回 false 的原因可能是 jpg 编码格式对于分辨率有最大限制。
这个最大限制为:
655,35 X 655,35
但是我使用模拟器和真机实际测试最大尺寸为:
655,00 X 163,93
需要注意的是:
- 上述数值不区分宽和高,也就是说两个值可以互换。
- 上述分辨率尺寸是我使用模拟器(Android 11.0 arm64-v8a)和真机(小米10u,MIUI12.5.3 ,Android 11)测试得到的。
注意:
以上只是导致返回 false 的原因之一,实际原因还有很多,请结合实际情况自行判断。
分析过程
目前已知的情况是:
- 该方法除了返回了 false 外,没有其他任何错误抛出,也没有其他任何日志可以供参考。
- 已知会返回 false 的情况是:第一个参数也就是图片编码为 Bitmap.CompressFormat.JPEG 且 bitmap 特别大。
- 如果将图片编码改为 Bitmap.CompressFormat.PNG 则不会返回 false。
当我遇到这个BUG的时候,结合上述已知情况,我首先想到的是追踪 compress 方法的实现方式,试图从源码中找到造成这个错误的原因。
compress 方法的源码如下:
@WorkerThread
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;
}
可以看到,该方法只是做了一些简单的判断,其核心调用了 C++ 代码。
追踪到的 C++ 源码如下:
static bool Bitmap_compress(JNIEnv* env, jobject clazz, SkBitmap* bitmap,
int format, int quality,
jobject jstream, jbyteArray jstorage) {
SkImageEncoder::Type fm; //创建类型变量
//将java层类型变量转换成Skia的类型变量
switch (format) {
case kJPEG_JavaEncodeFormat:
fm = SkImageEncoder::kJPEG_Type;
break;
case kPNG_JavaEncodeFormat:
fm = SkImageEncoder::kPNG_Type;
break;
case kWEBP_JavaEncodeFormat:
fm = SkImageEncoder::kWEBP_Type;
break;
default:
return false;
}
//判断当前bitmap指针是否为空
bool success = false;
if (NULL != bitmap) {
SkAutoLockPixels alp(*bitmap);
if (NULL == bitmap->getPixels()) {
return false;
}
//创建SkWStream变量用于将压缩后的图片数据输出
SkWStream* strm = CreateJavaOutputStreamAdaptor(env, jstream, jstorage);
if (NULL == strm) {
return false;
}
//根据编码类型,创建SkImageEncoder变量,并调用encodeStream对bitmap
//指针指向的图片数据进行编码,完成后释放资源。
SkImageEncoder* encoder = SkImageEncoder::Create(fm);
if (NULL != encoder) {
success = encoder->encodeStream(strm, *bitmap, quality);
delete encoder;
}
delete strm;
}
return success;
}
从上述源码可以看出,可能返回 false 的地方有:
- 编码格式不存在
- bitmap 为空
- SkWStream 创建失败
- 最后是调用的 encodeStream 返回 false
经过我的一一确认,1-3点是没有问题的,所以最后只剩下了第4点。
而这个 encodeStream 函数最终调用到的是一个第三方库: libjpeg ,这是一个被广泛应用的第三方库,理论上来说不会存在什么问题,我也搜索了一下,没有发现有人反馈这个库存在异常返回的问题。
于是我转变思路,既然会导致这个问题出现的原因有两个,就是编码为 JPG 时且 bitmap 特别大时,那会不会是内存溢出呢?
虽然正常来说,内存溢出会抛出OOM错误(事实上,如果我手动把bitmap设置的特别大,也会抛出OOM),但是我们不妨试一下,看看两者之间有何联系。
测试代码如下:
package com.example.myapplication
import android.graphics.Bitmap
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.os.Environment
import android.util.Log
import android.widget.Button
import java.io.File
import java.io.FileOutputStream
private const val TAG = "el, in Main"
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
startCompress()
}
private fun startCompress() {
val mainBtn = findViewById<Button>(R.id.main_btn)
mainBtn.setOnClickListener {
//val width = 65500
//val height = 16393
val height = 65500
val width = 16393
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
val savePath = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).toString())
saveBitmap2File(bitmap, "test", savePath, 50)
}
}
private fun saveBitmap2File(
bitmap: Bitmap,
fileName: String,
savePath: File?,
quality: Int): File {
val f = File(savePath, "$fileName.jpg")
val imgFormat: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG
if (!f.createNewFile()) {
Log.w(TAG, "file " + f + "has already exist")
}
val outputStream = FileOutputStream(f)
//Log.i(TAG, "saveBitmap2File: bitmap's width="+bitmap.getWidth()+" height="+bitmap.getHeight());
if (!bitmap.compress(imgFormat, quality, outputStream)) {
Log.e(TAG, "saveBitmap2File: write bitmap to file fail!")
throw Exception("saveBitmap2File: write bitmap to file fail!")
}
try {
outputStream.flush()
outputStream.close()
} catch (e: Exception) {
Log.e(TAG, "saveBitmap2File: ", e)
}
return f
}
}
通过使用上述代码,我不断修改 Bitmap 分辨率以测试辨率达到多少时,会返回 false 。
最终,测出来达到 655,00 X 163,93 能够刚好不返回 false。
至此,虽然没有抛出 OOM ,只是正常返回了 false 但是这就可以确定了,之所以会返回 false 确实和分辨率有关。
那么问题来了,为什么会有分辨率限制,以及这个限制是怎么来的呢?
我试图谷歌相关解释,但是并没有找到解释。
只是在 维基百科 上一笔带过了这么一句话:
JPEG/JFIF supports a maximum image size of 65,535×65,535 pixels
也就是说 jpg 文件确实会有分辨率限制,只是维基也没有解释为什么会有这个限制。
这段话的引用来源为 http://www.jpeg.org/public/jfif.pdf
也就是 jpg 文件标准文档,然后我翻遍这个文档,没有发现任何明确了 jpg 文件分辨率尺寸限制的地方。
只在 jpg 文件格式规范一节中找到这个:
也就是说,用于存储 jpg 图像的横纵像素密度信息的可用空间都是 2 byte ,也就是 65535 。
不过需要注意的是,这里定义的并不是像素数量,而是像素密度。
上面还定义了一个 1 byte 的字段用于表示密度单位,如果单位为 0 则表示像素密度就是像素数量;单位为 1 则表示单位英寸内的像素数量;单位为 2 则表示单位厘米内的像素数量。
另外,正如我们在上面提到的,安卓中使用的是 libjpeg 这个第三方库来处理 jpg 文件,而 libjpeg 中将分辨率尺寸限制在了 65500 ,而非 65535 。
但是我同样没有找到 libjpeg 这样设置的原因。
总结
由于 Bitmap.compress 方法的最终实现是调用了第三方库 libjpeg ,而 libjepg 将图像的最大分辨率定义为了 65500 ,这就导致如果传入图像分辨率大于这个值将会返回异常,反馈到安卓的 API Bitmap.compress 中就是返回 false。
参考资料
- Android图片编码机制深度解析(Bitmap,Skia,libJpeg)
- JPEG File Interchange Format
- JPEG vs. PNG.
- Is there a file size limit for .jpgs?
- JPEG
- Why does ImageMagick’s montage limit the JPG output to 65500 instead of 65535?
- Error: “JPEG Compression failed: Maximum supported image dimension is 65500 pixels(JPEG standard).."
- Data Definitions for libjpeg