Android开发或多或少都遇到过OutOfMemoryError,而平常开发中导致该错误的罪魁祸首很可能就是加载大图,为什么呢?因为我们解码出来需要显示的图片是实打实的位图(Bitmap),一个像素需要4bytes的内存,一张普通的壁纸1920x1080px,就需要接近8MB(1920x1080x4B)的内存,这还是最理想的铺满全屏的Bitmap,实际上现在我们手机拍摄的照片都远超过了这个尺寸,需要的内存几乎翻了好几倍,更别提一些照相馆使用专业作图软件编辑生成的艺术照,动辄上万像素的宽高,一不小心光加载一张图就花了1、2GB的内存。有人会说搁着骗人呢,BitmapFactory不是提供了只读图片宽高的方法吗?不是提供了inSampleSize这个用于采样的配置吗?读完宽高,算算inSampleSize,再真正加载Bitmap不是可以省很多Bitmap内存吗?如果这些对所有情况都有用,我也不会写这篇文章了😔。
现象
实际情况是,对某些图片inSampleSize在解码阶段失效了,也就是虽然我们得到的Bitmap是根据这个参数缩放后的Bitmap,但是使用解码器去解码图片这个过程还是按照原图的尺寸去加载的,会导致解码阶段内存暴涨,还轮不到java层抛OOM就已经被系统杀死进程了。下面以加载一张艺术照为例,图片的尺寸为19500x6000px,加载时设置inSampleSize=8,那么理论上占用的内存不会超过128MB,但如下图native内存暴涨到了1.2GB。
显然这时inSampleSize=8对解码图片失效了,这是在内存比较大的手机上加载的,所以没有崩溃,但如果我们在一些内存比较低的设备,比如大屏终端,用户觉得这么大的屏幕,不用来轮播自己拍的美美照片简直浪费,可是这种设备内存注定低,一加载大图它就闪退了,md把它砸了!
原因
根据我的总结,出现这个现象的是部分JPEG文件格式的大图,这些大图的特点是他们都是递增式JPEG,要理解和普通JPEG的区别还得从JPEG文件说起。
JPEG文件格式
JPEG(Joint Picture Expert Group) 本身只是描述如何将一个视频/图片转换为字节的数据流(如DCT变换、量化、哈夫曼树等等),但并没有说明这些字节如何在任何特定的存储媒体上被封存起来。JPEG/JFIF(JPEG File Interchange Format)和JPEG/Exif(Exchange image file Format) 才是描述怎么保存JPEG图片的标准:
- JPEG/JFIF文件格式标准是为了方便JPEG压缩图像在广泛的平台和应用间以最小的存储空间代价进行交换而设计的,它不包含JPEG/TIFF标准任何高级特性。
- JPEG/Exif文件格式标准是Camera产业联合会发布,主要用于摄像设备上,摄像产业把Exif作为行业的元数据(metadata)交换格式。
简单理解就是JPEG/JFIF只保存最基本的图片信息,而JPEG/Exif可以附加很多图片相关的信息在里面。但是忽略它们的区别,它们大致的文件格式是一样的,以JPEG/JFIF文件为例,由这几部分组成:SOI(文件头)+APP0(图像识别信息)+ DQT(定义量化表)+ SOF0(图像基本信息)+ DHT(定义Huffman表) + DRI(定义重新开始间隔)+ SOS(扫描行开始)+ EOI(文件尾),如图:
其中APP0段就是代表JPEG/JFIF,里面保存了图片的基本信息,JPEG/Exif与此的区别是它紧跟着还有一个APP1段或者它就只有APP1段而没有APP0段,在APP1段里是根据Exif规定的标准保存的图片信息。
EncodingFormat
上文只是简单介绍了JPEG文件格式的标准,除此之外,不同的JPEG间还有编码方式的不同,这块比较深奥我也没搞懂,具体参考 JPEG wikipedia,但是根据多次验证,正是不同的编码方式导致了本文描述的OOM现象。参考上面图片里的格式,图像(不是图片文件)的基本信息放在了SOF(Start Of Frame)这个段里面,但是通常存在两种SOF,一个叫SOF0用0xFFC0识别,一个叫SOF2用0xFFC2识别,存在SOF0则说明这是一张baseline DCT-based JPEG(顺序式编码),存在SOF2则说明这是一张progressive DCT-based JPEG(递增式编码),递增式编码图片可将图像分数次处理,以从模糊到清晰的方式来传送、显示图像,通过多次验证是这种编码的JPEG大图导致了本文提到OOM。下面以这张19500x6000px的递增式编码JPEG为例介绍SOF2段:
1 | 截取的bytes: FFC200110817704C2C03011100021101031101 |
其实如果把该图片转换成顺序式编码的,SOF0段内容和这个是一模一样的,我们只需要通过判断是否存在SOF0或者SOF2就可以区分是顺序式编码图片还是递增式编码图片。
解决方案
由于加载递增式JPEG的的确确需要在解码阶段占用超过图片宽x高x4bytes的内存,我无能为力,只能通过其他方式规避这个问题。一个方法是在加载图片前获取宽高,计算下如果不采样加载会花多少内存,如果超过一个阈值,比2GB,这时需要判断下是否是递增式编码JPEG,如果是,那么采取其他措施,比如直接放弃加载提示用户不支持递增式编码JPEG😄。还有一种方案是如果这时是递增式编码JPEG,那么启动一个子进程将这张递增式编码JPEG转换成普通的JPEG并保存,完事了通知主进程可以加载普通的JPEG,然后主动杀死子进程,具体可以使用libjpeg-turbo 里的tjTransform函数,它的实现方法和jpegtran是一样的,jpegtran介绍将JPEG文件转成JPEG文件会节约一些内存,用这张19500x6000px递增式编码JPEG测试转换成顺序式编码JPEG大概占用600MB的内存。
关键在于怎么判断递增式编码JPEG,我参考Android的ExifInterface写了一个判断增式编码JPEG的类:JpegEncodingFormatChecker.java
,可以判断增式编码JPEG的图片。
其他
至此,对图片加载理解更多一些,但是越学越觉得自己无知,作为应用开发者,其实只是一个承上启下的搬运工。承上,图片文件格式、编码算法?什么也不知道。启下,系统怎么渲染解码出来的位图的?也什么也不知道。再接再厉吧。
参考: