Android Bitmap.compress 方法返回 false 的一个可能原因

存档计划注:原文于 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

需要注意的是:

  1. 上述数值不区分宽和高,也就是说两个值可以互换。
  2. 上述分辨率尺寸是我使用模拟器(Android 11.0 arm64-v8a)和真机(小米10u,MIUI12.5.3 ,Android 11)测试得到的。

注意

以上只是导致返回 false 的原因之一,实际原因还有很多,请结合实际情况自行判断。

分析过程

目前已知的情况是:

  1. 该方法除了返回了 false 外,没有其他任何错误抛出,也没有其他任何日志可以供参考。
  2. 已知会返回 false 的情况是:第一个参数也就是图片编码为 Bitmap.CompressFormat.JPEG 且 bitmap 特别大。
  3. 如果将图片编码改为 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 的地方有:

  1. 编码格式不存在
  2. bitmap 为空
  3. SkWStream 创建失败
  4. 最后是调用的 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 文件格式规范一节中找到这个:

1

也就是说,用于存储 jpg 图像的横纵像素密度信息的可用空间都是 2 byte ,也就是 65535 。

不过需要注意的是,这里定义的并不是像素数量,而是像素密度。

上面还定义了一个 1 byte 的字段用于表示密度单位,如果单位为 0 则表示像素密度就是像素数量;单位为 1 则表示单位英寸内的像素数量;单位为 2 则表示单位厘米内的像素数量。

另外,正如我们在上面提到的,安卓中使用的是 libjpeg 这个第三方库来处理 jpg 文件,而 libjpeg 中将分辨率尺寸限制在了 65500 ,而非 65535 。

但是我同样没有找到 libjpeg 这样设置的原因。

总结

由于 Bitmap.compress 方法的最终实现是调用了第三方库 libjpeg ,而 libjepg 将图像的最大分辨率定义为了 65500 ,这就导致如果传入图像分辨率大于这个值将会返回异常,反馈到安卓的 API Bitmap.compress 中就是返回 false。

参考资料

  1. Android图片编码机制深度解析(Bitmap,Skia,libJpeg)
  2. JPEG File Interchange Format
  3. JPEG vs. PNG.
  4. Is there a file size limit for .jpgs?
  5. JPEG
  6. Why does ImageMagick’s montage limit the JPG output to 65500 instead of 65535?
  7. Error: “JPEG Compression failed: Maximum supported image dimension is 65500 pixels(JPEG standard).."
  8. Data Definitions for libjpeg