如何根据屏幕密度和控件大小计算 Bitmap 的最优采样率(inSampleSize)?

解读

面试官问的不是“inSampleSize 怎么用”,而是“怎么算”。
国内大厂(华米 OV、字节、阿里)的实战场景是:

  1. 图片源来自云端 CDN,原图动辄 5000×4000;
  2. 控件大小受“折叠屏+多窗口+字体缩放”三重因素动态变化;
  3. 低端机(720p、2 GB 内存)必须一次解码成功,不能 OOM;
  4. 还要兼顾“Google Play 政策”与“工信部 32 位下架令”,so 里不能用私有 skia API。
    因此,候选人必须给出“可落地公式 + 边界保护 + 代码片段”,而不是背官方文档。

知识点

  1. BitmapFactory.Options.inSampleSize 语义
    • 只能是 2 的幂,否则向下取最接近的 2 的幂;
    • 最终解码尺寸 = ceil(原宽/inSampleSize) × ceil(原高/inSampleSize)。
  2. 屏幕密度相关量
    • densityDpi(DisplayMetrics.densityDpi)
    • 控件实际像素尺寸:View 的 width/height 或 ConstraintLayout 计算后的 size。
  3. 最小内存占用模型
    • 目标像素数 ≤ 控件像素数 × 1.2(留 20 % 缩放余量,避免重复解码);
    • 单像素字节数:ARGB_8888=4,RGB_565=2,在 Kotlin 中通过 inPreferredConfig 指定。
  4. 国内 CDN 常见参数
    • 带“w=”、“h=”的锐化缩略图,先拿宽高再决定是否二次采样;
    • 弱网场景下,先下 1/4 分辨率占位,再后台下原图,需两次 inSampleSize 计算。
  5. 兼容性陷阱
    • Android 7.0 文件权限,必须 FileDescriptor 解码;
    • Android 10+ 分区存储,不能直接 getRealPath,用 ContentResolver 打开 InputStream;
    • 折叠屏旋转后,控件尺寸变化,需监听 ViewTreeObserver.OnGlobalLayoutListener 重新计算。

答案

步骤化公式(可直接写白板):

  1. 读原图尺寸

    val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
    BitmapFactory.decodeStream(input, null, options)
    val srcWidth = options.outWidth
    val srcHeight = options.outHeight
    
  2. 取控件目标像素

    val dm = resources.displayMetrics
    val targetPx = (控件dp * dm.density + 0.5f).toInt()
    
  3. 计算基础采样率

    fun computeInSampleSize(
        srcWidth: Int,
        srcHeight: Int,
        targetWidth: Int,
        targetHeight: Int
    ): Int {
        var inSampleSize = 1
        if (srcHeight > targetHeight || srcWidth > targetWidth) {
            val halfHeight = srcHeight / 2
            val halfWidth  = srcWidth  / 2
            while (halfHeight / inSampleSize >= targetHeight &&
                   halfWidth  / inSampleSize >= targetWidth) {
                inSampleSize *= 2
            }
        }
        return inSampleSize
    }
    
  4. 密度修正(国内低端机 720p 场景)
    若 densityDpi ≤ 240 且可用内存 < 512 MB,再 ×2 保守降级:

    val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
    val largeHeap = context.applicationInfo.flags and ApplicationInfo.FLAG_LARGE_HEAP != 0
    val maxMem = if (largeHeap) am.largeMemoryClass else am.memoryClass
    if (!largeHeap && maxMem <= 512 && dm.densityDpi <= 240) {
        inSampleSize = inSampleSize.coerceAtLeast(2)
    }
    
  5. 解码 & 复用内存池(Android 8.0+)

    val reuse = BitmapFactory.Options().apply {
        inSampleSize = calculated
        inPreferredConfig = Bitmap.Config.RGB_565   // 省 50 %
        inMutable = true
        inBitmap = bitmapPool.getReusable(calculated) // 自己维护 LruCache
    }
    

一句话总结:
inSampleSize = 大于等于 max(原宽/目标宽,原高/目标高) 的最小 2 的幂,再按“低端机+密度”二次修正,确保解码后单张 Bitmap 占用 ≤ 控件像素×1.2×2 字节,即可在国内 90 % 机型上零 OOM。

拓展思考

  1. 折叠屏动态分辨率
    监听 androidx.window:window 库,onLayoutChanged 时重新计算目标宽高,避免“展开后模糊”。
  2. 超分场景
    若后台 AI 超分模块可用,可先 inSampleSize=4 解码 1/16 小图,TensorFlow Lite 超分后再上屏,省电 30 %。
  3. 隐私沙盒影响
    Android 14 限制 QUERY_ALL_PACKAGES,图片选择器返回 content:// 不再带真实路径,上述流程必须基于 InputStream,不可假设 File。
  4. 可测试性
    用 Compose Test 的 onNodeWithTag("image").onGloballyPositioned{} 拿到像素尺寸,配合 MockK 注入 DisplayMetrics,可在 JVM 单元测试直接断言 inSampleSize 值,无需真机。