学习笔记-android大图加载详解

Posted 涂程

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了学习笔记-android大图加载详解相关的知识,希望对你有一定的参考价值。

关于android中大图处理的采样、缩放、平移、分块、并行、渐进加载。

1. 图片加载基础

1.1. 参数意义

图片加载过程涉及到

  • dpi:屏幕像素密度,每英寸内的像素点数,基准密度是160dpi
  • density:密度,比例值,等价于dpi/160
  • dp:密度无关像素单位,在所有屏幕上显示效果一直,1dp相当于160dpi的屏幕上面的一个像素。
  • px:实际像素单位,相当于dp * density

像素点存储的数据格式使用Bitmap.config 表示,用 ARGB来表示的,A 表示透明度;R 表示红色;G 表示绿色;B 表示蓝色。

  • ALPHA_8:A占8位,共占用1个字节,只有透明度,没有颜色。
  • RGB_565:R占5位,G占6位,B占5位,共占用2个字节。
  • ARGB_4444:每个通道各占4位,共占用2个字节。显示效果较差。
  • ARGB_8888:每个通道各占8位,共占用4个字节。
  • RGBA_F16:每个通道各占16位,共占用8个字节。

1.2. 内存占用

图片有多重存储方式,webp,png,jpg等等,他们会根据各自的压缩规则进行压缩。

当需要把图片加载到内存中时,会把每个像素点都加载到内存中,不会对相同像素进行压缩或者替换。

所以会有一个简单的公式,Bitmap 的内存占用等于Bitmap宽度 * Bitmap高度 * 单位像素所占用字节

众所周知,Bitmap有绕不过去的问题,就是OOM。对Bitmap的优化重点就是内存问题,通过公式可以看出来,优化只能通过两个方面来,一是宽高,二是单位像素所占用字节。

单位像素所占用字节只能通过更改Bitmap.config改变像素存储格式来调整,比如如果没有ALPHA通道的话,从ARGB_8888更改为RGB_565,每个像素占用的空间会直接小一倍,Bitmap占用的内存也会直接小一倍,貌似看起来是很喜人的结果。

虽然很多的地方都在说,ARGB_8888更改为RGB_565对显示效果影响不大,但来算一下的话,ARGB_8888每个通道2^8 = 256 ,而RGB_565的只有2^5 = 32。这个数量级相差的巨大。也就意味着图片的质量也是相差巨大的,大多数的场景下还是建议不要这样。

既然单位所占用字节不建议去做,那就只能在尺寸上下功夫。

这是后就需要使用Bitmap.Options图片进行解码时的配置参数,来控制加载的尺寸,比如:

  • inJustDecodeBounds:如果设置true,只查询Bitmap,但不加载对应像素数据,用于获得图片的数据,比如长、宽之类的。
  • inSampleSize:采样比例,比如设置为4的话,就会将原始图片4 * 4的像素块读取为1 * 1的像素块。需要设置为2的指数,否则向下取整。

1.3. 使用采样比例优化

当要把一张图片显示出来的时候,显示的区域是有限的,dpi是有限的,所能展示的也是有限的。而图片的像素块又是无限的,这就是优化的第一步,加载屏幕显示上限的分辨率与加载原始图片的分辨率,显示效果并不会有变化。换一个更直接的说法,一个物理像素块最多只能展示一个图像像素块的数据,所以加载图片时,只需要让尺寸满足大于物理像素块即可。

具体的可以设置一个minimumDPi,表示图片显示的最小dpi,用来控制最小的加载加载密度。

有了dpi的加入之后,像素点px的计算就转换成了dp的计算,又px = dp * dpi,所以目标像素数量reqWidth / minimumDPi = vWidth * averageDpi,原始大小和目标像素的比值就是采样比例。

val vWidth: Int = 0  // view宽高,px
val vHeight: Int = 0
val sWidth: Int = 0 // 图片宽高
val sHeight: Int = 0
val minimumDPi = 320 //可以设置一个的最低Dpi

private fun calculateInSampleSize(): Int {
    val metrics: DisplayMetrics = getResources().getDisplayMetrics()
    val averageDpi = (metrics.xdpi + metrics.ydpi) / 2 //屏幕dpi
    //Dpi比例
    val scaleDpi = 1f * minimumDPi / averageDpi

    //显示的目标像素数量
    val reqWidth = (vWidth * scaleDpi).toInt()
    val reqHeight = (vHeight * scaleDpi).toInt()

    var inSampleSize = 1

    if (sHeight > reqHeight || sWidth > reqWidth) {
        //原图像素量与目标像素量的比值就是采样比例
        val heightRatio = (1f * sHeight / reqHeight).roundToInt()
        val widthRatio = (1f * sWidth / reqWidth).roundToInt()
        //取较小值,对应的是FIT_CENTER的显示方式。
        inSampleSize = if (heightRatio < widthRatio) heightRatio else widthRatio
    }
    //保证2的指数
    var power = 1
    while (power * 2 < inSampleSize) {
        power *= 2
    }
    return if (power > 1) power / 2 else power
}

2. 图片手势操作

前面介绍了使用采样比例优化的方法,处理的场景是图片只在控件中进行展示。为了可以继续分析图片在控件中可以放大缩小和平移的情况,先介绍一下手势操作的实现。

2.1. 坐标系

在视图坐标系中,以左上角为原点,横向为X轴,纵向为Y轴,单位长度是像素点。

以同样的道理设立一个图片坐标系,以左上角为原点,横向为X轴,纵向为Y轴,单位长度是原始图片像素点,原始图片像素点。重要的说两遍,只有使用原始图片像素点坐标系才是固定的,采样后不一定固定。

2.2. 坐标系偏移

图片的放大缩放和平移的过程中,实际上就是两个坐标系的相互关系发生了改变。

定义两个参数来表示缩放比例和偏移值,将两个坐标系联系起来。

  • scale:缩放比例。相同面积内,视图像素点数与原始图片像素点数的比值。
  • translate:偏移值。图片原点在视图坐标系中的位置。

使用定义的scaletranslate,可以很简单完成两个坐标系的相互转换。

比如有一个点,在视图坐标系坐标为vPoint,在图片坐标系坐标为sPoint,那么两者的关系应该是:

vPoint = sPoint * scale + translate

这样就可以完全的确认一个点在视图以及图片中的位置。

2.3. 参数初始化

默认图片的显示方式是FIT_CENTER,即把图片按比例扩大/缩小到View的宽度,居中显示。那么在初始化的时候:

  • 把图片按比例扩大/缩小到View的宽度,长或者宽中较小的一个与view宽高相同,那么这时候的缩放比例也就是是控件大小和原始图片大小比值中的较小值。
  • 图片居中显示并且有一边对齐,那么偏移值是控件大小和图片显示大小差值的一半。
fun initScaleAndTranslate(){
    scale = Math.min(vWidth / sWidth, vHeight / sHeight)
    translate.x = (vWidth - scale * sWidth) / 2
    translate.y = (vHeight - scale * sHeight) / 2
}

2.4. 手势处理

接着是在手势操作与缩放比例以及偏移量的关系。这里只介绍双指的处理。

为了让手势操作跟手,处理的原则只有一点,两个手指的距离和中心点在图片坐标系中是固定的。

在视图坐标系,两个手指的距离d和两个手指的中心点PointCenter,对应scaletanslation的变化。

手指在图片坐标系中距离s固定,又s * scale = dscale的值应该是:

s1 = s2 
d1 / scale1 = d2 / scale2 
scale2 = scale1 * (d2 / d1)

手指在图片坐标系中心点坐标sPointCenter固定,又vPoint = sPoint * scale + translationtranslation应该是:

sPointCenter1 = sPointCenter2
(vPointCenter1 - translation1) / scale1 = (vPointCenter2 - translation2) / scale2
vtranslation2 = vPointCenter2 - (vPointCenter1 - translation1) * (scale2 / scale1)
vtranslation2 = vPointCenter2 - (vPointCenter1 - translation1) * (d2 / d1)

下面是具体的代码实现:

var scaleStart: Float = 0f
var vDistStart = 0f

var leftStart = 0f
var topStart = 0f

fun onTouchEvent(event: MotionEvent) {
    if (event.pointerCount < 2) return
    when (event.action) {
        MotionEvent.ACTION_POINTER_2_DOWN -> {

            //两个手指起始触碰点的绝对距离
            vDistStart = distance(event.getX(0), event.getX(1), event.getY(0), event.getY(1))

            scaleStart = scale

            //两个手指起始触碰点的中心点坐标
            val centerStartX = (event.getX(0) + event.getX(1)) / 2
            val centerStartY = (event.getY(0) + event.getY(1)) / 2

            //公式计算出的中间值
            leftStart = centerStartX - translate.x
            topStart = centerStartY - translate.y
        }
        MotionEvent.ACTION_MOVE -> {

            //两个手指触碰点的绝对距离
            val vDistEnd: Float = distance(event.getX(0), event.getX(1), event.getY(0), event.getY(1))

            //两个手指触碰点的中心点坐标
            val vCenterEndX = (event.getX(0) + event.getX(1)) / 2
            val vCenterEndY = (event.getY(0) + event.getY(1)) / 2

            //缩放比例调整
            scale = scaleStart * (vDistEnd / vDistStart)

            //偏移值调整
            val leftNow = leftStart * (vDistEnd / vDistStart)
            val topNow = topStart * (vDistEnd / vDistStart)
            translate.x = vCenterEndX - leftNow
            translate.y = vCenterEndY - topNow
        }
        ...
    }
}
private fun distance(x0: Float, x1: Float, y0: Float, y1: Float): Float {
    val x = x0 - x1
    val y = y0 - y1
    return sqrt(x * x + y * y)
}

2.5. 新的采样比例计算方法

scale定义的是相同面积内视图像素点数与原始图片像素点数的比值,是px的比值。

而采样比例前面推导的的是两者之间dp的比值,而这也就是scaleDpi的倒数。

所以前面计算采样比例的方法在这里可以大大精简。

val minimumDPi = 320 //可以设置不同的最低Dpi

private fun calculateInSampleSize(): Int {
    val metrics: DisplayMetrics = getResources().getDisplayMetrics()
    val averageDpi = (metrics.xdpi + metrics.ydpi) / 2 //屏幕dpi
    //Dpi比例
    val scaleDpi = 1f * minimumDPi / averageDpi

    val inSampleSize = (1f / scale / scaleDpi).roundToInt()

    //保证2的指数
    var power = 1
    while (power * 2 < inSampleSize) {
        power *= 2
    }
    return if (power > 1) power / 2 else power
}

3. 图片加载与显示

在交互放大的过程中,scale一直在变化,也就所需要的采样比例也可能发生变化。

在这个过程中,如果每一次都将图片不同采样比例完整的加载,就会导致一张图片的多种分辨率加载进内存,甚至超过将图片完全加载的大小。

另一点采样比例的变化肯定是伴随着图片的放大,而在这个时候,图片中可能大部分的区域都不会显示在屏幕中,也就是多了很多并没有用到的内存占用。

3.1. BitmapRegionDecoder

官方给出了一个方案BitmapRegionDecoder,使用它可以只加载指定区域的的图像。

BitmapRegionDecoder使用比较简单,通过newInstance()创建BitmapRegionDecoder对象。构造时的布尔值参数表示创建的流是否强引用(false表示强引用)。

val filePath = ""
val decoder = BitmapRegionDecoder.newInstance("imgPath", false)

创建好对象之后就可以调用方法decodeRegion()加载对应区域的bitmap,参数分别代表加载的区域和配置。

val rect = Rect()
val options = BitmapFactory.Options().also {
    it.inSampleSize = calculateInSampleSize()
}
val bitmap = decoder.decodeRegion(rect, options)

3.2. 分块

有了BitmapRegionDecoder之后,并不能直接使用来加载,图片的操作过程中,会非常频繁的改变显示区域,直接加载消耗不可估量。

比较好的解决方法是分块加载。

分块加载的基本原理就是将图片切割成一小块一小块的区域,等到这部分进入到了显示区域去加载它。

将最高的采样比例完全加载,作为背景放置。图片放大达到新的采样比例之后,将当前需要显示的图像快再加载进来,进行绘制,就可以很好的平衡分辨率和内存之间的问题。

为了方便管理分块,建立一个分块对象。

每个对象中维护自身的位置信息,以及加载所需数据和加载状态。

分块之后,每一块独自进行加载,并且加载bitmap也是个耗时操作,将加载bitmap异步实现。这样单独的块加载完成后触发重绘即可。

class Tile(
    sampleSize: Int,
    val sRect: Rect, //加载区域
    val decoder: BitmapRegionDecoder, //加载器
    val decoderLock: ReadWriteLock //锁
) {
    var bitmap: Bitmap? = null
    var loading = false
    var visible = false
    private val options = BitmapFactory.Options()

    init {
        options.inSampleSize = sampleSize
    }

    //加载bitmap
    suspend fun startLoadBitmap() {
        if (loading || !visible) return
        loading = true
        bitmap = withContext(Dispatchers.IO) {
            tryLoadBitmap()
        }
        loading = false
    }

    private fun tryLoadBitmap(): Bitmap? {
        decoderLock.readLock().lock()
        try {
            return decoder.decodeRegion(sRect, options)
        } catch (e: Exception) {
        }
        finally {
            decoderLock.readLock().unlock()
        }
        return null
    }
}

View中触发加载,使用lifecycleScope启动协程,忽略掉生命周期的处理。

因为在Tile.startLoadBitmap()中做了阻塞,所以在bitmap加载完成后才会调用invalidate()触发重绘。

fun loadBitmap(tile: Tile) {
    findViewTreeLifecycleOwner()?.lifecycleScope?.launch {
        tile.startLoadBitmap()
        invalidate()
    }
}

3.3. 分块逻辑

分块的触发应该在初始化完成之后,拿到了最高的采样比例,将所有可能出现的采样比例全部分好块。

对于最高的采样比例不进行分块,需要全部加载进来,作为整体的背景进行展示。

对于其他的采样比例都需要进行分块,分块的规则不需要过于严格,只需要分割形成的像素块的大小和控件的大小比较接近即可。

这里采用的分割后每块的大小不大于控件大小1.25倍的最小分割数。

    val tileMap = LinkedHashMap<Int, List<Tile>>() //所有的切片对象,key值是SampleSize,维持插入顺序

    val decoder = BitmapRegionDecoder.newInstance("filePath", false) //加载器
    val decoderLock: ReadWriteLock = ReentrantReadWriteLock(true) //锁
    var maxSampleSize: Int = 32 //最小缩放比例下的采样率,也就是最高的采样率

    fun initTiles() {
        tileMap.clear()
        maxSampleSize = calculateInSampleSize() //初始化时这里是最高采样率

        //首先创建最高采样率的切片并触发加载,这个切片直接是图片的大小,作为背景
        val tile = Tile(
            sampleSize = maxSampleSize,
            sRect = Rect(0, 0, sWidth, sHeight),
            decoder = decoder,
            decoderLock = decoderLock
        )
        tile.visible = true
        loadBitmap(tile) //触发加载
        tileMap[maxSampleSize] = listOf(tile)

        //从最高采样率/2,一直递归创建所有切片(采样率只能是2的指数)
        var sampleSize = maxSampleSize / 2

        //两个方向的切片数
        var xTiles = 1
        var yTiles = 1
        while (sampleSize > 1) {
            //切片的原始大小
            var sTileWidth: Int = sWidth / xTiles
            var sTileHeight: Int = sHeight / yTiles
            //切片的加载大小
            var subTileWidth = sTileWidth / sampleSize
            var subTileHeight = sTileHeight / sampleSize

            //一直增加切片,直到满足分割后每块的大小不大于控件大小*1.25
            while (subTileWidth > vWidth * 1.25) {
                xTiles += 1
                sTileWidth = sWidth / xTiles
                subTileWidth = sTileWidth / sampleSize
            }
            while (subTileHeight > vHeight * 1.25) {
                yTiles += 1
                sTileHeight = sHeight / yTiles
                subTileHeight = sTileHeight / sampleSize
            }

            //创建tile
            val tileGrid = mutableListOf<Tile>()
            for (x in 0 until xTiles) {
                for (y in 0 until yTiles) {
                    tileGrid.add(
                        Tile(
                            sampleSize = sampleSize,
                            sRect = Rect(
                                x * sTileWidth, //根据切片大小和位置计算出在图片坐标系中的位置
                                y * sTileHeight,
                                (x + 1) * sTileWidth,
                                (y + 1) * sTileHeight
                            ),
                            decoder = decoder,
                            decoderLock = decoderLock
                        )
                    )
                }
            }
            tileMap[sampleSize] = tileGrid
            sampleSize /= 2
        }
    }

3.4. 状态刷新

分好块之后还需要有一个触发加载和回收的逻辑,原则就是现在它在显示范围内就触发加载,离开显示范围就触发回收。

实将控件的区域映射到图片的坐标系上,然后和每块维护的位置信息做判断,重叠就加载显示,不重叠就触发回收。

fun refreshTiles() {
    val sampleSize = calculateInSampleSize() //当前采样率

    //将控件映射到图片的坐标系
    val vRect = RectF()
    vRect.left = (0 - translate.x) / scale  
    vRect.right = (vWidth - translate.x) / scale
    vRect.top = (0 - translate.y) / scale
    vRect.bottom = (vHeight - translate.y) / scale

    tileMap.values.flatten().forEach { tile ->
        if (tile.sampleSize == maxSampleSize) { 
            // 确保作为背景的bitmap一直存在
            tile.visible = true
            if (!tile.loading && tile.bitmap == null) {
                loadBitmap(tile)
            }
        } else {
            // 采样比例相等并且可见触发加载,否则触发回收
            if (tile.sampleSize == sampleSize && isTileVisible(tile.sRect, vRect)) {
                tile.visible = true
                if (!tile.loading && tile.bitmap == null) {
                    loadBitmap(tile)
                }
            } else {
                tile.visible = false
                tile.bitmap?.recycle()
                tile.bitmap = null
            }
        }
    }
}

//判断两个区域是否有重叠
fun isTileVisible(sRect: Rect, vRect: RectF): Boolean {
    return !(vRect.left > sRect.right
            || vRect.right < sRect.left
            || vRect.top > sRect.bottom
            || vRect.bottom < sRect.top)
}

3.5. 绘制

前面已经准备好了所有东西,现在只差一部,把这些切片绘制到屏幕上。

绘制前首先检测所需要显示的tile是否全部加载完成。如果加载完成了就只需要绘制这部分即可,如果加载没完成就需要将背景也绘制一下。

tileMapLinkedHashMap,循环时保证了按照插入的顺序,从下向上渲染,越下面分辨率越低。也就是低分辨率的作为背景绘制。

//绘制
override fun onDraw(canvas: Canvas?) {
    if (tileMap.isEmpty()) {
        return
    }
    val sampleSize = min(maxSampleSize, calculateInSampleSize())

    //检查需要显示的tile是否全部加载完成
    var hasMissingTiles = false
    tileMap[sampleSize]?.forEach { tile ->
        if (tile.visible && (tile.loading || tile.bitmap == null)) {
            hasMissingTiles = true
        }
    }

    //如果显示的tile加载完成,只绘制这部分即可
    //如果没有加载完成,会尝试对tile进行绘制
    if Android 大图加载显示

DOM探索之基础详解——学习笔记

Android:使用 Webview 从资产加载大图像

在片段中重新加载android视图

Android高效加载大图多图解决方案,有效避免程序OOM

Android 加载大图