3、采样加载(InSampleSize):如何通过降采样减少图片加载到内存中的尺寸,避免加载原图

好,咱们接着聊。上一章讲了图片在内存里到底是怎么「住」下的,你知道了 Bitmap 占多大,跟分辨率、色彩格式直接挂钩。那问题来了——如果一张图是 4000x3000 的,ImageView 只显示 400x300,你还把原图塞进内存,是不是有点傻?

说白了,这就是典型的「杀鸡用牛刀」。我早年做第一个电商项目时就踩过这个坑。商品详情页的轮播图,每张都是相机拍的 1200 万像素原图,直接加载,结果 OOM 来得比双十一的快递还快。后来才明白,降采样才是正解。

什么是降采样?

降采样,也叫下采样。意思就是,在解码图片的时候,直接告诉解码器:「你别把每个像素都解出来,给我按比例缩一缩再放进内存。」

举个例子:一张 4000x3000 的图,如果按 2:1 降采样,解码后就是 2000x1500。内存占用直接降到原来的四分之一。如果按 4:1,那就是 1000x750,内存只有原来的十六分之一。

核心结论:降采样不是在加载完原图后再做缩放,而是在解码阶段就「偷懒」,只生成你需要的那个小尺寸 Bitmap。内存里从来就没有出现过原图尺寸的数据。

Android 里的降采样工具:BitmapFactory.Options

Android 原生 SDK 给了我们一个很趁手的工具——BitmapFactory.Options。关键参数就一个:inSampleSize。

它的规则很简单:

  • inSampleSize = 1:不解码,原图尺寸。
  • inSampleSize = 2:宽高各缩小为 1/2,内存变为 1/4。
  • inSampleSize = 4:宽高各缩小为 1/4,内存变为 1/16。
  • 必须是 2 的幂(2, 4, 8, 16...),否则解码器会向下取整到最近的 2 的幂。

嗯,这里要注意:inSampleSize 只接受整数,而且是 2 的幂。你传个 3 进去,它实际按 2 处理。传个 5,按 4 处理。这是 Android 底层实现决定的,咱们记住就行。

实战:两步走策略

我个人习惯把降采样加载分成两个步骤:

  1. 第一步:获取原图尺寸(不加载像素)
  2. 第二步:计算合适的 inSampleSize,加载缩略图

为什么要分两步?因为你不先知道原图多大,就没法算该缩多少。总不能瞎猜一个值吧?

第一步:只读尺寸,不读像素

BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(getResources(), R.drawable.my_image, options);

int imageWidth = options.outWidth;
int imageHeight = options.outHeight;
String imageType = options.outMimeType;

关键就在 inJustDecodeBounds = true。这个开关一打开,解码器只解析图片的头部信息(宽、高、MIME 类型),不会分配像素内存。速度极快,几乎不占内存。

小技巧:我经常在列表滑动场景里用这个技巧。先快速扫一遍所有图片的尺寸,再决定每个 item 该用多大的采样率。避免在滑动过程中频繁触发大内存分配。

第二步:计算采样率,加载缩略图

public static int calculateInSampleSize(
        BitmapFactory.Options options, int reqWidth, int reqHeight) {
    // 原始图片的宽高
    final int height = options.outHeight;
    final int width = options.outWidth;
    int inSampleSize = 1;

    if (height > reqHeight || width > reqWidth) {
        final int halfHeight = height / 2;
        final int halfWidth = width / 2;

        // 不断翻倍,直到缩略图尺寸小于目标尺寸
        while ((halfHeight / inSampleSize) >= reqHeight
                && (halfWidth / inSampleSize) >= reqWidth) {
            inSampleSize *= 2;
        }
    }

    return inSampleSize;
}

这个算法很朴实:从 1 开始,不断翻倍,直到缩略图比目标尺寸小。注意,它保证的是「缩略图至少有一边小于等于目标尺寸」,而不是精确匹配。因为 inSampleSize 只能取 2 的幂,没法做到完全精确。

拿到采样率后,真正加载:

BitmapFactory.Options options = new BitmapFactory.Options();
options.inSampleSize = calculateInSampleSize(options, 200, 200);
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.my_image, options);
imageView.setImageBitmap(bitmap);

搞定。内存里只有一张 200x200 左右的 Bitmap,而不是 4000x3000 的庞然大物。

采样率计算对照表

为了方便你直观理解,我列个表:

原图尺寸 目标尺寸 inSampleSize 解码后尺寸 内存节省比例
4000x3000 200x200 16 250x188 99.6%
4000x3000 800x600 4 1000x750 93.75%
1920x1080 480x270 4 480x270 93.75%
1920x1080 960x540 2 960x540 75%

你看,一张 4000x3000 的图,如果只需要 200x200 的缩略图,用 inSampleSize = 16,内存占用从原来的 48MB(ARGB_8888)直接降到约 188KB。这差距,你想想看。

降采样 vs 加载后缩放

很多新手容易搞混这两个概念。我专门对比一下:

对比维度 降采样(inSampleSize) 加载后缩放(Matrix/Scale)
内存峰值 低(只存缩略图) 高(先存原图,再缩放)
解码速度 快(像素少) 慢(像素多)
图片质量 略低(硬采样) 高(可插值)
适用场景 缩略图、列表 item 需要高质量缩放的场景

说白了,降采样是用一点点质量换大量的内存。对于列表里的缩略图、头像、小图,这点质量损失肉眼根本看不出来。但内存节省是实打实的。

我曾经踩过的坑:有次做图片编辑功能,我用了降采样加载原图做滤镜预览。结果滤镜效果在缩略图上看着还行,一应用到原图就完全不对了。后来才意识到——降采样会丢失高频细节,做图像处理时必须用原图或高质量缩放,不能用降采样。

降采样的知识体系

我把这一章的核心逻辑画了张图,方便你整体把握:

降采样加载核心流程 原图 4000x3000 第一步:inJustDecodeBounds = true 只读宽高,不分配像素内存 第二步:计算 inSampleSize 根据目标尺寸,翻倍直到满足条件 第三步:inSampleSize 解码 解码器按比例只生成小尺寸 Bitmap 缩略图 250x188 关键点 • 内存里从未出现原图 • inSampleSize 必须是 2 的幂 • 质量换内存,缩略图场景首选 避坑指南 • 图像处理不要用降采样 • 传非 2 的幂会向下取整 • 列表滑动时配合缓存使用

几个实用建议

  • 目标尺寸怎么定?我一般直接用 ImageView 的宽高。如果 ImageView 还没测量完,可以用 ViewTreeObserver 等布局完成后再加载。
  • 要不要考虑屏幕密度?要。如果你用 decodeResource,系统会自动根据 density 缩放。建议用 decodeStream 或 decodeFileDescriptor 来避免这层缩放,自己控制采样率。
  • 列表滑动时怎么办?配合内存缓存和磁盘缓存一起用。降采样虽然省内存,但解码还是有一定开销。频繁解码会影响滑动流畅度。