在项目开发中会出现一些巨大图的加载问题,那么在加载大图的时候,如何避免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
}
}