图片加载解码渲染¶
在iOS中使用 UIImage
和UIImageView
来加载图片,遵守MVC
架构,UIImage
是数据相当于Model
,UIImageView
是视图相当于View
。
UIImage
负责加载图片
,UIImageView
负责渲染图片
。
图片的渲染流程分为3个阶段
- 加载(Load),数据缓冲区(DataBuffer)
- 解码(Decoder),图像缓冲区(imageBuffer)
- 渲染(Render),帧缓冲区(framebuffer)
以加载一个尺寸为:2048 px * 1536 px
,在磁盘上的大小为:590kb
的图片为例,来分析前两个阶段
1、图片加载¶
使用+imageWithContentsOfFile:
方法从磁盘加载一张图片文件到内存中,使用Image I/O创建CGImageRef内存映射数据,此时图像尚未解码。
这个过程中先从磁盘拷贝数据到内核缓冲区,再从内核缓冲区复制数据到用户空间。
DataBuffer¶
DataBuffer
只是一种包含一系列字节
的缓冲区。通常以某些元数据
开头,元数据描述了存储在数据缓冲区中的图像大小(包含图形数据本身),图像数据以某种形式编码(如 JPEG压缩或PNG),这意味着,该字节并不直接描述图像中像素的任何内容。此时的 DataBuffer
大小为 590kb
。
SD源码分析¶
在SDWebImage
中,图片加载完成后,在 sd_imageFormatForImageData
的方法中,是通过DataBuffer
的第一个字节
来判断图片的格式的。
+ (SDImageFormat)sd_imageFormatForImageData:(nullable NSData *)data {
if (!data) {
return SDImageFormatUndefined;
}
uint8_t c;
[data getBytes:&c length:1];
switch (c) {
case 0xFF:
return SDImageFormatJPEG;
case 0x89:
return SDImageFormatPNG;
case 0x47:
return SDImageFormatGIF;
case 0x49:
case 0x4D:
return SDImageFormatTIFF;
case 0x52:
// R as RIFF for WEBP
if (data.length < 12) {
return SDImageFormatUndefined;
}
NSString *testString = [[NSString alloc] initWithData:[data subdataWithRange:NSMakeRange(0, 12)] encoding:NSASCIIStringEncoding];
if ([testString hasPrefix:@"RIFF"] && [testString hasSuffix:@"WEBP"]) {
return SDImageFormatWebP;
}
}
return SDImageFormatUndefined;
}
2、图片的解码、解压缩(位图bitmap)¶
-
用 UIImage 或 CGImageSource 的那几个方法创建图片时,图片数据并不会立刻解码。
-
图片设置到 UIImageView 或者 CALayer.contents 中去,并且 CALayer 被提交到 GPU 前,CGImage 中的数据才会得到解码(还原成位图)。
-
把图像数据赋值给UIImageView,如果图像数据未解码(PNG/JPG),解码为位图数据。
-
这一步是发生在主线程的,并且不可避免。图片的解压缩是需要消耗大量CPU时间,非常消耗性能。
-
解压过的图片就不会重复解压,会缓存起来。
-
UIImage有两种缓存:一种是UIImage类的缓存,这种缓存保证imageNamed初始化的UIImage只会被解码一次。另一种是UIImage对象的缓存,这种缓存保证只要UIImage没有被释放,就不会再解码。
如果想要绕开这个机制,常见的做法是在后台线程先把图片绘制到 CGBitmapContext 中,然后从 Bitmap 直接创建图片。
目前常见的网络图片库都自带这个功能。
位图¶
位图(Bitmap),又称栅格图(英语:Raster graphics)或点阵图,是使用像素阵列(Pixel-array/Dot-matrix点阵)来表示的图像。
一张图片有成千上万个像素点,每一个像素点还原成红R绿G蓝B,透明度(默认1)。
位图就是一个像素数组,数组中的每个像素就代表着图片中的一个点。经常用到的 JPEG 和 PNG 图片就是位图。
获取图片的原始像素数据,用以下代码:
UIImage *image = [UIImage imageNamed:@"text.png"];
CFDataRef rawData = CGDataProviderCopyData(CGImageGetDataProvider(image.CGImage));
不管是 JPEG 还是 PNG 图片,都是一种压缩的位图图形格式。PNG图片是无损压缩,并且支持 alpha 通道,JPEG 图片是有损压缩,可以指定 0-100% 的压缩比。值得一提的是,在苹果的 SDK 中专门提供了两个函数用来生成 PNG 和 JPEG 图片:
// 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);
为什么需要解压缩¶
磁盘中的图片渲染到屏幕之前,必须先要得到图片的原始像素数据,才能执行后续的绘制操作。
解压缩原理¶
利用CoreGraphics对图片进行重新绘制。
当未解压缩的图片将要渲染到屏幕时,系统会在主线程对图片进行解压缩,而如果图片已经解压缩了,系统就不会再对图片进行解压缩。因此,也就有了业内的解决方案,在子线程提前对图片进行强制解压缩。
而强制解压缩的原理就是对图片进行重新绘制,得到一张新的解压缩后的位图。其中,用到的最核心的函数是 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);
这个函数用于创建一个位图上下文,用来绘制一张宽 width 像素,高 height 像素的位图。
- data :如果不为 NULL ,那么它应该指向一块大小至少为 bytesPerRow * height 字节的内存;如果 为 NULL ,那么系统就会为我们自动分配和释放所需的内存,所以一般指定 NULL 即可;
- width 和 height :位图的宽度和高度,分别赋值为图片的像素宽度和像素高度即可;
- bitsPerComponent :像素的每个颜色分量使用的 bit 数,在 RGB 颜色空间下指定 8 即可;
- bytesPerRow :位图的每一行使用的字节数,大小至少为 width * bytes per pixel 字节。有意思的是,当我们指定 0 时,系统不仅会为我们自动计算,而且还会进行 cache line alignment 的优化,更多信息可以查看为什么CoreAnimation要字节对齐? 为什么我的图像的每行字节数超过其每像素的字节数乘以其宽度?
图像的绘制¶
图像的绘制通常是指用那些以 CG 开头的方法把图像绘制到画布中,然后从画布创建图片并显示这样一个过程。这个最常见的地方就是 [UIView drawRect:] 里面了。
由于 CoreGraphic 方法通常都是线程安全的,所以图像的绘制可以很容易的放到后台线程进行。
YYImage\SDWebImage开源框架实现¶
用于解压缩图片的函数 YYCGImageCreateDecodedCopy 存在于 YYImageCoder 类中,核心代码如下
CGImageRef YYCGImageCreateDecodedCopy(CGImageRef imageRef, BOOL decodeForDisplay) {
...
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 {
...
}
}
YYImage加载流程:
- 获取图片二进制数据
- 创建一个CGImageRef对象
- 使用CGBitmapContextCreate()方法创建一个上下文对象context
- 使用CGContextDrawImage()方法绘制到上下文
- 使用CGBitmapContextCreateImage()生成CGImageRef对象。
- 最后使用imageWithCGImage()方法将CGImage转化为UIImage。
SDWebImage 中对图片的解压缩过程与上述完全一致,只是传递给 CGBitmapContextCreate 函数的部分参数存在细微的差别。
ImageBuffer¶
在图片加载
完后,需要将Data Buffer
的JPEG,PNG或其他编码的数据
,转换为每个像素
的图像信息
,这个过程,称为Decoder(解码)
,将像素信息
存放在ImageBuffer
。
占用内存大小¶
图片占用的内存大小与图像的宽高尺寸有关
,与它的文件大小无关
。解压缩后的图片大小 = 图片的像素宽 * 图片的像素高 * 每个像素所占的字节数。
在iOSSRGB
显示格式中(4byte空间显示一个像素)
,如果解析所有的像素,需要 2048 px * 1536 px * 4 byte/px = 10MB
的空间,此时的 ImageBuffer
的大小为10MB
。
在ImageBuffer
解析完后,提交给frameBuffer
进行渲染显示。
图片加载过程和消耗的内存如下图所示:
Xcode测试¶
在Xcode
工程中,当push新页面的时候,只加载一个图片。
加载前内存值:
加载后内存值:
大多数情况下,我们并不需要如此高精度的显示图片,占用了这么多的内存,能否减少加载图片时占用的内存值呢?
如何减少图像占用内存¶
向下采样¶
在苹果官方文档中,建议在对图片进行解码时使用向下采样(Downsampleing)
的技术,来加载图片,减少ImageBuffer
的大小。
方法如下:
// MARK: 向下采样,减少内存消耗
func downsample(imageAt imageURL: URL, to pointSize: CGSize, scale: CGFloat) ->UIImage {
//利用图像地址创建imageSource
let imageSourcesOptions = [kCGImageSourceShouldCache: false] as CFDictionary//原始图像不解码
let imageSource: CGImageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourcesOptions)!
//下采样
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
let downsampleOptions = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,// 缩小图像的同时进行解码
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize:maxDimensionInPixels
] as CFDictionary
let downsampledImage: CGImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)!
return UIImage(cgImage: downsampledImage)
}
测试一下:
let imageStr = Bundle.main.path(forResource: "view_site.jpeg", ofType: nil)
let imageURL = URL(string: "file://" + (imageStr ?? ""))
guard let imgURL = imageURL else {
return
}
imageView.image = downsample(imageAt:imgURL , to: CGSize(width: 200, height: 200), scale: UIScreen.main.scale)
加载之前时是13M
,加载之后是 17M
,节省了大约 5M
的内存空间。
SD源码分析解码过程¶
在SDWebIamge
中,一共有3种类型的解码器:SDImageIOCoder, SDImageGIFCoder, SDImageAPNGCoder
,根据DataBuffer
的编码类型,使用相对应的编码器。
在 -(UIImage *)decodedImageWithData:(NSData *)data
方法中,配置解码参数,开始进行解码操作。
在下面的方法中,完成图像解码。
//图像解码
+ (UIImage *)createFrameAtIndex:(NSUInteger)index source:(CGImageSourceRef)source scale:(CGFloat)scale preserveAspectRatio:(BOOL)preserveAspectRatio thumbnailSize:(CGSize)thumbnailSize options:(NSDictionary *)options {
// Some options need to pass to `CGImageSourceCopyPropertiesAtIndex` before `CGImageSourceCreateImageAtIndex`, or ImageIO will ignore them because they parse once :)
// Parse the image properties
NSDictionary *properties = (__bridge_transfer NSDictionary *)CGImageSourceCopyPropertiesAtIndex(source, index, (__bridge CFDictionaryRef)options);
CGFloat pixelWidth = [properties[(__bridge NSString *)kCGImagePropertyPixelWidth] doubleValue];
CGFloat pixelHeight = [properties[(__bridge NSString *)kCGImagePropertyPixelHeight] doubleValue];
CGImagePropertyOrientation exifOrientation = (CGImagePropertyOrientation)[properties[(__bridge NSString *)kCGImagePropertyOrientation] unsignedIntegerValue];
if (!exifOrientation) {
exifOrientation = kCGImagePropertyOrientationUp;
}
CFStringRef uttype = CGImageSourceGetType(source);
// Check vector format
BOOL isVector = NO;
if ([NSData sd_imageFormatFromUTType:uttype] == SDImageFormatPDF) {
isVector = YES;
}
NSMutableDictionary *decodingOptions;
if (options) {
decodingOptions = [NSMutableDictionary dictionaryWithDictionary:options];
} else {
decodingOptions = [NSMutableDictionary dictionary];
}
CGImageRef imageRef;
BOOL createFullImage = thumbnailSize.width == 0 || thumbnailSize.height == 0 || pixelWidth == 0 || pixelHeight == 0 || (pixelWidth <= thumbnailSize.width && pixelHeight <= thumbnailSize.height);
if (createFullImage) {
if (isVector) {
//省略代码
}
//重要‼️
imageRef = CGImageSourceCreateImageAtIndex(source, index, (__bridge CFDictionaryRef)[decodingOptions copy]);
} else {
decodingOptions[(__bridge NSString *)kCGImageSourceCreateThumbnailWithTransform] = @(preserveAspectRatio);
CGFloat maxPixelSize;
if (preserveAspectRatio) {
CGFloat pixelRatio = pixelWidth / pixelHeight;
CGFloat thumbnailRatio = thumbnailSize.width / thumbnailSize.height;
if (pixelRatio > thumbnailRatio) {
maxPixelSize = thumbnailSize.width;
} else {
maxPixelSize = thumbnailSize.height;
}
} else {
maxPixelSize = MAX(thumbnailSize.width, thumbnailSize.height);
}
decodingOptions[(__bridge NSString *)kCGImageSourceThumbnailMaxPixelSize] = @(maxPixelSize);
decodingOptions[(__bridge NSString *)kCGImageSourceCreateThumbnailFromImageAlways] = @(YES);
//重要‼️
imageRef = CGImageSourceCreateThumbnailAtIndex(source, index, (__bridge CFDictionaryRef)[decodingOptions copy]);
}
if (!imageRef) {
return nil;
}
// Thumbnail image post-process
if (!createFullImage) {
if (preserveAspectRatio) {
// kCGImageSourceCreateThumbnailWithTransform will apply EXIF transform as well, we should not apply twice
exifOrientation = kCGImagePropertyOrientationUp;
} else {
// `CGImageSourceCreateThumbnailAtIndex` take only pixel dimension, if not `preserveAspectRatio`, we should manual scale to the target size
CGImageRef scaledImageRef = [SDImageCoderHelper CGImageCreateScaled:imageRef size:thumbnailSize];
CGImageRelease(imageRef);
imageRef = scaledImageRef;
}
}
#if SD_UIKIT || SD_WATCH
UIImageOrientation imageOrientation = [SDImageCoderHelper imageOrientationFromEXIFOrientation:exifOrientation];
UIImage *image = [[UIImage alloc] initWithCGImage:imageRef scale:scale orientation:imageOrientation];
#else
UIImage *image = [[UIImage alloc] initWithCGImage:imageRef scale:scale orientation:exifOrientation];
#endif
CGImageRelease(imageRef);
return image;
}
选择正确的图片渲染格式¶
渲染格式¶
在 iOS中,渲染图片格式有4种
Alpha 8 Format
:1字节
显示1像素
,擅长显示单色调的图片。Luminance and alpha 8 format
: 亮度和 alpha 8 格式,2字节
显示1像素
,擅长显示有透明度的单色调图片。SRGB Format
: 4个字节显示1像素
。Wide Format
: 广色域格式,8个字节显示1像素
。适用于高精度图片,
如何正确的选择渲染格式¶
正确的思路是:不选择渲染格式,让渲染格式选择你
。
使用 UIGraphicsImageRender
来替换UIGraphicsBeginImageContextWithOptions
,前者在iOS12
以后,会自动选择渲染格式,后者默认都会选择SRGB Format
。
func render() -> UIImage{
let bounds = CGRect(x: 0, y: 0, width: 300, height: 100)
let render = UIGraphicsImageRenderer(size: bounds.size)
let image = render.image { context in
UIColor.blue.setFill()
let path = UIBezierPath(roundedRect: bounds, byRoundingCorners: UIRectCorner.allCorners, cornerRadii: CGSize(width: 20, height: 20))
path.addClip()
UIRectFill(bounds)
}
return image
}
此时,系统为自动选择Alpha 8 Format
格式,内容空间占用,将会减少75%
。
减少后备存储器的使用¶
减少或者不使用 draw(rect:) 方法¶
在需要绘制带有子视图的View时,不使用 draw(rect:)
方法,使用系统的View属性
或者添加子视图
的方式,将绘制工作交给系统来处理。
背景色直接通过UIView.backgroundColor
设置,而非使用draw(rect:)
如何在列表中加载图片¶
对图片进行子线程异步加载
,在后台进行 解码和下采样
。在列表中,有时会加载很多图片,此时应该注意线程爆炸
问题。
线程爆炸¶
当我们要求系统去做比CPU能够做的工作更多的工作时
就会发生这种情况,比如我们要显示8张图片
,但我们只有两个CPU
,就不能一次完成所有这些工作,无法在不存在的CPU上进行并行处理,
为了避免向一个全局队列中异步的分配任务时发生死锁
,GCD
将创建新线程来捕捉我们要求它所做的工作,然后CPU将花费大量时间,在这些线程
之间进行切换
,尝试在所有工作上取得我们要求操作系统为我们做的渐进式进展
,在这些线程之间不停切换
,实际上是相当大的开销,现在不是简单地将工作分派到全局异步队列之一
,而是创建一个串行队列。
在预取的方法中,异步的将工作分派到该队列,它的确意味着单个图像的加载,可能要比以前晚,才能开始取得进展,但CPU将花费更少的时间,在它可以做的小任务之间来回切换。
在SDWebImage
中,解码的队列 _coderQueue.maxConcurrentOperationCount = 1
就是一个串行队列。这样就很好的解决了多图片异步解码
时,线程爆炸
问题。
3、图像图形渲染流程¶
UIImageView UIView背后都是一个layer,有一个图层树。
-
隐式CATransaction 捕获到UIImageView layer树的变化。
-
在主线程的下一个 runloop 到来时,Core Animation 提交了这个隐式的 transaction ,这个过程可能会对图片进行 copy 操作,如果数据没有字节对齐,Core Animation会再拷贝一份数据,进行字节对齐,可能会涉及到以下操作:
-
分配内存缓冲区用于管理文件 IO 和解压缩操作
-
最后 Core Animation 中CALayer使用未压缩的位图数据渲染 UIImageView 的图层
- CPU计算好图片的Frame,对图片解压之后,就会将解码后纹理图片位图数据通过数据总线交给GPU,GPU处理位图数据,进行渲染。
渲染流程¶
着色器渲染过程:在渲染过程中,必须存储2种着色器,分别是顶点着色器、片元着色器。顶点着色器是第一个着色器、片元着色器是最后一个。顶点着色器处理顶点、片元着色器处理像素点颜色。
显示器上如何显示:
- 先确定顶点信息 交给顶点着色器
图片在什么位置显示 (iOS坐标转换屏幕坐标)
GPU获取图片的坐标(Frame),将坐标交给顶点着色器VertexShader (顶点变换计算)
图片/视频帧位置确定
iOS核心动画的旋转/缩放/平移,底层在顶点着色器用算法方式算好实现。
- 图元装配
三角形、线段、顶点
- 光栅化
图元转换为像素,获取图片对应屏幕上的像素点,实际绘制或填充每个顶点之间的像素
- 执行片元着色器(高度并发GPU)
片元着色器处理图片范围内每一个像素点颜色值。
片元着色器FragmentShader计算每个像素点的最终显示的颜色值,根据纹理坐标获取每个像素点的颜色值(如果出现透明值需要将每个像素点的颜色*透明度值)
例:1200个像素点 片元着色器需要执行1200次。一个着色器只处理一个像素点。所以CPU不够用,循环太多,需要GPU。
GPU有很多计算单元,能实现高并发。CPU是多核 切换时间片模拟并发。
- 帧缓冲区
最后的结果放在帧缓冲区中。
- 从帧缓存区中渲染到屏幕上
OpenGL_ES做什么¶
- 动画效果
- 图片进行滤镜/特效处理
- 视频中加入滤镜/特效处理
OpenGL只提供了点、线段、三角形。
如何构成圆形:通过三角形,三角形够多够细。三角形可以构成任何图形。
正方形由两个三角形组成。一个三角形需要三组顶点数据,两个三角形就需要6组顶点数据。
特效滤镜如何实现¶
图片基本原理
简单:颜色
滤镜处理其实就是影响每一个像素点的颜色。
使用可编程管线实现特效¶
灵魂出窍滤镜算法实现思路¶
图片上处理灵魂缩放的特效
灵魂出窍滤镜:是两个层的叠加,并且上面的那层随着时间的推移,会逐渐放大且不透明度逐渐降低。这里也用到了放大的效果,我们这次用片元着色器来实现。
两部分组成:
- 原始图层。没变化
- 放大的 透明度变低的图层
两者结合。
.vsh 叫 vertex shader 顶点着色器
vec4 叫四维向量。包括:x横坐标,y纵坐标,z深度值,w缩放
vec2 二维向量。纹理(图片)坐标。
注¶
vsh/fsh 由开发者自行编译链接执行,不能有中文注释,否则编译无法通过。