Android BitmapFactory实现AOSP ContentResolver.loadThumbnail快速取小缩略图,Kotlin

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 缩略图

下面这个实现会:

  1. 优先尝试 openTypedAssetFileDescriptor(uri, "image/*", opts, signal)

  2. 传入目标尺寸;

  3. 使用 BitmapFactory.Options.inJustDecodeBounds 获取宽高;

  4. 计算 inSampleSize

  5. BitmapFactory.decodeStream() 解码;

  6. 必要时二次缩小;

  7. 处理 orientation;

  8. 加 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() 最关键的性能优化。

相关推荐
weixin_440784111 小时前
【OkHttp实现原理】
android·java·okhttp
恋猫de小郭1 小时前
Jetpack Compose 8 月版正式发布,核心模块 1.12
android·前端·flutter
delta_hell1 小时前
【阅读源码--Android】动画之AnimatorSet--1
android·源码·animatorset
2501_9159214313 小时前
appuploader-cli 命令行上传 IPA 到 App Store Connect upload CI 集成
android·ci/cd·小程序·https·uni-app·iphone·webview
怪奇云呼军16 小时前
从声音特征到 CRM 回流:闪电智能 Voice Agent 沟通策略自适应系统 v1 实战
android·人工智能·python·音视频·语音识别
哦哦~92118 小时前
北理工|Composites Part‑A 综述:纤维‑增强复合材料传统成型与增材制造全解析
android·制造·复合材料·增材制造
一笑的小酒馆18 小时前
Android视频直播播放器简单封装
android
工会代表19 小时前
安卓手机搭建SOCKS5代理教程
android
雨白20 小时前
Android AOP 切面编程实战:优雅地处理全局断网拦截
android·架构