iOS-图片高级处理(二、图片的编码解码)

8,791 阅读9分钟

前言

    图片的编码:在当前APP的开发中,图片是经常会使用到的,关于图片有很多种格式,例如JPEG,PNG等。其实这些各种各样的图片格式都对应了位图(bitmap)经过不同算法编码(压缩)后的图片。(编码这里就不过多介绍了)

    图片的解码:app从磁盘中读入编码后的图片,需要经过解码把图片变成位图(bitmap)读入,这样才能显示在屏幕上。

    位图(bitmap):位图又被叫做点阵图像,也就是说位图包含了一大堆的像素点信息,这些像素点就是该图片中的点,有了图片中每个像素点的信息,就可以在屏幕上渲染整张图片了。

一、图片的本质

    图片本质上是位图,一堆像素点组成的二维数组,其中每个像素点都记录该点位的颜色等信息。显示出来就是一张图了。

    既然像素要存储颜色数据,这里就又引出一个颜色存储格式的概念。我们就以最简单普遍的32-bit RGBA 色彩存储格式为例子,他的意思是一个像素点存储的色彩所需空间是32bits或是4bytes,1byte或8bit存储是一个通道,对应下来就是:

  • R = red (占1byte或8bit)
  • G = green (占1byte或8bit)
  • B = blue (占1byte或8bit)
  • A = alpha (占1byte或8bit)

这样你就知道 32-bit RGBA 格式能够显示的颜色是 2^8 * 2^8* 2^8 (256 * 256 * 256),将近一千七百多万个颜色。还有颜色空间(Color Spaces)的概念这里就不再扩展了。

而位图是装载像素点的数组,这样你大概可以理解下一张普通位图包含着多少数据!同时,这里解释颜色是为了下面计算位图大小,便于理解我们为什么要进行图片编码。

二、位图需要编码本质

    通过iOS - 图形高级处理 (一、图片显示相关理论)的学习可以知道,图片的解压缩是一个非常耗时的 CPU 操作,并且它默认是在主线程中执行的。那么当需要加载的图片比较多时,就会对我们应用的响应性造成严重的影响,尤其是在快速滑动的列表上,这个问题会表现得更加突出。既然如此,图片不编码也就不用解码,都使用位图可以吗?这写在这里的确是明知故问的问题,下面就解释下为什么必须对图片进行编解码操作。

1、进行位图编码的原因:

一张位图的宽和高分别都是100个像素,那这个位图的大小是多少呢?

//计算一张位图size的公式
//bytesPerPixel每个像素点所需空间 
//32-bit RGBA 格式图片 bytesPerPixel = 4 (R,G,B,A各一个byte),理论看上面
size = width * height * bytesPerPixel 

这样把我们100x100 的位图代入该公式,可以得到其大小:

size = 100 * 100 * 4 = 40000B = 39KB

正常一张PNG或JPEG格式的100x100的图片,大概只有几KB。如果更大的图,位图所占空间更大,所以位图必须进行编码进行存储。

2、位图编码技术:

这里不过多介绍了,苹果提供2种图片编码格式,PNG和JPEG:

PNG 是无损压损,JPEG可以是有损压缩(0-100% ),即损失部分信息来压缩图片,这样压缩之后的图片大小将会更小。

// return image as PNG. May return nil if image has no CGImageRef or invalid bitmap format
UIKIT_EXTERN NSData * __nullable UIImagePNGRepresentation(UIImage * __nonnull image);

// return image as JPEG. May return nil if image has no CGImageRef or invalid bitmap format. compression is 0(most)..1(least)

UIKIT_EXTERN NSData * __nullable UIImageJPEGRepresentation(UIImage * __nonnull image, CGFloat compressionQuality);

三、图片需要解码的本质及项目中实际应用

1、编码后的图片需要解码的原因:

编码后的图片需要显示在屏幕上,我们需要获得图片所有信息,也就是对应编码前的位图。所以编码后的图片必须要经过解码才能正常显示。

2、2018WWDC 中说的三种 Buffer 理念:

Buffer 表示一片连续的内存空间。在这里,我们说的 Buffer 是指一系列内部结构相同、大小相同的元素组成的内存区域。有三种Buffer:Data Buffer、Image Buffer、Frame Buffer。这个理论是2018WWDC苹果上描述的概念,具体可看Image and Graphics Best Practices

  • Data Buffer 是指存储在内存中的原始数据,图像可以使用不同的格式保存,如 jpg、png。Data Buffer 的信息不能用来描述图像的位图像素信息。
  • Image Buffer 是指图像在内存中的存在方式,其中每个元素描述了一个像素点。Image Buffer 的大小和位图的大小相等。
  • Frame Buffer 和 Image Buffer 内容相同,不过其存储在 vRAM(video RAM)中,而 Image Buffer 存储在 RAM 中。

3、图片读入解码的过程:(部分图片读入理论可参考图片显示相关理论

图片解码过程:

1、假如在本地沙盒下有一张 JPEG 格式的图片或项目资源中读入一般都这么做

UIImageView *imageView = ...;
// UIImage *image = [UIImage imageNamed:@"xxx"];
UIImage *image = [UIImage imageWithContentsOfFile:@"xxx.JPG"];
imageView.image = image;

2、UIImage 是 iOS 中处理图像的高级类。创建一个 UIImage 实例只会加载 Data Buffer;也就是说以上只是把图片转为UIImage对象,该对象存储在Data Buffer里。此时并没有对图片进行解码。

3、当将图像显示到屏幕上会触发隐式解码。(必须同时满足图像被设置到 UIImageView 中、UIImageView 添加到视图,才会触发图像解码。)也就是说你就算实例了一个UIImageView,但是没有把他addSubview,显示到视图上,系统也是不会进行解码的。

现实问题产生:

这个解码过程默认是发生在主线程上面的,而且非常消耗 CPU,所以到如果在 tableView 或者 collectionView 中有相当多的图片需要显示的话,这些图片在主线程的解码操作必然会影响滑动的顺畅度。所以我们是否可以在子线程强制将其解码,然后在主线程让系统渲染解码之后的图片呢?当然可以,现在基本上所有的开源图片库都会实现这个操作。例如:YYImage\SDWebImage。

现实中解决方式:

自己手动解码的原理就是对图片进行重新绘制,得到一张新的解码后的位图。其中,用到的最核心的函数是 CGBitmapContextCreate :

CG_EXTERN CGContextRef __nullable CGBitmapContextCreate(void * __nullable data,size_t width, size_t height, size_t bitsPerComponent, size_t bytesPerRow,CGColorSpaceRef cg_nullable space, uint32_t bitmapInfo)CG_AVAILABLE_STARTING(__MAC_10_0, __IPHONE_2_0);

这个方法是创建一个图片处理的上下文 CGContext 对象,因为上面方法的返回值 CGContextRef 实际上就是 CGContext *。关于这个函数的详细讲解博文有很多,官方文档CGBitmapContextCreate。博客文章,图片解码

开源框架的解决方案基础也是基于这个API:

1、YYImage 中解码的代码:

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 {
   ...
   }
}

实际上, 这个方法的作用是创建一个图像的拷贝,它接受一个原始的位图参数 imageRef ,最终返回一个新的解码后的位图 newImage ,中间主要经过了以下三个步骤:

  • 使用 CGBitmapContextCreate 函数创建一个位图上下文;
  • 使用 CGContextDrawImage 函数将原始位图绘制到上下文中;
  • 使用 CGBitmapContextCreateImage 函数创建一张新的解压缩后的位图。

事实上,SDWebImage 中对图片的解压缩过程与上述完全一致,只是传递给 CGBitmapContextCreate 函数的部分参数存在细微的差别

2、SDWebImage的解码实现

+ (nullable UIImage *)decodedImageWithImage:(nullable UIImage *)image {
   if (![UIImage 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 = [UIImage colorSpaceForImageRef:imageRef];
       
       size_t width = CGImageGetWidth(imageRef);
       size_t height = CGImageGetHeight(imageRef);
       size_t bytesPerRow = kBytesPerPixel * width;

       // 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,
                                                    bytesPerRow,
                                                    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;
   }
}

+ (BOOL)shouldDecodeImage:(nullable UIImage *)image {
   // Prevent "CGBitmapContextCreateImage: invalid context 0x0" error
   if (image == nil) {
       return NO;
   }

   // do not decode animated images
   if (image.images != nil) {
       return NO;
   }
   
   CGImageRef imageRef = image.CGImage;
   
   CGImageAlphaInfo alpha = CGImageGetAlphaInfo(imageRef);
   BOOL anyAlpha = (alpha == kCGImageAlphaFirst ||
                    alpha == kCGImageAlphaLast ||
                    alpha == kCGImageAlphaPremultipliedFirst ||
                    alpha == kCGImageAlphaPremultipliedLast);
   // do not decode images with alpha
   if (anyAlpha) {
       return NO;
   }
   
   return YES;
}

SDWebImage 中和其他不一样的地方,就是如果一张图片有 alpha 分量,那就直接返回原始图片,不再进行解码操作。这么做是因为alpha 分量不可知,为了保证原图完整信息故不做处理。

SDWebImage 在解码操作外面包了 autoreleasepool,这样在大量图片需要解码的时候,可以使得局部变量尽早释放掉,不会造成内存峰值过高。

四、最后关于大图显示的痛点,苹果对于大图显示的解决方案:

大图显示这个问题,看似和图片编码解码无关。但是大的图片会占用较多的内存资源,解码和传输到 GPU 也会耗费较多时间。 因此,实际需要显示的图像尺寸可能并不是很大,如果能将大图缩小,便能达到优化的目的。以下是WWDC给的大图显示方案,功能是缩小图像并解码:

1、Objective-C:

// 大图缩小为显示尺寸的图
- (UIImage *)downsampleImageAt:(NSURL *)imageURL to:(CGSize)pointSize scale:(CGFloat)scale {
    // 利用图像文件地址创建 image source
    NSDictionary *imageSourceOptions =
  @{
    (__bridge NSString *)kCGImageSourceShouldCache: @NO // 原始图像不要解码
    };
    CGImageSourceRef imageSource =
    CGImageSourceCreateWithURL((__bridge CFURLRef)imageURL, (__bridge CFDictionaryRef)imageSourceOptions);

    // 下采样
    CGFloat maxDimensionInPixels = MAX(pointSize.width, pointSize.height) * scale;
    NSDictionary *downsampleOptions =
    @{
      (__bridge NSString *)kCGImageSourceCreateThumbnailFromImageAlways: @YES,
      (__bridge NSString *)kCGImageSourceShouldCacheImmediately: @YES,  // 缩小图像的同时进行解码
      (__bridge NSString *)kCGImageSourceCreateThumbnailWithTransform: @YES,
      (__bridge NSString *)kCGImageSourceThumbnailMaxPixelSize: @(maxDimensionInPixels)
       };
    CGImageRef downsampledImage =
    CGImageSourceCreateThumbnailAtIndex(imageSource, 0, (__bridge CFDictionaryRef)downsampleOptions);
    UIImage *image = [[UIImage alloc] initWithCGImage:downsampledImage];
    CGImageRelease(downsampledImage);
    CFRelease(imageSource);

    return image;
}

2、Swift

// Downsampling large images for display at smaller size
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)
}

参考文档