Android中加载一张大图,如何正常显示且不发生OOM ?

问题

Android中,获取一个1000*20000(宽1000px,高20000px)的大图,如何正常加载显示且不发生OOM呢?

分析

Android系统会为应用分配一定大小的堆内存
而如果遇到高分辨率图片时,如果它的配置为ARGB(每个像素占4Byte)
那么它要消耗的内存为1000200004=800000000,大约是80MB
这样轻而易举的就耗尽了内存,出现OOM

当然这是用系统原生方法来加载Bitmap,多数情况下我们都是使用第三方库像GlideFresoPicasso,它们对大图加载做了一定处理,但我们不能仅仅停留在会用,更要搞清楚如何解决大图加载OOM问题。

解决这个问题的方法有两种

  • 图片采样率缩放
  • 利用BitmapRegionDecoder加载图片的一部分

下面我们都来讲一下

解决方案一 : 图片采样率缩放

这种方法的原理是,将图片按一定比例缩放,降低分辨率,从而减少内存占用,这里具体用到了BitmapFactory.Options对象。

BitmapFacotry.Options为BitmapFactory的一个内部类,它主要用于设定和存储BitmapFactory加载图片的一些信息。
下面是Options中需要用到的属性:

  • inJustDecodeBounds:如果设置为true,将不把图片的像素数组加载到内存中
  • outHeight:图片的高度
  • outWidth:图片的宽度
  • inSampleSize:设置此值后,图片将依据此采样率进行加载,不能设置为小于1的数,例如设置为4,分辨率宽和高将为原来的1/4,这个时候整体所占内存将是原来的1/16

需要注意的是 :
如果inSampleSize设置为小于1则会被认为是1,而且它如果为2的倍数
如果不是那么会向下取整2的倍数 (但是这个并不是所有Android版本都成立的)

代码示例

首先,我们需要将长图放到assets文件夹下
然后通过代码,读取到文件流

var inputStream = assets.open("image.jpg")  

接着,通过BitmapFactory.Options,设置inJustDecodeBounds = true,不读取图像到内存,仅读取图片信息 (这样子可以避免读取过程中就发生OOM),这样,我们就可以获取到图片的宽高了。

val opts = BitmapFactory.Options()
//注意这里是关键 --> 不读取图像到内存中,仅读取图片的信息
opts.inJustDecodeBounds = true
BitmapFactory.decodeStream(inputStream, null, opts)
//获取图片的宽高
val imgWidth = opts.outWidth
val imgHeight = opts.outHeight

接着,我们要计算出合适的采样率
通过图片实际宽高 / 目标ImageView宽高,可以得到合适的采样率
最终的采样率以最大的方向为准

val targetImageWidth = targetImageView.width
val targetImageHeight = targetImageView.height
//计算采样率
val scaleX = imgWidth / targetImageWidth
val scaleY = imgHeight / targetImageHeight
//采样率依照最大的方向为准
var scale = max(scaleX, scaleY)
if (scale < 1) {
    scale = 1
}

最后,我们再次打开长图的文件流,并将inJustDecodeBounds设为falseinSampleSize赋值为指定的采样率
得到最终缩放后的Bitmap,展示到ImageView中

 // false表示读取图片像素数组到内存中,依照指定的采样率
opts.inJustDecodeBounds = false
opts.inSampleSize = scale
//由于流只能被使用一次,所以需要再次打开
inputStream = assets.open("image.jpg")
val bitmap = BitmapFactory.decodeStream(inputStream, null, opts)
targetImageView.setImageBitmap(bitmap)

再来看一下完整的代码

private fun loadBigImage(targetImageView: ImageView) {
    var inputStream = assets.open("image.jpg")
    val opts = BitmapFactory.Options()
    //注意这里是关键 --> 不读取图像到内存中,仅读取图片的信息
    opts.inJustDecodeBounds = true
    BitmapFactory.decodeStream(inputStream, null, opts)
    //获取图片的宽高
    val imgWidth = opts.outWidth
    val imgHeight = opts.outHeight
    val targetImageWidth = targetImageView.width
    val targetImageHeight = targetImageView.height
    //计算采样率
    val scaleX = imgWidth / targetImageWidth
    val scaleY = imgHeight / targetImageHeight
    //采样率依照最大的方向为准
    var scale = max(scaleX, scaleY)
    if (scale < 1) {
        scale = 1
    }
    Log.i(TAG, "loadBigImage:$scale")
    // false表示读取图片像素数组到内存中,依照指定的采样率
    opts.inJustDecodeBounds = false
    opts.inSampleSize = scale
    //由于流只能被使用一次,所以需要再次打开
    inputStream = assets.open("image.jpg")
    val bitmap = BitmapFactory.decodeStream(inputStream, null, opts)
    targetImageView.setImageBitmap(bitmap)
}

运行后效果如下

Android中加载一张大图,如何正常显示且不发生OOM ?_第1张图片

解决方案二 : 图片按区域加载

有时候不仅要求不出现OOM,还要求不能压缩,完全展示,这种情况下就要用到BitmapRegionDecoder类了。

    /**
     * 传入图片
     * BitmapRegionDecoder提供了一系列的newInstance方法来构造对象,
     * 支持传入文件路径,文件描述符,文件的inputStream等
     */
    BitmapRegionDecoder bitmapRegionDecoder = BitmapRegionDecoder.newInstance(inputStream,false);
    Rect rect = Rect();
    BitmapFactory.Options opetion = BitmapFactory.Options();
    /**
     * 指定显示图片的区域
     * 参数一很明显的rect //参数二是BitmapFactory.Options,
     * 你可以告诉图片的inSampleSize,inPreferredConfig等
     */
    bitmapRegionDecoder.decodeRegion(rect, opetion);

BitmapRegionDecoder主要用于显示图片的某一块矩形区域,这个类非常适合加载分区区域加载大图。

为了显示大图的全部,那么伴随着部分显示,必将要添加手势,使其可以上下拖动查看。
这样就需要自定义一个控件了,而自定义这个控件思想也很简单。

  • 提供一个设置图片的入口
  • 重写onTouchEvent,在里面根据用户一定的手势,去更新显示区域的参数。
  • 没有更新区域参数后,调用invalidateonDraw里面再去regionDecoder.decodeRegion拿到Bitmap,去draw绘制,就可以了。

代码示例

首先,我们要创建一个自定义View : BigImageView

class BigImageView(context: Context, attrs: AttributeSet) : View(context, attrs) {}

接着,声明一些常量
其中rect用来表示绘制的区域,后面会对其进行赋值。
options就是BitmapFactory.Options

companion object {
	private var decoder: BitmapRegionDecoder? = null
	
	//图片的宽度和高度
	private var imageWidth: Int = 0
	private var imageHeight: Int = 0
	
	//绘制的区域
	private var rect = Rect()
	private val options = BitmapFactory.Options().apply {
	    inPreferredConfig = Bitmap.Config.RGB_565
	}
}

onTouchEvent事件里,处理滑动事件
ACTION_DOWN的时候都会赋值downY为当前位置
ACTION_MOVE移动的时候,调用rect.offset,来修改recttopleftrightbottom

var downX = 0F
var downY = 0F

override fun onTouchEvent(event: MotionEvent): Boolean {
    super.onTouchEvent(event)

    when (event.action) {
        MotionEvent.ACTION_DOWN -> {
            downX = event.x
            downY = event.y
        }

        MotionEvent.ACTION_MOVE -> {
            val dY = (event.y - downY).toInt()
            if (imageHeight > height) {
                rect.offset(0, -dY)
                checkHeight()
                invalidate()
            }
        }

        MotionEvent.ACTION_DOWN -> {}
    }

    return true
}

当然,还要判断和处理边界
如果rect.top小于0,那么就将rect.top赋值为0rect.bottom赋值为组件的高度

private fun checkHeight() {
    if (rect.bottom > imageHeight) {
        rect.bottom = imageHeight
        rect.top = imageHeight - height
    }
    if (rect.top < 0) {
        rect.top = 0
        rect.bottom = height
    }
}

然后需要将图片传入

fun setBitmap(inputStream: InputStream) {
    var tempOptions = BitmapFactory.Options()
    tempOptions.inJustDecodeBounds = true
    BitmapFactory.decodeStream(inputStream, null, tempOptions)
    imageWidth = tempOptions.outWidth
    imageHeight = tempOptions.outHeight
    decoder = BitmapRegionDecoder.newInstance(inputStream, false)
    requestLayout()
    invalidate()
}

onMeasure的时候,设置rect为图片的大小

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)

    if (rect.right == 0 && rect.bottom == 0) {
        val width = measuredWidth
        val height = measuredHeight
        rect.left = 0
        rect.top = 0
        rect.right = rect.left + width
        rect.bottom = rect.top + height
    }
}

draw方法的时候,绘制指定区域

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)

    val bitmap = decoder?.decodeRegion(rect, options)
    if (bitmap != null) {
        canvas?.drawBitmap(bitmap, 0F, 0F, null)
    }
}

最后在Activity中调用

val inputStream =  assets.open("image.jpg")
binding.bigImageView.setBitmap(inputStream)

来看下完整的代码

class BigImageView(context: Context, attrs: AttributeSet) : View(context, attrs) {

    companion object {
        private var decoder: BitmapRegionDecoder? = null

        //图片的宽度和高度
        private var imageWidth: Int = 0
        private var imageHeight: Int = 0

        //绘制的区域
        private var rect = Rect()
        private val options = BitmapFactory.Options().apply {
            inPreferredConfig = Bitmap.Config.RGB_565
        }
    }

    var downX = 0F
    var downY = 0F

    override fun onTouchEvent(event: MotionEvent): Boolean {
        super.onTouchEvent(event)

        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                downX = event.x
                downY = event.y
            }

            MotionEvent.ACTION_MOVE -> {
                val dY = (event.y - downY).toInt()
                if (imageHeight > height) {
                    rect.offset(0, -dY)
                    checkHeight()
                    invalidate()
                }
            }

            MotionEvent.ACTION_DOWN -> {}
        }

        return true
    }

    private fun checkHeight() {
        if (rect.bottom > imageHeight) {
            rect.bottom = imageHeight
            rect.top = imageHeight - height
        }
        if (rect.top < 0) {
            rect.top = 0
            rect.bottom = height
        }
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)

        val bitmap = decoder?.decodeRegion(rect, options)
        if (bitmap != null) {
            canvas?.drawBitmap(bitmap, 0F, 0F, null)
        }
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)

        if (rect.right == 0 && rect.bottom == 0) {
            val width = measuredWidth
            val height = measuredHeight
            rect.left = 0
            rect.top = 0
            rect.right = rect.left + width
            rect.bottom = rect.top + height
        }
    }

    fun setBitmap(inputStream: InputStream) {
        var tempOptions = BitmapFactory.Options()
        tempOptions.inJustDecodeBounds = true
        BitmapFactory.decodeStream(inputStream, null, tempOptions)
        imageWidth = tempOptions.outWidth
        imageHeight = tempOptions.outHeight
        decoder = BitmapRegionDecoder.newInstance(inputStream, false)
        requestLayout()
        invalidate()
    }
}

效果如下所示

小结

到现在,我们就明白如何在Android中加载一张大图了

  • 方案一 : 图片采样率缩放 : 利用BitmapFactory.Options.inJustDecodeBounds先获取图片宽高,在通过设置采样率inSampleSize来缩小图片尺寸,从而达到减小图片大小的目的
  • 方案二 : 图片按区域加载 : 通过bitmapRegionDecoder.decodeRegion来绘制图片指定区域,来避免一次性加载整张图片,只显示屏幕需要的图片,从而避免OOM

本文源码下载 : Android中加载一张大图示例Demo

你可能感兴趣的:(Android日常经验,android,加载大图,OOM,按区域加载,分段加载)