安卓全面大图、多图、压缩处理

2,089 阅读9分钟

前序:

个人原因我有点想吐槽这个平台,草稿里写了蛮多的东西,因为习惯了mac的快捷键,就操作了一下,东西没了,所幸联系平台,后续版本会进行优化.

前言

大图的处理一直是面试中必问的考点,也是工作中时不时碰到的需求。为此我将基于不同场景进行分析。(当然这里面的内容很多都是参考其他博客整合的,为了日后自我提升)

场景一:小ImageView上显示超大的图片

在一个很小的ImageView上显示一张超大的图片不会带来任何视觉上的好处,但却会占用我们相当多宝贵的内存。而系统为我们程序提供的内存大小是有限制的(超出了会导致OOM),我们可以通过下面的代码看出每个应用程序最高可用内存是多少

int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
Log.d("TAG", "Max memory is " + maxMemory + "KB");

比如,你的ImageView只有12896像素的大小,只是为了显示一张缩略图,这时候把一张1024768像素的图片完全加载到内存中显然是不值得的。

因此对于这种情况,压缩图片势在必行。那怎么合理压缩呢,这就引申出BitmapFactory这个类。


BitmapFactory这个类提供了多个解析方法(decodeByteArray, decodeFile, decodeResource等)用于创建Bitmap对象,我们应该根据图片的来源选择合适的方法。比如SD卡中的图片可以使用decodeFile方法,网络上的图片可以使用decodeStream方法,资源文件中的图片可以使用decodeResource方法。这些方法会尝试为已经构建的bitmap分配内存,这时就会很容易导致OOM出现。为此每一种解析方法都提供了一个可选的BitmapFactory.Options参数,将这个参数的inJustDecodeBounds属性设置为true就可以让解析方法禁止为bitmap分配内存,返回值也不再是一个Bitmap对象,而是null。虽然Bitmap是null了,但是BitmapFactory.Options的outWidth、outHeight和outMimeType属性都会被赋值。这个技巧让我们可以在加载图片之前就获取到图片的长宽值和MIME类型,从而根据情况对图片进行压缩。代码示例

BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(getResources(), R.id.myimage, options);
int imageHeight = options.outHeight;
int imageWidth = options.outWidth;
String imageType = options.outMimeType;

那我们怎样才能对图片进行压缩呢?通过设置BitmapFactory.Options中inSampleSize的值就可以实现。比如我们有一张20481536像素的图片,将inSampleSize的值设置为4,就可以把这张图片压缩成512384像素。原本加载这张图片需要占用13M的内存,压缩后就只需要占用0.75M了(假设图片是ARGB_8888类型,即每个像素点占用4个字节)。下面的方法可以根据传入的宽和高,计算出合适的inSampleSize值:

public static int calculateInSampleSize(BitmapFactory.Options options,
		int reqWidth, int reqHeight) {
	// 源图片的高度和宽度
	final int height = options.outHeight;
	final int width = options.outWidth;
	int inSampleSize = 1;
	if (height > reqHeight || width > reqWidth) {
		// 计算出实际宽高和目标宽高的比率
		final int heightRatio = Math.r
        ound((float) height / (float) reqHeight);
		final int widthRatio = Math.round((float) width / (float) reqWidth);
		// 选择宽和高中最小的比率作为inSampleSize的值,这样可以保证最终图片的宽和高
		// 一定都会大于等于目标的宽和高
		inSampleSize = heightRatio < widthRatio ? heightRatio : widthRatio;
	}
	return inSampleSize;
}

提供了计算合适的inSampleSize值的方法后,那我们就将具体使用。

public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId,
        int reqWidth, int reqHeight) {
	// 第一次解析将inJustDecodeBounds设置为true,来获取图片大小
    final BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;
    BitmapFactory.decodeResource(res, resId, options);
    // 调用上面定义的方法计算inSampleSize值
     //inSampleSize 任何小于等于1的值都与1相同。注意:*解码器使用基于2的幂的最终值,任何其他值将*舍入到最接近的2的幂。
    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
    // 使用获取到的inSampleSize值再次解析图片
    options.inJustDecodeBounds = false;
    return BitmapFactory.decodeResource(res, resId, options);
}

上面需要注意inJustDecodeBounds这个变量,该值为true的时候进行解析并不会返回bitmap 而是null,这就意味着系统不会该bitmap分配内存。
第三步就是直接在项目中调用该方法

mImageView.setImageBitmap(
    decodeSampledBitmapFromResource(getResources(), R.id.myimage, 100, 100));

至此,第一种场景就处理完了。

第二种场景:页面上加载多图

页面上处理单图相对还是比较简单的,但是如果页面上加载一堆图片,那我们可能就需要担心会不会导致OOM,也许你们会说限制内存大小来尽可能的显示多图,但是比如listview这种呢,你理论上是可以放无数张照片的。那我们就需要考虑怎么保证内存的使用始终维持在一个合理的范围,同时在界面上迅速地加载图片,你又能比较快的加载这些图片,而不会出现OOM。

  • 1.保证保证内存的使用始终维持在一个合理的范围 其实这里面保证并不是在这个地方保证的,都是如listview,gridview去保证的。所以实际场景中我们不可避免去使用这些控件。很多博客提到通过缓存技术(基本上就是LruCache)去实现,这个说法其实是有问题的。LruCache并不会减少内存使用,它不会释放内存,相反会增加内存使用,使用LruCache的目的 不是避免OOM 而是为了加快加载的速度,避免反复从网络 或者磁盘获取图片。通过listview怎么保证去加载多图的时候不会出现OOM呢?这里面其实主要是listview的复用机制,可参考ListView复用和优化详解,通过我们去重写getView(...)方法去保证view复用,而你的listview中内存之所以没有增加,是因为listview会重用之前的ImageView, 而ImageView重新setImageBitmap, 就释放掉了对之前bitmap的引用,之前的bitmap就会被回收,因此能保持内存的不变,再多的图片也不会挂
  • 2.快速的加载图片 如果我们每次都得从网络上,或者磁盘里读取图片资源这个是比较耗时,且影响用户体验的。这时候才会用到缓存技术(LruCache),限制内存的大小,同时将图片尽可能的保存在内存里,这样读取速度就会快很多,增强用户体验,只有当超出内存约束的范围时,我们才考虑去磁盘、网络上获取。那么内存我们应该设置多大呢,一般是使用最大可用内存值的1/8作为缓存的大小。
// 获取到可用内存的最大值,使用内存超出这个值会引起OutOfMemory异常。
	// LruCache通过构造函数传入缓存值,以KB为单位。
	int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
	// 使用最大可用内存值的1/8作为缓存的大小。
	int cacheSize = maxMemory / 8;

下面是一个使用 LruCache 来缓存图片的例子:

private LruCache<String, Bitmap> mMemoryCache;
 
@Override
protected void onCreate(Bundle savedInstanceState) {
	// 获取到可用内存的最大值,使用内存超出这个值会引起OutOfMemory异常。
	// LruCache通过构造函数传入缓存值,以KB为单位。
	int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
	// 使用最大可用内存值的1/8作为缓存的大小。
	int cacheSize = maxMemory / 8;
	mMemoryCache = new LruCache<String, Bitmap>(cacheSize) {
		@Override
		protected int sizeOf(String key, Bitmap bitmap) {
			// 重写此方法来衡量每张图片的大小,默认返回图片数量。
			return bitmap.getByteCount() / 1024;
		}
	};
}
 
public void addBitmapToMemoryCache(String key, Bitmap bitmap) {
	if (getBitmapFromMemCache(key) == null) {
		mMemoryCache.put(key, bitmap);
	}
}
 
public Bitmap getBitmapFromMemCache(String key) {
	return mMemoryCache.get(key);
}

--------------------------------
//优先从内存中加载,其次按理来说应该是从磁盘加载,此处略掉,最后才从网络加载
public void loadBitmap(int resId, ImageView imageView) {
	final String imageKey = String.valueOf(resId);
	final Bitmap bitmap = getBitmapFromMemCache(imageKey);
	if (bitmap != null) {
		imageView.setImageBitmap(bitmap);
	} else {
		imageView.setImageResource(R.drawable.image_placeholder);
		BitmapWorkerTask task = new BitmapWorkerTask(imageView);
		task.execute(resId);
	}
}

--------------------------
//BitmapWorkerTask 还要把新加载的图片的键值对放到缓存中。
class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
	// 在后台加载图片。
	@Override
	protected Bitmap doInBackground(Integer... params) {
		final Bitmap bitmap = decodeSampledBitmapFromResource(
				getResources(), params[0], 100, 100);
		addBitmapToMemoryCache(String.valueOf(params[0]), bitmap);
		return bitmap;
	}
}

至此,不管是要在程序中加载超大图片,还是要加载大量图片,都不用担心OOM的问题了。 当然啦,引用的博客有讲到实现照片墙效果,其实就是一个列表图片而已,我觉得没什么好讲的,如有兴趣可参考Android照片墙应用实现实现,里面用到的是异步、网络请求等知识。

第三种场景:完整加载、显示巨图

第一种场景我们是讲到如何去压缩大图在小ImageView上显示,但是实际上对于图片加载还有种情况,就是单个图片非常巨大,并且还不允许压缩。比如显示:世界地图、清明上河图、微博长图等。

那么对于这种需求,该如何做呢?

首先不压缩,按照原图尺寸加载,那么屏幕肯定是不够大的,并且考虑到内存的情况,不可能一次性整图加载到内存中,所以肯定是局部加载,那么就需要用到一个类:

  • BitmapRegionDecoder 其次,既然屏幕显示不完,那么最起码要添加一个上下左右拖动的手势,让用户可以拖动查看。

那么综上,本篇博文的目的就是去自定义一个显示巨图的View,支持用户去拖动查看,大概的效果图如下: BitmapRegionDecoder主要用于显示图片的某一块矩形区域,如果你需要显示某个图片的指定区域,那么这个类非常合适。

对于该类的用法,非常简单,既然是显示图片的某一块区域,那么至少只需要一个方法去设置图片;一个方法传入显示的区域即可;详见:

BitmapRegionDecoder提供了一系列的newInstance方法来构造对象,支持传入文件路径,文件描述符,文件的inputstrem等。

  • BitmapRegionDecoder bitmapRegionDecoder = BitmapRegionDecoder.newInstance(inputStream, false); 上述解决了传入我们需要处理的图片,那么接下来就是显示指定的区域。
  • bitmapRegionDecoder.decodeRegion(rect, options);
    参数一很明显是一个rect,参数二是BitmapFactory.Options,你可以控制图片的inSampleSize,inPreferredConfig等。 下面列举一个例子:
mImageView = (ImageView) findViewById(R.id.id_imageview);  
        try  
        {  
            InputStream inputStream = getAssets().open("tangyan.jpg");  
  
            //获得图片的宽、高  
            BitmapFactory.Options tmpOptions = new BitmapFactory.Options();  
            tmpOptions.inJustDecodeBounds = true;  
            BitmapFactory.decodeStream(inputStream, null, tmpOptions);  
            int width = tmpOptions.outWidth;  
            int height = tmpOptions.outHeight;  
  
            //设置显示图片的中心区域  
            BitmapRegionDecoder bitmapRegionDecoder = BitmapRegionDecoder.newInstance(inputStream, false);  
            BitmapFactory.Options options = new BitmapFactory.Options();  
            options.inPreferredConfig = Bitmap.Config.RGB_565;  
            Bitmap bitmap = bitmapRegionDecoder.decodeRegion(new Rect(width / 2 - 100, height / 2 - 100, width / 2 + 100, height / 2 + 100), options);  
            mImageView.setImageBitmap(bitmap);  
  
  
        } catch (IOException e)  
        {  
            e.printStackTrace();  
        }  

上述代码,就是使用BitmapRegionDecoder去加载assets中的图片,调用bitmapRegionDecoder.decodeRegion解析图片的中间矩形区域,返回bitmap,最终显示在ImageView上。 当然,有了显示大图功能 而一个页面由显示不下,那就会涉及到拖动啊,缩放啊、双击啊等一系列手势操作,这块参考Android 高清加载长图或大图方案(这篇文章还是有些许小问题的,但是是我们入门的好去处)。

小结:基本上加载大图场景就是这些情况了。不过这边我还想做一些其他场景的延伸。

延伸

场景补充一:参考博客性能优化——Android图片压缩与优化的几种方式

上面讲到的图片压缩是通过改变inSample的值,其实只是图片压缩方式的一种。其实有更换图片格式,质量压缩,采样率压缩,缩放压缩,调用jpeg压缩等。

  • 图片格式压缩 Android目前常用的图片格式有png,jpeg和webp,

png:无损压缩图片格式,支持Alpha通道,Android切图素材多采用此格式

jpeg:有损压缩图片格式,不支持背景透明,适用于照片等色彩丰富的大图压缩,不适合logo

webp:是一种同时提供了有损压缩和无损压缩的图片格式,派生自视频编码格式VP8,从 谷歌官网 来看,无损webp平均比png小26%,有损的webp平均比jpeg小25%~34%,无损webp支持Alpha通道,有损webp在一定的条件下同样支持,有损webp在Android4.0(API 14)之后支持,无损和透明在Android4.3(API18)之后支持,采用webp能够在保持图片清晰度的情况下,可以有效减小图片所占有的磁盘空间大小

  • 质量压缩 质量压缩并不会改变图片在内存中的大小,仅仅会减小图片所占用的磁盘空间的大小,因为质量压缩不会改变图片的分辨率,而图片在内存中的大小是根据 widthheight一个像素的所占用的字节数 计算的,宽高没变,在内存中占用的大小自然不会变,质量压缩的原理是通过改变图片的位深和透明度来减小图片占用的磁盘空间大小,所以不适合作为缩略图,可以用于想保持图片质量的同时减小图片所占用的磁盘空间大小。另外, 由于png是无损压缩,所以设置quality无效,
File sdFile = Environment.getExternalStorageDirectory();
   File originFile = new File(sdFile, "originImg.jpg");
   Bitmap originBitmap = BitmapFactory.decodeFile(originFile.getAbsolutePath());
   ByteArrayOutputStream bos = new ByteArrayOutputStream();
   originBitmap.compress(format, quality, bos);
   try {
       FileOutputStream fos = new FileOutputStream(new File(sdFile, "resultImg.jpg"));
       fos.write(bos.toByteArray());
       fos.flush();
       fos.close();
   } catch (FileNotFoundException e) {
       e.printStackTrace();
   } catch (IOException e) {
       e.printStackTrace();
   }

  • 采样率压缩,也就是我们的场景一 采样率压缩是通过设置BitmapFactory.Options.inSampleSize,来减小图片的分辨率,进而减小图片所占用的磁盘空间和内存大小。
  • 缩放压缩 通过减少图片的像素来降低图片的磁盘空间大小和内存大小,可以用于缓存缩略图
public void compress(View v) {
    File sdFile = Environment.getExternalStorageDirectory();
    File originFile = new File(sdFile, "originImg.jpg");
    Bitmap bitmap = BitmapFactory.decodeFile(originFile.getAbsolutePath());
    //设置缩放比
    int radio = 8;
    Bitmap result = Bitmap.createBitmap(bitmap.getWidth() / radio, bitmap.getHeight() / radio, Bitmap.Config.ARGB_8888);
    Canvas canvas = new Canvas(result);
    RectF rectF = new RectF(0, 0, bitmap.getWidth() / radio, bitmap.getHeight() / radio);
    //将原图画在缩放之后的矩形上
    canvas.drawBitmap(bitmap, null, rectF, null);
    ByteArrayOutputStream bos = new ByteArrayOutputStream();
    result.compress(Bitmap.CompressFormat.JPEG, 100, bos);
    try {
        FileOutputStream fos = new FileOutputStream(new File(sdFile, "sizeCompress.jpg"));
        fos.write(bos.toByteArray());
        fos.flush();
        fos.close();
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    }
}