支持缩放的bitmap的view怎么设计缓存

77 阅读4分钟

总体思路(像地图那样干)

  1. 金字塔金层(Multi-Resolution Pyramid)

    按 1/2、1/4、1/8… 尺度抽样出多个层级(L0=原图,L1=1/2,L2=1/4…),每层再按固定尺寸(比如 256~512px)切成小块 tile

  2. 按需解码 + 绘制

    根据当前 scale + viewport 只解码可见区域对应的若干 tile;缩放变大就换到更高分辨率层级。

  3. 三层缓存

    • 内存 Tile LRU(核心):TileKey -> Bitmap,按字节数计费;
    • 原图/源文件磁盘缓存:网络图走 OkHttp/Glide/Coil 的 disk cache;
    • (可选)离线 Tile 磁盘缓存:把解码出的 tile 也写盘,冷启动更快(成本较高,可后做)。
  4. 任务管线

    专门的解码线程池(2~4 个),优先级=“越靠近视口中心越高”;视口变化时取消过时任务。


关键数据结构

// 1) tile 唯一键:同一源、同一层级、同一网格
data class TileKey(
    val sourceId: String,   // URL 或 file path 的 hash
    val level: Int,         // 金字塔层(0=原图,1=1/2,2=1/4…)
    val x: Int,             // 第 x 列
    val y: Int              // 第 y 行
)

// 2) LRU 内存缓存(按字节数)
class TileMemoryCache(maxBytes: Int) : LruCache<TileKey, Bitmap>(maxBytes) {
    override fun sizeOf(key: TileKey, value: Bitmap): Int = value.byteCount
}

// 3) tile 描述
data class Tile(
    val key: TileKey,
    @Volatile var state: State = State.Loading,
    @Volatile var bitmap: Bitmap? = null
) {
    enum class State { Loading, Ready, Failed }
}

内存预算:maxBytes ≈ min(运行时最大堆, 256MB) 的 15% ~ 25% 比较稳。比如 64MB~128MB 给 tile。


层级选择 & 网格计算

  • 当前缩放 scale(“图像像素 → 屏幕像素”的比例)。
  • 选择最佳层级:让1 个屏幕像素 ≈ 1~2 个源像素,避免过度采样/重采样。
fun chooseLevel(scale: Float): Int {
    // level 越大分辨率越低;scale≈1 用 L0;scale≈0.5 用 L1;scale≈0.25 用 L2 …
    val inv = 1f / scale
    return max(0, ln(inv.toDouble()) / ln(2.0)).roundToInt()
}
  • 以固定 tileSizeSrc = 256/512(源像素维度)切网格。
  • 由视口矩形(在源坐标系)计算需要的 tile 范围(x0..x1, y0..y1)。

解码器(region decode)

  • 本地文件:

    • API < 28:BitmapRegionDecoder + inSampleSize(2 的幂);
    • API 28+:ImageDecoder 配合 setTargetSampleSize() + setCrop(Rect);
  • 网络图片:先通过 Glide/OkHttp 拿到原图文件或内存映射,再 region decode;不要每块都重新下网。

重要:region decode 不要用 Hardware Bitmaps(硬件位图不能写像素、会有限制)。

private fun decodeTile(
    source: ImageSource, // 你封装的来源(file/bytebuffer…)
    level: Int,
    x: Int, y: Int,
    tileSizeSrc: Int
): Bitmap {
    val sample = 1 shl level
    val left   = x * tileSizeSrc * sample
    val top    = y * tileSizeSrc * sample
    val right  = min(source.width,  left + tileSizeSrc * sample)
    val bottom = min(source.height, top  + tileSizeSrc * sample)

    // ImageDecoder 示例(API 28+)
    val rect = android.graphics.Rect(left, top, right, bottom)
    val src = source.toImageDecoderSource() // 自己封装
    return ImageDecoder.decodeBitmap(src) { info, src2 ->
        src2.setTargetSampleSize(sample)
        src2.setCrop(rect)
        src2.setAllocator(ImageDecoder.ALLOCATOR_SOFTWARE) // 避免硬件位图
        src2.isMutableRequired = false
    }
}

内存缓存 + 任务队列

class TileRepository(
    private val sourceId: String,
    private val source: ImageSource,
    private val cache: TileMemoryCache,
    private val scope: CoroutineScope
) {
    private val jobs = ConcurrentHashMap<TileKey, Job>()

    fun requestTiles(keys: List<TileKey>, onTileReady: (TileKey, Bitmap?) -> Unit) {
        // 优先级:按与视口中心的曼哈顿距离排序(近的先)
        val sorted = keys.sortedBy { distanceToViewportCenter(it) }

        for (key in sorted) {
            cache.get(key)?.let { bmp ->
                onTileReady(key, bmp); continue
            }
            if (jobs.containsKey(key)) continue

            jobs[key] = scope.launch(Dispatchers.IO) {
                runCatching {
                    val bmp = decodeTile(source, key.level, key.x, key.y, TILE_SIZE_SRC)
                    cache.put(key, bmp)
                    withContext(Dispatchers.Main) { onTileReady(key, bmp) }
                }.onFailure {
                    withContext(Dispatchers.Main) { onTileReady(key, null) }
                }
                jobs.remove(key)
            }
        }
    }

    fun cancelNonVisible(keep: Set<TileKey>) {
        jobs.filterKeys { it !in keep }.values.forEach { it.cancel() }
        jobs.keys.removeAll { it !in keep }
    }
}

绘制层(View/Compose 通用思路)

  • 先画低清兜底:当前层级的父块(level+1 的 tile 组合)可做“马赛克”背景,等高清到再覆盖。

  • 再画高清 tile:逐块绘制到目标矩阵(canvas.concat(matrix) / Compose 的 DrawScope.drawImageRect)。

Compose 里可以把 tile 缓存在 remember 的 mutableStateMap<TileKey, ImageBitmap>,收到回调就 invalidate.


预取 & 取消

  • 可视区域外再多取 1~2 圈 tile(prefetch margin),改善滑动体验。

  • 视口变化(平移/缩放)立即:

    1)计算新集合 needed;

    2)cancelNonVisible(needed);

    3)requestTiles(needed)。

  • 快速缩放时可以短暂锁层级,等手指抬起再换更高清层,避免频繁抖动。


与第三方库协作(可选)

  • 直接复用 Glide 的 BitmapPool(LruBitmapPool)降低分配成本:把解码层用的临时位图从池里 get/put。
  • 最终缓存仍建议自己维护 TileMemoryCache<TileKey, Bitmap>,以 tile 为单位可控逐出。

细节策略(避免踩坑)

  • tileSizeSrc 选 256~512:512 解码次数更少,单块更大;256 更细腻,滑动更稳。
  • 内存不足时退化:缩小 tileSize 或减小内存 LRU 上限;解码失败记失败时间,避免抖动重试。
  • 旋转/EXIF:把源坐标统一到“正位图”,在解码时处理一次 EXIF 旋转。
  • 硬件加速:绘制阶段 OK;但解码不要 Hardware Bitmap。
  • 多实例共用缓存:把 TileMemoryCache & TileRepository 放到单例/DI,多个同源 View 共享。

要点

  • 金字塔 + tile 网格,按视口动态解码;

  • 三层缓存,内存 LRU 是核心,字节数计费;

  • 解码线程池带优先级,视口变化就取消旧任务;

  • 父层兜底 + 预取,体验稳;

  • 禁用硬件位图解码,预算≈可用内存 15%~25%。