SDWebImage设置大尺寸图片崩溃问题

昨天产品在teambition上提了一个bug:点击特定的页面app闪退。

我很是纳闷,因为通过其它类型索引进入的详情页面都不会出现这样的情况,为什么偏偏是这个页面?还是因为memory warning而闪退?而且内存不是慢慢增加,而是从80M左右激增到600M+

接着我查看了进入这个页面时获取的json,仔细观察后,发现并没有特别的地方。于是我决定使用instruments的Allocations查看到底是什么操作占用了如此庞大的内存。

进入界面之后,展示的界面如下图:

追踪到底是哪个函数调用申请了这么多内存:

根据经验,考虑到SDWebImage是直接使用原图进行渲染,所以初步可以断定是图片渲染导致内存的问题。

于是我查看了这个界面唯一的图片:

…WTF…

问了后台,这个图片是企业上传的,最大不超过2M,而且给App的图片并没有经过压缩处理。接着我又查看了安卓端,发现他们并没有显示出这一张logo。。。

图片链接

虽然图片只有1.8M,但是像素为15497*10166,iOS解压到内存并显示所需要的内存通过以下公式计算出:

1
2
3
4
5
6
#define bytesPerMB 1048576.0f 
// 这里针对32色来说 (32 / 8)
#define bytesPerPixel 4.0f
#define pixelsPerMB ( bytesPerMB / bytesPerPixel )

Width x Height / pixelsPerMB

所以大概可以计算出这张图片需要600M左右的内存进行解码显示。

google了下,最多的解决方式是利用UIGraphicsBeginImageContext对图片进行裁剪后渲染:

1
2
3
4
5
6
7
8
9
10
11

UIGraphicsBeginImageContext(size);
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextClearRect(context, CGRectMake(0, 0, size.width, size.height));

CGContextSetInterpolationQuality(context, 0.8);

[self drawInRect:drawRect blendMode:kCGBlendModeNormal alpha:1];

UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();

虽然这样做最终会让内存稳定在80M左右,但是在回落之前,内存会有一小段时间上升至300M左右。也就是说,这种方法在处理10000*10000px的图片时,还是有崩溃的危险的。

然后我又尝试了苹果提供的使用ImageIO进行缩小图片的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
CGImageRef MyCreateThumbnailImageFromData (NSData * data, int imageSize)
{
CGImageRef myThumbnailImage = NULL;
CGImageSourceRef myImageSource;
CFDictionaryRef myOptions = NULL;
CFStringRef myKeys[3];
CFTypeRef myValues[3];
CFNumberRef thumbnailSize;

// Create an image source from NSData; no options.
myImageSource = CGImageSourceCreateWithData((CFDataRef)data,
NULL);
// Make sure the image source exists before continuing.
if (myImageSource == NULL){
fprintf(stderr, "Image source is NULL.");
return NULL;
}

// Package the integer as a CFNumber object. Using CFTypes allows you
// to more easily create the options dictionary later.
thumbnailSize = CFNumberCreate(NULL, kCFNumberIntType, &imageSize);

// Set up the thumbnail options.
myKeys[0] = kCGImageSourceCreateThumbnailWithTransform;
myValues[0] = (CFTypeRef)kCFBooleanTrue;
myKeys[1] = kCGImageSourceCreateThumbnailFromImageIfAbsent;
myValues[1] = (CFTypeRef)kCFBooleanTrue;
myKeys[2] = kCGImageSourceThumbnailMaxPixelSize;
myValues[2] = (CFTypeRef)thumbnailSize;

myOptions = CFDictionaryCreate(NULL, (const void **) myKeys,
(const void **) myValues, 2,
&kCFTypeDictionaryKeyCallBacks,
& kCFTypeDictionaryValueCallBacks);

// Create the thumbnail image using the specified options.
myThumbnailImage = CGImageSourceCreateThumbnailAtIndex(myImageSource,
0,
myOptions);
// Release the options dictionary and the image source
// when you no longer need them.
CFRelease(thumbnailSize);
CFRelease(myOptions);
CFRelease(myImageSource);

// Make sure the thumbnail image exists before continuing.
if (myThumbnailImage == NULL){
fprintf(stderr, "Thumbnail image not created from image source.");
return NULL;
}

return myThumbnailImage;
}

结果和使用UIGraphicsBeginImageContext一样,会有一个内存波峰。

最终还是通过苹果找到了对应的解决方案:
LargeImageDownsizing(最终尝试后只对jpg有效)

在stackoverflow上用蹩脚的书面英文也获取了对应的方案:
App crashed when I display a large image by UIImageView

不过最终我还是采用了和安卓端一样的处理方法(考虑到后台以后会进行图片裁剪)。因为是针对所有显示图片的地方,所以我给UIImageView添加了一个分类来对图片大小进行限制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@implementation UIImageView (Extension)
+ (void)load
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class class = [self class];

SEL origionSel = @selector(setImage:);
SEL swizzlingSel = @selector(bq_setImage:);
Method origionMethod = class_getInstanceMethod(class, origionSel);
Method swizzlingMethod = class_getInstanceMethod(class, swizzlingSel);

BOOL hasAdded = class_addMethod(class, origionSel, method_getImplementation(swizzlingMethod), method_getTypeEncoding(swizzlingMethod));

if (hasAdded) {
class_replaceMethod(class, swizzlingSel, method_getImplementation(origionMethod), method_getTypeEncoding(origionMethod));
} else {
method_exchangeImplementations(origionMethod, swizzlingMethod);
}
});
}

- (void)bq_setImage:(UIImage *)image {
CGFloat maxImageWH = 3000;
if (image.size.width > maxImageWH || image.size.height > maxImageWH) {
image = self.placeholder;
}
[self bq_setImage:image];
}

- (void)setPlaceholder:(UIImage *)placeholder {
objc_setAssociatedObject(self, @selector(placeholder), placeholder, OBJC_ASSOCIATION_RETAIN);
}

- (UIImage *)placeholder {
return objc_getAssociatedObject(self, _cmd);
}
@end

在宽度或者高度可能超过3000的地方,提前设置placeholder,否则显示的将是一个空白UIImageView。

对于苹果提供的那种方法,后面再继续研究下,和YYWebImage的显示方式有点像,都是进行逐步显示,而不是直接对整个原图进行渲染。

2016-9-12 新动态

1
2
3
4
5
6
7
8
9
10
11

UIGraphicsBeginImageContext(size);
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextClearRect(context, CGRectMake(0, 0, size.width, size.height));

CGContextSetInterpolationQuality(context, 0.8);

[self drawInRect:drawRect blendMode:kCGBlendModeNormal alpha:1];

UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();

以上代码针对jpg是有效的,针对png会出现原来说的那种情况。

2016-9-23 新动态==,我傻逼了!

嗯,苹果提供的方法是可行的,下面代码创建字典的时候漏了一个限定图片大小的键值对。==|

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
CGImageRef MyCreateThumbnailImageFromData (NSData * data, int imageSize)
{
CGImageRef myThumbnailImage = NULL;
CGImageSourceRef myImageSource;
CFDictionaryRef myOptions = NULL;
CFStringRef myKeys[3];
CFTypeRef myValues[3];
CFNumberRef thumbnailSize;

// Create an image source from NSData; no options.
myImageSource = CGImageSourceCreateWithData((CFDataRef)data,
NULL);
// Make sure the image source exists before continuing.
if (myImageSource == NULL){
fprintf(stderr, "Image source is NULL.");
return NULL;
}

// Package the integer as a CFNumber object. Using CFTypes allows you
// to more easily create the options dictionary later.
thumbnailSize = CFNumberCreate(NULL, kCFNumberIntType, &imageSize);

// Set up the thumbnail options.
myKeys[0] = kCGImageSourceCreateThumbnailWithTransform;
myValues[0] = (CFTypeRef)kCFBooleanTrue;
myKeys[1] = kCGImageSourceCreateThumbnailFromImageIfAbsent;
myValues[1] = (CFTypeRef)kCFBooleanTrue;
myKeys[2] = kCGImageSourceThumbnailMaxPixelSize;
myValues[2] = (CFTypeRef)thumbnailSize;

// 就是这里,numValues应该是3,不是2。==
myOptions = CFDictionaryCreate(NULL, (const void **) myKeys,
(const void **) myValues, 3,
&kCFTypeDictionaryKeyCallBacks,
& kCFTypeDictionaryValueCallBacks);

// Create the thumbnail image using the specified options.
myThumbnailImage = CGImageSourceCreateThumbnailAtIndex(myImageSource,
0,
myOptions);
// Release the options dictionary and the image source
// when you no longer need them.
CFRelease(thumbnailSize);
CFRelease(myOptions);
CFRelease(myImageSource);

// Make sure the thumbnail image exists before continuing.
if (myThumbnailImage == NULL){
fprintf(stderr, "Thumbnail image not created from image source.");
return NULL;
}

return myThumbnailImage;
}

还有一篇关于这个的文章resizing-high-resolution-images-on-ios-without-memory-issues

大体的意思就是有以下几点:

  • 解码PNG占用了高内存
  • CoreGraphic缩放图片时,还是会对图片进行解码
  • 需要在不解码的情况下对图片进行缩放
  • ImageIO的ImageSource可以满足这一点,不解码并可以缩放

代码差不多是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
CGImageRef thumbnailImageWithData(NSData *data, NSInteger imageSize) {
CGImageSourceRef imageSource = CGImageSourceCreateWithData((CFDataRef)data, NULL);
CFDictionaryRef options =
(__bridge CFDictionaryRef) @{
(id) kCGImageSourceCreateThumbnailWithTransform : @YES,
(id) kCGImageSourceCreateThumbnailFromImageAlways : @YES,
(id) kCGImageSourceThumbnailMaxPixelSize : @(imageSize)
};
CGImageRef thumbnail = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options);
CFRelease(imageSource);

return thumbnail;
}

这样的话,最终的解决方案差不多可以是这样的:

  • 用MethodSwizzling替换UIImageView的setImage方法
  • 然后判断当前图片大小,如果大于某个限制,就用上面的函数缩放图片

嗯,就酱紫。还是要理解原理,然后细心点。