误用bitmap config导致的故障

某周六线上运营报障,在部分用户的Android手机上个别地图图标未能正常显示,只有部分手机有问题(7.0及以下版本)有问题,诡异的是只有个别图标(1个)显示有问题,大部分图标显示并没有问题,开发介入验证后发现更诡异的是,只在release版本才有这个问题,开发debug版本上并没有问题。
低版本显示有问题还好理解,这应该是一个版本兼容相关的问题。release和debug上个别图标有差别就有点匪夷所思了,写篇博客记录下。

0x01 问题说明

问题日志:

1
2
3
4
java.lang.NullPointerException: Attempt to read from field 'int android.graphics.Bitmap$Config.nativeInt' on a null object reference
10-19 14:27:36.501 20394-20394/? W/System.err: at android.graphics.Bitmap.copy(Bitmap.java:557)
10-19 14:27:36.501 20394-20394/? W/System.err: at com.amap.api.maps.model.BitmapDescriptor.<init>(BitmapDescriptor.java:28)
10-19 14:27:36.501 20394-20394/? W/System.err: at com.amap.api.maps.model.BitmapDescriptorFactory.fromBitmap(BitmapDescriptorFactory.java:258)

问题代码:

1
2
3
4
5
6
val opts = BitmapFactory.Options()
opts.inPreferredConfig = Bitmap.Config.RGB_565
opts.inTargetDensity = displayMetrics.densityDpi
opts.inScaled = true
opts.inDensity = DisplayMetrics.DENSITY_XXHIGH//typedValue.density
val var3 = BitmapFactory.decodeStream(var2, null, opts)

根据日志可以看出是Bitmap的config为null引起的空指针问题,在使用BitmapFactory.decodeStream时,正常解析到了Bitmap对象,但是该图片的Config获取为null,进而引发一个空指针异常,导致了后续图片加载的失败。该段代码也很简单,重新写了个demo应用单独运行这段代码,并特别使用了出现线上故障的资源图片,测试的结果依然是debug版本的demo Bitmap.getConfig获取正常,为ARGB.8888。release版本 Bitmap.getConfig获取为null。尝试修改inPreferredConfig为ARGB_8888或删除这个参数。demo运行正常,Bitmap和Bitmap.Config都能正常获取。
这是一个和inPreferredConfig参数有关的问题?考虑到该段代码加载的图片资源可能是透明的png,首先需要承认的是使用565模式解析有alpha通道的图片资源是并不科学的,虽然565配置对非透明图片的加载可以省一般的内存空间。但文档不是这么说的呀?inPreferredConfig参数指定的配置并不是一个非强制的选项,而是建议选项,Android在实际解码时会参考此参数的配置,但如果此配置不满足,Android会重新选取一个合适的配置来对图片进行解码。

所以基于文档的说明,使用RGB_565的配置decode有alpha通道的图片并不会有什么问题,在正常情况下Android系统实际还是会选择ARGB_8888的配置进行加载。现在很明显这不是正常情况。不正常的点在哪里?看起来肯定和这张图片有关,换个图片就能解决这个问题了,不然换个图片?不用inPreferredConfig参数也行,但是为什么?问题到底出在哪里?
一头扎进BitmapFactory.cpp中,然而并没有什么明显的线索,头大了~~

0x03 线索分析

看了大半天BitmapFactory的源码,对加载流程大致有了了解,但是对解决问题并没有什么思路,和大师交流这个问题时,他的一句话点醒了我。

最最诡异的其实是debug和release差异的问题啊。难道release打包时,资源打包对这个图片动了手脚?所有,手动将release版本的app解压出来,找到有问题的png。果然发现了不一样的地方,使用AS的图片查看器查看png图片,在右上角可以看到,原本在源码中32-bit color的图片在release包中变成了8-bit color。

原本2k的图片在从release包里解压出来后只有700B,aapt对资源做了压缩,难道是压缩引起的问题?在编译文件中配置cruncherEnabled false关闭对png的压缩操作。

1
2
3
aaptOptins{
cruncherEnabled false
}

问题消失了,实锤!和资源打包时对png的压缩有关~

什么是alpha通道?

0x04 调色板模式

将图像转换为调色板颜色模式时,会给每一个像素分配一个固定的颜色值,这些颜色值储存在简洁的颜色表中,或包含多达256种色的调色板中。因此,调色板颜色模式的图像包含的数据比24位颜色模式的图像小,对于颜色范围有限的图像,通过这个色彩转换模式效果更佳,用户可以设定转换颜色的调色板,从而得到指定颜色的阶数的位图。

调色板颜色模式也被称为是索引模式,调色板颜色模式只有在图像颜色小于等于256色的时候才有,16位高彩和24位32位真彩是没有调色板色的。它只支持单通道图像(8位/像素),因此,我们通过限制调色板、索引颜色减小文件大小,同时保持视觉上的品质不变——如用于多媒体动画的应用或网页。只有16位以下的才用调色板,真彩色不用调色板。

0x05 重新理一下问题

5.1 问题原因

  1. android aapt针对色值数量小于256个的资源图片,在 aaptOptins cruncherEnabledtrue时进行了png压缩操作。将png图片的32位全彩色模式图片转换成了8bit位索引模式
  2. android较低版本系统在使用PreConfig=565模式下解析8bit索引模式图片时能够正常返回解析后的Bitmap,但Bitmap Config为空。导致后续的加载错误。
  3. 基于以上两点原因,才出现了在debug模式下打包正常,在release下正常,有的图标正常,个别图标显示不出来的诡异操作。出问题的图片都是被转换成索引模式的图片,理论上可能并不止一个,只是暂时只发现了一个。

5.2 解决方案

选择使用ARGB8888或者不设置inPreferredConfig参数进行bitmap decode。其实默认inPreferredConfig值就是ARGB_8888

1
public Bitmap.Config inPreferredConfig = Bitmap.Config.ARGB_8888;

5.3 在使用inPreferredConfig的时候要注意什么?

参数inpreferredconfig表示图片解码时使用的颜色模式,也就是图片中每个像素颜色的表示方式。参数inpreferredconfig的可选值有四个,分别为ALPHA_8,RGB_565,ARGB_4444,ARGB_8888。它们的含义列举如下。

  • ALPHA_8:图片中每个像素用一个字节(8位)存储,该字节存储的是图片8位的透明度值
  • RGB_565:图片中每个像素用两个字节(16位)存储,两个字节中高5位表示红色通道,中间6位表示绿色通道,低5位表示蓝色通道
  • ARGB_4444:图片中每个像素用两个字节(16位)存储,Alpha,R,G,B四个通道每个通道用4位表示
  • ARGB_8888:图片中每个像素用四个字节(32位)存储,Alpha,R,G,B四个通道每个通道用8位表示

根据Android官方文档的说明,如果inPreferredConfig不为null,解码器会尝试使用此参数指定的颜色模式来对图片进行解码,如果inPreferredConfig为null或者在解码时无法满足此参数指定的颜色模式,解码器会自动根据原始图片的特征以及当前设备的屏幕位深,选取合适的颜色模式来解码,例如,如果图片中包含透明度,那么对该图片解码时使用的配置就需要支持透明度,默认会使用ARGB_8888来解码。
inPreferredConfig指定的配置并非是一个强制选项,而是建议的选项,Android在解码时会参考该配置,如果该配置不满足,Android会重新选取一个合适的配置来对图片进行解码。

详细的验证过程可参考https://blog.csdn.net/ccpat/article/details/46834089

0x06 为什么Bitmap.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的枚举值。如果这个值并未在 Java 层 Bitmap.Config 中公开,就返回 null,像索引颜色对应的 kIndex8_Config 就会导致 getConfig() 会返回 null。

可查看源码external/skia/include/core/SkBitmap.h,Skia的颜色配置,其中kIndex8_Config表示每像素8bits,使用 SkColorTable 来描述颜色,这个 SkColorTable 顾名思义就是颜色表了。

1
2
3
4
5
6
7
8
9
10
11
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,
};

external/skia/src/images/SkImageDecoder_libpng.cpp的getBitmapConfig方法,如果当前图片是调色板模式,会执行canUpscalePaletteToConfig方法,该方法返回 false 则 configp 被设置为 kIndex8_Config。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 {
// ...

当dstConfig为565_config并且图片包含alpha通道时,或者dstConfig不属于kARGB_8888_Config、kARGB_4444_Config 、kRGB_565_Config之一,则canUpscalePaletteToConfig如何才能令其返回false

1
2
3
4
5
6
7
8
9
10
11
12
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;
}
}

再看getPrefConfig的实现,getPrefConfig返回由java层设置的fDefaultPref,当config为kNo_Config时,返回GetDeviceConfig(),其实也是kNo_Config。那么config什么时候为SkBitmap::kNo_Config呢?当Java层设置的inPreferredConfig为null时,fDefaultPref会被赋值为kNo_Config。详情可查看getNativeBitmapConfig 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
}

综上所述,只要PNG文件本身是索引颜色格式,且在调用BitmapFactory.decodeXXX方法族时,将传入的BitmapFactory.Options.inPreferredConfig置为null即可解码得到索引颜色格式的Bitmap对象,如果这张PNG是带有alpha通道的,inPreferredConfig设置为RGB_565也可以。此时该Bitmap的Config为null。

参考文档

https://www.jianshu.com/p/ecacf2f60cb2
https://www.jianshu.com/p/cc17d18c3447
http://www.coreldrawchina.com/X7jiaocheng/cdr-tiaosebanyanse-moshi.html
https://www.jianshu.com/p/f56292504ad3
https://blog.csdn.net/ccpat/article/details/46834089
https://android.googlesource.com/platform/frameworks/base/+/marshmallow-dev/tools/aapt2/Png.cpp