大图加载简单整理

61 阅读11分钟

在项目开发中会出现一些巨大图的加载问题,那么在加载大图的时候,如何避免oom 问题;以及在加载过程中,大图的滚动事件,双击放大缩小事件等如何处理呢?

基本上在 java 代码进行大图加载需要考虑到的功能点如下:

1.进行大图的局部加载,避免直接将图片整体直接加载到内存中;这里使用到的是BitmapRegionDecoder,局部区域加载;

2.获取到大图的宽高,根据情况判断是否更改图片色值 (RGB565)降低内存占用;

以及针对加载图片 bitmap 的复用;使用到BitmapFactory.Options();

3.进行图片的手势移动,惯性滚动;图片的双击放大或者缩小;使用到依次是GestureDetector,ScaleGestureDetector;

4.需要将区域加载的图片,以及缩放后的图片进行绘制出来;使用到了

canvas.drawBitmap(bitmap, null, dstRect, null)进行 bimap 的绘制;

5.其他还涉及到了针对放大缩小的比例控制;在放大缩小时依触摸点为中心点进行图片的放大和缩小功能;

各个功能点的实现情况

1.区域加载功能实现如下:

   //1.创建区域加载实例
    decoder = BitmapRegionDecoder.newInstance(inputStream, false)
   //2.进行区域图片的渲染
     decoder?.let { dec ->
                if (srcRect.width() > 0 && srcRect.height() > 0) {
                    val bitmap = dec.decodeRegion(srcRect, options)
                    drawBitmap(bitmap, null, dstRect, null)
                }
            }

像素点区域:是针对大图的的像素点加载区域;

    其中 srcRect 属于加载的像素点区域;在大图加载用分为像素点区域和屏幕区域两个值;区域加载是加载大图上的像素点区域,这个地方需要明确(这个位置写的时候理解错了,出现了无法区域加载问题);

2.在加载的时候需要获取到屏幕展示区域的宽高,以及大图的宽高;

大图的宽高需要通过BitmapFactory.Options()进行获取,这个地方需要避免直接将图片直接加载到内存获取到宽高;代码如下:

 //1.创建options
  private val options = BitmapFactory.Options()
 //2.在加载图片流的时候获取对应尺寸
 options.inJustDecodeBounds = true
 BitmapFactory.decodeStream(inputStream, null, options)
 //获取到图片的宽高
  mImageHeight = options.outHeight
  mImageWidth = options.outWidth
 options.inJustDecodeBounds = false
 //开启进行复用
 options.inMutable = true
    //更改图片的配置
options.inPreferredConfig = Bitmap.Config.RGB_565
 //3.在 draw 方法中进行bitmap 复用
   //复用 bitmap
options.inBitmap = bitmap
 //生成区域bitmap
  val bitmap = dec.decodeRegion(srcRect, options)

上述代码就是区域加载的以及 bitmap 复用的逻辑处理;

3.针对手势移动以及惯性滚动,以及双击放大事件逻辑如下:

1)首先要将触摸事件传递给手势时间以及手势缩放事件,进行处理;

2)针对手势移动事件,需要针对点onDown,onScroll,onFling,onDoubleTapEvent事件进行处理;

3)在 onFling中使用overScroller.fling方法,进行惯性滚动处理,需要注意滚动方向需要加负号;需要处理滚动的边界值问题;

4)需要重新computeScroll方法针对惯性滚动进行处理,以及边界值问题进行处理。

5)在onScroll方法需要进行手势移动,这个地方需要考虑到移动的边界值问题。避免移除界外;

6)onDown事件时,需要强制关闭上次的惯性滚动事件;在方法后面需要返回 true;

7)针对双击放大事件;需要获取到点击点位置,作为触摸中心点,同时获取到当前放大的比例,如果超过最大放大比例就一直缩小,如果小于最小比例,就进行一直放大;针对后续的获取像素点区域逻辑;同后面的手势放大;

整体代码如下:

//1.
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        //进行布局的测量,获取到 view 的宽高
        viewWidth = MeasureSpec.getSize(widthMeasureSpec)
        viewHeight = MeasureSpec.getSize(heightMeasureSpec)
}
//2.
   private val gestureDetector: GestureDetector by lazy {
        GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
        //6.   
         override fun onDown(e: MotionEvent): Boolean {
                //点击的时候关闭移动
                if (!overScroller.isFinished) {
                    overScroller.forceFinished(true)
                }
                //在点击的时候记录开始时的区域大小
                recordRect.set(srcRect)
                return true
            }
//5.
            override fun onScroll(
                e1: MotionEvent?,
                e2: MotionEvent,
                distanceX: Float,
                distanceY: Float
            ): Boolean {
                srcRect.offset(distanceX.toInt(), distanceY.toInt())
                // 限制水平边界
                if (srcRect.left < 0) {
                    srcRect.left = 0
                    srcRect.right = recordRect.width()
                }
                if (srcRect.top < 0) {
                    srcRect.top = 0
                    srcRect.bottom = recordRect.height()
                }
                if (srcRect.right > mImageWidth) {
                    srcRect.right = mImageWidth
                    srcRect.left = (mImageWidth - recordRect.width()).coerceAtLeast(0)
                }

                if (srcRect.bottom > mImageHeight) {
                    srcRect.bottom = mImageHeight
                    srcRect.top = (mImageHeight - recordRect.height()).coerceAtLeast(0)
                }
                invalidate()
                return true
            }
//3.
            override fun onFling(
                e1: MotionEvent?,
                e2: MotionEvent,
                velocityX: Float,
                velocityY: Float
            ): Boolean {
                //这个地方需要设置边界值,maxX,maxY 是最大的移动量还是?
                overScroller.fling(
                    srcRect.left, srcRect.top, (-velocityX).toInt(),
                    (-velocityY).toInt(), 0, mImageWidth - srcRect.width(), 0,
                    mImageHeight - srcRect.height()
                )
                return true
            }
        //7.
            override fun onDoubleTapEvent(e: MotionEvent): Boolean {
                //在双击的时候,判断当前的缩放率,然后进行变大或者缩小;
                /**
                 * 想要实现效果在双击的时候,如果达到阈值再次点击就变小,需要一个变大或者变小的倍率
                 * 双击的时候,需要获取到倍率,然后根据倍率进行图片的变大或者缩小
                 */
                //实现一直放大或者一直缩小功能
                if (scaleRatio >= MAX_SCALE_VALUE) {
                    magnifyScale = false
                } else if (scaleRatio <= MIN_SCALE_VALUE) {
                    magnifyScale = true
                }
                //获得新的倍率后进行数据的变大缩小计算
                if (magnifyScale) {
                    scaleRatio /= 0.85f
                } else {
                    scaleRatio *= 0.85f//每次缩减是0.8
                }

                calculateNewArea(e.rawX, e.rawY)
                return true
            }
        })
    }
//4.
    override fun computeScroll() {
        super.computeScroll()
        if (overScroller.isFinished) {
            return
        }
        if (overScroller.computeScrollOffset()) {
            srcRect.top = overScroller.currY
            srcRect.left = overScroller.currX
            // 这个地方为啥 不直接加上可见视图的宽度,因为 rect 区域展示的是图片的像素点
            srcRect.right = ((overScroller.currX + (viewWidth / scaleRatio)).toInt())
            srcRect.bottom = ((overScroller.currY + (viewHeight / scaleRatio)).toInt())
        }
        postInvalidateOnAnimation()
    }

4.针对手势缩放使用到了scaleGestureDetector事件。

同样事件的获取页面在 onMeasure 方法中进行获取;代码如上 『1.』;在手势缩放实现图片的放大缩小,主要是展示像素区域改变的思路如下;

1.获取到触摸中心点坐标(这个坐标是针对view 展示区域的坐标;不是针对大图像素点的坐标);

2.获取到缩放比例后的像素点区域宽高

3.然后根据触摸中心点的坐标获取到当前对应像素区域的中心的坐标;

4.获取到区域左边 left(像素点左侧坐标);像素区域中心点坐标 - 旧区域宽高/2 +旧区域 left或者 top;得到对应图片像素点 对应的 left和top 的值

5.然后根据缩放后的宽高,获取区域新的 right 和bottom

6.然后设置限制条件,避免在缩小和放大时超出最大值范围;

7.重新赋值像素区域的值,并进行重新绘制;

    private fun calculateNewArea(centerX: Float, centerY: Float) {
        //获取到实际最大的缩放比例,如果不设置最小比例会出现白边情况;
        scaleRatio = scaleRatio.coerceIn(minScale, MAX_SCALE_VALUE)
        // 计算缩放后的新的区域宽高
        val newWidth = ((viewWidth / scaleRatio)).toInt()
        val newHeight = (viewHeight / scaleRatio).toInt()
        //获取到当前像素点区域中心点的坐标
        val focusXInSrc = centerX / viewWidth * recordRect.width()
        val focusYInSrc = centerY / viewHeight * recordRect.height()
        //获取到了中心点位置,获取到了像素区域的宽度,知道了记录区域的左边距
        var left = focusXInSrc - newWidth / 2 + recordRect.left
        var top = focusYInSrc - newHeight / 2 + recordRect.top
        var right = left + newWidth
        var bottom = top + newHeight
        // 边界修正
        if (left < 0) {
            left = 0f
            right = newWidth.coerceAtMost(mImageWidth).toFloat()
        }
        if (top < 0) {
            top = 0f
            bottom = newHeight.coerceAtMost(mImageHeight).toFloat()
        }
        if (right > mImageWidth) {
            right = mImageWidth.toFloat()
            left = (right - newWidth).coerceAtLeast(0f)
        }
        if (bottom > mImageHeight) {
            bottom = mImageHeight.toFloat()
            top = (bottom - newHeight).coerceAtLeast(0f)
        }
        srcRect.set(left.toInt(), top.toInt(), right.toInt(), bottom.toInt())
        invalidate()
    }

在手势事件中有两个方法

override fun onScaleBegin(detector: ScaleGestureDetector)

override fun onScaleEnd(detector: ScaleGestureDetector)

如果有些操作需要在手势前后处理可以在该方法中进行处理;

其他功能点:

1.在代码中一共创建了3个 Rect 值;分别是dstRect,srcRect,recordRect;

desRect:属于屏幕展示区域的值,在onMeasure 方法中进行设置,在

canvas.drawBitmap(bitmap, null, dstRect, null)中进行使用;

srcRect:属于像素点区域的值;同样在 onMearsure 中进行初始化,并在手势移动,滚动,手势缩放,双击中进行 srcRect 值的改变,来实现展示区域的局部加载;最后再 ondraw 中进行局部加载;val bitmap = dec.decodeRegion(srcRect, options);

recordRect:属于在每次点击的时候,获取到srcRect 的值,然后在手势缩放,双击缩放时,通过 recordRect 的值来生成新的像素点的区域值;

2.在页面关闭的时候进行 bitmap 的移除操作;

缺陷:

1.没有在大图加载时开始加载位置没有提供可设置方法;当前初始就是展示在左上角。可以在onMeasure 方法中针对初始化的 srcRect 进行更改; 2.没有配置 style针对展示的最大值和最小值,以及双击缩放的比例值没有提供只有设置入口; 3.没有使用 matrix 实现手势缩放功能的逻辑,水平有限尝试几次,效果控制不好; 4.细节没有打磨;

整体代码:

class BigImgLoadView
@JvmOverloads
constructor(context: Context, attributeSet: AttributeSet? = null) :
    View(context, attributeSet, 0) {
    companion object {
        const val MAX_SCALE_VALUE = 2f
        const val MIN_SCALE_VALUE = 0.5f
    }


    //创建 Rect,创建 overScroller
    private val overScroller = OverScroller(context)
    private val srcRect = Rect()
    private val dstRect = RectF()

    //    布局的宽度
    private var viewWidth: Int = 0
    private var viewHeight: Int = 0

    //图片的实际宽度
    private var mImageWidth: Int = 0
    private var mImageHeight: Int = 0

    //图片的缩放比例,默认为1
    private var scaleRatio = 1f

    private val options = BitmapFactory.Options()

    private var decoder: BitmapRegionDecoder? = null

    private var bitmap: Bitmap? = null

    private var minScale = MIN_SCALE_VALUE

    //默认加载图片点击时是先放大,达到一定阈值后,就进行缩小。
    private var magnifyScale = true

    init {
        findViewTreeLifecycleOwner()?.let { lifeOwner ->
            lifeOwner.lifecycleScope.launch {
                lifeOwner.repeatOnLifecycle(Lifecycle.State.DESTROYED) {
                    //在生命周期结束时进行一些处理
                }
            }
        }
    }


    private val gestureDetector: GestureDetector by lazy {
        GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
            override fun onDown(e: MotionEvent): Boolean {
                //点击的时候关闭移动
                if (!overScroller.isFinished) {
                    overScroller.forceFinished(true)
                }
                //在点击的时候记录开始时的区域大小
                recordRect.set(srcRect)
                return true
            }

            override fun onScroll(
                e1: MotionEvent?,
                e2: MotionEvent,
                distanceX: Float,
                distanceY: Float
            ): Boolean {
                srcRect.offset(distanceX.toInt(), distanceY.toInt())
                // 限制水平边界

                if (srcRect.left < 0) {
                    srcRect.left = 0
                    srcRect.right = recordRect.width()

                }
                if (srcRect.top < 0) {
                    srcRect.top = 0
                    srcRect.bottom = recordRect.height()
                }
                if (srcRect.right > mImageWidth) {
                    srcRect.right = mImageWidth
                    srcRect.left = (mImageWidth - recordRect.width()).coerceAtLeast(0)
                }

                if (srcRect.bottom > mImageHeight) {
                    srcRect.bottom = mImageHeight
                    srcRect.top = (mImageHeight - recordRect.height()).coerceAtLeast(0)
                }

                invalidate()
                return true
            }

            override fun onFling(
                e1: MotionEvent?,
                e2: MotionEvent,
                velocityX: Float,
                velocityY: Float
            ): Boolean {
                //这个地方需要设置边界值,maxX,maxY 是最大的移动量还是?
                overScroller.fling(
                    srcRect.left, srcRect.top, (-velocityX).toInt(),
                    (-velocityY).toInt(), 0, mImageWidth - srcRect.width(), 0,
                    mImageHeight - srcRect.height()
                )
                return true
            }

            override fun onDoubleTapEvent(e: MotionEvent): Boolean {
                //在双击的时候,判断当前的缩放率,然后进行变大或者缩小;
                /**
                 * 想要实现效果在双击的时候,如果达到阈值再次点击就变小,需要一个变大或者变小的倍率
                 *
                 * 双击的时候,需要获取到倍率,然后根据倍率进行图片的变大或者缩小
                 */
                //实现一直放大或者一直缩小功能
                if (scaleRatio >= MAX_SCALE_VALUE) {
                    magnifyScale = false
                } else if (scaleRatio <= MIN_SCALE_VALUE) {
                    magnifyScale = true
                }
                //获得新的倍率后进行数据的变大缩小计算
                if (magnifyScale) {
                    scaleRatio /= 0.85f
                } else {
                    scaleRatio *= 0.85f//每次缩减是0.8
                }

                calculateNewArea(e.rawX, e.rawY)
                return true
            }
        })
    }

    //计算获得新的区域值
    /**
     * 1.获取到中心点坐标
     * 2.获取到缩放比例后的区域宽高
     * 3.然后根据中心点的坐标获取到当前对应像素区域的中心的坐标(属于缩放后区域的中心点坐标,其实是一个区域中对应位置点的值,)
     * 4.获取到区域左边 left(像素点左侧坐标);像素区域中心点坐标 - 旧区域宽高/2 +旧区域 left或者 top;得到对应图片像素点 对应的 left和top 的值
     * 5.然后根据缩放后的宽高,获取区域新的 right 和bottom
     * 6.然后设置限制条件,
     * 7.重新赋值
     */
    private fun calculateNewArea(centerX: Float, centerY: Float) {
        //获取到实际最大的缩放比例,如果不设置最小比例会出现白边情况;
        scaleRatio = scaleRatio.coerceIn(minScale, MAX_SCALE_VALUE)

        // 手势中心点在 View 坐标系,
        // 计算缩放后的 srcRect 大小
        val newWidth = ((viewWidth / scaleRatio)).toInt()
        val newHeight = (viewHeight / scaleRatio).toInt()
        //获取到区域中心点的坐标
        val focusXInSrc = centerX / viewWidth * recordRect.width()
        val focusYInSrc = centerY / viewHeight * recordRect.height()

        //获取到了中心点位置,获取到了像素区域的宽度,知道了记录区域的左边距
        var left = focusXInSrc - newWidth / 2 + recordRect.left
        var top = focusYInSrc - newHeight / 2 + recordRect.top
        var right = left + newWidth
        var bottom = top + newHeight

        // 边界修正
        if (left < 0) {
            left = 0f
            right = newWidth.coerceAtMost(mImageWidth).toFloat()

        }
        if (top < 0) {
            top = 0f
            bottom = newHeight.coerceAtMost(mImageHeight).toFloat()
        }
        if (right > mImageWidth) {
            right = mImageWidth.toFloat()
            left = (right - newWidth).coerceAtLeast(0f)
        }

        if (bottom > mImageHeight) {
            bottom = mImageHeight.toFloat()
            top = (bottom - newHeight).coerceAtLeast(0f)
        }

        srcRect.set(left.toInt(), top.toInt(), right.toInt(), bottom.toInt())
        invalidate()
    }


    override fun computeScroll() {
        super.computeScroll()
        if (overScroller.isFinished) {
            return
        }
        if (overScroller.computeScrollOffset()) {
            srcRect.top = overScroller.currY
            srcRect.left = overScroller.currX
            // 这个地方为啥 不直接加上可见视图的宽度,因为 rect 区域展示的是图片的像素点
            srcRect.right = ((overScroller.currX + (viewWidth / scaleRatio)).toInt())
            srcRect.bottom = ((overScroller.currY + (viewHeight / scaleRatio)).toInt())
        }
        postInvalidateOnAnimation()
    }


    /**
     * 问题:
     * 如何在放到最指定比例时,不进行移动呢?
     * 如何在放大的时候不进行滚动呢?
     * 在放大的时候,是不是由于区域固定了。但是却不断的计算之前的 left导致位置一直发生改变
     */

    //创建一个新的 rect用于记录 每次缩放后的 srcRect 用于在每次手势缩放时,针对记录 rect 进行处理
    //属于缩放事件
    private val recordRect = Rect()
    private val scaleGestureDetector by lazy {
        ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
            override fun onScale(detector: ScaleGestureDetector): Boolean {
                //这个是在滚动和 fling 时进行除获取到的尺寸的大小
                scaleRatio *= detector.scaleFactor
                //计算中心点位置
                calculateNewArea(detector.focusX, detector.focusY)

                return true
            }

            /*            override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
                            //记录
                            Timber.e("--------- > move end onScaleBegin start $recordRect  ... src $srcRect")
                            recordRect.set(srcRect)
                            Timber.e("--------- > move end onScaleBegin $recordRect")
                            return super.onScaleBegin(detector)
                        }

                        override fun onScaleEnd(detector: ScaleGestureDetector) {
                            super.onScaleEnd(detector)
                            Timber.e("--------- > move end onScaleEnd ")
                            recordRect.set(srcRect)
                        }*/
        })
    }

    fun setImageResource(inputStream: InputStream) {
        try {
            // 保证流可重复读取
            val bufferedInput = if (inputStream.markSupported()) {
                inputStream
            } else {
                BufferedInputStream(inputStream)
            }
            //仅仅获取图片的宽高尺寸,避免将整个图片加载到内存中
            options.inJustDecodeBounds = true
            bufferedInput.mark(Int.MAX_VALUE)

            //解析流数据
            BitmapFactory.decodeStream(inputStream, null, options)
            bufferedInput.reset()
            //获取到图片的宽高
            mImageHeight = options.outHeight
            mImageWidth = options.outWidth

            // 如果需要 计算合适的采样率 inSampleSize 在这个地方设置
//            options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)=
            options.inJustDecodeBounds = false
            //进行复用
            options.inMutable = true
            //更改图片的配置
            options.inPreferredConfig = Bitmap.Config.RGB_565
            //解码,如果需要获取到整体的 bitmap采用这种解封方式
//             BitmapFactory.decodeStream(bufferedInput, null, options)
            //区域解码
            decoder = BitmapRegionDecoder.newInstance(inputStream, false)
        } catch (e: Exception) {
            e.printStackTrace()
        }

        requestLayout()
    }


    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        //将事件交给手势
        event?.let {
//            手势监听
            gestureDetector.onTouchEvent(it)
            //缩放监听
            scaleGestureDetector.onTouchEvent(it)
        }
        return true
    }


    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        //进行布局的测量,获取到 view 的宽高
        viewWidth = MeasureSpec.getSize(widthMeasureSpec)
        viewHeight = MeasureSpec.getSize(heightMeasureSpec)
        //配置区域,也就是暂时的像素点区域,这个地方就是起始点从图片的左上角开始

        srcRect.set(0, 0, viewWidth, viewHeight)

        dstRect.set(
            0f,
            0f,
            viewWidth.toFloat(),
            viewHeight.toFloat()
        )
        //获取到最小值
        minScale = min(
            mImageWidth / mImageHeight.toFloat(),
            mImageHeight / mImageWidth.toFloat()
        ).coerceAtMost(MIN_SCALE_VALUE)
        Timber.e("------- > scale Ratio .. $srcRect ..$dstRect")
    }


    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        dstRect.set(0f, 0f, w.toFloat(), h.toFloat())
        srcRect.set(0, 0, w, h)
    }


    @SuppressLint("DrawAllocation")
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        //将区域解码的数据进行绘制
        if (decoder == null) {
            return
        }
        canvas.withSave {
            //复用 bitmap
            options.inBitmap = bitmap
            decoder?.let { dec ->
                if (srcRect.width() > 0 && srcRect.height() > 0) {
                    val bitmap = dec.decodeRegion(srcRect, options)
                    drawBitmap(bitmap, null, dstRect, null)
                }
            }
        }
        /**
         *出现滚动异常的原因:
         * decodeRegion(preRect, options)
         * 已经把大图裁剪成了一个新的 Bitmap(区域内容)。
         * 这个新的 bitmap 的坐标系是 从 (0,0) 开始,宽高就是 preRect 的大小。
         * 也就是说,decodeRegion 返回的 bitmap 是一个“小图”,它只包含原始大图的一部分。
         * drawBitmap(bitmap, src, dst, paint)
         * 如果 src = null → 表示把整个小图绘制到 dst。
         * 如果 src != null → 又会在这个“小图”里再裁一次区域。
         */
    }
    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        bitmap?.recycle()
        bitmap = null
    }
}