理解Android图像处理-拍照、单/多图选择器及图像优化

如以上DEMO截图所示效果,我们对于这种类似的功能肯定不算陌生,因为这可以说是实际开发中一类非常常见的功能需求了。而关于它们的实现,其实主要涉及到的知识面应该就是 Android当中的图像处理了。简单来说就比如:图像获取(例如常见的设置头像(获取单张图片);发布动态/朋友圈(获取多张图片))、图像显示以及图像优化等等。所以理解和掌握关于这方面的原理和相关技术、手段等肯定对我们是非常有帮助的。所以在这里尽量逐层推进的来整理一下相关的知识,及总结过程中可能会遇到的一些坑及解决方法,算是做一个简单的回顾和归纳。


图片获取

从某种意义上来说,通常如果我们把一个所谓的APP还原一下本质,可以发现其实其主体内容就是由一系列的文字和图像信息混搭起来的一个数据集合的呈现,所以基本上每个应用都离不开对于图像的使用。那么,既然我们的应用内将要涉及到图像,那么首先应该考虑到的就是如何去获取图像。粗泛一点来说,在应用内对于图像的主要的获取方式 大体可以分为两种:

  • 第一种情况:其它空间(例如网络) → 应用内存 → 设备存储空间
    (举例来说,假设现有一个新闻浏览的应用客户端,我们从服务器得到了最新的新闻数据,某条新闻详情内含有图片内容。显然目前我们拿到的仅仅是图片对应的URL,它自身只是一串文本数据,所以如果我们想要在自身应用内获取到其对应的图片内容,自然就需要通过网络进行下载获取,然后写入内存进行显示。最后,如果有存储(缓存)图像的需求,那么图片内容则还会再由内存写入设备的存储空间)。
  • 第二种情况:设备存储空间 → 应用内存 → 其它空间(例如网络)
    (同样,我们也可能会使用类似微博,朋友圈等功能。在这些Social性质的应用里,我们常常会有一些图片想要分享给他人,那么前提则是需要首先在本地的存储空间获取到对应的图片,从而才能上传到服务器。这时图片的行为路径则通常是与我们说到的第一类情况是相反的。)

显然,第一种方式的本质其实通常就是基于HTTP协议的网络通信,本文中我们的主要关注点不在这里,故不加赘述。这里主要探讨一下,对于第二种情况来说,我们通常有哪些方式或者途径 可以在自己的应用内获取到想要的图片数据。

获取单张图片

好了,不再废话,我们就以一个简单的需求作为切入。假设现在想要实现一个常见的功能“设置用户头像”,分析一下我们应该如何去做。首先,我想我们可以明确的一点就是,设置头像这种功能肯定就会涉及到图片数据的获取。但这时的获取行为有一个特点就在于:本次我们需要获取的图像的数量将是固定的,就为1张。所以,实际上我们需要实现的其实就是 对于单张图片的获取。那么,接着分析的重点就在于,如果以一台手机来说,我们想要得到一张图片的途径有哪些呢?简单思考一下我们便能想到,基本上概括来就是两种途径:

  • 通过相机(摄影应用)来拍摄并获取到一张全新的图像。
  • 通过在本地相册(存储)中查找并获取一张已存在的心仪图像。

相机拍摄

OK,有了之前简单的的分析作为基础,我们正式来看一些更加实际的东西。首先分析一下,对于“拍摄获取图片”这种需求究竟应当如何实现?其实简单归纳,可以发现实际问题就是:想要在自身应用内通过拍摄获取照片,但是自身应用内并不存在支持拍摄的组件。

那么举个例子,这就好比说:我们想要和一个使用英语的人进行交流,但是我自己又不会英语。这时我们的解决办法其实通常就是两种:要么自己设法掌握英语;要么找一个会说英语的人充当中间人的角色。那么回归到我们这里的功能需求,其实同样也就可以有两种选择:

  • 系统已存在的支持拍摄图像的程序。
  • 自己编写一个支持拍摄图像的程序。

那么,且不提编写相机应用本身就不是件容易的事情。而即使你完全具备这个能力,而对应于我们这里本身的需求来说,也有一种“杀鸡用牛刀”,“高射炮打蚊子的”的感觉。所以,显然我们最简单的方法就是通过系统现在已存在的相机应用去拍摄并获取图片。那么,试图在自身应用启动其它应用的组件,如果我们没有明确的目标信息,显然我们最容易想到的就是通过隐式的Intent去寻求那些能够响应“拍摄图像”的应用,从而来实现我们的需求了。那么什么样的Intent可以打开能够拍摄图像的程序呢?很简单:

    private void takePicture() {
        // Action : android.media.action.IMAGE_CAPTURE
        Intent intent  = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        startActivity(intent);
    }

没错,其实我们只是创建了一个Action为"android.media.action.IMAGE_CAPTURE"的Intent,就已经能够让我们打开那些能够拍摄图像的相机应用程序了。究其原因,我们来看看系统源码中对于该Action的一段注释说明:

Standard Intent action that can be sent to have the camera application capture an image and return it。

标准的操作意图,可以发送给相机应用程序捕获一个图像并返回它。

我们注意到,其实注释已经告诉我们,通过此Intent我们可以通过某个相机应用程序捕获并返回一个图像,是不是完美符合我们的需求呢。但我们之前的代码还需要完善,因为此时它的意义仅仅是去打开一个相机程序进行拍照而已,此时我们还无法获取到返回的图像。由此则不难想到,我们肯定应该选择通过startActivityForResult而非startActivity去启动intent:

    private void takePicture() {
        Intent intent  = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        startActivityForResult(intent,0x001);
    }

OK,既然我们已经改为通过startActivityForResult启动程序,那么对于返回的图像自然就是在onActivityResult方法中进行处理了。那么,现在要考虑的问题自然就是:在这里的返回结果中,我们应该如何去解析出本次拍摄到的图像呢?很简单:

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        Bundle extras = data.getExtras(); 
        Bitmap bitmap = (Bitmap) extras.get("data");
        Log.d(TAG+"==>",bitmap.getWidth()+"//"+bitmap.getHeight());
        super.onActivityResult(requestCode, resultCode, data);
    }

现在运行程序,启动某个相机应用去拍摄一张照片,会发现得到类似的Log打印信息:

由此我们便成功获取到了图像,但是也可以发现通过这种方式获取到的图像的宽高像素是很低的,如这里就仅为240和135。并且,我们可以发现此时我们除了获取到了一个bitmap对象之外,对于其它的信息都无从得知。那么,有没有其它的方式解析返回的图像呢?当然是有的:

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        Log.d(TAG+"==>",data.getData().toString());
        
        Cursor cursor = getContentResolver().query(data.getData(),null,null,null,null);
        cursor.moveToFirst();
        String path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA));
        Log.d(TAG+"==>",path);
        
        Bitmap bitmap = BitmapFactory.decodeFile(path);
        Log.d(TAG+"==>",bitmap.getWidth()+"//"+bitmap.getHeight());

    }

此时我们重新编译运行程序,发现得到如下的输出结果:

也就是说,我们可以试图通过调用返回的intent对象的getData方法去获取一个URI。在上面的截图中,我们可以看到获取到的该URI 其使用的协议是“conent://”。这就代表着:其实我们利用该URI最终就可以通过对应的内容提供者解析出该URI所代表的图像文件的文件路径,从而获取到该图像。最后,我们发现本次解析获取到的图像其宽高像素为3920*2204,比之前一种方式获取的图片像素要远远高出许多。而实际上,这才是拍摄的图像的原始真实像素。

然而,照成这种差异的原因是什么呢?我们暂且不说这个。而让人注意的另一个点在于:不难发现对于此时我们拍摄的图片 其最终的存储路径是无法由我们掌控的,而通常是由此次负责拍摄照片的相机应用程序来决定。举例来说,假设我们调用的是系统自带的相机来进行拍摄,那么如果拍摄的文件会被写入到存储空间,则最后可以发现该图像被存储的位置通常就是系统相机对应的图像文件夹。

但显然很多时候,我们会希望能够独立管理属于我们自身应用中的各种文件及数据,所以通常我们会在手机的存储空间中创建自己应用的"专属路径"。那么我们如何才能让拍摄的图片被存放在属于我们自己的应用的路径下面呢?这时应该怎么做呢,同样很简单:

    private void takePicture() {
        File image = new File(mkAppImagesDir(),"test.jpg");
        Intent intent  = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        // MediaStore.EXTRA_OUTPUT : "output"
        intent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(image));
        startActivityForResult(intent,0x001);
    }

    private File mkAppImagesDir(){
        String path = Environment.getExternalStorageDirectory() + "/" + getPackageName() + "/" + "Images";

        File file = new File(path);

        if(!file.exists())
            file.mkdirs();

        return file;
    }

此时我们重新运行程序,就会发现拍摄的照片被存放在了我们指定的路径下面。也就是说,其实我们要做的很简单,就是通过在intent的extra中指定“output”就可以指定图像的输出路径。同样的,我们再看看源码中对于该Extra的说明:

The caller may pass an extra EXTRA_OUTPUT to control where this image will be written.If the EXTRA_OUTPUT is not present, then a small sized image is returned as a Bitmap object in the extra field. This is useful for applications that only need a small image.If the EXTRA_OUTPUT is present, then the full-sized image will be written to the Uri value of EXTRA_OUTPUT

通过注释说明,首先我们可以明白为什么之前通过第一种形式获取的图像的像素很小;另外也可以理解对于ACTION_IMAGE_CAPTURE原本的设计思想。我们可以简单归纳为:

  • 如果没有提供EXTRA_OUTPUT,那么返回的intent中会以 在extra中携带一个small-size的bitmap的形式返回图像。
  • 而当我们提供了EXTRA_OUTPUT时,则会以full-sized(即原图)的形式将拍摄的图像文件写入到我们Uri指定的路径当中。

可能遇到的各种坑

如果仅仅是像我们以上谈到的东西,那么通过相机应用程序拍摄获取照片的需求实现起来显然是不难的,但其实真正的情况没有看上去那么轻松。我们知道开源是Android最大的优势之一,但与此同时带来的一个麻烦就是各种烦人的适配工作。

因为我们是通过隐式Intent的方式去启动相机应用程序,那么:首先且不提用户的设备上可能会同时存在多个可以响应该Intent的相机程序,谁也不知道这些应用对于intent的响应处理方式究竟是否相同。而即使都同样选择通过系统自带的相机程序去拍摄照片,也会因为各种机型的不同,版本的不同而导致响应处理方式不同。所以,这里我们就来总结一下,在这里我们可能会遇到的坑:

  • 通过从extra中读取Bitmap获取图像导致空指针异常
    (导致这种异常的原因并不难理解,之前通过对于注释的说明,可以知道当我们提供了EXTRA_OUTPUT的时候,采用的响应方式是将full-size的图像写入到指定路径下,但同时要记住的是:按照其设计思想,此时就不会返回small-size的bitmap缩略图了。所以在这种情况下,就会导致空指针异常。值得注意的就是,在有的机型上这又是行的通的,例如我前两年用过的Sony-L36H其响应的方式就是无论是否设置了EXTRA_OUTPUT,都会返回small-size的bitmap,所以则不会导致异常。做这个说明是因为如果你是一个刚接触Android,刚接触这类需求的开发的朋友,如果恰好使用了这种方式,则千万不要因为恰好在某个机型上发现它能完美运行,就认为它是没问题的。就如同源码中所描述的,这种方式最适合的场景是:只是需要拍摄得到一张size很小的图像,并且不需要它写入到存储空间当中。)

  • 通过从getData读取URI获取图像导致空指针异常
    (导致这种异常的原因的可能性更多一点,首先前面说到了:当没有提供EXTRA_OUTPUT的时候,会直接返回一个small-size的bitmap对象。同样,需要明白的,按照本身的设计思想,这时拍摄的图片自身则并不会被写入到手机存储空间。那么,既然根本没有进行过存储,自然无法提供其对应的URI,从而自然将导致空指针异常。但是!同理,这里仍然又可能存在不同的处理方式,例如有的机型的相机即使没有提供EXTRA_OUTPUT,它仍然会将拍摄的文件进行存储,就像我之前的截图里体现的一样,虽然我没有设置EXTRA_OUTPUT,但通过系统相机拍摄的照片仍然被写入到了系统相册的文件路径下,所以这个时候我仍然能成功拿到返回对应的URI。但是呢,选择这种响应方式的机型对另一种情况仍然又可能存在不同的处理方式,那就是反之当提供了EXTRA_OUTPUT的时候,有的机型会将文件写入到对应的路径后,返回正确的URI;有的则虽然会正确写入存储,但却不会返回URI,所以这个时候又可能导致空指针异常)

所以,其实看似简单的一个拍照获取图像的功能,其实可能遇到的坑也是不少的。那么我们应该如何避免这些可能出现的异常呢?本质上肯定是加强解析代码的健壮性判断;而解析思路上我们可以选择的一种方式则是:默认通过读取Uri的方式获取图像,如果获取Uri为空,我们再通过读取bitmap对象的方式获取。如果两者都为空,则代表本次获取图像的行为失败了。其体现在代码上就大概类似于:

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        if (resultCode == RESULT_OK) {
            Bitmap bitmap = null;

            Uri imageUri = data.getData();
            if (imageUri != null) {
                Cursor cursor = getContentResolver().query(data.getData(), null, null, null, null);
                if (cursor != null)
                    if (cursor.moveToFirst()) {
                        String path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA));
                        bitmap = BitmapFactory.decodeFile(path);
                    }
            } else {
                Bundle extras = data.getExtras();
                bitmap = (Bitmap) extras.get("data");
            }

            if (bitmap != null) {
                // 成功获取
            }
        }
    }

但如果是使用了EXTRA_OUTPUT的情况,我们就有更好的处理方案了,因为此时我们还多了一种选择:

    private String mCurrentPath;
    private void takePicture() {
        File image = new File(mkAppImagesDir(), "test.jpg");
        mCurrentPath = image.getAbsolutePath();
        ...
    }
    
    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        Bitmap bitmap = BitmapFactory.decodeFile(mCurrentPath);
        super.onActivityResult(requestCode, resultCode, data);
    }

可以看到:因为此时可以明确得知本次拍摄的图像的路径,所以我们在onActivityResult中则可直接使用该path,这样一来我们不再做Uri的解析;二来前面我们说到有的机型,当我们自己指定了EXTRA_OUTPUT的时候虽然会将文件写入到对应路径,但却不会返回对应的URI,所以这样做还能避免空指针。

但这里其实仍然还有值得我们注意的地方,那就是当我们成功启动某个相机应用程序并拍下图像后,我们到指定的路径下也发现图片已经被成功存储。但打开系统相册,却发现找不到我们刚刚拍摄的图片;而重新刷新或者重启手机后,发现图片则出现在了相册当中。这是为什么呢?其实是因为,虽然我们拍摄并保存了新的图像,但并没有通知系统媒体这个动作。所以,在拍照完成后,别忘记发送一条通知媒体扫描仪对我们新拍的图像进行扫描:

    public static void informMediaScanner(Context context, Uri uri) {
        Intent localIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri);
        context.sendBroadcast(localIntent);
    }

好吧,如果我们以为到这里就已经说完了常见的因调用相机程序拍照可能遇到的坑,那我们就年轻了。事实上还有一种很可能会遇到的问题:那就是我们会发现在有的机型上,将拍摄好的图像读取到内存中进行显示过后,发现显示的图片的方向是不正确的。这是因为这些机型的系统相机,其拍摄出来的照片是带有旋转角度的。所以,其实对于这些图片,将其读取进内存过后,还要获取其旋转角度,将bitmap对象进行对应角度的旋转过后,才能够正确显示。

    /**
     * 获取图片的旋转角度
     *
     * @param path 图片路径
     * @return 旋转角度
     */
    public static int getBitmapDegree(String path) {
        int degree = 0;
        try {
            // 从指定路径下读取图片,并获取其EXIF信息
            ExifInterface exifInterface = new ExifInterface(path);
            // 获取图片的旋转信息
            int orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION,
                    ExifInterface.ORIENTATION_NORMAL);
            switch (orientation) {
                case ExifInterface.ORIENTATION_ROTATE_90:
                    degree = 90;
                    break;
                case ExifInterface.ORIENTATION_ROTATE_180:
                    degree = 180;
                    break;
                case ExifInterface.ORIENTATION_ROTATE_270:
                    degree = 270;
                    break;
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return degree;
    }


    /**
     * 旋转Bitmap对象
     *
     * @param bm     Bitmap对象
     * @param degree 旋转角度
     * @return 旋转后的Bitmap对象
     */
    public static Bitmap rotateBitmapByDegree(Bitmap bm, int degree) {
        if (degree == 0)
            return bm;

        Bitmap returnBm = null;

        // 根据旋转角度,生成旋转矩阵
        Matrix matrix = new Matrix();
        matrix.postRotate(degree);
        try {
            // 将原始图片按照旋转矩阵进行旋转,并得到新的Bitmap对象
            returnBm = Bitmap.createBitmap(bm, 0, 0, bm.getWidth(), bm.getHeight(), matrix, true);
        } catch (OutOfMemoryError e) {
            e.printStackTrace();
        }
        if (returnBm == null) {
            returnBm = bm;
        }
        if (bm != returnBm) {
            bm.recycle();
        }
        return returnBm;
    }

如上述代码随时,通过getBitmapDegree方法我们可以获取到对应路径下的图片文件的旋转角度,当旋转角度不为0的时候,我们就应该将对应的bitmap对象通过rotateBitmapByDegree方法旋转对应的角度后,再进行显示。

注:在6.0以后,比如使用相机以及读取/写入存储空间都需要实现运行时权限;同时在7.0以后,拍照时为MediaStore.EXTRA_OUTPUT指定的URI如果是代表文件真实路径的URI,则需要使用FileProvider。所以实际开发中我们还要记得这些版本适配的工作,但是因为这不是本文关注的重点,所以我们就不加以整理了。


获取已存储图片

OK,对于调用相机应用拍照的总结就到这里,更多的东西还是得我们自己实际使用到的时候能够更好的理解。接下来,我们来看看如何获取设备上已存储的图片。实际上对应于拍摄获取图像来说,从设备上已存储的图片中进行获取可能是一种更为常用的途径。因为它的优势在于:

  • 相对于拍摄获取的单一途径,这里的图片 其来源更加广泛;
  • 即使是拍摄的图片,可能用户也更愿意通过现今各种炫酷的美图软件进行美化,重新存储后再使用。而非直接使用拍摄的原图。

那么,既然是获取设备中已经存储的图片,显然最容易想到的方法就是对手机存储空间中的所有路径进行递归遍历,获取到所有的图像文件。但显然这绝不是一种聪明的做法,并且效率低下。与之前调用相机程序的道理相同,这里我们仍然可以选择通过隐式的Intent来实现我们的需求。而对于获取图片,我们通常有两种方式去构建Intent对象,首先来看第一种:

    private void getImages() {
        // Action : android.intent.action.GET_CONTENT
        Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
        intent.setType("image/*");
        startActivityForResult(intent, 0x002);
    }

这里我们首先将Action指定为了“android.intent.action.GET_CONTENT”,简单来说它的意义就是允许用户选择指定类型的数据并返回。那么既然我们注意到了是指定类型的数据,所以紧接着,我们就通过setType指定了数据的MIME-Type是图像类型。除此之外,通过以下的另一种方式也能实现相同的功能:

    private void getImage(){
        // Action : android.intent.action.PICK
        Intent intent = new Intent(Intent.ACTION_PICK);
        intent.setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*");
        startActivityForResult(intent, 0x002);
    }

OK,这时我们多半会有一个疑问就是关于这两种方式之间的区别。看下看源码中对于ACTION_GET_CONTENT的注释中的一段描述:

This is different than {@link #ACTION_PICK} in that here we just say what kind of data is desired, not a URI of existing data from which the user can pick.

也就是说,从字面上来理解,可以简单总结为:这两者虽然都可以允许用户选择指定类型的数据,但不同在于:ACTION_GET_CONTENT只需要我们告诉它需要什么类型(MIME-TYPE)的数据就行了;而ACTION_PICK则可以通过提供 已存在的可选择数据 的URI来获取相应数据。

而事实上,对于我们这里选择图片的需求来说,其实它们最大的不同之处就在于:在onActivityResult当中对于返回的Uri的解析工作。为什么这么说呢?因为通常能够响应ACTION_GET_CONTENT的应用会更广泛,比如其不仅仅能被那些用于浏览图片的类似于相册的应用程序响应,还能通过文件管理器等方式响应。这也就意味着它返回的URI可能有两种不同的格式:

如上述截图所示,当我通过系统相册选择了一张图片时,返回的URI是第一种格式;而通过文件管理器选择的一张图片,返回的URI则是第二种。相反,而对于通过ACTION_PICK来选择的图片文件,因为我们传入的URI是MediaStore.Images.Media.EXTERNAL_CONTENT_URI,它其实代表的是提供给查询媒体数据库的内容提供者的URI,所以其返回的的URI则都将是第二种。实际上对于第二种URI的解析工作我们已经很熟悉了,之前也已经写过了通过ContentResovler来解析它的代码。但如果觉得麻烦,系统其实也有相应的工具类已经封装了相关的解析,所以我们也可以选择直接使用它们来解析这种URI:

    public static String getImagePath(Activity activity, Uri imageUri, String selection) {
        String path = null;
        // query projection
        String[] projection = {MediaStore.Images.Media.DATA};
        // 执行查询
        Cursor cursor;
        if (Build.VERSION.SDK_INT < 11) {
            cursor = activity.managedQuery(imageUri, projection, selection, null, null);
        } else {
            CursorLoader cursorLoader = new CursorLoader(activity, imageUri, projection, selection, null, null);
            cursor = cursorLoader.loadInBackground();
        }

        if (cursor != null) {
            // 从查询结果解析path
            if (cursor.moveToFirst()) {
                path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA));
            }
            cursor.close();
        }
        return path;
    }

而对于返回的第一种URI(即代表文件真实路径的URI),对其解析的工作就更简单了,调用getPath方法就可以直接获取到对应的文件路径:

imagePath = imageUri.getPath();

所以其实我们面临的问题就是,如果我们是通过ACTION_GET_CONTENT来启动图片选择器,那么我们在onActivityResult对于解析URI来获取图片文件路径的代码逻辑会更复杂一点,因为我们需要判断返回的到底是哪种格式的URI:

    private void handleWithChooseFromAlbum(Intent data) {
        // 获取Uri
        Uri imageUri = data.getData();

        // 根据Uri获取文件路径
        String imagePath = null;
        if (imageUri.getScheme().equalsIgnoreCase("content")) {
            imagePath = getImagePath(this,imageUri, null);
        } else if (imageUri.getScheme().equalsIgnoreCase("file")) {
            imagePath = imageUri.getPath();
        }

        // displayImage(imagePath);
    }

然而这还不算完,因为在 Android4.4 以后,这里返回的URI格式又发生了变化,举例来说,就变为了类似于下面两种类似格式的URI:


content://com.android.providers.media.documents/document/image%3A50
// download目录下的图片
content://com.android.providers.downloads.documents/document/1

可以看到,虽然此时返回的仍然是content://协议的URI,但是它的路径信息等都是经过封装的,所以此时如果我们直接将该URI传入到我们之前封装的解析方法中,是会导致异常的。所以我们还需要针对于4.4以上版本的URI做额外的解析后,再通过ContentResovler解析出路径:

    @RequiresApi(api = Build.VERSION_CODES.KITKAT)
    private void handleWithChooseFromAlbumAPI19(Intent data) {
        Uri imageUri = data.getData();
        String imagePath = null;

        if (DocumentsContract.isDocumentUri(this, imageUri)) {
            String docID = DocumentsContract.getDocumentId(imageUri);

            if (imageUri.getAuthority().equals("com.android.providers.media.documents")) {
                // 解析出数字格式的ID
                String id = docID.split(":")[1];
                // id用于执行query的selection当中
                String selection = MediaStore.Images.Media._ID + " = " + id;
                // 查询path
                imagePath = getImagePath(this,MediaStore.Images.Media.EXTERNAL_CONTENT_URI, selection);
            } else if (imageUri.getAuthority().equals("com.android.providers.downloads.documents")) {
                Uri contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads/"), Long.valueOf(docID));
                imagePath = getImagePath(this,contentUri, null);
            }
           // displayImage(imagePath);
        } else {
            handleWithChooseFromAlbum(data);
        }
    }

这就是使用两种不同Action的Intent所带来的不同的解析工作,所以究竟是选择哪种方式来选择图片,就看自己的想法了。


多图选择器

OK,那么到了现在我们已经知道了在自身应用中如何通过调用相机应用或者现有程序来获取一张图片,再面临类似的需求,肯定难不倒我们了。但问题是我们也可以发现,对于获取已存储的图片内容来说,通过类似“系统相册”或者“文件选择器”的方式其实也是有一定的限制的。例如我们想要一次选择多张图片或者说对于选择图片的操作方式有一定特殊的要求,就需要自己来实现了。

这里就以一个比较实用的“多图选择器”的功能为例,简单的来分析一下其实现思路。实际上只要我们真正理解了之前通过隐式Intent去启动图片选择的本质,其实就会发现这种功能并不难实现。因为说到底我们现在要做的仍然就是获取手机存储空间当中的所有图片进行显示,就类似系统相册所做的一样。不同的就是在于,之前我们一次只能选择一张图片,现在需要支持一次选择多张图片。

事实上实现该需求的难点就在于,如何去获取手机上存储的图片文件。我们前面也说到了,如果选择递归遍历存储空间肯定不是一个明智的做法。那么回想一下:在说到ACTION_PICK的例子时,我们有使用到一个叫做“MediaStore.Images.Media.EXTERNAL_CONTENT_URI”的东西,其实关键就是MediaStore这个类了。这是系统为我们提供的一个操作媒体数据库的类,事实上我们手机上所有的媒体文件信息(不止图片,还包括音频,视频)都会被存入媒体数据库当中。所以如果我们想要获取手机上的媒体文件,其实并不需要真的去遍历存储空间,只需要到该数据库进行指定条件的查询就搞定了。这其实也是为什么,之前我们在说调用相机应用拍照后,记得发送一条广播让媒体扫描仪进行扫描工作的原因之一。因为媒体扫描仪的工作就是对存储空间中的媒体文件进行扫描,然后将相关信息存放进媒体数据库中。

那么,就好像别人想要访问我们应用内的数据库当中的数据一样,这时的做法自然就是通过我们提供的ContentProvider来进行访问。所以我们想要访问系统的媒体数据库的数据,自然也只能通过对应的ContentProvider来进行访问。看下面的方法:

    private Map<String, List<String>> directoryMap;
    private final Uri EXTERNAL_IMAGE_URI = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
    private final String IMAGE_SELECTION = MediaStore.Images.Media.MIME_TYPE + " =? or " + MediaStore.Images.Media.MIME_TYPE + " =?";
    private final String[] IMAGE_SELECTION_ARGS = new String[] {"image/jpeg","image/png"};

    public void scanImage(){
        // Map根据目录分别存放
        directoryMap = new HashMap<>();
        // 获取ContentResolver
        ContentResolver resolver = getContentResolver();
        // 执行查询
        Cursor cursor = resolver.query(EXTERNAL_IMAGE_URI ,null,IMAGE_SELECTION,IMAGE_SELECTION_ARGS, MediaStore.Images.Media.DATE_MODIFIED+" desc");

        if(cursor == null)
            return;

        while(cursor.moveToNext()){
            // 获取图片路径
            String path = cursor.getString(cursor
                    .getColumnIndex(MediaStore.Images.Media.DATA));

            // 获取该图片的父路径名
            String parentFileName = new File(path).getParentFile().getName();

            if (!directoryMap.containsKey(parentFileName)) {
                List<String> directoryList = new ArrayList<>();
                directoryList.add(path);
                directoryMap.put(parentFileName, directoryList);
            } else {
                directoryMap.get(parentFileName).add(path);
            }
        }
    }

简单的分析一下上面的代码,首先看到对于query方法我们传入的URI其实就是MediaStore.Images.Media.EXTERNAL_CONTENT_URI,它的实际值其实就是“content://media/external/images/media”。细心一点的朋友可能就会发现这个URI看上去非常眼熟,没错,回忆一下之前我们从系统相册获取单张图片时返回的URI,比如“content://media/external/images/media/3227”。我们会发现其实二者唯一的差异就是后者相较之下多了一个路径分隔和"3227",其实这个所谓的3227就是指图片的ID,前面对于4.4版本以上的URI做的额外解析的核心工作实际上也就是解析得到这个ID。

那么,这两个URI之间的区别在哪呢?简单来说,只要我们有一点点的sql基础,就可以理解为:前者做的查询是"select * from table",而后者则是"select * from table where id = 3227"。没错,其实就是查询整张表的内容和查询该表内id为某个指定值的内容的区别。因为这里我们本身就是意图获取所有的图像文件,所以肯定是使用第一种URI了。那么同理,我们也就不难理解之前使用ACTION_PICK获取图片时,传入的URI为MediaStore.Images.Media.EXTERNAL_CONTENT_URI的原因了。

接着,我们在query中还传入了selection和selectionArgs,简单来说,在上述代码中这两者结合起来,其实就可以理解为本次sql的查询条件是“where mime_type = image/jpeg or mime_type = image/png”。也就是说我们本次只查询那些格式为jpg或者png的图片,这样做的目的自然是排除其他格式(例如gif等)的图片。

当然我们最后还设置了sortOrder参数,它的排序根据被我们设定为图像文件的修改日期,默认的情况下,排序将采用升序的模式,这里我将其定义为desc则代表我希望采用降序的排序模式。所以总的归纳一下,我们这里的做的查询的意义就是:从系统媒体数据库中存放图片文件信息的表中,查询所有格式为jpg或者png的图片,并且查询到的结果按添加日期从最新到最旧的顺序进行排序

在成功获取到查询结果后,其实所做的工作我们就很熟悉了,无非就是遍历查询结果并解析,从而得到图片的文件路径并存放进对应的List。而我们额外做的就是,还将图片按所属路径的不同分别进行存放,这样之后如果我们想要实现类似分路径查看图片的功能也就很轻松了。

而在以上的解析工作都已经完成后,接下来要做的其实就非常简单了,比如用一个RecyclerView来将对应的结果进行显示就行了。而所谓的多图选择器,实际就是用户选中了某项后,并不急着返回,而是将该项的路径保存,然后当用户最后选择完成后,一次性将结果返回就行了。考虑到篇幅,就不加赘述了。


图片优化

那么,到目前为止,其实我们谈到的主要都是关于如何获取和选择图片的内容。而其实对于Android中的图像处理工作,还有一个很重要的内容就是对于图像的压缩和优化。我们都知道现在手机的配置都越来越好,在这个摄像头像素动则千万级的年代,带来的一个结果就是,图片的像素变得越来越高。但同时也就意味着一张图片的体积也就越来越大。

所以说,我们在将一张图片读取进应用内存进行显示的时候,如果图片文件的像素和体积很大,那么所消耗的内存会是非常夸张的。而我们要明白,其实对于移动设备来说,内存还是非常珍贵的。所以如果我们不进行相应的处理,就很容易因为内存占用过大导致应用运行速度变慢乃至因为内存溢出直接崩溃。

因为Android发展到现在,已经有了很多强大而且成熟的图片加载框架。所以不像之前,如今很多时候我们甚至基本上不用考虑对于图像的内存优化工作,通过这些框架往往只需要一两行代码就能搞定图片的加载。而这本质上也是因为这些框架已经默默的帮我们完成了这些优化工作。

但是,显然我们也不能因为有了这些框架的出现,就直接不去了解和掌握关于图片的优化方面的相关知识了。所以在这里我们就来看看,摒弃这些框架,使用原汁原味的方式时,是否对图片进行优化会有多大的影响。首先,我们在相关的路径放置一张名为“test.jpg”的图片:

通过文件属性我们可以看到,该张图片的大小为1.13MB;像素为3504*2336。那么,我们首先来看一下,我们直接将原图读取到内存中,并且打印其相关信息会是如何:

        Bitmap rawBitmap = BitmapFactory.decodeFile(filePath);
        ivBeforeCompress.setImageBitmap(rawBitmap);
        float byteCount = (float) rawBitmap.getByteCount() / 1024 / 1024;
        tvBeforeCompress.setText("原图所占的内存空间为:" + (float) (Math.round(byteCount * 100)) / 100 + "MB"
                + "\n width pixel is : " + rawBitmap.getWidth() + "\n height pixel is" + rawBitmap.getHeight());

我们从如上截图中可以看到,也就是说我们将图片读取到内存过后其像素并未有何不同。而让人留意的是,虽然我们之前已经看到该图像文件自身的大小是1.13MB,但将其读取到内存过后发现竟然占到了31.22MB左右的空间。那么,现在我们对于图像究竟有多吃内存应该有了一个初步但直观的印象了。

一张图片既然消耗了如此大的内存,我们肯定就应该想办法弄清楚如何进行一些优化工作,让其不再占用那么大的内存消耗。那么,首先我们应该搞明白的自然就是一张图片在Android设备中占据的内存究竟是如何计算的呢?因为Android中图片是以bitmap形式存在的,所以我们关注的点就变为了:bitmap所占内存大小的计算方式。

事实上Bitmap所占内存大小的计算公式为:图片长度 x 图片宽度 x 一个像素点占用的字节数。其中图片长度和宽度很好理解,就是其宽高的像素,所以其实疑问点就在于一个像素点占用的字节数到底是如何计算的呢?这其实和bitmap采取的depth(颜色深度)的计算方式有关。而在Android中,为bitmap提供的depth在Bitmap类中的Config枚举中有定义,分别为:

在这之中,A代表透明度;R代表红色;G代表绿色;B代表蓝色。而它们具体的意义以及depth的计算方式为:

  • ALPHA_8
    表示8位Alpha位图,即A=8,一个像素点占用1个字节,它没有颜色,只有透明度
  • ARGB_4444
    表示16位ARGB位图,即A=4,R=4,G=4,B=4,一个像素点占4+4+4+4=16位,2个字节
  • ARGB_8888
    表示32位ARGB位图,即A=8,R=8,G=8,B=8,一个像素点占8+8+8+8=32位,4个字节
  • RGB_565
    表示16位RGB位图,即R=5,G=6,B=5,它没有透明度,一个像素点占5+6+5=16位,2个字节

那么,了解了以上相关的知识,我们就能知道为什么我们之前读取进内存的Bitmap占用这么大的内存空间了。我们已经知道了其图片像素,而Bitmap默认会采用ARGB_8888的方式来计算深度,所以我们读取这张图片所占用的内存空间的计算过程就是:

  • 3504 * 2336 * 4 / 1024 / 1024 = 31.224609375MB ≈ 31.22MB。

所以,很显然的,如果我们希望对于这张图片进行内存优化。那么我们可以考虑的显然就是两个方向:一个是减少它的像素;另一个自然就是从颜色深度上着手。第二种方式这里我们就不多加赘述了,简单举例来说,如果我们认为默认的ARGB_8888占用内存过大,则可以考虑换为RGB_565,因为位深度减少了一半,所以最终得到的bitmap对象也就减少了一半的内存占用。

这里我们重点看一下通过压缩像素来优化图片的内存占用,以我们的测试用图来说,它的宽高像素分别达到了3504和2336。但显然,很多时候我们在手机上是远远用不了这么高的像素的,所以我们可以根据具体情况适当的去对像素进行压缩。比如说我们现在让这张图片的宽高分别压缩一半,变为1752*1168,那么其占用的内存就变为了:

  • 1752 * 1168 * 4 / 1024 / 1024 = 7.80615234375MB ≈ 7.81MB

也就是说,因为宽高各减少了一半,所以最后整个bitmap所占的内存空间就直接减少了4倍左右。我们可以通过代码来验证一下我们的理解是否有误:

        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inSampleSize = 2;

        Bitmap sampledBitmap = BitmapFactory.decodeFile(filePath,options);

        ...

由此可以验证,我们的理解完全没有问题。而对bitmap进行像素压缩的方式也很简单,就是在option中设置好inSampleSize再读取bitmap就行了。inSampleSize的值就是我们进行压缩的比例,这里设置为2,我们可以看到宽高像素则分别被压缩为了原来的一半。同理,设置为4,则会被压缩值原本的1/4,以此类推。

并且我们可以看到,尽管将宽高像素分别压缩了一半,但是其实将其显示到ImageView上后,其实效果并没什么影响。这就是我们前面说到的,很多时候,手机上其实用不到那么高的像素。所以,其实我们究竟将像素压缩到什么程度最为合适呢?其实让其像素和用于显示它的控件的宽高相近是最好的。也就是说,我们需要配合控件的宽高来计算inSampleSize,而这其实也不复杂,我们定义如下两个方法:

    public static Bitmap decodeSampledBitmapFromFile(String filePath, int reqWidth, int reqHeight) {
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true; // 第一次测量时,只读取图片的像素宽高,但不将位图写入内存
        BitmapFactory.decodeFile(filePath, options);
        // 计算像素压缩的比例
        options.inSampleSize = calculateSampleSize(options, reqWidth, reqHeight);
        options.inJustDecodeBounds = false;
        // 将压缩过像素后的位图读入内存
        return BitmapFactory.decodeFile(filePath, options);
    }
    
    /**
     * 根据需求的宽高计算位图的像素压缩比例
     *
     * @param options
     * @param reqWidth
     * @param reqHeight
     * @return
     */
    private static int calculateSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
        int sampleSize = 1;

        int srcWidth = options.outWidth;
        int srcHeight = options.outHeight;

        if (srcWidth > reqWidth || srcHeight > reqHeight) {
            int widthRatio = Math.round((float) srcWidth / (float) reqWidth);
            int heightRatio = Math.round((float) srcHeight / (float) (reqHeight));
            sampleSize = widthRatio > heightRatio ? widthRatio : heightRatio;
        }

        return sampleSize;
    }

好的,以上的代码认真看下应该不难理解,思路就是我们配合最终需要的宽高以及图片本身的宽高来计算出sampleSize,然后根据该sampleSize对bitmap对象进行像素上的压缩。可以看到,在这个过程中我们其实会执行两次decodeFile,但需要注意的就是,第一次的时候,我们把option的inJustDecodeBounds设置为了true。它的意思就是指定本次decode行为不会真的将bitmap进行读取进内存,所以说此时我们如果去获取bitmap对象将返回null。但是尽管如此,它却仍然会将图片相关的信息,比如宽高读取进option当中。这其实就代表着第一次decode行为既不会消耗任何内存,但又能成功获取图片本身的宽高像素,之后我们就能根据它们计算出需要的sampleSize值,从而我们也就能decode得到像素压缩后的bitmap对象了。当然不要忘了的就是,在第二次decodeFile的时候,我们要记得把option当中的inJustDecodeBounds重新设置为false。然后我们仍然通过对应代码来验证一下:

如上图所示,在这个布局中,我将两个ImageView的宽高设置为了200dp,因为我测试的手机的屏幕密度是480,所以其最终实际的宽高就是600px。配合该宽高像素,我们发现最终的bitmap的像素则被压缩为了876*584,其所占内存空间则变为了仅仅1.95MB。我们可以看到压缩前后的bitmap在ImageView上的显示效果其实没有太大区别,但是占用的内存空间却减少了将近30MB。

我们来分析一下这里可能会出现的疑问,首先如果我们注意看,就会发现在这里:以我们定义的对于计算sampleSize的方法来说,最终计算的结果应该是6才对。但是我们通过压缩后的bitmap的像素来说,会发现似乎sampleSize应该是4才会得到这种像素。通过查看源码,发现BitmapFactory进行decodeFile其实最终是通过底层的native方法完成的。所以这里我也不太确定具体造成这种结果的原因。但是我自己通过测试发现:应该是当按照option中设定的sampleSize的值来进行计算时,如果得到的结果不为整数,就会将sampleSize减小,直到能计算出宽高都为整数的像素。也就是说,这里我们无论将sampleSize设为5,6,7,最终得到的像素都仍然是876584。而如果将sampleSize设为8,才会得到438292的像素。

另外一方面,本次我将两个ImageView的scaleType都改为了fit_xy,也就是说此时会不按比例的去缩放图片,让其宽高正好填充满整个ImageView。那么以原图来说,虽然其本来的像素高达35042336,但其实最终仍然要被压缩到600600的像素,所以简单来说我们也可以理解为有29041736的像素其实都可以视作是无效的。而以我们压缩至876584的图像来说,则在宽度方面仍然有276个像素点是多余的,而在高度方面因为不足以铺满ImageView,则需要进行拉伸。我们需要知道的是:这种拉伸行为肯定会在一定程度上导致图像清晰度的丢失,但是显然这里的拉伸量非常小,所以实际上最终我们在视觉上其实基本体会不到差异。

当然,只是通过这样的文字描述,可能有时候我们还是不太容易理解这个原理。所以这里我画了一张非常简易的草图,可以一起来看一下:

假设这里的图1和图2分别代表我们的原图和压缩过后的图,其中的圆圈我们就看作图中的像素点。那么图3和图4就分别对应于它们面临fit_xy的缩放处理方式,由此也可以看到对于宽度上的缩放,其实它们的效果是相同的,但是因为图2在高度上的像素本就已经低于标准,所以图4想要和图3保持相同的尺寸显示图像,就只能选择进行拉伸,自然也就导致其高度上的像素点之间的间隔变大;而另一层面上,如果更加专业一点的来说,现在的情况就是:在相同尺寸内的一块区域中,图3能够打印的像素点数比图4更多,所以就代表图4相对于图3,在图像显示的清晰度肯定会较差一些。

所以,简单来说也可以这样理解,如果我们想要对一张图像进行像素上的压缩,但是同时又不希望影响任何一点点的图像显示的质量。那么就需要保证原图的像素在经过压缩之后,仍然至少要大于等于显示它的ImageView的尺寸。举例来说,假设我们有一张20001500的原图,用于显示它的ImageView的宽高则是500500。那么我们肯定也可以选择将sampleSize设置为4,压缩后的图像的像素则变为了500375。从而因为压缩后的图像的高度已经不足以填满ImageView,所以如果想要完全显示这张图片,就肯定会导致精度的丢失。所以如果不希望出现这样的情况,我们的做法就应该是将sampleSize设置为2,从而将图像的像素压缩为1000750。所以总的来说,这个平衡本质上其实就是在精度丢失和内存开销中做取舍了。

到了这里,我相信我们一定能够非常清楚的体会到对图片进行合理的优化有多么的重要了。OK,这是我们谈到的第一种非常重要和实用的图像优化方案。接着我们来看另一种优化方案,即所谓的“质量压缩”,这是什么意思呢?我们暂且不提,先看一看这种压缩应该如何实现:

            fos = new FileOutputStream(path);
            bitmap.compress(Bitmap.CompressFormat.JPEG, quality, fos);

没错,其实非常简单,通过对原本的bitmap对象调用compress方法就能够实现所谓的质量压缩。这里的参数quality就代表质量压缩的程度,它的值我们可以在1-100之间进行选择,值越高质量也就越高。那么,这里我将quality设置为25来进行测试,看看最终的效果如何:

没错,经过质量压缩之后,我们惊奇的发现不管是占用的内存空间还是bitmap宽高像素都没有任何变化,这不是坑爹吗?其实并没有错,质量压缩并不会对bitmap的这些内容产生影响,但我们可以看到compress的第三个参数其实是一个输出流对象,这是不是意味着我们可以将该输出流输出到外部文件呢?答案是可以的。这里我将FileOutputStream的路径设置为了与test.jpg相同路径下的compress.jpg。看看会发生什么:

没错,可以看到经过质量压缩后重新写入存储空间的相同图片,在宽高没有发生变化的情况,文件大小由原本的1.13MB变为了301KB。这其实就是质量压缩的意义所在。如果我们将quality参数设置得更低,压缩后的文件大小将更小,但同时图片失真也会更严重,所以选择一个合适的quality也是比较重要的。关于质量压缩的具体方式可能就涉及更多关于jpeg图片自身的体积计算原理了,而我们需要记得的是因为jpeg格式是有损压缩格式,所以才能支持这种质量压缩;而例如png则是无损压缩格式,所以做这种压缩是没有用的。

同时这里也可以看到,通常我们对图像进行质量压缩的时候,都会先将原图的bitmap读取进内存,前面我们也说到读取像素越高的bitmap将会占用越多的内存,但其实同时还意味着读取行为耗费的时间也相对越长,所以很多时候我们最好采用异步加载的方式去读取图片,以避免因UI线程阻塞导致ANR。

好的,到了这里我们可以知道,对图片进行像素压缩可以减少将其读取进内存后的占用空间;而进行质量压缩则可以减小图片文件的体积。而实际上,将这两者配合使用好,按一定比例去对图像进行压缩,可以在最大程度保证图片效果不丢失过多的情况下减小图片的体积。所以,如何巧妙的利用好这两种技术来对图片进行优化是非常重要的,当然具体的使用还是根据实际的需求而定。

那么,我们不免会想对图像进行像素压缩是为了节约内存空间。那么,通过压缩减小图像文件的体积,意义又在哪呢?可能最显著的应用途径就是,能够减小图片上传时的流量消耗以及提高传输速度。但我们也知道对图片进行压缩肯定会在某种程度上降低图片原本的效果。所以,有的时候我们也会提供类似“发送原图”的选项。比如微信,这是因为它们并不只一个平台的客户端,虽然在手机上往往需要不了那么高的像素。但如果你的图片将会发送给电脑端的用户,那么情况就不同了。

最后值得一提的就是,请回忆一下,前面我们说到某些机型的相机拍摄的照片将会带有一定的旋转角度。那么,可能我们有时候因为手机上并不需要使用过高像素的图像,所以会被要求对拍摄到的照片进行一定程度的像素和质量压缩后重新存储,以减小图像文件的体积。这个时候就要注意了,通过我们之前说到的方法对图片完成压缩重新存储后,这个时候新的图片文件将会丢失掉旋转角度的信息。所以这个时候即使用我们之前所讲过的方法也无法让图像正确显示,所以当我们完成压缩重新存储后,一定要记得为新的图像文件写入对应的旋转角度信息。

public static void setBitmapDegree(String path, int degree) {
        try {
            // 从指定路径下读取图片,并获取其EXIF信息
            ExifInterface exifInterface = new ExifInterface(path);

            switch (degree) {
                case 90:
                    exifInterface.setAttribute(ExifInterface.TAG_ORIENTATION, String.valueOf(ExifInterface.ORIENTATION_ROTATE_90));
                    break;
                case 180:
                    exifInterface.setAttribute(ExifInterface.TAG_ORIENTATION, String.valueOf(ExifInterface.ORIENTATION_ROTATE_180));
                    break;
                case 270:
                    exifInterface.setAttribute(ExifInterface.TAG_ORIENTATION, String.valueOf(ExifInterface.ORIENTATION_ROTATE_270));
                    break;
            }
            exifInterface.saveAttributes();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

好了,大致就总结到这里吧。关于文章开头的演示动图里的DEMO,已经上传到了github,地址为:
https://github.com/RawnHwang/AndroidLaboratory/tree/master/AndroidPictureProcessing
如果有兴趣或者有需要 了解类似效果如何实现的朋友直接自己查看源码就行了,希望对你有所帮助。

推荐阅读更多精彩内容