理解图像(二)- 解码、编码(基于ImageIO)

1,979 阅读12分钟

理解图像(一)- 基础
理解图像(二)- 解码、编码(基于ImageIO)

概要

为什么要对图像进行解码、编码呢?

相信很多人有会有这样的疑问,在上一篇介绍位图时我们得知,位图占用的内存空间是很大的,是不利于硬盘存储、网络传输的,因此是需要被压缩处理(也就是编码)。实际上我们直观上接触的 JPEG、PNG 等图像都是一种“被压缩”后的位图图形格式,当需要将磁盘中的图像显然到屏幕上之前,需要先获取图像的原始像素数据(位图数据),才能执行后续的渲染等操作,因为需要对图像进行解压缩处理,也就是解码。

ImageIO

本篇基于系统库 ImageIO 简要介绍一下图像编、解码相关API,上层的 UIKit、CoreImage、CoreGraphics 等都依赖 ImageIO。因此,掌握 ImageIO 的基本编、解码操作,对项目中一些图像相关的数据处理是非常有帮助。

ImageIO 框架支持常见的图像格式,如:JPEG、PNG、APNG、GIF、BMP、TIFF、HEIF(iOS11) 等,具体可通过函数 CGImageSourceCopyTypeIdentifiers 打印查看。其中,解码、编码的实现主要是通过 CGImageSource、CGImageDestination 两个类实现的。

解码

图像解码,也称图像解压缩, 是将已经被编码过的图像封装格式的数据(JPEG 等),转换为可以被渲染的图像数据(位图)。下面分别对静态图、动态图两种图像类型,分别说明一下解码的的过程。

静态图

静态图的解码,基本步骤如下:

1. 创建CGImageSource
CGImageSource 表示的是待解码的图像数据源,之后的解码、读取元数据等操作都会用到这个Source。 CGImageSource 的创建方式有:

  • CGImageSourceCreateWithData
    通过内存中的二进制数据(CGData)来创建 ImageSource;

  • CGImageSourceCreateWithURL
    通过 URL(网络文件 或 本地文件) 来创建 ImageSource;

  • CGImageSourceCreateWithDataProvider
    通过一个 DateProvider 来创建 ImageSource;DataProvider 提供了很多数据输入,包括:内存、本地文件、网络、流等。

示例:

CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL);

2. 读取图像格式元数据
创建完 CGImageSource 后是可以立即解码的,有时可能还需要获取一些图像信息,如:图像的格式、图像数量等。这些信息可直接通过 CGImageSource 获取,如:

  • 图像格式
CFStringRef type = CGImageSourceGetType(imageSource);
  • 图像数量(动图)
NSUInteger frameCount = CGImageSourceGetCount(imageSource);
  • 图像容器属性(EXIF)
    注意 CGImageSourceCopyProperties  用来获取图像容器的属性,而不是图像的属性。
NSDictionary *properties = (__bridge NSDictionary *)CGImageSourceCopyProperties(source, NULL);
// EXIF信息
NSDictionary *exifProperties = properties[(__bridge NSString *)kCGImagePropertyExifDictionary]; 
// EXIF拍摄时间
NSString *exifCreateTime = exirProperties[(__bridge NSString *)kCGImagePropertyExifDateTimeOriginal]; 
  • 图像属性
    CGImageSourceCopyPropertiesAtIndex 才是用来获取图像的元信息,对于静态图 index 传 0 即可。示例:
CFDictionaryRef imageProperties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil);
//宽度,像素值
NSUInteger width = [imageProperties[(__bridge NSString *)kCGImagePropertyPixelWidth] unsignedIntegerValue];
//高度,像素值
NSUInteger height = [imageProperties[(__bridge NSString *)kCGImagePropertyPixelHeight] unsignedIntegerValue]; 
//是否含有Alpha通道
BOOL hasAlpha = [imageProperties[(__bridge NSString *)kCGImagePropertyHasAlpha] boolValue]; 

3. 解码得到 CGImage
通过 CGImageSourceCreateImageAtIndex 方法可直接得到 CGImage,同样对于静态图 index 传 0 即可。该方法调用之后会立即开始解码,直到解码完成。

CGImageRef imageRef = CGImageSourceCreateImageAtIndex(source, 0, NULL);

注意:ImageIO 所有的方法都是线程安全的,也的同步的。因此大图的解码尽量不要放到主线程执行,避免发生长卡。

4. 生成 UIImage
至此,生成完 CGImage 后,解码操作基本完成,可直接生成 UIImage 对象用于UI 渲染。其中图像的 orientation 可通过 EXIF 元信息获得。
示例:

//转换成 UIImageOrientation 
UIImageOrientation imageOrientation = [self imageOrientationFromExifOrientation:exifOrientation];
//生成 UIImage
UIImage *image = [[UIImage imageWithCGImage:imageRef scale:[UIScreen mainScreen].scale orientation:imageOrientation];

// 清理 C指针,避免内存泄漏
CGImageRelease(imageRef);
CFRelease(source)

动态图

动态图的解码就是多张图像的解码,相当于在上面静态图的解码基础上增加一层循环。获取到动图的帧数,通过遍历循环每一帧进行解码,然后生成一个 UIImage 的数组,最后将数组中的每一帧图像合并成一个动图即可。

动态图解码,基本步骤如下:

下面以一个 GIF 为例,简单介绍一下动态图像的解码过程,示例:

//1. 创建 CGImageSource
CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL);
//获取 gif 帧数
NSUInteger frameCount = CGImageSourceGetCount(source); 
NSMutableArray <UIImage *> *images = [NSMutableArray array];
double totalDuration = 0;
for (size_t i = 0; i < frameCount; i++) {
  	//2. 获取单帧图像属性
    NSDictionary *frameProperties = (__bridge NSDictionary *) CGImageSourceCopyPropertiesAtIndex(source, i, NULL);
    NSDictionary *gifProperties = frameProperties[(NSString *)kCGImagePropertyGIFDictionary];  
    // GIF原始的帧持续时长,秒数
    double duration = [gifProperties[(NSString *)kCGImagePropertyGIFUnclampedDelayTime] doubleValue]; 
    // 方向
    CGImagePropertyOrientation exifOrientation = [frameProperties[(__bridge NSString *)kCGImagePropertyOrientation] integerValue];
    // 3. 生成 CGImage
    CGImageRef imageRef = CGImageSourceCreateImageAtIndex(source, i, NULL); 
    UIImageOrientation imageOrientation = [self imageOrientationFromExifOrientation:exifOrientation];
    // 4. 生成 UIImage
    UIImage *image = [[UIImage imageWithCGImage:imageRef scale:[UIScreen mainScreen].scale orientation:imageOrientation];
    totalDuration += duration;
    [images addObject:image];
}

// 将解码的图像生成动图
UIImage *animatedImage = [UIImage animatedImageWithImages:images duration:totalDuration];

注意:上面这样处理生成 GIF 动图的每一帧的时长是相同的,平均分配的每一帧。但实际中大部分动图都是不同时长的,最后看到的动画会错乱。针对这个问题的解决方案可参考SDWebImage 动图解码 ,原理是让每一帧根据它所占总时长的比例,重复对应的次数,以此来充满应该播放的时长。

CGImageSource 的应用

1. 生成缩略图(下采样)
图像解码是非常消耗内存的,移动端的内存又非常有限,对于大图来说很可能造成 OOM,因此在不影响用户体验的条件下,可以对图像进行下采样,即生成缩率图进行渲染,从而减少内存消耗,大概步骤是:

  • 创建 CGImageSource
  • 生成 CGImage:使用 CGImageSourceCreateThumbnailAtIndex 方法

示例:

+ (CGImageRef)createDownsampleImageRefFromImage:(NSURL *)imageUrl pointSize:(CGSize)pointSize scale:(CGFloat)scale  {
    NSDictionary *imageSourceOptions = @{ (__bridge id)kCGImageSourceShouldCache : @NO ,
                                          (__bridge id)kCGImageSourceShouldCacheImmediately : @NO };
  	//创建 ImageSource
    CGImageSourceRef imageSource = CGImageSourceCreateWithURL((CFURLRef)imageUrl, (__bridge CFDictionaryRef)imageSourceOptions);
    CGFloat maxDimensionInPixels = MAX(pointSize.width, pointSize.height) * scale;
    if (imageSource) {
        NSDictionary *options = @{ (__bridge id)kCGImageSourceShouldCache : @NO ,
                                   (__bridge id)kCGImageSourceTypeIdentifierHint : (id)kUTTypeJPEG,
                                   (__bridge id)kCGImageSourceShouldCacheImmediately : @NO,
                                   (__bridge id)kCGImageSourceCreateThumbnailWithTransform : @YES,
                                   (__bridge id)kCGImageSourceCreateThumbnailFromImageAlways : @YES,
                                   (__bridge id)kCGImageSourceThumbnailMaxPixelSize : @(maxDimensionInPixels) };
      	//生成缩率图的 CGImage
        CGImageRef downsampleImageRef = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, (__bridge CFDictionaryRef)options);
        CFRelease(imageSource);
        NSLog(@"downsampleImage success");
        return downsampleImageRef;
    }
    NSLog(@"downsampleImage fail");
    return NULL;
}

2. 渐进式解码
所谓渐进式解码,是指在图像文件数据不完整的情况下,通过多次增量解码逐步完成图像的界面和展示。如当网络加载较慢或加载一张较大的图像时,无需等待整个图像数据下载完成就可开始开到图像的预览,极大提升了用户体验。
大概步骤是:

  • 创建 CGImageSource: 使用 CGImageSourceCreateIncremental
  • 更新 Data:使用 CGImageSourceUpdateData, 注意每次传入全部的 Data
  • 生成 CGImage:使用 CGImageSourceCreateImageAtIndex

示例:

    var imgData = Data()
      //网络数据回调
		func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
       //拼接收到的data数据
       imgData.append(data)
       let imageOptions = [kCGImageSourceShouldCache : kCFBooleanTrue,kCGImageSourceShouldAllowFloat : kCFBooleanTrue] as CFDictionary
       //创建 ImageSource
       let incrementSource = CGImageSourceCreateIncremental(nil)
       //更新 Data
       let finished = dataTask.countOfBytesExpectedToReceive == self.imgData.count
       CGImageSourceUpdateData(incrementSource, self.imgData as CFData, finished)
       let status = CGImageSourceGetStatus(incrementSource)
       switch status {
             case .statusComplete,.statusIncomplete:
               //生成 CGImage
               if let cgImage = CGImageSourceCreateImageAtIndex(incrementSource, 0, imageOptions){
                   DispatchQueue.main.async {
                    self.imgView.image = UIImage(cgImage: cgImage, scale: 1.0, orientation: UIImage.Orientation.up)
                   }
               }
             default:
                break
           }
    }

编码

与解码过程相反,将图像格式的数据,经过压缩处理输出为 Data 的过程。
图像编码,也称图像解压缩,通过对图像数据的处理,去除冗余信息,降低数据的冗余度,同时保持图像质量,从而实现了图像的高效存储和传输。

静态图

静态图的编码,基本步骤如下:

1. 创建 CGImageDestination

CGImageDestination 表示图像编码后的输出,之后会向该 Destination 中添加图像数据、图像元数据等信息,最后将编码后的数据输出。 CGImageDestination 的创建方式有:

  • CGImageDestinationCreateWithData
    通过指定一个 Data 对象来接收编码后的数据,来创建Destination

  • CGImageDestinationCreateWithURL
    通过指定一个文件路径来创建 Destination

  • CGImageDestinationCreateWithDataConsumer
    通过指定一个 DataConsumer 来接收编码数据,来创建Destination

示例:

//编码后的图像格式,如 kUTTypeJPEG
CFStringRef imageUTType; 
//创建一个CGImageDestination
CGImageDestinationRef destination = CGImageDestinationCreateWithData((__bridge CFMutableDataRef)imageData, imageUTType, 1, NULL);

2. 添加CGImage、元数据(可选) 创建完 ImageDestination 后,接下来就要向其中添加待编码的图像 CGImage 了,由于CGImage 只包含图像的基本信息,当然还有一些额外的 EXIF 等元信息也可以添加到编码后的数据中,使用到的接口是:

  • CGImageDestinationAddImage
  • CGImageDestinationAddImageAndMetadata 可添加一些自定义的元信息
  • CGImageDestinationAddImageFromSource   可直接添加图像的 ImageSource
// 待编码的CGImage
CGImageRef imageRef = image.CGImage; 
// 元信息,比如EXIF方向
CGImagePropertyOrientation exifOrientation = kCGImagePropertyOrientationUp;
NSMutableDictionary *frameProperties = [NSMutableDictionary dictionary];
imageProperties[(__bridge_transfer NSString *) kCGImagePropertyExifDictionary] = @(exifOrientation);
// 添加图像和元信息
CGImageDestinationAddImage(destination, imageRef, (__bridge CFDictionaryRef)frameProperties);

3. 输出 Data

最后触发真正的编码,得到图像格式的数据,直接调用 CGImageDestinationFinalize 即可,编码后的数据会写入到 CGImageDestination 的初始化时提供的 Data 中 ( 或 URL、DataConsumer)。

if (CGImageDestinationFinalize(destination)) {
    // 编码成功
    imageData = nil;
} else {
    // 编码失败
}
//释放
CFRelease(destination);

动态图

动态图的编码,相比解码来说容易的多。只需要将动图的每一帧按顺序一一添加到 ImageDestination 中接口。

动态图编码,基本步骤如下:

区别于静态图的编码是,在创建 CGImageDestination 时需要提供动图的帧数,然后再循环的向 ImageDestination 中添加 CGImage 和图像EXIF 元信息即可。

示例:

//动图 UIImage 数组
NSArray<UIImage *> *images;
//每帧时长
float durations[frameCount];
//创建
CGImageDestinationRef destination = CGImageDestinationCreateWithData((__bridge CFMutableDataRef)imageData, imageUTType, frameCount, NULL);
//循环添加
for (size_t i = 0; i < frameCount; i++) {
    float frameDuration = durations[i];
    CGImageRef frameImageRef = images[i].CGImage;
    NSDictionary *frameProperties = @{(__bridge_transfer NSString *)kCGImagePropertyGIFDictionary : @{(__bridge_transfer NSString *)kCGImagePropertyGIFUnclampedDelayTime : @(frameDuration)}};
    CGImageDestinationAddImage(imageDestination, frameImageRef, (__bridge CFDictionaryRef)frameProperties);
}

  if (CGImageDestinationFinalize(destination)) {
    // 编码成功
    imageData = nil;
	} else {
  	// 编码失败
  }
	//释放
	CFRelease(destination);

CGImageDestination 的应用

1. 图像格式的转换

从上面介绍编码过程中得知,可通过方法 CGImageDestinationAddImageFromSource 从一个任意的 ImageSource,添加一个图像帧到 ImageDestination中。
主要是用图是图像类型的转换,如:一般用于图像类型的转换,比如将格式 A 的图像转换成个格式 B,不需要先对 A 解码生成 UIImage,再进行编码得到 B。可直接生成 A 的 ImageSource , 再添加到 B 的  ImageDestination 中,直接在中间就进行了转换,没有额外的消耗。

示例:

+ (UIImage *)changeImageType {
    //1.
  	//需要被转换的图片 Data
 	  UIImage *sourceImage = [UIImage imageNamed:@"001.HEIC"];  	 CFDataRef imageData = CFDataCreateMutable(kCFAllocatorDefault, 0);
    CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)imageData, NULL);
    //原图片属性
    CFDictionaryRef imageProperties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil);
    //2.
    //创将 Destination
    NSMutableData *destinationData = [NSMutableData data];
    CGImageDestinationRef destination = CGImageDestinationCreateWithData(
        (__bridge CFMutableDataRef)mutableData, @"002.png" , 1, nil);
    //3. 
    //将 source 添加到 Destination 中
    CGImageDestinationAddImageFromSource(destination, source, 0, (__bridge CFDictionaryRef)imageProperties);
    //4.
    //执行编码
    CGImageDestinationFinalize(destination);
    //释放
    CFRelease(source);
    CFRelease(destination);
    return [UIImage imageWithData:destinationData];
}

2. 制作动图

类似上面动图编码的过程,将多个 UIImage 图像合并在一起编码成一个动图。以常见的 GIF 动图为例,组成动态的基本属性有两点:总帧数和每一帧的时间。总帧数决定了动画效果,每一帧的时间决定了动画的流畅度。
通过 ImageDestination 生成动图,主要步骤是:(原理同动图编码)

  • 通过设置 CGImageDestinationSetProperties 来配置动图属性
  • 通过 CGImageDestinationAddImage  来添加动图的每一帧

示例:

- (void)createAnimatedImage:(NSString *)filePath {
  NSArray<UIImage *>* frames = @[@"001.png", ..., @"060.png"];
  NSInteger loopCount = 1;
  NSInteger framecount = frames.count;
  //gif 属性
  NSDictionary* fileProperty = @{(NSString *)kCGImagePropertyGIFDictionary: @{(NSString *)kCGImagePropertyGIFLoopCount: loopCount}};
  NSDictionary* frameProperty = @{(NSString *)kCGImagePropertyGIFDictionary: @{(NSString *)kCGImagePropertyGIFDelayTime: 1.0 / framecount}};
  NSURL* destinationURL = [NSURL fileURLWithPath:filePath];
  //创建 destination
  CGImageDestinationRef destination = CGImageDestinationCreateWithURL((CFURLRef)destinationURL, kUTTypeGIF, 1, nil);
  CGImageDestinationSetProperties(destination, (__bridge CFDictionaryRef)fileProperty);
  for (int i = 0; i < framecount; i++) {
    CGImageRef frameImageRef = frames[i].CGImage;
    //逐帧添加
    CGImageDestinationAddImage(destination, frameImageRef, (__bridge CFDictionaryRef)frameProperty);
  }
  //执行编码
  CGImageDestinationFinalize(destination)
  //释放
  CFRelease(destination);
}

开源框架中的 “预解码”

我们常见的图像(本地 or 网络)都是压缩后的格式,需要进行解码并转成对应的 UIImage,最后交给 UIImageView 将图像呈现到设备的屏幕上,整个渲染流程是:

  1. 一次 RunLoop 完成
  2. Core Aniamtion 提交渲染树
  3. 遍历所有 Layer 的 contents
  4. 遇到UIImageView 的 contents 是 CGImage 类型的
  5. 拷贝 CGImage 的 Bitmap Buffer 到 Surface(Metal 或 OpenGL ES Texture)上
  6. Surface 渲染到硬件管线上

从解码到渲染的整个过程都是在主线程执行的。因此,若主线程中有频发的解码操作,会造成帧率的下降。

目前,业内解决方案是:通过强制ImageIO 产生的 CGImage 解码 ,预先获取图像的 Bitmap,这样最后渲染的时候,就不会再次触发解码操作,生成的 Bitmap 会被复用。

常见的主流框架的具体解决方式是,在子线程,通过 CGContext 创建一个画布,通过 CGContextDrawImage 方法画一次原始的 CGImage ,此时 ImageIO 内部会立即解码并分配 Bitmap 内存。
下面分别看下 SDWebImage 和 Kingfisher 两个框架的具体实现。

SDWebImage


+ (CGImageRef)CGImageCreateDecoded:(CGImageRef)cgImage orientation:(CGImagePropertyOrientation)orientation {
    if (!cgImage) {
        return NULL;
    }
    size_t width = CGImageGetWidth(cgImage);
    size_t height = CGImageGetHeight(cgImage);
    //
    ... 省略
    //
    CGContextRef context = CGBitmapContextCreate(NULL, newWidth, newHeight, 8, 0, [self colorSpaceGetDeviceRGB], bitmapInfo);
    if (!context) {
        return NULL;
    }
    // Apply transform
    CGAffineTransform transform = SDCGContextTransformFromOrientation(orientation, CGSizeMake(newWidth, newHeight));
    CGContextConcatCTM(context, transform);
    //核心
    CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage); // The rect is bounding box of CGImage, don't swap width & height
    CGImageRef newImageRef = CGBitmapContextCreateImage(context);
    CGContextRelease(context);
    
    return newImageRef;
}

Kinfisher

    /// Returns the decoded image of the `base` image. It will draw the image in a plain context and return the data
    /// from it. This could improve the drawing performance when an image is just created from data but not yet
    /// displayed for the first time.
    ///
    /// - Note: This method only works for CG-based image. The current image scale is kept.
    ///         For any non-CG-based image or animated image, `base` itself is returned.
    public var decoded: KFCrossPlatformImage { return decoded(scale: scale) }
    
    public func decoded(scale: CGFloat) -> KFCrossPlatformImage {
        guard let imageRef = cgImage else {
            assertionFailure("[Kingfisher] Decoding only works for CG-based image.")
            return base
        }

        let size = CGSize(width: CGFloat(imageRef.width) / scale, height: CGFloat(imageRef.height) / scale)
        //draw
        return draw(to: size, inverting: true, scale: scale) { context in
	          //核心方法
            context.draw(imageRef, in: CGRect(origin: .zero, size: size))
            return true
        }
    }

总结

本篇主要基于系统框架 ImageIO 来简要介绍一下图像的编、解码,以及一些特殊场景下的应用,具体 API 的实现细节还得更加深入的学习一下 ImageIO 的源码。

  • CGImageSource 的主要用途

    • 图像解码
    • 获取图像的相关信息(如:定位、拍摄设备信息、分辨率等)
    • 渐进式加载
  • CGImageDestination 的主要用途

    • 图像编码
    • 图像格式的转换、输出、写入元数据
    • 制作动图(如 GIF)
  • ImageIO 的应用

    • 缩率图(下采样)
    • 渐进式解码
    • 图像格式

参考:

Apple - Image/IO

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

iOS-图像高级处理(三、图像处理实践)

分类: iOS | 小猪的博客