關于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:偏移值,圖片原點在視圖坐標系中的位置,
使用定義的scale和translate,可以很簡單完成兩個坐標系的相互轉換,
比如有一個點,在視圖坐標系坐標為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,對應scale和tanslation的變化,
手指在圖片坐標系中距離s固定,又s * scale = d,scale的值應該是:
s1 = s2
d1 / scale1 = d2 / scale2
scale2 = scale1 * (d2 / d1)
手指在圖片坐標系中心點坐標sPointCenter固定,又vPoint = sPoint * scale + translation,translation應該是:
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的比值,而這也就是scale乘Dpi的倒數,
所以前面計算采樣比例的方法在這里可以大大精簡,
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是否全部加載完成,如果加載完成了就只需要繪制這部分即可,如果加載沒完成就需要將背景也繪制一下,
tileMap是LinkedHashMap,回圈時保證了按照插入的順序,從下向上渲染,越下面解析度越低,也就是低解析度的作為背景繪制,
//繪制
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 (hasMissingTiles) {
tileMap.values.flatten()
} else {
tileMap[sampleSize]
}?.filter {
!it.loading && it.bitmap != null
}?.forEach { tile ->
resetMatrix(tile)
canvas?.drawBitmap(tile.bitmap ?: return, mMatrix, bitmapPaint)
}
}
val vRect = Rect()
val mMatrix = Matrix()
val bitmapPaint = Paint()
val srcArray = FloatArray(8)
val matrixArray = FloatArray(8)
//計算matrix,將圖片映射到顯示區域
fun resetMatrix(tile: Tile) {
mMatrix.reset()
val bitmap = tile.bitmap ?: return
srcArray[0] = 0f
srcArray[1] = 0f
srcArray[2] = bitmap.width.toFloat()
srcArray[3] = 0f
srcArray[4] = bitmap.width.toFloat()
srcArray[5] = bitmap.height.toFloat()
srcArray[6] = 0f
srcArray[7] = bitmap.height.toFloat()
sRectToVRect(tile.sRect, vRect)
matrixArray[0] = vRect.left.toFloat()
matrixArray[1] = vRect.top.toFloat()
matrixArray[2] = vRect.right.toFloat()
matrixArray[3] = vRect.top.toFloat()
matrixArray[4] = vRect.right.toFloat()
matrixArray[5] = vRect.bottom.toFloat()
matrixArray[6] = vRect.left.toFloat()
matrixArray[7] = vRect.bottom.toFloat()
mMatrix.setPolyToPoly(srcArray, 0, matrixArray, 0, 4)
}
//圖片坐標映射到控制元件坐標
fun sRectToVRect(sRect: Rect, vRect: Rect) {
vRect.set(
((sRect.left * scale) + translate.x).toInt(),
((sRect.top * scale) + translate.y).toInt(),
((sRect.right * scale) + translate.x).toInt(),
((sRect.bottom * scale) + translate.y).toInt()
)
}
推薦
90分鐘教你從零開始打造自定義圖片加載框架
即學即用的Android高級技能大長圖加載原理及手寫實作
Android App開發——如何從打造一款可商用的圖片加載框架?
Android進階之如何打造一個圖片加載框架
轉載請註明出處,本文鏈接:https://www.uj5u.com/yidong/300273.html
標籤:其他
