Android BitmapFactory实现AOSP ContentResolver.loadThumbnail快速取小缩略图,Kotlin
摘要:Android中快速加载缩略图的实现原理。关键点在于:1)通过openTypedAssetFileDescriptor传入目标尺寸,让MediaProvider直接返回合适的缩略图,避免全尺寸解码;2)采用二次采样策略,先获取图片尺寸计算采样率,再按需解码;3)处理方向信息时优先使用Provider提供的元数据;4)最终仍做防御性缩放确保尺寸合规。文中提供了基于BitmapFactory的Kotlin实现方案,吸收系统优化思路的同时,也指出其与系统实现(如ImageDecoder)的性能差异,为开发者实现高效缩略图加载提供了实用参考。
ContentResolver.loadThumbnail() 之所以快,核心不是因为它"神奇地解码快",而是它把缩略图尺寸需求下沉给 ContentProvider/MediaProvider,让 Provider 尽量返回"已经是小图"的数据源,然后再做防御性降采样。
用 BitmapFactory 实现一个类似的 URI 缩略图解码器。
1. 为什么 contentResolver.loadThumbnail() 加载小缩略图会快?
核心代码里,最关键的是这句:
content.openTypedAssetFile(uri, "image/*", opts, signal)
其中 opts 里带了目标尺寸:
opts.putParcelable(EXTRA_SIZE, new Point(size.getWidth(), size.getHeight()));
这意味着它不是简单地:
打开原图 URI → 全尺寸 decode → 再缩小
而是告诉 Provider:
我只要一个大约 size 大小的 image/*
Provider,尤其是 MediaProvider,可以做很多优化。
2. loadThumbnail() 的性能优势来源
2.1 Provider 可能直接返回已有缩略图
例如 MediaStore 里的图片、视频,系统可能已经有:
预生成缩略图
缓存缩略图
EXIF 内嵌 thumbnail
数据库关联的 thumbnail
视频关键帧 thumbnail
这样 App 拿到的就不是几十 MB 的原图,而是一个小很多的图。
2.2 避免全尺寸图片 IO
如果原图是:
4000 x 3000
6000 x 8000
10MB / 20MB / 50MB
普通 openInputStream(uri) + BitmapFactory.decodeStream() 很容易先读大图头、甚至走大图路径。
而 openTypedAssetFile(..., EXTRA_SIZE) 可能直接返回:
200 x 200
300 x 300
512 x 384
级别的小图数据,IO 少很多。
2.3 让 MediaProvider 在更接近数据源的位置优化
MediaProvider 知道:
真实文件路径
媒体类型
旋转角度
缩略图缓存位置
是否视频
是否云媒体
是否已生成 thumbnail
App 直接用 URI 解码时,不一定知道这些信息。
2.4 AOSP 仍然会做防御性降采样
代码里:
final int widthSample = info.getSize().getWidth() / size.getWidth();
final int heightSample = info.getSize().getHeight() / size.getHeight();
final int sample = Math.max(widthSample, heightSample);
if (sample > 1) {
decoder.setTargetSampleSize(sample);
}
意思是:
即使 Provider 没返回小图,而返回了大图,也要再降采样。
这是非常值得吸收的点。
2.5 orientation 通过 side-channel 传递
final Bundle extras = afd.getExtras();
orientation.value = (extras != null) ? extras.getInt(EXTRA_ORIENTATION, 0) : 0;
因为有些缩略图不带原图 EXIF 旋转信息,所以 Provider 可以通过 AssetFileDescriptor.extras 传 orientation。
这也比 App 自己读 EXIF 更省。
3. 用 BitmapFactory 实现时可以吸收哪些点?
可以吸收这些:
1. 优先使用 openTypedAssetFileDescriptor + EXTRA_SIZE 请求 Provider 缩略图。
2. 如果 Provider 不支持,再 fallback 到 openInputStream 原图。
3. 先 decode bounds,计算 inSampleSize。
4. 再二次打开 URI,按 inSampleSize 解码。
5. 解码后如果仍大于目标尺寸,再 createScaledBitmap 缩小。
6. 处理旋转。
7. 支持 CancellationSignal。
8. 加 Trace 埋点,方便确认耗时。
需要注意:
BitmapFactory无法完全等价替代ImageDecoder。它没有
ImageDecoder#setAllocator()那套能力,也不如ImageDecoder对新格式、硬件分配、header 回调灵活。但做小缩略图足够实用。
4. Kotlin 实现:基于 BitmapFactory 的快速 URI 缩略图
下面这个实现会:
-
优先尝试
openTypedAssetFileDescriptor(uri, "image/*", opts, signal); -
传入目标尺寸;
-
使用
BitmapFactory.Options.inJustDecodeBounds获取宽高; -
计算
inSampleSize; -
用
BitmapFactory.decodeStream()解码; -
必要时二次缩小;
-
处理 orientation;
-
加 trace 点。
如果不想依赖 EXIF,可以把
readExifOrientation相关逻辑删掉。
4.1 Gradle 依赖
如果需要 EXIF 旋转支持:
implementation "androidx.exifinterface:exifinterface:1.3.7"
4.2 Kotlin 代码
import android.content.ContentResolver
import android.content.Context
import android.content.res.AssetFileDescriptor
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.graphics.Point
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal
import android.os.Trace
import android.util.Size
import androidx.exifinterface.media.ExifInterface
import java.io.IOException
import java.io.InputStream
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
object FastUriThumbnailDecoder {
/**
* AOSP ContentResolver.EXTRA_SIZE / EXTRA_ORIENTATION 对应的 key。
* 为了兼容不同编译环境,这里直接使用字符串。
*/
private const val EXTRA_SIZE_KEY = "android.content.extra.SIZE"
private const val EXTRA_ORIENTATION_KEY = "android.content.extra.ORIENTATION"
data class Options(
val preferProviderThumbnail: Boolean = true,
/**
* 如果只用于小缩略图展示,可以考虑 RGB_565,内存减半。
* 但会损失透明度和颜色精度。
*/
val useRgb565: Boolean = false,
/**
* 解码后如果仍大于目标 size,是否再精确缩小。
*/
val scaleDownAfterDecode: Boolean = true,
/**
* 如果 provider extras 没给 orientation,是否再读取原图 EXIF。
* 读取 EXIF 会多一次 IO。
*/
val readExifIfNeeded: Boolean = true,
/**
* 是否允许 Bitmap.Config.HARDWARE。
* 注意:如果后面需要旋转/裁剪,不建议用 HARDWARE。
*/
val allowHardware: Boolean = false
)
/**
* 基于 BitmapFactory 的 URI 缩略图加载。
*
* 注意:建议在 Dispatchers.IO / 后台线程调用,不要在主线程调用。
*/
@Throws(IOException::class)
fun loadThumbnail(
context: Context,
uri: Uri,
size: Size,
signal: CancellationSignal? = null,
options: Options = Options()
): Bitmap {
val resolver = context.contentResolver
signal?.throwIfCanceled()
Trace.beginSection("FastThumb#decodeBounds")
val boundsResult = try {
decodeBounds(resolver, uri, size, signal, options)
} finally {
Trace.endSection()
}
if (boundsResult.width <= 0 || boundsResult.height <= 0) {
throw IOException("Failed to decode bounds for uri=$uri")
}
val sampleSize = calculateSampleSize(
srcWidth = boundsResult.width,
srcHeight = boundsResult.height,
reqWidth = size.width,
reqHeight = size.height
)
signal?.throwIfCanceled()
Trace.beginSection("FastThumb#decodeBitmap_sample_$sampleSize")
var bitmap = try {
decodeBitmap(
resolver = resolver,
uri = uri,
size = size,
signal = signal,
options = options,
sampleSize = sampleSize
)
} finally {
Trace.endSection()
}
signal?.throwIfCanceled()
/**
* 优先使用 Provider extras 里的 orientation。
* 如果没有,再根据配置尝试读 EXIF。
*/
var orientation = boundsResult.orientation
if (orientation == 0 && options.readExifIfNeeded) {
Trace.beginSection("FastThumb#readExif")
try {
orientation = readExifOrientationDegrees(context, uri)
} finally {
Trace.endSection()
}
}
if (orientation != 0) {
Trace.beginSection("FastThumb#rotate_$orientation")
bitmap = try {
rotateBitmapIfNeeded(bitmap, orientation)
} finally {
Trace.endSection()
}
}
if (options.scaleDownAfterDecode) {
Trace.beginSection("FastThumb#scaleDownIfNeeded")
bitmap = try {
scaleDownIfNeeded(bitmap, size.width, size.height)
} finally {
Trace.endSection()
}
}
signal?.throwIfCanceled()
return bitmap
}
private data class BoundsResult(
val width: Int,
val height: Int,
val orientation: Int
)
private data class OpenResult(
val inputStream: InputStream,
val orientation: Int
)
@Throws(IOException::class)
private fun decodeBounds(
resolver: ContentResolver,
uri: Uri,
size: Size,
signal: CancellationSignal?,
options: Options
): BoundsResult {
var orientationFromProvider = 0
val bmOptions = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
openInputForDecode(resolver, uri, size, signal, options).use { opened ->
orientationFromProvider = opened.orientation
BitmapFactory.decodeStream(opened.inputStream, null, bmOptions)
}
return BoundsResult(
width = bmOptions.outWidth,
height = bmOptions.outHeight,
orientation = orientationFromProvider
)
}
@Throws(IOException::class)
private fun decodeBitmap(
resolver: ContentResolver,
uri: Uri,
size: Size,
signal: CancellationSignal?,
options: Options,
sampleSize: Int
): Bitmap {
val bmOptions = BitmapFactory.Options().apply {
inJustDecodeBounds = false
inSampleSize = max(1, sampleSize)
inMutable = false
inPreferredConfig = when {
options.useRgb565 -> Bitmap.Config.RGB_565
options.allowHardware &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> {
/**
* 注意:
* 如果后续需要 rotate/scale,HARDWARE 可能不适合。
* 当前实现后面可能旋转/缩放,所以默认不建议开启 allowHardware。
*/
Bitmap.Config.HARDWARE
}
else -> Bitmap.Config.ARGB_8888
}
if (options.useRgb565) {
inDither = true
}
}
openInputForDecode(resolver, uri, size, signal, options).use { opened ->
val bitmap = BitmapFactory.decodeStream(opened.inputStream, null, bmOptions)
return bitmap ?: throw IOException("BitmapFactory.decodeStream returned null, uri=$uri")
}
}
/**
* 优先走 provider thumbnail 路径:
*
* openTypedAssetFileDescriptor(uri, "image/*", opts, signal)
*
* 这个是吸收 ContentResolver.loadThumbnail 的关键点。
*/
@Throws(IOException::class)
private fun openInputForDecode(
resolver: ContentResolver,
uri: Uri,
size: Size,
signal: CancellationSignal?,
options: Options
): OpenResult {
signal?.throwIfCanceled()
if (options.preferProviderThumbnail) {
try {
val opts = Bundle().apply {
putParcelable(
EXTRA_SIZE_KEY,
Point(size.width, size.height)
)
}
val afd: AssetFileDescriptor? =
resolver.openTypedAssetFileDescriptor(uri, "image/*", opts, signal)
if (afd != null) {
val orientation = afd.extras?.getInt(EXTRA_ORIENTATION_KEY, 0) ?: 0
/**
* 注意:
* 使用 afd.createInputStream(),不要直接 decodeFileDescriptor。
* 因为 AssetFileDescriptor 可能带 startOffset/length,
* createInputStream() 能正确处理。
*/
return OpenResult(
inputStream = afd.createInputStream(),
orientation = orientation
)
}
} catch (e: Throwable) {
/**
* 某些 Provider 不支持 openTypedAssetFileDescriptor,
* 或者不支持 EXTRA_SIZE,这里 fallback 到 openInputStream。
*/
}
}
signal?.throwIfCanceled()
val stream = resolver.openInputStream(uri)
?: throw IOException("openInputStream returned null, uri=$uri")
return OpenResult(
inputStream = stream,
orientation = 0
)
}
/**
* 类似 AOSP loadThumbnail 的 defensive sample:
*
* 如果 provider 返回了大图,继续降采样。
*/
private fun calculateSampleSize(
srcWidth: Int,
srcHeight: Int,
reqWidth: Int,
reqHeight: Int
): Int {
if (srcWidth <= 0 || srcHeight <= 0 || reqWidth <= 0 || reqHeight <= 0) {
return 1
}
val widthSample = srcWidth / reqWidth
val heightSample = srcHeight / reqHeight
return max(1, max(widthSample, heightSample))
}
private fun scaleDownIfNeeded(
bitmap: Bitmap,
reqWidth: Int,
reqHeight: Int
): Bitmap {
if (reqWidth <= 0 || reqHeight <= 0) return bitmap
val width = bitmap.width
val height = bitmap.height
if (width <= reqWidth && height <= reqHeight) {
return bitmap
}
val scale = min(
reqWidth.toFloat() / width.toFloat(),
reqHeight.toFloat() / height.toFloat()
)
if (scale >= 1f) return bitmap
val targetWidth = max(1, (width * scale).roundToInt())
val targetHeight = max(1, (height * scale).roundToInt())
val scaled = Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true)
if (scaled !== bitmap && !bitmap.isRecycled) {
bitmap.recycle()
}
return scaled
}
private fun rotateBitmapIfNeeded(
bitmap: Bitmap,
degrees: Int
): Bitmap {
if (degrees == 0) return bitmap
val matrix = Matrix().apply {
postRotate(degrees.toFloat())
}
val rotated = Bitmap.createBitmap(
bitmap,
0,
0,
bitmap.width,
bitmap.height,
matrix,
true
)
if (rotated !== bitmap && !bitmap.isRecycled) {
bitmap.recycle()
}
return rotated
}
private fun readExifOrientationDegrees(
context: Context,
uri: Uri
): Int {
return try {
context.contentResolver.openInputStream(uri)?.use { input ->
val exif = ExifInterface(input)
when (
exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90
ExifInterface.ORIENTATION_ROTATE_180 -> 180
ExifInterface.ORIENTATION_ROTATE_270 -> 270
else -> 0
}
} ?: 0
} catch (_: Throwable) {
0
}
}
private inline fun <T : AutoCloseable, R> T.use(block: (T) -> R): R {
var closed = false
try {
return block(this)
} catch (t: Throwable) {
try {
closed = true
close()
} catch (closeException: Throwable) {
t.addSuppressed(closeException)
}
throw t
} finally {
if (!closed) {
close()
}
}
}
}
5. 调用方式
val bitmap = FastUriThumbnailDecoder.loadThumbnail(
context = context,
uri = uri,
size = Size(200, 200),
signal = cancellationSignal,
options = FastUriThumbnailDecoder.Options(
preferProviderThumbnail = true,
useRgb565 = true,
scaleDownAfterDecode = true,
readExifIfNeeded = true
)
)
建议放在 IO 线程:
val bitmap = withContext(Dispatchers.IO) {
FastUriThumbnailDecoder.loadThumbnail(
context = context,
uri = uri,
size = Size(200, 200),
signal = cancellationSignal,
options = FastUriThumbnailDecoder.Options(
preferProviderThumbnail = true,
useRgb565 = true
)
)
}
6. 这个实现吸收了 loadThumbnail() 哪些优化?
对应关系如下:
AOSP loadThumbnail() |
Kotlin BitmapFactory 实现 |
|---|---|
openTypedAssetFile(uri, "image/*", opts) |
openTypedAssetFileDescriptor(uri, "image/*", opts, signal) |
EXTRA_SIZE 传目标尺寸 |
Bundle.putParcelable("android.content.extra.SIZE", Point(...)) |
| Provider 返回小图/缩略图 | 优先尝试 provider thumbnail |
ImageDecoder 读取 size |
BitmapFactory.Options.inJustDecodeBounds |
setTargetSampleSize(sample) |
BitmapFactory.Options.inSampleSize |
| orientation side-channel | 读取 afd.extras["android.content.extra.ORIENTATION"] |
| 防御性缩小 | scaleDownIfNeeded() |
| cancellation | decode 前后 signal.throwIfCanceled() |
| Trace 可观测 | Trace.beginSection() |
7. 但要注意:它不一定比 ContentResolver.loadThumbnail() 更快
如果 URI 是 MediaStore 图片:
contentResolver.loadThumbnail(uri, Size(200, 200), signal)
通常仍然是优先推荐方案。
因为系统实现可以用:
MediaProvider 缩略图缓存
EXIF thumbnail
视频帧缓存
ImageDecoder allocator
Provider 私有优化
而自己用 BitmapFactory,即使走 openTypedAssetFileDescriptor,仍然可能有一些限制:
1. BitmapFactory 没有 ImageDecoder allocator 能力。
2. 两次 decode 需要打开两次流。
3. 对 HEIF/新格式/特殊 provider 的处理不如 ImageDecoder 灵活。
4. BitmapFactory 不能在 native decode 中真正响应 CancellationSignal。
所以更准确地说:
这个 Kotlin 实现是"吸收 loadThumbnail 思路的 BitmapFactory 版本",不是完全替代 AOSP
loadThumbnail()。
8. 总结
ContentResolver.loadThumbnail() 快的核心是:
通过 openTypedAssetFile + EXTRA_SIZE 向 Provider 请求"目标尺寸缩略图",
避免 App 自己全尺寸读图和解码。
基于 BitmapFactory 实现时,最重要的是也走:
openTypedAssetFileDescriptor(uri, "image/*", opts, signal)
然后再用:
inJustDecodeBounds
inSampleSize
scaleDownIfNeeded
orientation 修正
这样才能接近 loadThumbnail() 的性能思路。
如果只是简单:
contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(input)
那就没有吸收到 loadThumbnail() 最关键的性能优化。