iOS 深入分析大图显示问题

8,207 阅读10分钟

前言

依稀记得很久以前被问到过这么一个问题。

如果网络下载下来的图片很大的情况下要怎么处理。

那时候对这块内容不是特别了解,大致只知道内存肯定会爆掉。然后回答的是超大图就不显示了吧😂😂😂。后面也尝试去Google了,但是可能那时候比较急躁,没有很深入的去理解这个问题。今天我在回味YY大佬的iOS 处理图片的一些小 Tip的时候看到了下面的评论里面有人也提了相同的问题,大佬的回答是

可以参考苹果官方例子: https://developer.apple.com/library/ios/samplecode/LargeImageDownsizing/ 另外,在用图片库时,用参数禁用解码。

鉴于我最近高涨的学习兴趣,决定去一探究竟。

加载大图可能出现的问题

我这边尝试写了个demo来看看具体会发生什么。(空的初始化工程,只是首页展示了这张图片)

  • Xcode 9.3,模拟器是iPhoneX。
  • 图片是8.3MB,JPEG格式,7033 × 10110尺寸。

具体结果:

  • 当用[UIImage imageNamed:name]的方式加载本地大图的时候,内存的变化是 45 MB —> 318.5MB。可以说是内存暴增了,这样的暴增带来的结果很有可能就是闪退。

  • 当用SDWebImage或者YYWebImage加载的时候结果类似,有细微的几MB的差别。差不多都是 45MB -> 240MB -> 47Mb。可以看到还是有段时间是内存暴增的情况,还是存在闪退的风险。

图片加载流程

搞清楚这个问题之前,我们先来看一下图片加载的具体流程。方便后面理解。

假设我们用的是imageNamed的方式来加载图片

  • 1.先在bundle里查找图片的文件名返回给image。

  • 2.加载图片前,通过文件名加载image赋值给imageView。这个时候图片并不会直接解压缩。

  • 3.一个隐式的 CATransaction 捕获到了 UIImageView 图层树的变化;

  • 4.在主线程的下一个 run loop 到来时,Core Animation 提交了这个隐式的 transaction ,这个过程可能会对图片进行 copy 操作,而受图片是否字节对齐等因素的影响,这个 copy 操作可能会涉及以下部分或全部步骤:

      a.分配内存缓冲区用于管理文件 IO 和解压缩操作;
      b.将文件数据从磁盘读到内存中;
      c.将压缩的图片数据解码成未压缩的位图形式,这是一个非常耗时的 CPU 操作;  
      d.最后 Core Animation 使用未压缩的位图数据渲染 UIImageView 的图层。
    

我们可以看到,图片并不是一赋值给imageView就显示的。图片需要在显示之前解压缩成未压缩的位图形式才能显示。但是这样的一个操作是非常耗时的CPU操作,并且这个操作是在主线程当中进行的。所以如果没有特殊处理的情况下,在图片很多的列表里快速滑动的情况下会有性能问题。

为什么要解压缩呢

在接下去讲内容之前先来解释下这个问题。不管是 JPEG 还是 PNG 图片,都是一种压缩的位图图形格式。按照我的理解就是把原始的位图数据压缩一下,按照特定的格式删掉一些内容,这样一来数据就变少了。图片也就变小了。但是我们展示的时候这样压缩过的格式是无法直接显示的,我们需要拿到图片的原始数据,所以我们就需要在展示前解压缩一下。

解压缩卡顿问题

上面提到,如果我们不做特殊处理的话,解压缩会带来一些性能问题。但是如果我们给imageView提供的是解压缩后的位图那么系统就不会再进行解压缩操作。这种方式也是SDWebImageYYWebImage的实现方式。具体解压缩的原理就是CGBitmapContextCreate方法重新生产一张位图然后把图片绘制当这个位图上,最后拿到的图片就是解压缩之后的图片。

SDWebImage里面的这部分代码

- (nullable UIImage *)sd_decompressedImageWithImage:(nullable UIImage *)image {
    if (![[self class] shouldDecodeImage:image]) {
        return image;
    }
    
    // autorelease the bitmap context and all vars to help system to free memory when there are memory warning.
    // on iOS7, do not forget to call [[SDImageCache sharedImageCache] clearMemory];
    @autoreleasepool{
        
        CGImageRef imageRef = image.CGImage;
        CGColorSpaceRef colorspaceRef = [[self class] colorSpaceForImageRef:imageRef];
        
        size_t width = CGImageGetWidth(imageRef);
        size_t height = CGImageGetHeight(imageRef);
        
        // kCGImageAlphaNone is not supported in CGBitmapContextCreate.
        // Since the original image here has no alpha info, use kCGImageAlphaNoneSkipLast
        // to create bitmap graphics contexts without alpha info.
        CGContextRef context = CGBitmapContextCreate(NULL,
                                                     width,
                                                     height,
                                                     kBitsPerComponent,
                                                     0,
                                                     colorspaceRef,
                                                     kCGBitmapByteOrderDefault|kCGImageAlphaNoneSkipLast);
        if (context == NULL) {
            return image;
        }
        
        // Draw the image into the context and retrieve the new bitmap image without alpha
        CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);
        CGImageRef imageRefWithoutAlpha = CGBitmapContextCreateImage(context);
        UIImage *imageWithoutAlpha = [UIImage imageWithCGImage:imageRefWithoutAlpha
                                                         scale:image.scale
                                                   orientation:image.imageOrientation];
        
        CGContextRelease(context);
        CGImageRelease(imageRefWithoutAlpha);
        
        return imageWithoutAlpha;
    }
}

YYWebImage里的这部分代码

CGImageRef YYCGImageCreateDecodedCopy(CGImageRef imageRef, BOOL decodeForDisplay) {
    if (!imageRef) return NULL;
    size_t width = CGImageGetWidth(imageRef);
    size_t height = CGImageGetHeight(imageRef);
    if (width == 0 || height == 0) return NULL;
    
    if (decodeForDisplay) { //decode with redraw (may lose some precision)
        CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(imageRef) & kCGBitmapAlphaInfoMask;
        BOOL hasAlpha = NO;
        if (alphaInfo == kCGImageAlphaPremultipliedLast ||
            alphaInfo == kCGImageAlphaPremultipliedFirst ||
            alphaInfo == kCGImageAlphaLast ||
            alphaInfo == kCGImageAlphaFirst) {
            hasAlpha = YES;
        }
        // BGRA8888 (premultiplied) or BGRX8888
        // same as UIGraphicsBeginImageContext() and -[UIView drawRect:]
        CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
        bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
        CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, YYCGColorSpaceGetDeviceRGB(), bitmapInfo);
        if (!context) return NULL;
        CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef); // decode
        CGImageRef newImage = CGBitmapContextCreateImage(context);
        CFRelease(context);
        return newImage;
        
    } else {
        CGColorSpaceRef space = CGImageGetColorSpace(imageRef);
        size_t bitsPerComponent = CGImageGetBitsPerComponent(imageRef);
        size_t bitsPerPixel = CGImageGetBitsPerPixel(imageRef);
        size_t bytesPerRow = CGImageGetBytesPerRow(imageRef);
        CGBitmapInfo bitmapInfo = CGImageGetBitmapInfo(imageRef);
        if (bytesPerRow == 0 || width == 0 || height == 0) return NULL;
        
        CGDataProviderRef dataProvider = CGImageGetDataProvider(imageRef);
        if (!dataProvider) return NULL;
        CFDataRef data = CGDataProviderCopyData(dataProvider); // decode
        if (!data) return NULL;
        
        CGDataProviderRef newProvider = CGDataProviderCreateWithCFData(data);
        CFRelease(data);
        if (!newProvider) return NULL;
        
        CGImageRef newImage = CGImageCreate(width, height, bitsPerComponent, bitsPerPixel, bytesPerRow, space, bitmapInfo, newProvider, NULL, false, kCGRenderingIntentDefault);
        CFRelease(newProvider);
        return newImage;
    }
}

问题分析

现在来分析一下为什么会出现内存暴增的问题,上面的内容看似和这个问题没有上面联系。其实不然,上面我们都知道,用系统的方法和用SDWebImage或者YYWebImage加载图片的时候都涉及到了一个解压缩的操作,而解压缩的操作有涉及到了一个位图的创建。

第三方库

先看看SDWebImage或者YYWebImage,它们用的是CGBitmapContextCreate这个方法。我在文档里发现我们需要传递一个data参数,文档里的解释是如下。

data A pointer to the destination in memory where the drawing is to be rendered. The size of this memory block should be at least (bytesPerRow*height) bytes.

Pass NULL if you want this function to allocate memory for the bitmap. This frees you from managing your own memory, which reduces memory leak issues.

也就是说我们需要去生成一个一块大小为bytesPerRow*height的内存,通过查阅其它博客发现最后的计算内存大小的逻辑是(宽度 * 高度 * 4 (一个像素4个字节)),当然你也可以传NULL,这样的话系统就会帮你去创建。我们上面用的图片是7033 × 10110计算出来的尺寸是271MB。这里和上面看见的大小有细微出入,因为不是用的instruments所以可能不是很准,而且可能其它的东西会影响到这个结果。这里暂且不论。我们看到上面的demo里内存最后会有一个回落的过程,结合上面两个库里的代码可以看到,拿到图片之后这两个库都把位图释放掉了,内存得以释放。所以内存也就回落了。

系统方法

看不到系统具体的创建方法,但是创建位图的过程应该类似。并且我们都知道imageNamed加载图片的方式最后会把位图存到一个全局缓存里面去,所以用系统的方式我们看不到内存的回落。

阶段总结

内存暴增就是因为,解压缩展示大图的时候我们创建的位图太大了,占用了很大的内存空间。

解决方法

通过上面的分析我们已经知道具体的原因了。

1.第三方库用参数禁用解码

之前YY大佬的解释里的用参数禁用解码也就很好理解了。由于用第三方库的时候都是提前做了一步解压缩操作,所以当图片很大的情况下这一步创建的位图会占用很大的内存。所以我们需要禁止解压缩。

2.使用苹果的方案来显示图片

如果图片不是解压缩完的位图,那么想要显示在屏幕上无论如何都是要做解压缩的,之前第三方只是提前做了这步的操作。竟然有现成的方案了,我们来看一下具体是需要怎么处理的。

核心的方法逻辑:

  • 计算大图显示对应当前机型对应的缩放比例,分块绘制的高度等等数据,分块绘制的组的数量
  • CGBitmapContextCreate方法生成一张比例缩放的位图。
  • CGImageCreateWithImageInRect根据计算的rect获取到图片数据。
  • CGContextDrawImage根据计算的rect把获取到的图片数据绘制到位图上。
  • CGBitmapContextCreateImage绘制完毕获取到图片显示。

画了个好像没什么用的图:

具体代码:

-(void)downsize:(id)arg {
    // 创建NSAutoreleasePool
    NSAutoreleasePool* pool = [[NSAutoreleasePool alloc] init];

    // 获取图片,这个时候是不会绘制
    sourceImage = [[UIImage alloc] initWithContentsOfFile:[[NSBundle mainBundle] pathForResource:kImageFilename ofType:nil]];
    if( sourceImage == nil ) NSLog(@"input image not found!");

    // 拿到当前图片的宽高
    sourceResolution.width = CGImageGetWidth(sourceImage.CGImage);
    sourceResolution.height = CGImageGetHeight(sourceImage.CGImage);

    // 当前图片的像素
    sourceTotalPixels = sourceResolution.width * sourceResolution.height;

    // 当前图片渲染到界面上的大小
    sourceTotalMB = sourceTotalPixels / pixelsPerMB;

    // 获取当前最合适的图片渲染大小,计算图片的缩放比例
    imageScale = destTotalPixels / sourceTotalPixels;

    // 拿到缩放后的宽高
    destResolution.width = (int)( sourceResolution.width * imageScale );
    destResolution.height = (int)( sourceResolution.height * imageScale );

    // 生成一个rgb的颜色空间
    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
    
    // 缩放情况下的每一行的字节数
    int bytesPerRow = bytesPerPixel * destResolution.width;

    // 计算缩放情况下的位图大小,申请一块内存
    void* destBitmapData = malloc( bytesPerRow * destResolution.height );
    if( destBitmapData == NULL ) NSLog(@"failed to allocate space for the output image!");

    // 根据计算的参数生成一个合适尺寸的位图
    destContext = CGBitmapContextCreate( destBitmapData, destResolution.width, destResolution.height, 8, bytesPerRow, colorSpace, kCGImageAlphaPremultipliedLast );

    // 如果生成失败了释放掉之前申请的内存
    if( destContext == NULL ) {
        free( destBitmapData ); 
        NSLog(@"failed to create the output bitmap context!");
    }        

    // 释放掉颜色空间
    CGColorSpaceRelease( colorSpace );
    
    // 坐标系转换
    CGContextTranslateCTM( destContext, 0.0f, destResolution.height );
    CGContextScaleCTM( destContext, 1.0f, -1.0f );
    
    // 分块绘制的宽度(原始宽度)
    sourceTile.size.width = sourceResolution.width;
    
    // 分块绘制的高度
    sourceTile.size.height = (int)( tileTotalPixels / sourceTile.size.width );     
    NSLog(@"source tile size: %f x %f",sourceTile.size.width, sourceTile.size.height);
    sourceTile.origin.x = 0.0f;

    // 绘制到位图上的宽高
    destTile.size.width = destResolution.width;
    destTile.size.height = sourceTile.size.height * imageScale;        
    destTile.origin.x = 0.0f;
    NSLog(@"dest tile size: %f x %f",destTile.size.width, destTile.size.height);
    
    // 重合的像素
    sourceSeemOverlap = (int)( ( destSeemOverlap / destResolution.height ) * sourceResolution.height );
    NSLog(@"dest seem overlap: %f, source seem overlap: %f",destSeemOverlap, sourceSeemOverlap);    
    CGImageRef sourceTileImageRef;

    // 分块绘制需要多少次才能绘制完成
    int iterations = (int)( sourceResolution.height / sourceTile.size.height );
    int remainder = (int)sourceResolution.height % (int)sourceTile.size.height;
    if( remainder ) iterations++;
    
    // 添加重合线条
    float sourceTileHeightMinusOverlap = sourceTile.size.height;
    sourceTile.size.height += sourceSeemOverlap;
    destTile.size.height += destSeemOverlap;    

    // 分块绘制
    for( int y = 0; y < iterations; ++y ) {
        // create an autorelease pool to catch calls to -autorelease made within the downsize loop.
        NSAutoreleasePool* pool2 = [[NSAutoreleasePool alloc] init];
        NSLog(@"iteration %d of %d",y+1,iterations);
        sourceTile.origin.y = y * sourceTileHeightMinusOverlap + sourceSeemOverlap; 
        destTile.origin.y = ( destResolution.height ) - ( ( y + 1 ) * sourceTileHeightMinusOverlap * imageScale + destSeemOverlap );
        
        // 分块拿到图片数据
        sourceTileImageRef = CGImageCreateWithImageInRect( sourceImage.CGImage, sourceTile );
        
        // 计算绘制的位置
        if( y == iterations - 1 && remainder ) {
            float dify = destTile.size.height;
            destTile.size.height = CGImageGetHeight( sourceTileImageRef ) * imageScale;
            dify -= destTile.size.height;
            destTile.origin.y += dify;
        }
        
        // 绘制到位图上
        CGContextDrawImage( destContext, destTile, sourceTileImageRef );
        
        // 释放内存
        CGImageRelease( sourceTileImageRef );
        [sourceImage release];
        [pool2 drain];
        
        // 更新图片显示
        if( y < iterations - 1 ) {            
            sourceImage = [[UIImage alloc] initWithContentsOfFile:[[NSBundle mainBundle] pathForResource:kImageFilename ofType:nil]];
            [self performSelectorOnMainThread:@selector(updateScrollView:) withObject:nil waitUntilDone:YES];
        }
    }

    // 显示图片,释放内存
    [self performSelectorOnMainThread:@selector(initializeScrollView:) withObject:nil waitUntilDone:YES];
	CGContextRelease( destContext );
    [pool drain];
}

最后

希望能对大家有一点点的帮助。

参考链接

谈谈 iOS 中图片的解压缩

iOS 处理图片的一些小 Tip