OpenGL 图片从文件渲染到屏幕的过程

本篇探索图片从文件渲染到屏幕的过程,目的在于理解着色器的渲染过程。

图形图片的渲染过程

显示器显示图像原理
屏幕显示图像原理

显示器的电子枪从上到下逐行扫描,一行扫描完会发送一个水平同步信号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
imageWithContentsOfFileimageWithData在第一次渲染到屏幕时同样会在主线程进行图片解码操作,若把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;
}

初始化时定义了一个runloopModeNSRunLoopCommonModes,表示在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避免了全局缓存,在图片显示之前就异步强制图片解压缩,对性能有很大提高,其实这个库还有很多优点,没有再仔细的去看,以后会抽时间看一下。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容