Android 内存优化之索引颜色位图(上)

什么是索引颜色位图?

位图(Bitmap)最常见的编码方式是 RGBA 颜色编码(又叫直接颜色编码),即通过红、绿、蓝三原色的光学强度加透明度(Alpha)来表示一种颜色。而根据四个分量占用的存储空间不同,又可分为 RGBA8888 和 RGBA4444 等颜色格式,前者每个分量占用 8bits 即一个字节,后者每分量占 4bits 即半个字节。而有些场合下可能用不上透明度值,于是又有诸如 RGB888/RGB565 等不带透明度的颜色格式。这些颜色格式存储时都是直接以颜色分量表示像素:

Android 内存优化之索引颜色位图(上)_第1张图片
image.png

而本文讨论的索引颜色位图则采用了另一种编码方式:
从位图图片中选择最有代表性的若干种颜色(通常不超过256种)编制成颜色表,然后将图片中原有颜色用颜色表的索引来表示。这样原图片可以被大幅压缩。适合于压缩网页、GUI 等领域中颜色数较少的图片,不适合压缩色彩丰富的照片。

Android 内存优化之索引颜色位图(上)_第2张图片
image.png

上图从上至下分为三个部分,第一部分是位图的存储视图,可以看到颜色值分量(RGBA)并未被直接存储在位图矩阵里,而是 0~3 这些索引值来表示每个像素。
而第二部分则是该位图的颜色表(Color Table),俗称调色板(Palette),它以一维数组形式记录了索引对应的颜色,比如0号是红色,2号是蓝色。
最后一部分就是位图最终的显示结果,通过对位图矩阵中每个索引值进行查表来确定每个像素要显示的实际颜色。


为什么要使用索引颜色位图?

使用索引颜色位图的原因显而易见:可以减少位图对存储空间的占用。以每像素用一个字节来保存索引值来算,同样宽高的位图占用的空间仅为 RGBA8888 格式的 1/4,或者仅为 RGB565 格式的 1/2,这对于内存资源有限的移动应用来说很有吸引力。
当然索引颜色格式也有个明显的缺点,就是支持的颜色数太少,用一个字节存储一种颜色,最多能使用的颜色为 256 种,如果用于编码动辄几十上百万颜色的照片的话失真会很严重。下面分别是 RGB888 原始位图、2 位索引位图、4 位索引位图、8 位索引位图的效果比较:

Android 内存优化之索引颜色位图(上)_第3张图片
image.png

可以明显看到2位索引(共4种颜色)及4位索引(8种颜色)已经目不忍睹了,最后那张8位索引还算可以接受,但也是图片内容简单总颜色数不够多的缘故。
虽然用来编码拍摄的照片可能不够用,但编码 UI 界面的图片资源(如图标、按钮背景等)是没问题的,因为 UI 设计师制作的图片一般不会用太多的颜色。


怎样创建索引颜色位图?

说了半天,那么 Android 能否将保存为索引格式的图片文件解码为内存中的索引颜色位图呢?答案是肯定的,下面就来讲讲怎样才能做到。
在 Android 的 API 中,可以通过 Bitmap.Config 枚举来指定位图的颜色格式,部分源码如下:

public enum Config {
    // these native values must match up with the enum in SkBitmap.h
    ALPHA_8     (2),
    RGB_565     (4),
    ARGB_4444   (5),
    ARGB_8888   (6);

    final int nativeInt;
}

里面有四个枚举值: ALPHA_8、RGB_565、ARGB_4444、ARGB_8888。后面三个前文已经介绍过了。第一个 ALPHA_8 比较特殊,它也只用了一个字节来存储一个像素,但它不保存 RGB 三原色分量,仅保存透明度 Alpha 分量,所以主要是用于作为掩码(mask)位图使用,不直接用于显示。
看完并没发现哪个跟索引颜色格式有关的。但里面的注释提到了这些枚举对应的 native 值必须与 SkBitmap.h 中的匹配。SkBitmap 是 Skia 图形库提供的位图类,它是 Java 层的 Bitmap 类在 Native 层的真身。Skia 是一个由 C++ 编写的图形库,用于在低端装置如手机上呈现高品质的 2D 图形。Android UI 的一部分绘制能力就由 Skia 提供。
虽然 Java 层的 Bitmap.Config 中没有公开与索引位图相关的枚举值 ,但底层的 Skia 要解码索引格式的图片文件,就必须对其进行支持,打开系统源码的 external/skia/include/core/SkBitmap.h,找到颜色格式枚举:

enum Config {
    kNo_Config,         
    kA1_Config,         
    kA8_Config,         
    // !< 8 -bits per pixel, using SkColorTable to specify the colors
    kIndex8_Config,
    kRGB_565_Config,    
    kARGB_4444_Config,  
    kARGB_8888_Config,  
    kRLE_Index8_Config,
};

注意里头的 kIndex8_Config,它的注释写得很清楚:每像素8bits,使用 SkColorTable 来描述颜色,这个 SkColorTable 顾名思义就是颜色表了。
果然 Skia 的 SkBitmap 支持索引颜色,只是 Framework 并未把它开放给开发者而已。
那我们有没有可能让 Android 直接解码出索引位图给我们用呢?这就得试着从 Skia 的源码入手看看有无可能了。
解码各种图片格式的解码器的实现代码在 external/skia/src/images/ 目录下,以下是 Android 4.4 所有的解码器实现文件:

SkImageDecoder_libbmp.cpp 
SkImageDecoder_libgif.cpp 
SkImageDecoder_libico.cpp 
SkImageDecoder_libjpeg.cpp 
SkImageDecoder_libpng.cpp 
SkImageDecoder_libwebp.cpp 
SkImageDecoder_wbmp.cpp

png、bmp、gif、ico 等图片格式都支持索引颜色格式,平时最常用的还属 png 格式,所以从 png 解码器 external/skia/src/images/SkImageDecoder_libpng.cpp 的代码下手来看看其具体的解码流程。
无论解码 APK 资源图片还是外部图片文件最后都会调到 SkPNGImageDecoder::onDecode,我们择出它前部分与颜色格式设置有关的的代码来分析一下:

bool SkPNGImageDecoder::onDecode(SkStream* sk_stream, SkBitmap* decodedBitmap, Mode mode) {
    png_structp png_ptr;
    png_infop info_ptr;
    // ...
    SkBitmap::Config    config;
    bool                hasAlpha = false;
    SkPMColor           theTranspColor = 0; // 0 tells us not to try to match

    // 注意这里:
    if (!this->getBitmapConfig(png_ptr, info_ptr, &config, &hasAlpha, &theTranspColor)) {
        return false;
    }

    const int sampleSize = this->getSampleSize();
    SkScaledBitmapSampler sampler(origWidth, origHeight, sampleSize);
    decodedBitmap->setConfig(config, sampler.scaledWidth(), sampler.scaledHeight()); // 设置输出颜色格式
    // ...

从 10 行可以看到先将 config 作为出参传给 this->getBitmapConfig 方法,获取到 config 后于 16 行将其设置给 decodedBitmap(解码图片的输出 Bitmap),这样就确定了位图的颜色格式。
所以决定 Config 值的关键就在 getBitmapConfig 方法里,继续来看它的实现:

bool SkPNGImageDecoder::getBitmapConfig(png_structp png_ptr, png_infop info_ptr, SkBitmap::Config* configp, bool* hasAlphap, SkPMColor* theTranspColorp) {
    png_uint_32 origWidth, origHeight;
    int bitDepth, colorType;
    png_get_IHDR(png_ptr, info_ptr, &origWidth, &origHeight, &bitDepth, &colorType, int_p_NULL, int_p_NULL, int_p_NULL);
    // ...
    if (colorType == PNG_COLOR_TYPE_PALETTE) {
        bool paletteHasAlpha = hasTransparencyInPalette(png_ptr, info_ptr);
        *configp = this->getPrefConfig(kIndex_SrcDepth, paletteHasAlpha);
        // now see if we can upscale to their requested config
        if (!canUpscalePaletteToConfig(*configp, paletteHasAlpha)) {
            *configp = SkBitmap::kIndex8_Config;    // 注意这里
        }
    } else {
    // ...

一开始先通过 libpng 库函数 png_get_IHDR 获取 png 文件的元数据,注意其中的出参 colorType,它用于标识图片是否索引颜色格式编码,如果图片是索引颜色格式就会执行第 10 行的 canUpscalePaletteToConfig 方法,该方法返回 false 则 configp 被设置为 kIndex8_Config。那么 canUpscalePaletteToConfig 方法是做什么的呢,如何才能令其返回 false?从注释可以看到它用来检查能否 upscale(提升)颜色格式的,来看看具体实现:

static bool canUpscalePaletteToConfig(SkBitmap::Config dstConfig, bool srcHasAlpha) {
    switch (dstConfig) {
        case SkBitmap::kARGB_8888_Config:
        case SkBitmap::kARGB_4444_Config:
            return true;
        case SkBitmap::kRGB_565_Config:
            // only return true if the src is opaque (since 565 is opaque)
            return !srcHasAlpha;
        default:
            return false;
    }
}

方法接受两个参数,第一个是期望的 Config 枚举值,第二个是位图是否包含 Alpha 通道(透明色)标志。方法中判断 dstConfig 值如果为 kARGB_8888_Config 或 kARGB_4444_Config 就直接返回 true(可以 upscale);如果是 kRGB_565_Config 则看位图有没有透明色,有则返回 false(不能 upscale),无则返回 true;剩下的情况均返回 false。 而我们想得到索引颜色位图,就得让它 upscale 失败才行,要想达到这个目的,就要控制 dstConfig 的值,让它不为 kARGB_8888_Config、kARGB_4444_Config 或 kRGB_565_Config 之一。在前一段代码里是通过 getPrefConfig 方法得到这个值的:*configp = this->getPrefConfig(kIndex_SrcDepth, paletteHasAlpha);,其实现如下:

SkBitmap::Config SkImageDecoder::getPrefConfig(SrcDepth srcDepth, bool srcHasAlpha) const {
    SkBitmap::Config config = SkBitmap::kNo_Config;

    if (fUsePrefTable) {    // 普通图片解码不会进入这个分支
        switch (srcDepth) {
            // ...
        }
    } else {
        config = fDefaultPref;  // 注意这里
    }

    if (SkBitmap::kNo_Config == config) {
        config = SkImageDecoder::GetDeviceConfig();
    }
    return config;
}

普通图片解码只会执行第 9 行的逻辑:将成员变量 fDefaultPref 赋值给出参 config 了,当 config 为 kNo_Config 时会将其设为 GetDeviceConfig(此方法实际上仍返回 kNo_Config)。而 kNo_Config 不属于 kARGB_8888_Config、kARGB_4444_Config 、kRGB_565_Config 之一,将 fDefaultPref 设为 kNo_Config 即可满足我们的要求。
那么 fDefaultPref 的值又是哪儿来的,答案在基类的 decode 方法里:

bool SkImageDecoder::decode(SkStream* stream, SkBitmap* bm, SkBitmap::Config pref, Mode mode) {
    // we reset this to false before calling onDecode
    fShouldCancelDecode = false;
    // assign this, for use by getPrefConfig(), in case fUsePrefTable is false
    fDefaultPref = pref;    // 注意这里

    // pass a temporary bitmap, so that if we return false, we are assured of
    // leaving the caller's bitmap untouched.
    SkBitmap    tmp;
    if (!this->onDecode(stream, &tmp, mode)) {
        return false;
    }
    bm->swap(tmp);
    return true;
}

该方法直接将参数 pref 赋值给了 fDefaultPref。此 decode 方法又被 frameworks/base/core/jni/android/graphics/BitmapFactory.cpp 里的 onDecode 函数调用,onDecode 则被 Java 层的 BitmapFactory.decodeXXX 方法族调用(例如 decodeResource),从下面的代码看到它的 jobject options 参数就是 Java 层传进来的 BitmapFactory.Options 对象,所以 fDefaultPref 实际是由 BitmapFactory.Options.inPreferredConfig 指定的:

jclass options_class = env->FindClass("android/graphics/BitmapFactory$Options");
gOptions_configFieldID = getFieldIDCheck(env, options_class, "inPreferredConfig", "Landroid/graphics/Bitmap$Config;");
// ...

static jobject doDecode(JNIEnv* env, SkStreamRewindable* stream, jobject padding, jobject options, bool allowPurgeable, bool forcePurgeable = false) {
    SkBitmap::Config prefConfig = SkBitmap::kARGB_8888_Config;  // 默认使用 ARGB_8888
    // ...
    if (options != NULL) {  // 如果外部传入了 options
        jobject jconfig = env->GetObjectField(options, gOptions_configFieldID);
        prefConfig = GraphicsJNI::getNativeBitmapConfig(env, jconfig);  // 从 Java 层传入的 options 对象里取 inPreferredConfig 成员对应的 Native 枚举值
     // ...
    }
    // ...
    SkBitmap* bitmap = new SkBitmap;
    // ...
    SkImageDecoder* decoder = SkImageDecoder::Factory(stream);
    // ...
    decoder->decode(stream, bitmap, prefConfig, decodeMode); // 将 Config 值传入解码器

这里并未直接赋值,而在第 10 行调用了 getNativeBitmapConfig 方法,也是最后也是最关键的方法:

SkBitmap::Config GraphicsJNI::getNativeBitmapConfig(JNIEnv* env, 
                                                jobject jconfig)
{
    if (NULL == jconfig) {
        return SkBitmap:kNo_Config;
    }
    // ...
}

可以看到只要 BitmapFactory.Options.inPreferredConfig 为 null 就能让 prefConfig 赋值为 kNo_Config。
至此我们可以梳理出结论了:只要 PNG 文件本身是索引颜色格式,且在调用 BitmapFactory.decodeXXX 方法族时,将传入的 BitmapFactory.Options.inPreferredConfig 置为 null 即可解码得到索引颜色格式的 Bitmap 对象。


验证

这里就不上代码了,直接给验证结果吧:
1、在 xhdpi 像素密度的手机上,往 App 的 res/drawable-xhdpi 下面放置一张 120x120 的索引颜色格式的 png。通过 BitmapFactory.decodeResource 方法进行解码,并先后设置其 Options 参数的 inPreferredConfig 成员为 Config.ARGB_8888 和 NULL。解码后产生的两个 Bitmap 位图信息如下:

Options.inPreferredConfig = Config.ARGB_8888: 
  getWidth()/getHeight() => 120/120 
  getConfig() => Config.ARGB_8888 
  getRowBytes() => 480 
  getByteCount() => 230400
----------------------------------------------    
Options.inPreferredConfig =NULL: 
  getWidth()/getHeight() => 120/120 
  getConfig() => null 
  getRowBytes() => 120 
  getByteCount() => 14400

inPreferredConfig 设置成 NULL 的结果果然与索引位图的指标相吻合,空间仅为 ARGB_8888 格式的 1/4。而 getConfig() 方法居然返回了 null,从 getConfig 方法的文档可以看到这个描述: If the bitmap’s internal config is in one of the public formats, return that config, otherwise return null.
如果位图的内部 config 是公开格式的其中之一就返回这个 config,否则返回 null。
这里的内部格式,就是指 SkBitmap::Config 枚举值了,如果这个值并未在 Java 层 Bitmap.Config 中公开,就返回 null,像索引颜色对应的 kIndex8_Config 就会导致 getConfig() 会返回 null。
2、继续验证,还是用上面的示例,在非 xhpi 屏的机器上跑,发现 inPreferredConfig 置为 NULL 后仍被解码成了 ARGB_8888 位图,这是图片资源与屏幕像素密度不匹配导致的缩放造成的,BitmapFactory.cpp 的 doDecode 函数有个缩放系数参数,Java 层在调用 doDecode 时如果图片资源与屏蔽密度不匹配会计算一个缩放系统传入,doDecode 解码出原始尺寸的 Bitmap 后,根据该系数创建一个新的 Bitmap(ARGB_8888),并将原始 Bitmap 内容缩放后绘制到新 Bitmap 上,最终返回新建的 Bitmap(具体代码就不贴了,有兴趣可自行阅读)。
这个问题的解决方式比较直接,尽量针对不同屏幕密度制作多套资源图片,避免解码时出现缩放。当然这样也会带来较大的安装包增量,所以可以折衷一些,仅适配一部分市场占有率较高的屏幕密度。不过就算是没有为所有密度准备资源,使用索引颜色仍然是有意义的:它可以降低解码时的内存占用峰值。

另外,由于相同宽高的索引颜色格式的图片比真彩图片的数据量少,解码时的 I/O 等开销应该都会相对最低,理论上该方案还能有效提升图片解码性能,验证过程中也印证了这一点:索引颜色格式的 PNG 图片比相同宽高的真彩格式 PNG 图片的解码开销低了将近 70%!可谓性能、内存双赢。


小结

本文介绍了索引颜色格式以及底层 Skia 的相关处理逻辑,并根据处理逻辑找到一个让开发者可以使用索引位图的方案,下面对此方案进行简单总结:

  1. 资源文件使用 PNG 格式并保存为索引颜色格式,像 Photoshop 这类图片处理软件都支持索引颜色。或者使用一些 PNG 压缩工具(如 tinypng.com)也可以得到;
  2. 使用 BitmapFactory.decodeXXXX 族方法解码资源,并设置 Options.inPreferredConfig 为 null 以生成索引位图;
  3. 如果资源在 drawable-xxxx 目录下,则只有在对应屏幕密度的手机上可以生成索引位图,否则只会在解码过程中生成索引位图,最终经过缩放的结果仍是 ARGB_8888 格式;
  4. 本方案在 Android 4.1 上有问题,除非为所有密度分别提供资源以避免缩放(该版本占有率很低,具体原因不详述)。
    这个方案的主要优点是可以节省内存,同样的资源使用索引位图的内存占用仅为 ARGB_8888 的 1/4,或者仅为 RGB_565 的 1/2;其次解码索引颜色位图比解码同尺寸的真彩位图能节省约 70% 的性能开销。

但索引颜色位图也有其限制,除了上面讲过的颜色总数限制之外,我们无法通过 Bitmap.create 方法直接创建;也无法使用这种位图创建 Canvas 并对其进行绘制,因为 Skia 并不支持将索引位图作为 Canvas 使用,目测原因至少有效率和效果两个方面的考虑:

  • 往索引位图上绘制就需求将 ARGB 颜色格式通过反查颜色表(ColorTable)找到索引值,如果这个颜色不在颜色表里,还需要做最佳匹配,找出最接近的颜色来,效率低下;
  • 极端情况下,如果有一张红色的索引位图(其颜色表里只有红色),而我们想往上面绘制绿色,那无论如何也只能得到红色的索引,绘制完后看不出绘制效果。
    如果能突破这些限制,那么索引颜色位图的应用范围可以更加广泛,后续的文章将尝试探讨如何解除这些限制。

你可能感兴趣的:(Android 内存优化之索引颜色位图(上))