Android Bitmap 初探

3,205 阅读9分钟

最近一段时间的开发中和Bitmap接触较多,就Bitmap的使用有了一些新的认识,如何对Bitmap进行压缩,减少内存占用有了一些总结。

背景

社交类(或者说是包含用户系统)的APP基本上都会包含用户自定义头像的功能,可以让用户从相册选择或拍摄一张图片作为自己的头像,这样才能显现出每个人的个性嘛!每个用户的手机里各种各样不可描述的照片,从尺寸到大小各不相同,因此如何把用户选择的图片正确的加载到ImageView里就成了一件值得探讨的事情。好了,废话不说,下面就让我们一步步揭开Bitmap的神秘面纱。

从相册加载一张图片

我们先从简单的入手,看看从手机相册加载一张图片到ImageView的正确方式。

我们就以上图为列,这张图片在我手机里的信息如下:

可以看到,图片大小不足1M。那么把他加载到手机内存中时又会发生什么呢?

打开相册加载图片

    /**
     * 打开手机相册
     */
    private void selectFromGalley() {
        Intent intent = new Intent();
        intent.setType("image/*");
        intent.setAction(Intent.ACTION_GET_CONTENT);
        intent.addCategory(Intent.CATEGORY_OPENABLE);
        startActivityForResult(intent, REQUEST_CODE_PICK_FROM_GALLEY);
    }

在Android 中打开相册是一件非常方便的事情,选择好图片之后就可以在onActivityResult中接收这张图片

                if (resultCode == Activity.RESULT_OK) {
                    Uri uri = data.getData();
                    if (uri != null) {
                        ProcessResult(uri);
                    }
                }

根据Uri得到Bitmap

@TargetApi(Build.VERSION_CODES.KITKAT)
    private void ProcessResult(Uri destUrl) {
        String pathName = FileHelper.stripFileProtocol(destUrl.toString());
        showBitmapInfos(pathName);
        Bitmap bitmap = BitmapFactory.decodeFile(pathName);
        if (bitmap != null) {
            mImageView.setImageBitmap(bitmap);
            float count = bitmap.getByteCount() / M_RATE;
            float all = bitmap.getAllocationByteCount() / M_RATE;
            String result = "这张图片占用内存大小:\n" +
                    "bitmap.getByteCount()== " + count + "M\n" +
                    "bitmap.getAllocationByteCount()= " + all + "M";
            info.setText(result);
            Log.e(TAG, result);
            bitmap = null;
        } else {
            T.showLToast(mContext, "fail");
        }
    }

    /**
     * 获取Bitmap的信息
     * @param pathName
     */
    private void showBitmapInfos(String pathName) {
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeFile(pathName, options);
        int width = options.outWidth;
        int height = options.outHeight;

        Log.e(TAG, "showBitmapInfos: \n" +
                "width=: " + width + "\n" +
                "height=: " + height);
        options.inJustDecodeBounds = false;
    }

这里的处理很简单,需要注意的一点是onActivityResult 方法中返回的Intent返回的图片地址是一个Uri类型,包含具体协议,为了方便使用BitmapFactory的decode方法,需要将这个个Uri类型的地址转换为普通的地址,stripFileProtocol具体实现可参考源码

showBitmapInfos 这个方法就是很简单,就是获取一下所要加载图片的信息。这里主要还是靠inJustDecodeBounds 这个参数,当此参数为true时,BitmapFactory 只会解析图片的原始宽/高信息,并不会去真正的加载图片。

我们看一下输出日志及内存变化:

关于getByteCount和getAllocationByteCount的区别,这里暂时不讨论,只要知道他们都可以获取Bitmap占用内存大小

可以看到,由于这张图片是放在手机内部SD卡上,所以showBitmapInfos 解析后获取的图片宽高信息和之前是一致的,宽x高为 2160x1920。看到所占用的内存 15M,是不是有点意外,一张658KB 的加载后居然要占这么大的内存。在看一下monitor检测的内存变化,在20s后选择图片后,占用内存有了一个明显的上升。占用这么大的内存,显然是不好的。可能很多人和我一样,在这个时候想到的第一个词是压缩图片,把图片变小他占的内存不就会变小了吗?好,那就压缩图片

压缩图片

压缩图片方案一(Compress)

因为我们要处理的是Bitmap,首先从他自带的方法出发,果然找到了一个compress方法。

    private Bitmap getCompressedBitmap(Bitmap bitmap) {
        try {
            //创建一个用于存储压缩后Bitmap的文件
            File compressedFile = FileHelper.createFileByType(mContext, destType, "compressed");
            Uri uri = Uri.fromFile(compressedFile);
            OutputStream os = getContentResolver().openOutputStream(uri);
            Bitmap.CompressFormat format = destType == FileHelper.JPEG ?
                    Bitmap.CompressFormat.JPEG : Bitmap.CompressFormat.PNG;
            boolean success = bitmap.compress(format, compressRate, os);
            if (success) {
                T.showLToast(mContext, "success");
            }

            final String pathName = FileHelper.stripFileProtocol(uri.toString());
            showBitmapInfos(pathName);
            bitmap = BitmapFactory.decodeFile(pathName);
            os.close();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return bitmap;
    }

bitmap.compress(format, compressRate, os) 会按照指定的格式和压缩比例将压缩后的bitmap写入到os 所对应的文件中。compressRate的取值在0-100之间,0表示压缩到最小尺寸。

在ProcessResult方法中,我们获取bitmap后,首先通过上述方法将bitmap压缩,然后在显示到ImageView中。我们看一下,压缩过后的情况。

上面的日志,第一个showBitmapInfos 显示的是选择的图片通过BitmapFactory解析后的信息,第二个showBitmapInfos
显示的压缩后图片的宽高信息,最后很意外,我们的压缩方法似乎没起到作用,占用的内存没有任何变化,依旧是15M。
难道是compress方法没生效吗?其实不然,至少从UI上看compress的确生效了, 当compressRate=0时,懒羊羊的图片显示到ImageView上时已经非常不清晰了,失真非常严重。那么到底是为什么呢?

这里就得从概念上说起,一开始我们提到了这张懒羊羊的图片大小时658KB,这是它在手机存储空间所占的大小,而当我们在选择这张图片,并解析为Bitmap时,他所站的15MB是在内存中所占的大小;而compress方法只能压缩前一种大小,也就是所使用Bitmap的compress方法只是压缩他在存储空间的大小,结果就是导致图片失真;而不能改变他在内存中所占用的大小

那么怎样才能让Bitmap所占用的内存变小呢?这就的从Bitmap占用内存的计算方法入手,在这篇文章中已经对bitmap所占用内存大小做了深入分析,从中我们可以得出结论,决定一张图片所占内存大小的因素是图片的宽高和Bitmap的格式。这里我们加载的时候对Bitmap格式未做更改,也就是默认的ARGB_8888,因此我们就得从宽高入手,得出如下的压缩方案。

压缩图片方案二 (Crop)

    private void CropTheImage(Uri imageUrl) {
        Intent cropIntent = new Intent("com.android.camera.action.CROP");
        cropIntent.setDataAndType(imageUrl, "image/*");
        cropIntent.putExtra("cropWidth", "true");
        cropIntent.putExtra("outputX", cropTargetWidth);
        cropIntent.putExtra("outputY", cropTargetHeight);
        File copyFile = FileHelper.createFileByType(mContext, destType, String.valueOf(System.currentTimeMillis()));
        copyUrl = Uri.fromFile(copyFile);
        cropIntent.putExtra("output", copyUrl);
        startActivityForResult(cropIntent, REQUEST_CODE_CROP_PIC);
    }

这里调用了系统自带的图片裁剪控件,并创建了一个copyFile 的文件,裁剪过后的图片的地址指向就是这个文件所对应的地址。
当cropTargetWidth=1080,cropTargetHeight=920时,我们看一下日志:

可以看到,Bitmap所占用的内存终于变小了,而且由于在裁剪时宽高各缩小了1/2,整个内存的占用也是缩小了1/4,变成了3.9M左右。同时图片在手机存储空间也变小了。

当然,这里要注意的是,com.android.camera.action.CROP 中两个参数 "outputX" 和"outputY",决定了压缩后图片的大小,因此当这两个值的大小超过原始图片的大小时,内存占用反而会增加,这一点应该很好理解,所以需确保传递合适的值,否则会适得其反。

图片压缩方案三 (Sample )

采用Sample,也就是是采样的方式压缩图片之前,我们首先需要了解一下inSampleSize 这个参数。

inSampleSize 是BitmapFactory.Options 的一个参数,当他为1时,采样后的图片大小为图片原始大小;当inSampleSize 为2时,那么采样后的图片其宽/高均为原图大小的1/2,而像素数为原图的1/4,其占有的内存大小也为原图的1/4。inSampleSize 的取值应该是2的指数。

    private Bitmap getRealCompressedBitmap(String pathName, int reqWidth, int reqHeight) {
        Bitmap bitmap;
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeFile(pathName, options);
        int width = options.outWidth / 2;
        int height = options.outHeight / 2;
        int inSampleSize = 1;

        while (width / inSampleSize >= reqWidth && height / inSampleSize >= reqHeight) {
            inSampleSize = inSampleSize * 2;
        }

        options.inSampleSize = inSampleSize;
        options.inJustDecodeBounds = false;
        bitmap = BitmapFactory.decodeFile(pathName, options);
        showBitmapInfos(pathName);
        return bitmap;
    }

可以如下调用这个方法:

            if (needSample) {
                bitmap = getRealCompressedBitmap(pathName, 200, 200);
            }

我们希望将2160x1920像素的原图压缩到200x200 像素的大小,因此在getRealCompressedBitmap方法中,通过while循环inSampleSize的值最终为8,因此内存占用率将变为原来的1/64,这是一个很大的降幅。我们看一下日志,看看到底是否能够如我们所愿:

可以看到,使用这种方法进行图片压缩后,增加的内存只有0.24M,几乎可以忽略不计了。当然前提是我们要使用的图片的确不需要很大,比如这里,需要用这张图片作为用户头像的话,那么将原图缩略成200x200 px的大小是没有问题的。

三种方案对比

上面提到的三种压缩方案,通过对比可以发现,第一种方案适用于进行纯粹的文件压缩,而不适用进行图像处理压缩;第二种方案压缩方案适用于进行图像编辑时的压缩,就像手机自带相册的编辑功能,可以随着裁剪区域的大小进行最终的压缩;第三种方案相对来说,适应性较强,各种场景都会符合。

从Camera 获取Bitmap

有时候,我们除了从相册获取图片之外,还可以通过手机自带的相机拍摄图片。

    private void openCamera() {
        Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        //创建一个临时文件夹存储拍摄的照片
        File file = FileHelper.createFileByType(mContext, destType, "test");
        imageUrl = Uri.fromFile(file);
        takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, imageUrl);
        if (takePictureIntent.resolveActivity(getPackageManager()) != null) {
            startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PIC_CAMERA);
        }
    }

不同于从相册选取图片,打开相机之前需要我们自己定义一个存储图片的临时文件file,这个临时文件既可以在应用的临时存储区也可以在手机存储的临时存储区;通过这个文件就可以生成一个Uri对象,有了这个Uri对象,相机拍摄完照片之后就可以在onActivityResult方法中通过这个Uri获取到Bitmap了。

这里我们可以试一下,随便用手机拍摄一张图片转为Bitmap加载会占多大的手机内存(以我用的小米手机5为列,拍摄一张图片):

可以看到这张图片的分辨率达到了3456x4608 像素,而他加载到内存是所占的大小居然达到了60M,这是非常不科学的做法,也是毫无意义的做法,因为我们的手机可见区域并没有这么大,将整张照片完全加载是没有意义的。因此可以按照之前的压缩方案进行压缩。

bitmap = getRealCompressedBitmap(pathName, screenWidth, screenHeight);

我们可以将原来的图片压缩到手机屏幕大小的图片

可以看到占用内存有了明显的减少。

将拍摄的图片添加到手机相册中

有时需要将拍摄出来的照片添加到手机相册中,方便从相册直接查看

    private void insertToGallery(Uri imageUrl) {
        Uri galleryUri = Uri.fromFile(new File(FileHelper.getPicutresPath(destType)));
        boolean result = FileHelper.copyResultToGalley(mContext, imageUrl, galleryUri);
        if (result) {
            Intent mediaScanIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
            mediaScanIntent.setData(galleryUri);
            sendBroadcast(mediaScanIntent);
        }
    }

copyResultToGalley 方法的实现很简单,就是将imageUri 这个地址的文件复制到galleryUri 这个地址,复制成功后发送一条
action="ACTION_MEDIA_SCANNER_SCAN_FILE" 的广播即可。

好了,关于Bitmap的初探就说到这里,对于上面提到的各种压缩方案,有兴趣的同学可结合一下demo测试。Github 地址

总结

用了很久的ImageView,发现Bitmap才是Android中图像处理最核心的东西,有很多东西值得去深入了解。