本篇探索图片从文件渲染到屏幕的过程,目的在于理解着色器的渲染过程。
图形图片的渲染过程
显示器显示图像原理
显示器的电子枪从上到下逐行扫描,一行扫描完会发送一个水平同步信号H-Sync,一页都扫描完就显示一帧画面并发出一个垂直同步信号V-Sync,就开始下一页的扫描。
实际上iPhone有两个帧缓冲区,一个叫屏幕缓冲区,一个叫离屏缓冲区。
- CPU计算图片的frame->图片解码->绘制纹理图片然后交给GPU渲染。
- GPU等待垂直同步信号V-Sync,在收到V-Sync后进行着色器渲染,即顶点数据经过顶点着色器的坐标变换···光栅化处理成像素片元,在片元着色器处理后进行纹理混合,最后放到离屏缓冲区。
- 视频控制器指针指向屏幕缓冲区交由显示器去显示,在显示完一帧图像,两个缓冲区交换,视频控制器指针指向新的缓冲区内容,且此时GPU会收到一个V-Sync继续渲染新一帧内容。
从上述流程可以看出,图片的显示是通过CPU和GPU的配合完成,这其中会出现一个问题,就是在GPU收到V-Sync信号时,CPU和GPU开始工作,在下一个V-Sync信号出现时CPU和GPU都没有处理完自己的工作,导致此时的离屏缓冲区中没有帧数据,就会出现卡顿现象。
更详细的解释可以参考ibireme大神的iOS 保持界面流畅的技巧。
图片的加载的工作流程
在开始探索图片加载的流程前,先弄清楚两个概念。
- 位图:位图是一个像素数组,数组中的每个元素代表位图的一个像素,jpeg和png就是位图,只不过是压缩过的位图,jpeg是有损压缩(0~1),png是无损压缩。
- 像素:是图像的一个基本元素,图像是由许多的像素点组成,一个像素有且仅有一种颜色,像素由4种不同的向量组成即RGBA,red、green、blue、alpha(有多少光透过当前的这张图片)。
我们先说说CPU都要做什么?
当使用UIImage的几个方法加载图片时,图片并不会立刻解码,它会经过一系列的步骤
- 1、将图片从磁盘读入缓冲区:比如我们使用
imageWithContentsOfFile
从磁盘中加载一个图片,这时候图片并没有解压缩,而是把图片(jpeg或png等)的二进制数据读入内核缓冲区,即Data Buffer
。 - 2、内核缓冲区拷贝到用户内存空间
- 3、把
image
设置到imageView
上,即在将要显示这个图片时,把图片的二进制数据解压缩还原为原始位图数据,Core Animation
的layer使用位图数据渲染UIImageView图层,这一步非常耗CPU的时间。 - 4、隐式的
CATransaction
捕获到UIImageView图层的变化 - 5、在主线程下一次
Runloop
到来时,Core Animation
提交隐式的CATransaction
开始进行图像渲染。- 5.1、如果数据没有字节对齐,Core Animation会再拷贝一份数据,进行字节对齐。
- 6、GPU处理位图数据开始渲染(这一步就是GPU的处理了)
- GPU获取纹理坐标
- 把纹理坐标交给顶点着色器处理转换为标准化坐标
- 把转换完的数据进行图元装配,变成图元
- 把图元光栅化处理成像素片元
- 交由片元着色器着色。
GPU处理实际上是通过着色器渲染到屏幕的过程,具体的看下面的着色器渲染的过程
着色器的渲染过程
- 顶点数据->顶点着色器
经过顶点着色器处理每个顶点,比如平移、缩放、旋转,进行各种模型变换、视变换、投影变换等操作,将其变成标准化坐标。 - 顶点着色器->细分着色器->几何着色器
描述物体的形状,处理模型几何体(使其更加平顺,放弃凸缘),生成最终状态,在OpenGL ES中,开发者只能处理顶点着色器和片元着色器,所以这里不做过多纠结。 - 几何着色器->图元设置
将处理完的数据进行图元装配,使数据变成图元 - 图元设置->剪切
根据视口对图元进行剪切,将视口看不见的地方剪裁掉。 - 剪切->光栅化
实际上就是对图元的处理,将图元解析成数学描述,变成屏幕可以显示的坐标及像素片元。 - 光栅化->片元着色器
对像素片元进行着色。
以上就是着色器的渲染过程。
看到这里,我们已经知道图片解码过程非常耗时,对于App来说,静止的倒还好说,滑动的列表下,性能影响就会很严重了。
我们通常会用帧数FPS(Frames Per Second)来衡量手机的性能问题,60FPS(每秒60帧)是衡量会不会卡顿的标准,换算过来1帧的处理时间不应该超过0.1666s即16.7ms,接下来学习一下UIImage和YYImage的处理方式有什么可取之处。
UIImage的图片处理
UIImage有多种方法可以获取图片,那这些不同方法的区别是什么呢?
一、imageNamed
imageNamed
方式,在第一次渲染到屏幕上时会在主线程进行图片解码操作,位图数据会被缓存起来,之后再访问这个图片时,就从缓存获取了,看网上有说在手机发出内存警告时会清除UIImage的缓存。有两个问题:
1、第一次的图片解码操作还是在主线程做的
2、位图数据如果非常大,这么存到缓存里不太好。
二、imageWithContentsOfFile和imageWithData
imageWithContentsOfFile
和imageWithData
在第一次渲染到屏幕时同样会在主线程进行图片解码操作,若把UIImage
对象释放掉以后,再访问还是会出现在主线程进行图片解码的操作,这个小icon还好,大图就是有问题
所以一般市面上的做法是把图片解码强制提前执行,即在CPU进行解压缩前使用CGBitmapContextCreate
在子线程中对其重新绘制。
YYImage的图片处理
找了YYKit的Demo直接拿来用了,YYImage的调用方式如下
YYImage *image = [YYImage imageNamed:name];
[self addImage:image size:CGSizeZero text:text];
YYImage集成自UIImage,重写了imageNamed
方法避免了系统的全局缓存
+ (YYImage *)imageNamed:(NSString *)name {
//如果没有扩展名就得找了,所以以后写图片最好写上扩展名
NSArray *exts = ext.length > 0 ? @[ext] : @[@"", @"png", @"jpeg", @"jpg", @"gif", @"webp", @"apng"];
//scales为类似@[@1,@2,@3]的数组,由于不同机型的物理分辨率或逻辑分辨率不同,相应的查询优先级也不同。
NSArray *scales = _NSBundlePreferredScales();
for (int s = 0; s < scales.count; s++) {
scale = ((NSNumber *)scales[s]).floatValue;
NSString *scaledName = _NSStringByAppendingNameScale(res, scale);
for (NSString *e in exts) {
path = [[NSBundle mainBundle] pathForResource:scaledName ofType:e];
if (path) break;
}
if (path) break;
}
NSData *data = [NSData dataWithContentsOfFile:path];
return [[self alloc] initWithData:data scale:scale];
}
这里面基本上就是从磁盘获取图片的二进制数据的过程。
scales
是类似@[@1,@2,@3]的数组,由于不同机型的物理分辨率或逻辑分辨率不同,相应的查询优先级也不同。
YYImage的多个方法都统一调用initWithData
,接着看initWithData
- (instancetype)initWithData:(NSData *)data scale:(CGFloat)scale {
if (data.length == 0) return nil;
if (scale <= 0) scale = [UIScreen mainScreen].scale;
//这里初始化一个线程锁,是控制预加载的锁,保证_preloadedFrames的读写安全
_preloadedLock = dispatch_semaphore_create(1);
//会产生大量的临时变量,用自动释放池降低内存峰值
@autoreleasepool {
//获取图片的一些信息、图片宽高、帧数、图片类型
YYImageDecoder *decoder = [YYImageDecoder decoderWithData:data scale:scale];
//得到了图片(解压缩过的, CGImageRef)
YYImageFrame *frame = [decoder frameAtIndex:0 decodeForDisplay:YES];
//
UIImage *image = frame.image;
if (!image) return nil;
self = [self initWithCGImage:image.CGImage scale:decoder.scale orientation:image.imageOrientation];
if (!self) return nil;
_animatedImageType = decoder.type;
if (decoder.frameCount > 1) {
_decoder = decoder;
_bytesPerFrame = CGImageGetBytesPerRow(image.CGImage) * CGImageGetHeight(image.CGImage);
_animatedImageMemorySize = _bytesPerFrame * decoder.frameCount;
}
self.yy_isDecodedForDisplay = YES;
}
return self;
}
_preloadedLock
:这是线程锁,通过信号量控制,为了预加载数据读取安全。
@autoreleasepool
:在内部会产生大量的临时变量,为保证内存峰值不会暴涨,使用自动释放池控制
YYImageDecoder
:获取图片的一些基本信息,图片宽高、帧数、图片类型
YYImageFrame
:此时已经得到了解压缩后的位图
解压缩原理
先来看看 YYImage 中的两种解码方式
一、CGImageGetDataProvider(imageRef)方式
CGColorSpaceRef space = CGImageGetColorSpace(imageRef);
size_t bitsPerComponent = CGImageGetBitsPerComponent(imageRef);
size_t bitsPerPixel = CGImageGetBitsPerPixel(imageRef);
size_t bytesPerRow = CGImageGetBytesPerRow(imageRef);
CGBitmapInfo bitmapInfo = CGImageGetBitmapInfo(imageRef);
if (bytesPerRow == 0 || width == 0 || height == 0) return NULL;
CGDataProviderRef dataProvider = CGImageGetDataProvider(imageRef);
if (!dataProvider) return NULL;
CFDataRef data = CGDataProviderCopyData(dataProvider); // 主要耗时操作(解码)
if (!data) return NULL;
CGDataProviderRef newProvider = CGDataProviderCreateWithCFData(data);
CFRelease(data);
if (!newProvider) return NULL;
CGImageRef newImage = CGImageCreate(width, height, bitsPerComponent, bitsPerPixel, bytesPerRow, space, bitmapInfo, newProvider, NULL, false, kCGRenderingIntentDefault);
CFRelease(newProvider);
return newImage;
下面是对核心代码的解释
-
CGDataProviderRef
:位图数据提供者,CGImageGetDataProvider
函数,总之就是能从这里拿到位图数据 -
CFDataRef
:CGDataProviderCopyData
,从DataProvider中获取CFDataRef数据。 -
CGImageRef
:CGImageCreate
,根据位图数据再创建一个CGImage。
二、CGBitmapContextCreate方式
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;
//alpha是在最低有效位比如RGBA,还是在最高有效位比如ARGB
//当图片不包含 alpha 的时候使用 kCGImageAlphaNoneSkipFirst ,否则使用 kCGImageAlphaPremultipliedFirst 。
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);
NSLog(@"currentThread = %@", [NSThread currentThread]);
CFRelease(context);
return newImage;
}
}
这里使用的是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(10.0, 2.0);
可以看到这个函数有如下参数
-
data
:一般指定为NULL,让系统自动分配和释放内存 -
width
:位图的像素宽度 -
height
:位图的像素高度 -
bitsPerComponent
:像素的每个颜色向量占用的字节数,一般是8字节,RGBA有4个向量,所以一共是32字节。 -
bytesPerRow
:位图每一行占用字节数,一般写0/NULL,理论上占用width * bitsPerPixel
,填0的话系统还会帮我们优化。 -
space
:颜色空间,填RGB即可 -
bitmapInfo
:布局信息,在有alpha的情况下为kCGImageAlphaPremultipliedFirst
,在没有alpha的情况下为kCGImageAlphaNoneSkipFirst
。
通过打印执行这个函数时的线程可以看到,这个操作是异步执行的,后面我们会知道这确切来说是异步串行。
YYAnimatedImageView解析
YYAnimatedImageView集成UIImageView,并重写了很多方法,先从初始化开始看
- (instancetype)initWithImage:(UIImage *)image {
self = [super init];
//设置runloop的mode为common,意思是在runloop切换时也要播放动图
_runloopMode = NSRunLoopCommonModes;
//是否自动播放
_autoPlayAnimatedImage = YES;
self.frame = (CGRect) {CGPointZero, image.size };
self.image = image;//重写了setter方法
return self;
}
初始化时定义了一个runloopMode
为NSRunLoopCommonModes
,表示在runloop切换时也要播放动图。
- (void)setImage:(UIImage *)image {
if (self.image == image) return;
[self setImage:image withType:YYAnimatedImageTypeImage];
}
- (void)setImage:(id)image withType:(YYAnimatedImageType)type {
[self stopAnimating];
if (_link) [self resetAnimated];
_curFrame = nil;
switch (type) {
case YYAnimatedImageTypeNone: break;
case YYAnimatedImageTypeImage: super.image = image; break;
case YYAnimatedImageTypeHighlightedImage: super.highlightedImage = image; break;
case YYAnimatedImageTypeImages: super.animationImages = image; break;
case YYAnimatedImageTypeHighlightedImages: super.highlightedAnimationImages = image; break;
}
[self imageChanged];
}
_link
:是CADisplayLink
,计时器,用来播放动画的。
第一次进入setImage
会先执行imageChanged
方法来确定图片和容器大小,以及标记定时器,等待下次runloop开始播放动画。
- (void)imageChanged {
//获取图片类型
YYAnimatedImageType newType = [self currentImageType];
id newVisibleImage = [self imageForType:newType];
NSUInteger newImageFrameCount = 0;
if (newImageFrameCount > 1) {
[self resetAnimated];
_curAnimatedImage = newVisibleImage;
_curFrame = newVisibleImage;
_totalLoop = _curAnimatedImage.animatedImageLoopCount;
_totalFrameCount = _curAnimatedImage.animatedImageFrameCount;
[self calcMaxBufferCount];
}
//标记 RunLoop,定时器(CAD)
[self setNeedsDisplay];
[self didMoved];
}
在resetAnimated
方法中进行了初始化操作,包括_lock
线程锁、_buffer
缓存容器的初始化、_requestQueue
线程队列的初始化以及定时器_link
的初始化等等。
- (void)resetAnimated {
if (!_link) {
_lock = dispatch_semaphore_create(1);
_buffer = [NSMutableDictionary new];
_requestQueue = [[NSOperationQueue alloc] init];
//异步串行队列
_requestQueue.maxConcurrentOperationCount = 1;
//播放当前图片
_link = [CADisplayLink displayLinkWithTarget:[_YYImageWeakProxy proxyWithTarget:self] selector:@selector(step:)];
if (_runloopMode) {
[_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:_runloopMode];
}
_link.paused = YES;
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didReceiveMemoryWarning:) name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didEnterBackground:) name:UIApplicationDidEnterBackgroundNotification object:nil];
}
[_requestQueue cancelAllOperations];
LOCK(
if (_buffer.count) {
NSMutableDictionary *holder = _buffer;
_buffer = [NSMutableDictionary new];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
// Capture the dictionary to global queue,
// release these images in background to avoid blocking UI thread.
[holder class];
});
}
);
_link.paused = YES;
_time = 0;
if (_curIndex != 0) {
[self willChangeValueForKey:@"currentAnimatedImageIndex"];
_curIndex = 0;
[self didChangeValueForKey:@"currentAnimatedImageIndex"];
}
_curAnimatedImage = nil;
_curFrame = nil;
_curLoop = 0;
_totalLoop = 0;
_totalFrameCount = 1;
_loopEnd = NO;
_bufferMiss = NO;
_incrBufferCount = 0;
}
NSOperationQueue
:初始化的线程队列是用NSOperationQueue
,切设置了maxConcurrentOperationCount
最大并发数为一个,表示这是一个一串行异步队列
,但它并不阻塞当前线程,会另开辟一个线程来执行任务。
_YYImageWeakProxy
:为了防止循环引用,使用了继承NSProxy的类进行消息转发。
_buffer
:缓存池,避免了UIImage的全局缓存,并注册了两个通知,在收到内存警告和进入后台时清除缓存。
其他一些参数的初始化。
总结
1、图片渲染到屏幕的过程:从磁盘读取文件->计算Frame->图片解码->通过数据总线提交给GPU渲染->顶点着色器->光栅化处理->片元着色器着色->渲染到帧缓冲区->视频控制器指向帧缓冲区->显示。
2、YYImage避免了全局缓存,在图片显示之前就异步强制图片解压缩,对性能有很大提高,其实这个库还有很多优点,没有再仔细的去看,以后会抽时间看一下。