理解图像(一)- 基础
理解图像(二)- 解码、编码(基于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 将图像呈现到设备的屏幕上,整个渲染流程是:
- 一次 RunLoop 完成
- Core Aniamtion 提交渲染树
- 遍历所有 Layer 的 contents
- 遇到UIImageView 的 contents 是 CGImage 类型的
- 拷贝 CGImage 的 Bitmap Buffer 到 Surface(Metal 或 OpenGL ES Texture)上
- 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 的应用
- 缩率图(下采样)
- 渐进式解码
- 图像格式
参考: