iOS图片加载渲染的优化

岑俊弼
2023-12-01

首先我们来看iOS加载一张图片所经历的过程:(下面所讲述的代码基本以      imageWithContentsOfFile 方法来举例)

数据加载

  1. 我们优先创建UIImageView,把获得的图像数据赋值UIImageView
  2. 识别到我们缓冲区没有数据,就会去从磁盘拷贝数据到缓冲区
  3. 然后加载我们的图片
  4. 拿到了图片,下面到了视图渲染

视图渲染

  1. 图片数据在CoreAnimation流水线中,执行如下流程
  2. 优先计算视图Frame,进行视图构建和图片格式转换
  3. 如果图像未解码,则优先解码成位图数据
  4. 进行打包处理,主线程Runloop将其提交给Render Server
  5. 在提交之前,如果数据没有字节对齐,CoreAnimation会再拷贝一份数据,进行字节对齐
  6. 然后经过GPU图形渲染管线处理以后将渲染结果放入帧缓冲区
  7. 然后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,给显示器显示

从上述渲染过程中,我寻找其可优化点。

  1. 图像解码
  2. 内存加载
  3. 对齐字节

1.图像解码

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

        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种情况讨论:

1.对于小于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;
}

2.对于大于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核心数保持一致。针对大文件做缓存的图像体积也大,这个时候使用内存映射读取文件优势很大,内存拷贝的量少,拷贝后占用用户内存也不高,文件越大内存映射优势越大。

下面我们再看一下苹果官方提供的降低采样率方案:

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)
}

对应重写的OC版

//缩略图核心代码
+ (UIImage *)thumbnailWithImageWithoutScale:(UIImage *)image size:(CGSize)asize{
    UIImage *newimage;
    if (nil == image) {
      newimage = nil;
    }else{
      CGSize oldsize = image.size;
      CGRect rect;
      if (asize.width/asize.height > oldsize.width/oldsize.height) {
        rect.size.width = asize.height*oldsize.width/oldsize.height;
        rect.size.height = asize.height;
        rect.origin.x = (asize.width - rect.size.width)/2;
        rect.origin.y = 0;
      }else{
        rect.size.width = asize.width;
        rect.size.height = asize.width*oldsize.height/oldsize.width;
        rect.origin.x = 0;
        rect.origin.y = (asize.height - rect.size.height)/2;
      }
      UIGraphicsBeginImageContext(asize);
      CGContextRef context = UIGraphicsGetCurrentContext();
      CGContextSetFillColorWithColor(context, [[UIColor clearColor] CGColor]);
      UIRectFill(CGRectMake(0, 0, asize.width, asize.height));//clear background
      [image drawInRect:rect];
      newimage = UIGraphicsGetImageFromCurrentImageContext();
      UIGraphicsEndImageContext();
    }
    return newimage;
}

我们在上文的写缩略图生成过程中,已经对图片进行解码操作

2.内存加载

 用过FastImageCache的同学,都知道其使用了虚拟内存,进行文件映射,进行读写文件。

void* mmap(void* start,size_t length,int prot,int flags,int fd,off_t offset);

start:映射开始地址,设置NULL则让系统决定映射开始地址;
length:映射区域的长度,单位是Byte;
prot:映射内存的保护标志,主要是读写相关,是位运算标志;(记得与下面fd对应句柄打开的设置一致)
flags:映射类型,通常是文件和共享类型;
fd:文件句柄;
off_toffset:被映射对象的起点偏移;

我们使用NSData与mmap之间的关系可以获取到映射的数据,如下

+ (id)dataWithContentsOfFile:(NSString *)path options:(NSDataReadingOptions)readOptionsMask error:(NSError **)errorPtr;

针对NSDataReadingOptions的几种类型有如下解释:

NSDataReadingMappedIfSafe 提示显示文件应该映射到虚拟内存,如果可能和安全
NSDataReadingUncached 提示显示文件不应该存储在文件系统缓存。数据读取一次,丢弃,这个选项可以提高性能
NSDataReadingMappedAlways 在如果可能提示映射文件

我们使用NSDataReadingMappedIfSafe,能够保证安全。

我们在SDWebImage中也可以看到相应的代码:

- (nullable NSData *)diskImageDataBySearchingAllPathsForKey:(nullable NSString *)key {
    if (!key) {
        return nil;
    }
    
    NSData *data = [self.diskCache dataForKey:key];
    if (data) {
        return data;
    }
    
    // Addtional cache path for custom pre-load cache
    if (self.additionalCachePathBlock) {
        NSString *filePath = self.additionalCachePathBlock(key);
        if (filePath) {
            data = [NSData dataWithContentsOfFile:filePath options:self.config.diskCacheReadingOptions error:nil];
        }
    }

    return data;
}

经过分析,SD中SDImageCache缓存文件中默认是NSDataReadingMappedIfSafe。

说白了就是用mmap把文件映射到用户空间里的虚拟内存,文件中的位置在虚拟内存中有了对应的地址,可以像操作内存一样操作这个文件,相当于已经把整个文件放入内存,但在真正使用到这些数据前却不会消耗物理内存,也不会有读写磁盘的操作,只有真正使用这些数据时,也就是图像准备渲染在屏幕上时,虚拟内存管理系统VMS才根据缺页加载的机制从磁盘加载对应的数据块到物理内存,再进行渲染。这样的文件读写文件方式少了数据从内核缓存到用户空间的拷贝,效率很高。

3.对齐字节

我们都知道CoreAnimation在图像数据非字节对齐的情况下渲染前会先拷贝一份图像数据。

我们从堆栈中也能看得出系统使用了这个copy_image,进行图像数据拷贝。

字节对齐是为了提高读取的性能。因为处理器读取内存中的数据不是一个一个字节读取的,而是一块一块读取的一般叫做cache lines。如果一个不对齐的数据放在了2个数据块中,那么处理器可能要执行两次内存访问。当这种不对齐的数据非常多的时候,就会影响到读取性能了。这样可能会牺牲一些储存空间,但是提升了内存的读取性能。

我们在使用CGBitmapContextCreate创建绘图上下文的时候,目前我们使用的机器基本上是处理器的是64byte,所以可以指定bytesPerRow为64的整数倍,这样就可以减少这部分是耗时,提升性能。

我们能做的优化远不止于此,不断的探索才是我们的目标,加油!!!骚年

 类似资料: