总体思路(像地图那样干)
-
金字塔金层(Multi-Resolution Pyramid) :
按 1/2、1/4、1/8… 尺度抽样出多个层级(L0=原图,L1=1/2,L2=1/4…),每层再按固定尺寸(比如 256~512px)切成小块 tile。
-
按需解码 + 绘制:
根据当前 scale + viewport 只解码可见区域对应的若干 tile;缩放变大就换到更高分辨率层级。
-
三层缓存:
- 内存 Tile LRU(核心):TileKey -> Bitmap,按字节数计费;
- 原图/源文件磁盘缓存:网络图走 OkHttp/Glide/Coil 的 disk cache;
- (可选)离线 Tile 磁盘缓存:把解码出的 tile 也写盘,冷启动更快(成本较高,可后做)。
-
任务管线:
专门的解码线程池(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%。