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合成用户编辑图片的方法  
Bitmap outBmp = Bitmap.createBitmap(editWidth, editHeight, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(outBmp);
PaintFlagsDrawFilter pfd = new PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
canvas.setDrawFilter(pfd);

Bitmap oriBmp = ((BitmapDrawable) mBitmapDisplayed.getBitmap()).getBitmap();
Matrix oriMatrix = new Matrix();
float[] matrixValues = new float[9];
Matrix displayMatrix = getImageViewMatrix();
displayMatrix.getValues(matrixValues);
float transX = matrixValues[Matrix.MTRANS_X];
float transY = matrixValues[Matrix.MTRANS_Y];
float osx = editWidth * 1f / getWidth();
float osy = editHeight * 1f / getHeight();
oriMatrix.postScale(osx, osy);
oriMatrix.postConcat(displayMatrix);
oriMatrix.postTranslate(-transX, -transY);
oriMatrix.postTranslate(transX * osx, transY * osy);

canvas.drawBitmap(oriBmp, oriMatrix, null);
return outBmp;
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是第三级操作的关键方法。
public static Bitmap decodeRegionInternal(String operFilepath, int orientation,
Rect originRegion, Matrix originMatrix,
Rect cropRegion, float cropRegionScale,
boolean deleteFile)
operFilepath是sample以后图片文件,orientation是图片文件旋转信息,originRegion是图片原始大小的区域,originMatrix是图片编辑矩阵,cropRegion是实际裁剪框,cropRegionScale微调放大系数,deleteFile是否删除中间文件。
decodeRegionInternal进来后,先验证参数有效性,将originRegion进行旋转校正,再利用originMatrix得到最终编辑操作以后图片所在区域,包含超出屏幕的部分。再利用cropRegionScale校正最终实际裁剪框cropRect,并平移到(0,0)参考点。
BitmapRegionDecoder regionDecoder = BitmapRegionDecoder.newInstance(operFilepath, false);
RectF rotatedOriginRectF = new RectF(0, 0, rotatedOriginWidth, rotatedOriginHeight);
RectF operRectF = new RectF();
originMatrix.mapRect(operRectF, rotatedOriginRectF);
Rect cropRect = new Rect(0, 0, cropWidth, cropHeight);
int[] offset = new int[2];
offset[0] = 0 - (int) operRectF.left;
offset[1] = 0 - (int) operRectF.top;
cropRect.offset(offset[0], offset[1]);
if (cropRegionScale != 1f) {
cropRect.offset((int) (cropRect.left * cropRegionScale - cropRect.left), (int) (cropRect.top * cropRegionScale - cropRect.top));
cropRect.right = (int)(cropRect.left + cropWidth * cropRegionScale);
cropRect.bottom = (int)(cropRect.top + cropHeight * cropRegionScale);
}
若文件里的图片有过旋转,还需继续校正cropRect
if (orientation != 0) {
tmpRectF = new RectF(0, 0, operWidth, operHeight);
Matrix backRotateMatrix = new Matrix();
backRotateMatrix.postRotate(-orientation);
backRotateMatrix.mapRect(tmpRectF);
float transX = 0 - tmpRectF.left;
float transY = 0 - tmpRectF.top;
tmpRectF.offset(transX, transY);
RectF cropRectF = new RectF(cropRect.left, cropRect.top, cropRect.right, cropRect.bottom);
backRotateMatrix.mapRect(tmpRectF, cropRectF);
tmpRectF.offset(transX, transY);
cropRect = new Rect((int)tmpRectF.left, (int)tmpRectF.top, (int)tmpRectF.right, (int)tmpRectF.bottom);
}
最后调用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条件下最大放大倍数。
public static Bitmap smartCrop(final String filepath, int orientation,
Rect CROP_RECT, Rect DISPLAY_RECT,
Matrix displayMatrix, float suppScale, float maxScale)
首先验证参数条件,生成基本绘图信息后,再利用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以下。
if ( maxScale / suppScale >= 2) {  //x
int sample = (int) ( maxScale / suppScale);
option = new BitmapFactory.Options();
option.inJustDecodeBounds = false;
option.inSampleSize = sample;
Bitmap sampleBitmap = BitmapFactory.decodeFile(filepath, option);
if (sampleBitmap == null || sampleBitmap.isRecycled()) {
System.gc();
Log.e(tag, "sample bitmap error");
return null;
}

Bitmap rotatedSampleBitmap = sampleBitmap;
if (orientation != 0) {
rotatedSampleBitmap = Bitmap.createBitmap(sampleBitmap, 0, 0, sampleBitmap.getWidth(), sampleBitmap.getHeight(),
rotateMatrix, true);
}
sampleBitmap = null;
if (rotatedSampleBitmap == null || rotatedSampleBitmap.isRecycled()) {
System.gc();
Log.e(tag, "rotate sample bitmap error");
return null;
}
int operWidth = (int)(rotatedOriginWidth / maxScale);
int operHeight = (int)(rotatedOriginHeight / maxScale);
Bitmap operBitmap = Bitmap.createScaledBitmap(rotatedSampleBitmap, operWidth, operHeight, true); //相当于用matrix再缩放一层小数点
rotatedSampleBitmap.recycle();
rotatedSampleBitmap = null;
if (operBitmap == null || operBitmap.isRecycled()) {
System.gc();
Log.e(tag, "scale sample bitmap error");
}

String tmpFilePath = getCustomDirectory(APP_PIC_DIR) + "smartcrop_sample.jpg";
savePhoto(operBitmap, tmpFilePath);
operBitmap.recycle();
operBitmap = null;
return decodeRegionInternal(tmpFilePath, orientation, ROTATED_ORIGIN_RECT, originMatrix, CROP_RECT, 1f, true); //tmpFilePath已经是rotated图片了,可能这里应该orientation=0,待测
}
我们也看过一些竞品,如噗印、艺术狗等App,都是采用最通用的传统方式。淘宝定制更是采用了H5,更加无法控制图片处理的性能,实测还没达到3000*3000像素的图片就OOM了。我们也是因为域内大部分用户都是摄影高端玩家,小白级的手机摄像头拍出的照片已无法满足他们的定制需求,虽然现在很多图片定制类App和Sdk采用了H5架构,但如果要走高端用户的路线,native的性能的确是一个天然优势。

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