iOS 性能优化之图片 - AlvinSunny/OC-TheUnderlying GitHub Wiki

认识图片

图片是指由图形、图像等构成的平面媒体。图片的格式很多,但总体上可以分为点阵图矢量图两大类,我们常用BMP(位图)、JPG等格式都是点阵图形,而SWF、CDR、AI等格式的图形属于矢量图形。

有形式的事物,我们看到的,是图画照片拓片等的统称。图是技术制图中的基础术语,指用点、线、符号、文字和数字等描绘事物几何特征、形态、位置及大小的一种形式。随着数字采集技术和信号处理理论的发展,越来越多的图片以数字形式存储

图片编码

图片本质是一个文件,其后缀表示了图片数据压缩后的格式,压缩模式一般包括:顺序式编码、递增式编码、无失真编码、阶梯式编码

感兴趣可以看看图片编码介绍

图片解码

为什么要编码和解码 ?

体积大小可简单描述为:像素宽 * 像素高 * 每个像素点所占字节数 = width * height * 4bytes(RGBA占4字节)

举一个简单的例子:

一张1280 * 720 的RGBA图像大小等于: 1280 * 720 * 4 = 3.516MB

一张1280 * 720 的YUV图像大小等于: 1280 * 720 * 1 + 1280 *720 * 0.5 = 1.318MB

假设fps为24,一般电影长度90分钟大小等于: 1.318MB * 24fps * 90min * 60s = 166.8GB 如此大的内存占用一台计算机存一部电影就用完了所有内存,这肯定是不行的,所以需要对数据进行编码以达到减少内存占用并且可以正常显示目的

编码过之后的数据没法直接使用,所以就需要解码;也称之为解压缩

图片的显示

 self.headImageView.image = [UIImage imageWithContentsOfFile:@"headicon.png"];

一张本地图片显示有三个步骤: 加载、解码、渲染

加载: 从磁盘拷贝原始压缩数据到内核缓冲区,从内核缓冲区拷贝数据到用户空间(注意经历了两次拷贝)

解码: 把png、jpg格式的未解码数据解码成位图数据(CPU解压成未压缩的图片数据位图(imageBuffer))

渲染: CATransaction捕获到UIImageView layer树的变化,主线程Runloop提交CATransaction,开始进行图像渲染,如果数据没有字节对齐,Core Animation会再拷贝一份数据,进行字节对齐。接下来由GPU处理位图数据,会生成frameBuffer,帧缓存,进行渲染最终显示到屏幕

image

Data Buffer

Data Buffer存储了图片的元数据,我们常见的图片格式,jpeg,png等都是压缩图片格式。Data Buffer的内存大小就是源图片在磁盘中的大小

image

Image Buffer

Image Buffer存储的就是图片解码后的像素数据/像素数组,也就是我们常说的位图。 Buffer中每一个元素描述的一个像素的颜色信息,buffer的size和图片的size成正相关关系

image

Frame Buffer

FrameBuffer 存储了app每帧的实际输出,和OpenGL中FrameBuffer类似,苹果不允许我们直接渲染操作屏幕显示,而是把渲染数据放入帧缓存中,由系统按照60hz-120hz的频率扫描显示

image

当app视图层级发生变化时,UIKit 会结合 UIWindow 和 Subviews,渲染出一个 frame buffer,然后按60hz的频率扫描(ipad最高可以达到120hz)显示到屏幕上

有一个UIImageView当其显示到屏幕上需要UIImage作为数据源,当UIImage持有的数据是未解码的压缩数据时就需要对图像数据进行解码,使其转化为位图数据,解码是一个计算量较大的任务,一般由CPU完成;解码后图片体积的大小只跟分辨率有关

分辨率和像素的关系

像素和分辨率是两个密不可分的重要概念,它们的组合方式决定了图像的数据量,数据量决定了图像所占的内存空间;同样大小的图像,分辨率越高包含的像素就越多,一英寸平方的图像如果分辨率是72的话,包含5148个像素,如果分辨率是300的话,包含9万个像素,高分辨率的图像要比低分辨率的图像包含更多的像素,所以像素点会更小,像素的密度更高。

分辨率 = 屏幕X轴上像素值*Y轴上的像素值,屏幕如果是1024x720,就表示每英寸水平方向上有1024个像素点,垂直方向上有720个像素点

分辨率是数学公式,像素是物理元件,分辨率指的是精密度的表达形式;像素有广义概念和狭义概念之分。广义像素分为两种,一种是物理像素( 实际像素 ),另一类是插值像素。此外,按照像素的工作情况,还分为有效像素和无效像素。狭义上,像素就是指物理像素; 物理像素是平均分布在传感器上的光电二极管( 一个像素 = 一个光点二极管,也可以叫微透镜 ),是传感器的最小组成元件

image

图片的显示势必要经历解码的阶段,解码操作是一个复杂耗时的任务;如果UITableView滑动过程中在cellForRowAtIndexPath方法中加载图片赋值给UIImageView,就相当于在主线程同时进行IO、解码等操作,这样会造成内存迅速增长、CPU负载瞬间提升

内存的占用会导致我们APP的CPU占用高,直接导致耗电大,APP响应变慢进而就会出现卡顿现象

并且内存迅速增加最终会触发系统的内存回收机制,尝试回收其他后台进程的内存,继续下去如果还无法满足就会结束当前进程(FOOM)

由此可见加载图片需要注意的关键点是:线程、内存

image

解码的触发

当你用 UIImage 或 CGImageSource的那几个方法创建图片时,图片数据并不会立刻解码;图片设置到 UIImageView 或者 CALayer.contents 中去, 并且 CALayer 被提交到 GPU 前,CGImage 中的数据才会得到解码;这一步是发生在主线程的,并且不可避免。那么当需要加载的图片比较多时,就会对我们应用的响应性造成严重的影响,尤其是在快速滑动的列表上,这个问题会表现得更加突出

那么如何解决滑动时内存飙升的问题 ?

移动端大方向上有两个途径:开启子线程提前处理解码任务、原图降采样 服务端可以在存储图片时将存储的图片生成各种不同规格的图片,来支持客户端通过拼接不同的字段,来获取不同质量的图片

开启子线程提前处理解码任务

当未解压缩的图片将要渲染到屏幕时,系统会在主线程对图片进行解压缩,而如果图片已经解压缩了,系统就不会再对图片进行解压缩。因此,也就有了业内的解决方案,在子线程提前对图片进行强制解压缩

下载图片主要简化流程如下

  • 从网络下载图片源数据,默认放入内存和磁盘缓存中
  • 异步解码,解码后的数据放入内存缓存中(注意这里缓存的是解码后的数据,避免重复解码)
  • 回调主线程渲染图片
  • 内部维护磁盘和内存的cache,支持设置定时过期清理(LRU),内存cache的上限等

业界优秀的图片处理框架

SDWebImage

YImage 设计思路,实现细节剖析

YYWebImage 源码剖析:线程处理与缓存策略

iOS图片加载速度极限优化—FastImageCache解析

iOS默认会在主线程对图像进行解码。解码过程是一个相当复杂的任务,需要消耗非常长的时间。由于在主线程超过16.7ms的任务会引起掉帧,所以我们把解码操作从主线程移到子线程,让耗时的解码操作不占用主线程的时间,解码的核心方法如下:

CGContextRef CGBitmapContextCreate(
void * data, //这块内存用于存储被绘制的图形,这块内存的size最小不能小于bytesPerRow*height(图形每行的字节数乘以图形的高度),传递NULL意味着由这个函数来管理图形的内存,这可以减少内存泄漏的问题;
size_t width, //图形的width
size_t height,//图形的height
size_t bitsPerComponent, //像素的每个颜色分量使用的 bit 数,在 RGB 颜色空间下指定 8 即可
size_t bytesPerRow,//位图的每一行使用的字节数,大小至少为 width * bytes per pixel 字节。有意思的是,当我们指定 0 时,系统不仅会为我们自动计算,而且还会进行 cache line alignment 的优化
CGColorSpaceRef  _Nullable space, //就是我们前面提到的颜色空间,一般使用 RGB 即可;
uint32_t bitmapInfo//是一个枚举,
)

异步解码上代码

 dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
      NSString *imagePath = self.imagePaths;
      UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
      UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, YES, 0);
      [image drawInRect:imageView.bounds];
      image = UIGraphicsGetImageFromCurrentImageContext();
      UIGraphicsEndImageContext();
      dispatch_async(dispatch_get_main_queue(), ^{
      });
  });


虽说能够正常解压,但是我们也会发现一个问题,就是大图片的解压,所以这个地方按照苹果和各大三方代码中的提示要分为2种情况讨论

  • 对于小于60M的图片我们直接对图片解码,下面是SD的代码
+ (UIImage *)decodedImageWithImage:(UIImage *)image {
    if (![self shouldDecodeImage:image]) {
        return image;
    }
    
    CGImageRef imageRef = [self CGImageCreateDecoded:image.CGImage];
    if (!imageRef) {
        return image;
    }
    UIImage *decodedImage = [[UIImage alloc] initWithCGImage:imageRef scale:image.scale orientation:image.imageOrientation];
    CGImageRelease(imageRef);
    SDImageCopyAssociatedObject(image, decodedImage);
    decodedImage.sd_isDecoded = YES;
    return decodedImage;
}


  • 对于大于60M的图片,会对原图片进行缩放以减少占用内存空间,并且解码图片时会把原始的图片数据分成多个tail进行解码,下面是SD的代码
+ (UIImage *)decodedAndScaledDownImageWithImage:(UIImage *)image limitBytes:(NSUInteger)bytes {
    if (![self shouldDecodeImage:image]) {
        return image;
    }
    
    if (![self shouldScaleDownImage:image limitBytes:bytes]) {
        return [self decodedImageWithImage:image];
    }
    
    CGFloat destTotalPixels;
    CGFloat tileTotalPixels;
    if (bytes == 0) {
        bytes = kDestImageLimitBytes;
    }
    destTotalPixels = bytes / kBytesPerPixel;
    tileTotalPixels = destTotalPixels / 3;
    CGContextRef destContext;
    
    // 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 sourceImageRef = image.CGImage;
        
        CGSize sourceResolution = CGSizeZero;
        sourceResolution.width = CGImageGetWidth(sourceImageRef);
        sourceResolution.height = CGImageGetHeight(sourceImageRef);
        CGFloat sourceTotalPixels = sourceResolution.width * sourceResolution.height;
        // Determine the scale ratio to apply to the input image
        // that results in an output image of the defined size.
        // see kDestImageSizeMB, and how it relates to destTotalPixels.
        CGFloat imageScale = sqrt(destTotalPixels / sourceTotalPixels);
        CGSize destResolution = CGSizeZero;
        destResolution.width = MAX(1, (int)(sourceResolution.width * imageScale));
        destResolution.height = MAX(1, (int)(sourceResolution.height * imageScale));
        
        // device color space
        CGColorSpaceRef colorspaceRef = [self colorSpaceGetDeviceRGB];
        BOOL hasAlpha = [self CGImageContainsAlpha:sourceImageRef];
        // iOS display alpha info (BGRA8888/BGRX8888)
        CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
        bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
        
        // kCGImageAlphaNone is not supported in CGBitmapContextCreate.
        // Since the original image here has no alpha info, use kCGImageAlphaNoneSkipFirst
        // to create bitmap graphics contexts without alpha info.
        destContext = CGBitmapContextCreate(NULL,
                                            destResolution.width,
                                            destResolution.height,
                                            kBitsPerComponent,
                                            0,
                                            colorspaceRef,
                                            bitmapInfo);
        
        if (destContext == NULL) {
            return image;
        }
        CGContextSetInterpolationQuality(destContext, kCGInterpolationHigh);
        
        // Now define the size of the rectangle to be used for the
        // incremental bits from the input image to the output image.
        // we use a source tile width equal to the width of the source
        // image due to the way that iOS retrieves image data from disk.
        // iOS must decode an image from disk in full width 'bands', even
        // if current graphics context is clipped to a subrect within that
        // band. Therefore we fully utilize all of the pixel data that results
        // from a decoding operation by anchoring our tile size to the full
        // width of the input image.
        CGRect sourceTile = CGRectZero;
        sourceTile.size.width = sourceResolution.width;
        // The source tile height is dynamic. Since we specified the size
        // of the source tile in MB, see how many rows of pixels high it
        // can be given the input image width.
        sourceTile.size.height = MAX(1, (int)(tileTotalPixels / sourceTile.size.width));
        sourceTile.origin.x = 0.0f;
        // The output tile is the same proportions as the input tile, but
        // scaled to image scale.
        CGRect destTile;
        destTile.size.width = destResolution.width;
        destTile.size.height = sourceTile.size.height * imageScale;
        destTile.origin.x = 0.0f;
        // The source seem overlap is proportionate to the destination seem overlap.
        // this is the amount of pixels to overlap each tile as we assemble the output image.
        float sourceSeemOverlap = (int)((kDestSeemOverlap/destResolution.height)*sourceResolution.height);
        CGImageRef sourceTileImageRef;
        // calculate the number of read/write operations required to assemble the
        // output image.
        int iterations = (int)( sourceResolution.height / sourceTile.size.height );
        // If tile height doesn't divide the image height evenly, add another iteration
        // to account for the remaining pixels.
        int remainder = (int)sourceResolution.height % (int)sourceTile.size.height;
        if(remainder) {
            iterations++;
        }
        // Add seem overlaps to the tiles, but save the original tile height for y coordinate calculations.
        float sourceTileHeightMinusOverlap = sourceTile.size.height;
        sourceTile.size.height += sourceSeemOverlap;
        destTile.size.height += kDestSeemOverlap;
        for( int y = 0; y < iterations; ++y ) {
            @autoreleasepool {
                sourceTile.origin.y = y * sourceTileHeightMinusOverlap + sourceSeemOverlap;
                destTile.origin.y = destResolution.height - (( y + 1 ) * sourceTileHeightMinusOverlap * imageScale + kDestSeemOverlap);
                sourceTileImageRef = CGImageCreateWithImageInRect( sourceImageRef, 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 );
            }
        }
        
        CGImageRef destImageRef = CGBitmapContextCreateImage(destContext);
        CGContextRelease(destContext);
        if (destImageRef == NULL) {
            return image;
        }
#if SD_MAC
        UIImage *destImage = [[UIImage alloc] initWithCGImage:destImageRef scale:image.scale orientation:kCGImagePropertyOrientationUp];
#else
        UIImage *destImage = [[UIImage alloc] initWithCGImage:destImageRef scale:image.scale orientation:image.imageOrientation];
#endif
        CGImageRelease(destImageRef);
        if (destImage == nil) {
            return image;
        }
        SDImageCopyAssociatedObject(image, destImage);
        destImage.sd_isDecoded = YES;
        return destImage;
    }
}

上述代码中,我们看对原始图片进行了缩放,并且对把原始图片分成多块进行批量解码,并且添加了自动释放池,保证了内存的释放操作,由于操作了底层相关的东西,也进行了手动内存的释放,这点是要注意的。当然子线程解码我们也要控制子线程数量,线程的数量控制最好合CPU核心数保持一致。针对大文件做缓存的图像体积也大,这个时候使用内存映射(mmap)读取文件优势很大,内存拷贝的量少,拷贝后占用用户内存也不高,文件越大内存映射优势越大。

iOS图片加载渲染的优化 中讲的很好

原图降采样(DownSampling)

苹果在WWDC18推荐开发者使用降采样的方式来降低图片显示时的内存占用过大问题

在视图比较小,图片比较大的场景下,直接展示原图片会造成不必要的内存和CPU消耗,这里就可以使用ImageIO的接口,DownSampling,也就是生成缩略图

image

我们加载jpeg的图片,然后进行相关设置,解码后根据设置生成CGImage缩略图,最后包装成UIImage,最终传递给UIImageView渲染

根据图片生成缩略图的原理

比如有一张像素大小为20002000的高清大图,要将其缩小为100100像素小图,这时候就相当于对原来的图片缩小400倍,因此将原来图片划分为400个互不相交的小块,然后计算小块的颜色平均值,该值作为缩小图像对应的颜色值

因此整改降采样的过程如下:

  • 在CGImageSource对象边读取图像数据边解压的过程中,只需要开辟这一小块大小的内存空间来存储解压后的颜色数据,然后通过颜色数据就可以得出该小块区域的颜色平均值
  • 然后通过开辟一个缩略图大小的内存空间用来存储缩略图的颜色数据,将之前算出来颜色平均值填充到缩略图对应的内存空间上,然后将小块的内存空间清空,继续存放下一个小块的颜色数据
  • 就这样对高清大图边读取边解码,然后计算出对应的像素平均值,填充到对应的缩率图像素内存空间上

这个过程中内存只用到了缩略图的占用的内存空间和临时存储小块数据的内存空间,与之前高清大图解码所需的内存空间相比,会小很多

因此这种方式可以很好的解决刚才提到的降采样时对高清大图进行解码造成的内存飙升问题

OC版本

+ (UIImage *)downSamplingWithScale:(CGFloat)scale
                            imgUrl:(NSURL *)imgURL
                        targetSize:(CGSize)targetSize {

    //避免下次产生缩略图时大小不同,但被缓存了,取出来是缓存图片
    //所以要把kCGImageSourceShouldCache设为false
    CFStringRef key[1];
    key[0] = kCGImageSourceShouldCache;
    CFTypeRef value[1];
    value[0] = (CFTypeRef)kCFBooleanFalse;

    CFDictionaryRef imageSourceOption = CFDictionaryCreate(NULL,
                                                           (const void **) key,
                                                           (const void **) value,
                                                           1,
                                                           &kCFTypeDictionaryKeyCallBacks,
                                                           &kCFTypeDictionaryValueCallBacks);
    CGImageSourceRef imageSource = CGImageSourceCreateWithURL((__bridge CFURLRef)imgURL, imageSourceOption);

    CFMutableDictionaryRef mutOption = CFDictionaryCreateMutable(NULL,
                                                                 4,
                                                                 &kCFTypeDictionaryKeyCallBacks,
                                                                 &kCFTypeDictionaryValueCallBacks);


    CGFloat maxDimension = MAX(targetSize.width, targetSize.height) * scale;
    NSNumber *maxDimensionNum = [NSNumber numberWithFloat:maxDimension];

    // · kCGImageSourceCreateThumbnailFromImageAlways
    //这个选项控制是否生成缩略图(没有设为true的话 kCGImageSourceThumbnailMaxPixelSize 以及 CGImageSourceCreateThumbnailAtIndex不会起作用)默认为false,所以需要设置为true
    CFDictionaryAddValue(mutOption, kCGImageSourceCreateThumbnailFromImageAlways, kCFBooleanTrue);//

    // · kCGImageSourceShouldCacheImmediately
    // 是否在创建图片时就进行解码(当然要这么做,避免在渲染时解码占用cpu)并缓存,
    /* Specifies whether image decoding and caching should happen at image creation time.
    * The value of this key must be a CFBooleanRef. The default value is kCFBooleanFalse (image decoding will
    * happen at rendering time). //默认为不缓存,在图片渲染时进行图片解码
    */
    CFDictionaryAddValue(mutOption, kCGImageSourceShouldCacheImmediately, kCFBooleanTrue);

    // · kCGImageSourceCreateThumbnailWithTransform
    //指定是否应根据完整图像的方向和像素纵横比旋转和缩放缩略图
    /* Specifies whether the thumbnail should be rotated and scaled according
     * to the orientation and pixel aspect ratio of the full image.(默认为false
     */
    //要设为true,因为我们要缩小他!
    CFDictionaryAddValue(mutOption, kCGImageSourceCreateThumbnailWithTransform, kCFBooleanTrue);


    // · kCGImageSourceThumbnailMaxPixelSize
    /* Specifies the maximum width and height in pixels of a thumbnail.  If
     * this this key is not specified, the width and height of a thumbnail is
     * not limited and thumbnails may be as big as the image itself.  If
     * present, this value of this key must be a CFNumberRef. */
    //指定缩略图的宽

    CFDictionaryAddValue(mutOption, kCGImageSourceThumbnailMaxPixelSize, (__bridge CFNumberRef)maxDimensionNum);

    CFDictionaryRef dowsamplingOption = CFDictionaryCreateCopy(NULL, mutOption);


    //生成缩略图
    CGImageRef rf = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, dowsamplingOption);
    //用UIImage把他装起来,返回
    UIImage *img = [UIImage imageWithCGImage:rf];
    return img;
}

swift版本


func downsample(imageAt imageURL: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage {
    let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
    let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)!
    let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
    let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways: true,
    kCGImageSourceShouldCacheImmediately: true,
    kCGImageSourceCreateThumbnailWithTransform: true,
    kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as CFDictionary
    let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)!
    return UIImage(cgImage: downsampledImage)
}

这里有两个注意事项

设置kCGImageSourceShouldCache为false,避免缓存解码后的数据,64位设置上默认是开启缓存的,(很好理解,因为下次使用该图片的时候,可能场景不同,需要生成的缩略图大小是不同的,显然不能做缓存处理)

设置kCGImageSourceShouldCacheImmediately为true,避免在需要渲染的时候才做解码,默认选项是false

这样的缩略图方式可以省去大量的内存和CPU消耗,官方Case给出的前后内存对比

image

异步+降采样

image

从用户的体验来分析,滑动的操作往往是间断性触发,在滑动的瞬间有较大的工作量,而且由于都是在主线程进行操作无法进行任务分配,CPU 2处于闲置。由此引申出两种优化手段:Prefetching(预处理)和 Background decoding/downsampling(子线程解码和降采样)。综合起来,可以在Prefetching的时候把降采样放到子线程进行处理,因为降采样过程就包括解码操作

image

Prefetching回调中,把降采样的操作放到同步队列serialQueue中,处理完毕之后抛给主线程进行update操作。 需要特别注意,此处不能是并发队列,否则会造成线程爆炸

预加载(Prefetching)协议 UITableViewDataSourcePrefetching,支持最低版本iOS10

UITableViewDataSourcePrefetching协议其实是用来通知我们,当前滑动到某个区域后,根据这次滑动的方向接下去可能还会滑向哪些indexPaths。好让我们做一些数据上的预备或者销毁

@protocol UITableViewDataSourcePrefetching <NSObject>

@required

// indexPaths are ordered ascending by geometric distance from the table view
- (void)tableView:(UITableView *)tableView prefetchRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;

@optional

// indexPaths that previously were considered as candidates for pre-fetching, but were not actually used; may be a subset of the previous call to -tableView:prefetchRowsAtIndexPaths:
- (void)tableView:(UITableView *)tableView cancelPrefetchingForRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;

@end

iOS 10 中的prefetchingEnabled属性中介绍了预加载相关的注意点

关于线程爆炸官方原文如下:

Thread Explosion(线程爆炸)
More images to decode than available CPUs(解码图像数量大于CPU数量)
GCD continues creating threads as new work is enqueued(GCD创建新线程处理新的任务)
Each thread gets less time to actually decode images(每个线程获得很少的时间解码图像)

小Tips: 使用串行队列可以很好地避免Thread Explosion,线程切换的代价是非常昂贵的,所以在我们app中应该使用GCD串行队列创建一个解码线程

官方实现UI实例

我们现在需要实现下面的live按钮

image

先看一种不合理的实现方式

image

我们先来分析这种方案的问题所在

image

UIView是通过CALayer创建FrameBuffer最后显示的。重写了drawRect方法,Calayer会创建一个Backing Store,然后在Backing Store上执行draw函数,最后将内容传递给frameBuffer最终显示

Backing Store的默认大小和View的大小成正比,以iphone6为例,750 * 1134 * 4 字节 ≈ 3.4 Mb。

iOS 12,对 backing store 有做优化,它的大小会根据图片的色彩空间,动态改变。 在此之前,如果你使用 sRGB 格式,但是实际绘制的内容,只使用了单通道,那么大小会比实际要的大,造成不必要开销。iOS 12 会自动优化这部分

总结下这种使用drawRect绘制方案的问题:

  1. Backing Store的创建造成了不必要的内存开销

  2. UIImage先绘制到Backing Store,再渲染到frameBuffer,中间多了一层内存拷贝

  3. 背景颜色不需要绘制到Backing Store,直接使用BackGroundColor绘制到FrameBuffer

所以,正确的实现姿势是将这个大的view拆分成小的subview逐个实现。

Drawing Off-Screen

对于需要离屏渲染的场景推荐使用UIGraphicsImageRenderer替代UIGraphicsBeginImageContext,性能更好,并且支持广色域。

图片加载框架+降采样

由于一般都会使用框架来实现图片的下载,而框架会帮我们把下载好的图片直接解压后回调给我们; 这样降采样我们就没法操作了,那么能不能把两者结合起来呢 ? 答案是肯定的

  • 首先,利用图片加载框架去加载图片,图片加载成功之后,先不进行解码,拿到图片的原始数据

  • 然后,依据显示视图控件的尺寸,利用原始图片数据,去生成对应的缩略图

  • 在原来图片的URL后面添加上对应的尺寸大小,生成新的URL链接,然后利用该链接生成对应的MD5值,来作为缩略图的存储名称,将缩略图存储到磁盘和内存中,并显示到视图控件上。

  • 下次再加载的时候,先去根据当前的URL链接依据想要获取的尺寸,生成对应的缩略图URL,然后获取缩略图URL的MD5值,去判断本地是否有缓存,如果有直接从磁盘缓存或者内存缓存返回,没有重走之前的加载逻辑

- (void)sd_setImageWithURL:(nullable NSURL *)url
          placeholderImage:(nullable UIImage *)placeholder
                targetSize:(CGSize)targetSize
                   options:(SDWebImageOptions)options
                  progress:(nullable SDImageLoaderProgressBlock)progressBlock
                 completed:(nullable SDExternalCompletionBlock)completedBlock {
    
    NSString *imageUrl = url.absoluteString;
    if (!imageUrl.length) {
        return;
    }
    
    NSString *targetImageUrl = [UIImageView sd_targetImageUrlWithImageUrl:imageUrl targetSize:targetSize];
    BOOL isCachedTargetImage = [UIImageView sd_isCachedImageWithImageUrl:imageUrl targetSize:targetSize];
    if (isCachedTargetImage) {
        [self sd_setImageWithURL:[NSURL URLWithString:targetImageUrl] placeholderImage:placeholder options:options progress:progressBlock completed:completedBlock];
    } else {
        [self sd_setImageWithURL:url placeholderImage:placeholder options:options | SDWebImageAvoidAutoSetImage | SDWebImageAvoidDecodeImage progress:nil completed:^(UIImage * _Nullable image, NSError * _Nullable error, SDImageCacheType cacheType, NSURL * _Nullable imageURL) {
            
            if ([UIImageView sd_isNormalImageWithImageUrl:imageUrl]) {
                dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
                    NSString *imagePath = [[SDImageCache sharedImageCache].diskCache cachePathForKey:url.absoluteString];
                    CGSize imageSize = [UIImage imageSizeWidthTargetWidth:targetSize.width originalSize:image.size];
                    UIImage *targetImage = [UIImage downSamplingWithScale:[UIScreen mainScreen].scale imgUrl:[NSURL fileURLWithPath:imagePath] targetSize:imageSize];
                    [[SDImageCache sharedImageCache] storeImage:targetImage forKey:targetImageUrl completion:^{
                        [self sd_setImageWithURL:[NSURL URLWithString:targetImageUrl] placeholderImage:placeholder options:options progress:progressBlock completed:completedBlock];
                    }];
                });
                
            } else if([UIImageView sd_isGifImageWithImageUrl:imageUrl]) {
                dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
                    NSArray<SDImageFrame *> *animatedImageFrameArray = [SDImageCoderHelper framesFromAnimatedImage:image];
                    NSMutableArray<SDImageFrame *> *tmpThumbImageFrameMarray = [NSMutableArray array];
                    [animatedImageFrameArray enumerateObjectsUsingBlock:^(SDImageFrame * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
                        CGSize imageSize = [UIImage imageSizeWidthTargetWidth:targetSize.width originalSize:obj.image.size];
                        NSData *imgData = UIImageJPEGRepresentation(obj.image , 0.75);
                        UIImage *targetImage = [UIImage downSamplingWithScale:[UIScreen mainScreen].scale imgData:imgData targetSize:imageSize];
                        SDImageFrame *thumbFrame = [SDImageFrame frameWithImage:targetImage duration:obj.duration];
                        [tmpThumbImageFrameMarray addObject:thumbFrame];
                    }];
                    
                   UIImage *thumbAnimatedImage = [SDImageCoderHelper animatedImageWithFrames:tmpThumbImageFrameMarray];
                    [[SDImageCache sharedImageCache] storeImage:thumbAnimatedImage forKey:targetImageUrl completion:^{
                        [self sd_setImageWithURL:[NSURL URLWithString:targetImageUrl] placeholderImage:placeholder options:options progress:progressBlock completed:completedBlock];
                    }];
                });
            } else {
                [self sd_setImageWithURL:url placeholderImage:placeholder options:options progress:progressBlock completed:completedBlock];
            }
        }];
    }
}

固定本地图片资源推荐使用Image Asset Catalogs,Apple推荐的图片资源管理工具,压缩效率更高,在iOS 12的机器上有10~20%的空间节约,并且每个版本Apple都会持续对其进行优化。

总结:

大量图片显示或超大图显示需要注意解码内存占用和线程问题,以免出现内存飙升CPU负载过重,轻则出现卡顿重则直接被系统强杀;这对一个APP来说都是致命的

列表滑动中展示图片处理:

推荐采取预加载+异步+字节对齐+降采样的方式提前对图片进行解压处理以达到对内存占用、CPU、GPU性能优化的目的

超大图片处理:

直接使用SDWebImage或者YYWebImage的默认解码缓存技术方案去加载多张这样的大图,带来的结果会是内存爆掉、程序闪退,可以设置SDWebImage或者YYWebImage的Option选项不解码下载好的图片,之后异步降采样解码

场景一:一张超大图加载在一个小的view上

解决办法: 使用苹果推荐的缩略图DownSampling方案即可

场景二: 微信,微博长图详情那样,全屏加载大图,通过拖动来查看不同位置图片细节

解决方法: 使用苹果的CATiledLayer去加载。原理是分片渲染,滑动时通过指定目标位置,通过映射原图指定位置的部分图片数据解码渲染。这里不再累述,有兴趣的小伙伴可以自行了解下官方API

拓展与思考

用提问的方式来拓展一下,针对每个问题进行深入的思考

问题一:图像展示有这么多细节在里面,可是为什么在平常开发中为什么没有感觉到,可以从哪些地方对自己的工程进行优化

答:我们平常大部分会使用UIImage imageNamed这样的API加载了本地图片,而网络图片则使用了SDWebImage或者YYWebImage等框架来加载。根本上需要关注内存和线程问题,尽量减少内存占用+异步处理复杂编解码

问题二: 使用imageNamed,系统何时去解码,有没有缓存,缓存的大小是多少,有没有性能问题,和imageWithContentsOfFile有什么区别

首先先说imageNamed和imageWithContentsOfFile有什么区别,想必大部分小伙伴都很清楚,因为这也是面试老生常谈的东西。imageNamed加载本地图片会缓存图片,也就是加载一千张相同的本地图片,内存中也只会有一份,而imageWithContentsOfFile不会缓存,也就是重复加载相同图片,在内存中会有多份图片数据

imageNamed加载图片会将图片源数据和解码后的数据加载入内存缓存中,只有收到内存警告的时候才会释放,有兴趣的小伙伴可以自行调试一下。

关于UIImage对象何时去解码,其实刚刚我们在降低采样的时候已经提到了,kCGImageSourceShouldCacheImmediately属性系统默认是false,我们可以看ImageIO/CGImageSource.h文件中kCGImageSourceShouldCache的注释

也就是说UIImage只有在屏幕上渲染时再去解码。而关于UIImageView的操作一定是在主线程,解码操作是放在主线程的。所以如果在tableview滑动中频繁的创建较大的UIImage渲染展示,会造成主线程阻塞。

imageNamed默认带缓存,缓存通过NSCache实现。适用于需要频繁复用的图片的加载,而imageWithContentsOfFile不会缓存,适用于不常用的较大图片的加载,由于系统默认主线程解码UIImage,所以imageNamed仅仅适用于加载较小的例如APP各个tab的icon,需要在首屏展示的图片。而不适用于滑动的下载好的大量网络图片的本地加载。会造成主线程阻塞

问题三: 离屏渲染是什么? 离屏渲染在什么情况下会被触发 ? 离屏渲染的缺点是什么 ?优点是什么 ?

如果要在显示屏上显示内容,我们至少需要一块与屏幕像素数据量一样大的FrameBuffer(帧缓存区)作为像素数据存储区域,然后由显示器把帧缓存区的数据显示到屏幕上。 有时因为面临一些限制,比如说给视图设置阴影、遮罩、圆角等等此时需要显示的图层不再是单一图层而是混合图层,这种图层更加复杂,GPU无法吧渲染结果直接写入FrameBuffer,而是先暂时把中间的一个临时状态存入另外新开辟的内存区域,之后再写入FrameBuffer,这个过程被称之为离屏渲染

触发: 当视图层级结构比较复杂的时候就需要开辟临时内存区域来渲染数据,这时就触发了离屏渲染

离屏渲染的缺点:

  • 开辟缓冲区大小有限,只有2.5个屏幕大小
  • 需要多次切换上下文环境对性能有影响

离屏渲染的优点:

  • 专门被用来处理预合成数据,为丰富的图像呈现提供了很好的支持

离屏渲染操作,按对性能影响等级从高到低进行排序: shadows(阴影)、圆角、mask(遮罩)、allowsGroupOpacity(组不透明)、edge antialiasing(抗锯齿)

深入了解图片相关收录文章面试必看:

iOS 图片使用探究(1)-- 图片基础知识+图片格式

iOS 图片使用探究(2)-- iOS 动效方案对比

iOS 图片渲染的原理

iOS 图片加载速度极限优化—FastImageCache解析

iOS 图像渲染及卡顿问题优化

iOS 图片加载渲染的优化

iOS 离屏渲染

iOS 离屏渲染分析/优化

iOS GIF动画加载

iOS 深入分析大图显示问题

iOS的文件内存映射——mmap

iOS性能优化——图片加载和处理

iOS 图片加载解码造成内存峰值的一些思考

WWDC2018-降采样官方视频

WWDC2018 图像最佳实践

WWDC心得与延伸:iOS图形性能

谈谈 iOS 中图片的解压缩

分辨率、帧速率、码流、采样位深、采样率、比特率

iOS中ImageIO框架详解与应用分析 原

⚠️ **GitHub.com Fallback** ⚠️