Android自定义View(16) 《如何利用Bitmap加载一个8K高清图片》

概述

这两天在写一个控件用来显示多个图片组合,所以就仔细研究了一下Bitmap的加载问题,所以今天就写一下这个内容了。

从资源文件获取Bitmap

获取Bitmap我们主要通过BitmapFactory类来加载一张图片,那么接下来我们就用下面这个图片来演示各种情况下图片的加载吧~

girl.jpg

原图的分辨率是7680*4320,也就是说,如果按ARGB_8888的格式存储,每个像素占4个字节,那么这个图片转换成bitmap类后,足足会有7680x4320x4个字节,是一张实实在在的8k高清壁纸 (壁纸下载戳这里)
这个大小如果完全加载的话是肯定会内存溢出的,那么接下来我们开始尝试几种加载方式

1.全部加载

获取bitmap

 private fun loadBitmapCompletely(){
        bitmap = BitmapFactory.decodeResource(resources,R.drawable.girl)
        Log.d("LargeImageView","bitmap size ---> ${bitmap!!.byteCount}")
    }

在画布中绘制

canvas.drawBitmap(bitmap!!,viewRect,viewRect,null)

控件宽高矩阵的初始化

 @SuppressLint("ResourceType")
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        viewRect = Rect(0,0,width,height)
        loadBitmapCompletely()
    }

那么运行结果是这样的

2021-09-29 20:45:22.287 15880-15880/com.tx.txcustomview E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.tx.txcustomview, PID: 15880
    java.lang.RuntimeException: Canvas: trying to draw too large(1003622400bytes) bitmap.
        at android.graphics.RecordingCanvas.throwIfCannotDraw(RecordingCanvas.java:280)
        at android.graphics.BaseRecordingCanvas.drawBitmap(BaseRecordingCanvas.java:88)
        at com.tx.txcustomview.view.LargeImageView.onDraw(LargeImageView.kt:33)
        at android.view.View.draw(View.java:22473)
        at android.view.View.updateDisplayListIfDirty(View.java:21341)
        at android.view.View.draw(View.java:22201)
        at android.view.ViewGroup.drawChild(ViewGroup.java:4540)
        at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4299)
        at androidx.constraintlayout.widget.ConstraintLayout.dispatchDraw(ConstraintLayout.java:1882)
        at android.view.View.updateDisplayListIfDirty(View.java:21332)
        ...

好家伙,加载这么大一张图直接加载不出来了,所以我们现在开始打开正确的加载方式

2.压缩加载

首先,我们需要整理压缩加载图片的步骤

  • 1.首先需要获取Bimap的宽高,和view控件的宽高来确定采样率
  • 2.利用采样率来获取压缩后的图片
  • 3.将获取到的压缩后的图片跟控件宽高做对比,然后来再次进行bitmap缩放创建一个适应屏幕的bitmap
  • 4.最终这个适应控件宽高的bitmap就是我们要加载的图片了
    好,开始coding
    获取图片
 private fun loadBitmap(){
        // 获取图片的宽高
        var option = BitmapFactory.Options()
        // 设置inJustDecodeBounds为true可以解析出Bitmap的图片信息,比如宽高,但是不会获取bitmap对象,设为true以后调用
        // BitmapFactory.decodeResource()解析会返回null对象
        option.inJustDecodeBounds = true
        BitmapFactory.decodeResource(context.resources, R.drawable.girl,option)
        // 开始计算获取到的图片的宽高比,我们这里采用宽度优先,优先保证按宽度来显示图片,如果是垂直方向上的长图那就要再看情况了
        var fraction = option.outHeight.toFloat()/option.outWidth.toFloat()
        // 根据获取到的图片的宽高,和我们的实际控件的宽高来做对比,计算采样率
        // 比如我们有一张500x500的图,但是我们只需要显示100x100,那么我们采样率就可以设为4
        // 这样获取到的图片宽高就是125*125,然后我们可以再进行一次bitmap缩放来获取合适的bitmap,也可以缩小bitmap的占用内存
        option.inSampleSize = getSampleSize(option.outWidth,option.outHeight,width,(fraction*width).toInt())
        // 这里记住一定要设回false,否则获取不到图片
        option.inJustDecodeBounds = false
        // 开始获取压缩后的图片
        bitmap = BitmapFactory.decodeResource(context.resources,R.drawable.girl,option)
        // 对获取后的图片进行缩放处理,获取一个新的适合控件的图片
        var bmpTemp = postBitmap(bitmap!!,width, (fraction*width).toInt())
        if (bitmap!=null && !bitmap!!.isRecycled){
            bitmap!!.recycle()
        }
        bitmap = bmpTemp
        Log.d("LargeImageView","sampleSize ---> ${option.inSampleSize}")
        Log.d("LargeImageView","byteCount ---> ${bitmap!!.byteCount}")
    }

获取采样率的方法

      /**
      * 获取采样率
      */
    private fun getSampleSize(bmpW :Int ,bmpH : Int,targetW:Int ,targetH:Int) : Int{
        var sampleSize = 1
        while ((bmpW/sampleSize>=targetW)||(bmpH/sampleSize>=targetH)){
            sampleSize *= 2
        }
        return sampleSize
    }

缩放bitmap的方法

private fun postBitmap(bitmap:Bitmap, targetW: Int, targetH: Int):Bitmap{
        return Bitmap.createScaledBitmap(bitmap,targetW,targetH,false)
    }

然后我们把图片绘制一下

override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        var matrix = Matrix()
        matrix.postTranslate(0f, (height/2).toFloat()-bitmap!!.height)
        canvas.drawBitmap(bitmap!!,matrix,null)
    }

接下来我们看一下运行结果

2021-09-29 21:16:19.388 23385-23385/com.tx.txcustomview D/LargeImageView: sampleSize ---> 8
2021-09-29 21:16:19.388 23385-23385/com.tx.txcustomview D/LargeImageView: byteCount ---> 2622240

根据日志的打印我们可以看到图片的采样率变成了8,也就是说缩小了64倍的内存大小,从我们打印的内存结果也可以看出来内存明显缩小了一大截,总之我们现在是可以完整显示这个图而且不变形了
显示结果


large_image_view.png

那么问题来了,如果现在我们必须要完整显示整个图片的高清画质呢?那么接下来我们就要来进行正确的完整加载高清大图了

3.局部加载

首先在写代码之前我们需要知道局部加载的原理,局部加载主要依靠了BitmapRegionDecoder类,这个类可以解析Bitmap的局部内容,那么知道了工具类,我们再来了解一下加载的方式


large_view.PNG

中间的矩形也就是我们的显示区域,这个区域的获取是相对于Bitmap的一个矩形区域,我们解析的时候也是通过不断解析并且显示这部分的图片来进行显示的,知道了加载过程,直接上完整源码

package com.tx.txcustomview.view

import android.annotation.SuppressLint
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.util.Log
import android.view.MotionEvent
import android.view.View
import com.tx.txcustomview.R
import java.io.InputStream

/**
 * create by xu.tian
 * @date 2021/9/28
 */
public class LargeImageView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
    // 解析的Bitmap对象
    private var bitmap:Bitmap? = null
    // 获取到的Bitmap矩阵
    private lateinit var bitmapRect : Rect
    // 解析的区域在Bitmap矩阵中的位置矩阵区域
    private lateinit var positionRect : Rect
    // 控件View矩阵
    private lateinit var viewRect : Rect
    // 从资源文件中获取的输入流,因为要支持滑动刷新bitmap,为了避免重复创建对象,进行复用
    private lateinit var inputStream : InputStream
    // 解析器
    private lateinit var decoder: BitmapRegionDecoder
    
    // 手指按下的初始位置x左标
    private var startX = 0f
    // 手指按下的初始位置的y坐标
    private var startY = 0f
    
    // 绘制bitmap
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.drawBitmap(bitmap!!,viewRect,viewRect,null)
    }
    
    override fun onTouchEvent(event: MotionEvent): Boolean {
        when(event.action){
            MotionEvent.ACTION_DOWN ->{
                startX = event.x
                startY = event.y
            }
            MotionEvent.ACTION_MOVE ->{
                var dx = event.x - startX
                var dy = event.y - startY

                startX = event.x
                startY = event.y

                var left = positionRect.left-1.5*dx
                var top = positionRect.top-1.5*dy

                when {
                    left<0 -> {
                        positionRect.left = 0
                    }
                    left>bitmapRect.width()-width -> {
                        positionRect.left = bitmapRect.width()-positionRect.width()
                    }
                    else -> {
                        positionRect.left = left.toInt()
                    }
                }

                when {
                    top<0 -> {
                        positionRect.top = 0
                    }
                    top>bitmapRect.height()-height -> {
                        positionRect.top = bitmapRect.height()-positionRect.height()
                    }
                    else -> {
                        positionRect.top = top.toInt()
                    }
                }
                positionRect.right = positionRect.left+width
                positionRect.bottom = positionRect.top+height
                loadBitmapRegion()
                invalidate()
            }
        }
        return true
    }

    @SuppressLint("ResourceType")
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        viewRect = Rect(0,0,width,height)
        positionRect = Rect(0,0,width,height)
        inputStream = context.resources.openRawResource(R.drawable.girl)
        decoder = BitmapRegionDecoder.newInstance(inputStream,false)
        loadBitmapRegion()
    }
    @SuppressLint("ResourceType")
    private fun loadBitmapRegion(){
        var option = BitmapFactory.Options()
        option.inJustDecodeBounds = true
        BitmapFactory.decodeResource(context.resources, R.drawable.girl,option)
        bitmapRect = Rect(0,0,option.outWidth,option.outHeight)
        try {
            option.inJustDecodeBounds = false
            bitmap?.recycle()
            bitmap = decoder.decodeRegion(positionRect,option)
            Log.d("LargeImageView","byteCount ---> ${bitmap!!.byteCount}")
        }catch (e: Exception){
            e.printStackTrace()
        }

    }
    /**
     * 关闭输入流,回收bitmap
     */
    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        if (inputStream!=null){
            inputStream.close()
        }
        if(bitmap!=null && !bitmap!!.isRecycled){
            bitmap!!.recycle()
        }
    }
}

运行结果


large_image_view_completetly.gif

日志结果

2021-09-29 21:55:52.439 28684-28684/com.tx.txcustomview D/LargeImageView: byteCount ---> 9357120

这里的bitmap大小比我们最开始的压缩加载要大很多,是因为我们对之前的图片进行了基于宽度上的压缩,不过现在我们已经可以很nice的加载一张完整的高清大图啦~这个内存上的消耗是没有办法避免的咯,毕竟是为了高清的原生画质

总结要点

    1. 如果Bitmap分辨率比显示的区域大,那么我们可以进行合适的压缩,采样率在设置时只可以设置为2的倍数,然后再把这个bitmap进行一次针对view的缩放,这样就可以尽可能的在保证图片正常显示的基础上使用最小的内存
    1. 如果Bitmap分辨率比显示区域小,这时候千万不要再去利用一个新的bitmap来进行对这个这个bitmap的缩放,因为bitmap的大小和像素点数是有直接关系的,我们要合理的在canvas中利用Matrix类来对Bitmap绘制时进行缩放,这样并不会改变bitmap本身所占用的内存大小,而且与直接操作bitmap缩放是效果一致的
  • 3.如果需要完整加载一张高清大图,那么我们需要使用局部解析的方法,但是在这个过程中我们需要注意
    (1)输入流对象的控制,尽可能复用
    (2) 控制解析的矩形范围,在我写的例子中其实是还有优化空间的,我没有提前解析出一块更大的区域,这样就会导致在滑动的过程中会频繁的触发bitmap的创建和老bitmap对象的回收,而且也很耗性能,我的建议是提前设计好一块稍大一点的区域,直到用户滑到已经解析好的区域的边界时,再进行下一个区 域的解析,这样就可以降低bitmap对象的更新次数,不过这样也会同时增加这个bitmap的内存占用,不过流畅度和缓存大小总得有一个做出牺牲嘛

最后划重点!!!

view销毁时释放资源!

view销毁时释放资源!

view销毁时释放资源!

好了今天就这样吧,下篇再看写点啥。。。

你可能感兴趣的:(Android自定义View(16) 《如何利用Bitmap加载一个8K高清图片》)