Android单反级超高清图片裁剪之防止OOM

BitmapFactory.decode缺陷

在很多App中都有上传背景图功能,需用BitmapFactory读取用户本地相册图片,缩放平移裁剪以后上传到服务端。一般App为了防止OOM,都会限制最大长度或最大像素,如Lofter之前在Android3.0以上限制最大4MB,超过则sample,相信90%以上的App都是这么做的。这种方式尴尬的地方在于,Android的sample只能是2的幂次,若图片为4.1MB,就会被sample到2.05MB。这样有可能用户传了一张高清的图片反而变模糊。对于普通图片社交应用而言,这种方式也完全够用,也可以通过RunTime.maxMemory来获取当前可分配的最大内存,在高端手机上可以加大单张图片的分配上限。设置largeHeap=true可以增加app内存上限,但上限值依赖于system/build.prop文件的设置。

单反级超高清图片的合成

1) 基于ImageViewTouch的原始合成算法,无损但OOM

Lofter乐乎印品早期 基于ImageViewTouch做图片缩放, 由于用户经常印制高清图片,初期为了追求分辨率,增加单次内存分配的宽高上限为4000*4000,虽经测试大部分Android4.0机子以上崩溃率不高,但线上还是容易引发OOM。但即便到4000的分辨率,依然无法满足高端用户的需求,有很多用户需导入单反级10000*10000的超高清图片。以下为 早期基于ImageViewTouch合成用户编辑图片的方法:


displayMatrix为ImageViewTouch返回的操作矩阵,getWidth和getHeight是View显示的宽高,editWidth和editHeight是需要合成的大小,只要把view上显示的displayMatrix转化为真实裁剪的oriMatrix,再绘制到canvas。

2) 自适应采样的最优裁剪算法,走最高端的大图定制

如Lofter乐乎印品的照片书商品,用户合成的图片只有2000*2000像素,若用户导入一张10000*10000的图片,先sample到4000*4000再载入内存,那为了让图片放大以后dpi不失真,编辑的时候最多只能放大2倍,那编辑放大的时候就展现不出10000*10000图片里的某一个区域细节了,因为这跟你导入一张4000*4000的图片,编辑起来没有任何区别。我们写了一种竞品从未使用过的算法,相比先载入原图再用canvas绘制的方法,使用了先sample原图再取一块图片区域的算法,虽然实现成本比较高,但由于sample和取图片区域会让整个图片处理的内存成倍缩小,以时间换空间。就以导入10000*10000的图片为例,加入编辑以后图片刚好撑满编辑区域,只需要2500*2500的内存,比传统的方法省掉了400%的内存!


decodeRegionInternal是第三级操作的关键方法。  


operFilepath是sample以后图片文件,orientation是图片文件旋转信息,originRegion是图片原始大小的区域,originMatrix是图片编辑矩阵,cropRegion是实际裁剪框,cropRegionScale微调放大系数,deleteFile是否删除中间文件。

decodeRegionInternal进来后,先验证参数有效性,将originRegion进行旋转校正,再利用originMatrix得到最终编辑操作以后图片所在区域,包含超出屏幕的部分。再利用cropRegionScale校正最终实际裁剪框cropRect,并平移到(0,0)参考点。


若文件里的图片有过旋转,还需继续校正cropRect  


最后调用BitmapRegionDecoder,省略一些异常处理

cropBitmap = regionDecoder.decodeRegion(cropRect, new BitmapFactory.Options());

有了第三级decodeRegionInternal,我们只需要把原图根据用户编辑参数进行sample和scale。sample作为第一级是减少内存分配的重要保障,至于sample多少,取决于用户如何编辑,刚刚提到省去400%内存,是在用户选了一张图默认撑满编辑框的场景下,即便用户绝大部分情况下会这么选择,因为用户往往想印照片的整体,这类用户占了80%。如果用户将图片放大到dpi不下降条件下的最大倍数,那也只需要解码编辑区域的图片像素到JVM,若编辑区域是刚才的情况,那也只有2000*2000。 那么在sample和scale的环节中,需对用户编辑矩阵进行分类。

smartCrop是优化内存空间的关键方法,目前smartCrop的参数可以很方便从ImageViewTouch里抽出来 。filepath是原图图片文件路径,orientation是原图图片文件的旋转方向,CROP_RECT是图片实际裁剪区,DISPLAY_RECT是屏幕上图片显示区域,displayMatrix是显示矩阵,suppScale是图片缩放操作后的放大倍数,maxScale是图片不影响dpi条件下最大放大倍数。


首先验证参数条件,生成基本绘图信息后,再利用displayMatrix得到编辑矩阵originMatrix 

Matrix originMatrix = new Matrix();

originMatrix.postScale(CROP_TO_DISPLAY_SCALE, CROP_TO_DISPLAY_SCALE);

originMatrix.postConcat(displayMatrix);

originMatrix.postTranslate(-displayTransX, -displayTransY);

originMatrix.postTranslate(displayTransX * CROP_TO_DISPLAY_SCALE, displayTransY * CROP_TO_DISPLAY_SCALE);

根据用户操作,可分为3种情形:

①图片撑满显示后,用户放大到最大

此时maxScale=suppScale,直接返回decodeRegionInternal裁剪的Bitmap,这种情况需加载编辑区域大小的图片像素到JVM

if (Math.abs(suppScale - maxScale) < 0.01) {

    return decodeRegionInternal(filepath, orientation, ROTATED_ORIGIN_RECT, originMatrix,

            CROP_RECT, 1f, false);

}

②图片撑满显示后,用户放大较多,接近最大倍数

也返回decodeRegionInternal裁剪的Bitmap,这种情况需加载的图片像素是编辑区域大小的scale倍,而scale<2&&scale>1,对于10000*10000的图片,若编辑区域是2000*2000,那需要吃掉4000*4000的空间,但这是最坏的情况,而且对于20000*20000的图片也只吃掉这么多。

else if (1 < maxScale / suppScale && maxScale / suppScale < 2) { //x

    float scale = maxScale / suppScale;

    return decodeRegionInternal(filepath, orientation, ROTATED_ORIGIN_RECT, originMatrix,

            CROP_RECT, scale, false);

}

③图片撑满显示后,用户放大较少

这种情况最复杂,需要三级处理,第一级先用2的幂次方sample,第二级再用createBitmap进行scale,保存为一个临时文件,最后返回decodeRegionInternal。采用这种方式,对于10000*10000的图片,这样最多可能吃掉5000*5000的内存。但如果对该方法继续优化,如果实际裁剪区域占的比例相对更小,10000/log(maxScale/suppScale)) > cropRegion*suppScale,排除一些极端的场景,那吃掉的内存大概率降低到4000*4000以下。


我们也看过一些竞品,都是采用最通用的传统方式。淘宝定制更是采用了H5,更加无法控制图片处理的性能,实测还没达到3000*3000像素的图片就OOM了。我们也是因为域内大部分用户都是摄影高端玩家,小白级的手机摄像头拍出的照片已无法满足他们的定制需求,虽然现在很多图片定制类App和Sdk采用了H5架构,但如果要走高端用户的路线,native的性能的确是一个天然优势。

本文来自网易实践者社区,经作者范晨灿授权发布。