從YYImage原始碼中學習如何處理圖片顯示

OneAlon發表於2019-09-18

YYImage是一個強大的影像框架, 支援高效的動圖顯示.

  • 支援WebP, APNG, GIF動畫影像的編碼/解碼/播放
  • 支援WebP, PNG, GIF, JPEG, JP2, TIFF, BMP, ICO, ICNS靜態影像的編碼/解碼/顯示
  • 支援PNG, GIF, JPEG, BMP影像的漸進式/逐行掃描/隔行掃描解碼
  • 支援幀動畫播放, 支援單張圖片sprite sheet動畫
  • 高效的動態記憶體快取管理, 高效能低記憶體的動畫播放
  • 預留可擴充套件介面, 支援自定義動畫

一. 圖片被渲染到螢幕的過程

在開始看YYImage如何處理高效能圖片之前, 先來大致瞭解一下圖片是如何被渲染到螢幕上的, 這部分內容是筆者自己的粗淺理解.

圖片摘抄自網路

  1. 通過imageNamed:等方法讀取圖片資料
  2. 生成imageView, 將圖片資料賦值給imageView, 將圖片資料解碼為點陣圖資料(耗時操作)
  3. CPU將點陣圖資料通過匯流排傳遞給GPU
  4. GPU通過頂點著色器和片元著色器等渲染圖片

在上述過程中, 解壓縮圖片是非常耗時的, 為什麼需要對圖片進行解碼呢?

GPU渲染圖片需要圖片的原始資料, 可以理解為點陣圖, 是使用畫素矩陣來表示的影像, GPU通過拿到畫素矩陣, 根據矩陣中的畫素色值來渲染圖片, 當一張為解碼的圖片需要顯示到螢幕上時, 需要先對圖片進行解壓縮操作, 才能顯示.

二. iOS中顯示圖片的優化

imageNamed:imageWithData:區別?

使用imageNamed:建立UIImage時, 會先在系統快取中根據檔名稱查詢快取, 返回最適合螢幕顯示的image物件. 如果快取中沒有對應的image物件, 就會在資原始檔中查詢根據檔名, 並將檔名放到UIImage中返回, 這時並沒有對實際的檔案進行讀取和解碼. 當UIImage第一次被顯示到螢幕上的時候, 才真正開始解碼, 同時解碼的結果會儲存到一個系統快取中. (系統快取中儲存的是解碼以後的圖片)

imageWithData:也是第一次顯示的時候才會解碼, 解碼資料會快取到image物件內部, 但是不會將image快取到全域性快取中, 當image被釋放的時候, image內部的快取資料也會被釋放掉.

imageNamed:會對圖片進行全域性快取, 適合小並且使用頻率高的圖片.

imageWithData:不會對圖片全域性快取, 適合大並且使用頻率低的圖片.

瞭解了圖片渲染到螢幕的過程和imageNamed:方法, 在顯示圖片時可以從哪些方面進行優化呢?

imageNamed:方法在圖片第一次被顯示到螢幕上的時候才會解碼圖片, 這時是在主執行緒執行的顯示圖片, 而解碼圖片是非常耗時的. 可以在子執行緒中將圖片進項強制提前解壓, 將圖片解壓成點陣圖資料, 使用CoreGraphics中的CGBitmapContextCreate方法實現.

YYImage就是使用這種方式, 將圖片提前在子執行緒強制解壓, 緩解主執行緒的壓力.

三. YYImage架構

YYImage
影像層: YYImage YYFrameImage YYSpriteSheetImage

展示層: YYAnimatedImageView

解碼層: YYImageCoder

YYImage YYFrameImage YYSpriteSheetImage: 通過YYImageCoder將data資料轉換為Image, 通過[[UIImage alloc] initWithData:data]建立UIImage, YYAnimatedImageView展示動圖.

四. YYImage

YYImageUIImage的子類, 對UIImage類的擴充套件以支援WebP APNG GIF格式圖片解碼. 並且遵循了YYAnimatedImage協議, 可以使用YYImageVeiw展示動畫.

重寫imageNamed:方法, 避免系統快取.

+ (YYImage *)imageNamed:(NSString *)name {
	...
    NSArray *exts = ext.length > 0 ? @[ext] : @[@"", @"png", @"jpeg", @"jpg", @"gif", @"webp", @"apng"];
    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;
    }
    if (path.length == 0) return nil;
    
    // 根據path, 從Bundle中獲取圖片二進位制資料
    NSData *data = [NSData dataWithContentsOfFile:path];
    if (data.length == 0) return nil;
    
    return [[self alloc] initWithData:data scale:scale];
}
複製程式碼
  • 在根據圖片名稱在Bundle中查詢圖片時, 如果指定了圖片型別, 就按照指定的圖片名稱+字尾型別去查詢圖片, 如果未指定圖片型別, 會遍歷圖片字尾型別查詢, 所以為了減少遍歷次數, 儘量指定圖片字尾型別.
- (instancetype)initWithData:(NSData *)data scale:(CGFloat)scale {
    ...
    @autoreleasepool {
        YYImageDecoder *decoder = [YYImageDecoder decoderWithData:data scale:scale];
        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;
}
複製程式碼

使用YYImageDecoder對data進行解碼(解碼相關內容會在YYImageCoder中提到), image預設展示的是第一幀資料.

五. YYFrameImage

幀動畫, 僅支援PNGJPEG格式圖片, 可以使用YYImageView顯示幀動畫, 內部遵循了YYAnimatedImage協議.

YYFrameImage中提供了4中初始化方法.

// 提供圖片路徑和每一幀持續時間
- (nullable instancetype)initWithImagePaths:(NSArray<NSString *> *)paths
                           oneFrameDuration:(NSTimeInterval)oneFrameDuration
                                  loopCount:(NSUInteger)loopCount;
// 提供圖片資料和每一幀持續時間
- (nullable instancetype)initWithImageDataArray:(NSArray<NSData *> *)dataArray
                               oneFrameDuration:(NSTimeInterval)oneFrameDuration
                                      loopCount:(NSUInteger)loopCount;
複製程式碼

YYFrameImage的職責就是將提供的圖片路徑或者圖片資料陣列通過YYAnimatedImage協議方法提供給YYImageView展示.

六. YYSpriteSheetImage

雪碧圖: 將圖片按照固定的間距排列在同一張大圖上, 可以理解為按照rect和索引可以拿到大圖中對應的各個小圖.(把一個動畫分解成多個動畫幀, 按照順序將這些動畫幀排布在一張畫布中, 播放動畫時按照每一幀的尺寸大小和對應索引去畫布中提取對應的幀顯示, 達到動畫的效果.)

使用雪碧圖的目的: 相比對多張圖片的載入和解壓縮, 對一張大圖的載入和解壓縮的效能要高.

YYSpriteSheetImageUIImage的子類, 同樣也遵循了YYAnimatedImage協議, 在 YYSpriteSheetImage提供了每一幀圖片在大圖中的座標位置.

- (CGRect)animatedImageContentsRectAtIndex:(NSUInteger)index {
    if (index >= _contentRects.count) return CGRectZero;
    return ((NSValue *)_contentRects[index]).CGRectValue;
}
複製程式碼

contentsRectCALayer的屬性, 預設值是[0, 0, 1, 1], 用法:

    YYSpriteSheetImage *sheet = ...;
    UIImageView *imageView = ...;
    imageView.image = sheet;
    imageView.layer.contentsRect = [sheet contentsRectForCALayerAtIndex:6];
複製程式碼

設定contentsRect可以顯示大圖的某一塊位置.

七. YYImageView

YYImageView是檢視表現層, 用於展示YYImage YYFrameImageYYSpriteSheetImage 動畫.

1. YYAnimatedImage協議

YYAnimatedImage 提供了YYImageView顯示動圖需要實現的方法, UIImage的子類實現這些協議方法, 可以使用YYImageVeiw展示動圖效果.

/// 動畫總幀數
- (NSUInteger)animatedImageFrameCount;

/// 迴圈次數, 0表示不限制
- (NSUInteger)animatedImageLoopCount;

/// 在記憶體中每一幀的位元組數, 在優化記憶體快取時可能用到
- (NSUInteger)animatedImageBytesPerFrame;

/// 返回指定索引的幀影像, 可能在後臺執行緒執行
- (nullable UIImage *)animatedImageFrameAtIndex:(NSUInteger)index;

/// 返回指定索引的影像顯示時間
- (NSTimeInterval)animatedImageDurationAtIndex:(NSUInteger)index;

/// 這個是可選方法, 針對spriteSheet, 用於顯示每一幀影像在spritesheet畫布中的位置
- (CGRect)animatedImageContentsRectAtIndex:(NSUInteger)index;
複製程式碼

2. YYAnimatedImageView

YYAnimatedImageViewYYAnimatedImage 協議中獲取需要展示的圖片資訊, 進行動圖展示.

思考: 動圖展示的原理?

進行動圖展示需要遍歷總幀數(animatedImageFrameCount), 初始化定時器, 根據設定的動畫執行時長(animatedImageDurationAtIndex:)切換imageView展示的圖片(根據animatedImageFrameAtIndex獲取).

2.1 YYImageWeakProxy

YYImageWeakProxy持有target物件(弱引用), 因為在YYAnimatedImageView中有一個定時器會對target進行強引用, 這裡使用Proxy打破迴圈引用.

NSProxy是一個抽象類, 如果要使用的話必須繼承NSProxy(未提供init方法), 通常將訊息轉發給真實的target去處理, 通過forwardInvocation:methodSignatureForSelector:方法轉發給真實的target.

需要注意的是NSProxy接受到訊息的時候: 直接進行轉發

而普通物件: 查詢本類->查詢父類->動態解析->訊息轉發.

如果要訊息轉發的話, 相比來說NSProxy的效能更高.

2.2 YYAnimatedImageViewFetchOperation

YYAnimatedImageViewFetchOperation是NSOperation的子類, 實現了main方法, 將需要執行的操作放在main方法中.

YYAnimatedImageViewFetchOperation主要是用於獲取curImage對應nextIndex索引的圖片, 並進行解壓縮, 放入view對應的緩衝區(整個操作都是在子執行緒進行的)

在main方法中作者對operation進行了多次的isCancelled判斷, 因為在operation有被取消的可能.

view的_incrBufferCount屬性表示當前緩衝區中的快取個數, 當緩衝區快取個數為0時, 會自動在calcMaxBufferCount方法中為view分配最大快取個數.

- (void)main {
	...
    for (int i = 0; i < max; i++, idx++) {
        @autoreleasepool {
            if (idx >= total) idx = 0;
            if ([self isCancelled]) break;
            __strong YYAnimatedImageView *view = _view;
            if (!view) break;
            LOCK_VIEW(BOOL miss = (view->_buffer[@(idx)] == nil));
            
            if (miss) {
                UIImage *img = [_curImage animatedImageFrameAtIndex:idx];
                img = img.yy_imageByDecoded;
                if ([self isCancelled]) break;
                LOCK_VIEW(view->_buffer[@(idx)] = img ? img : [NSNull null]);
                view = nil;
            }
        }
    }
}
複製程式碼

先根據索引從view的緩衝區中檢視快取是否存在, 如果不在緩衝區, 將索引對應幀的圖片進行解碼, 並存放到view對應的緩衝區_buffer中, 供下次顯示使用, 提高效能.

在操作view的_buffer緩衝區時, 為了保證多執行緒安全, 使用了執行緒鎖

#define LOCK_VIEW(...) dispatch_semaphore_wait(view->_lock, DISPATCH_TIME_FOREVER); \
__VA_ARGS__; \
dispatch_semaphore_signal(view->_lock);
複製程式碼

2.3 YYAnimatedImageView

YYAnimatedImageView是UIImageView的子類, 通過重寫setImage:等方法將圖片設定到super.image中, 最後進行一些初始化的配置.

YYAnimatedImageView中使用CADisplayLink定時器做計時任務, 系統每一幀回撥都會觸發, 大約1/60s觸發一次, 在step:方法中展示動畫.

YYAnimatedImageView還是比較簡單的, 搞清楚圖片顯示的流程, 程式碼讀起來應該沒什麼難度.

- (void)step:(CADisplayLink *)link {
    // 當前顯示圖片
    UIImage <YYAnimatedImage> *image = _curAnimatedImage;
    // 緩衝區
    NSMutableDictionary *buffer = _buffer;
    UIImage *bufferedImage = nil;
    // 獲取下一幀索引
    NSUInteger nextIndex = (_curIndex + 1) % _totalFrameCount;
    BOOL bufferIsFull = NO;
    
    if (!image) return;
    if (_loopEnd) { // view will keep in last frame
        [self stopAnimating];
        return;
    }
    
    // 處理時間
    NSTimeInterval delay = 0;
    if (!_bufferMiss) {
        // 如果當前定時器執行時長 < 圖片的顯示時間, 就沒必要切換圖片.
        _time += link.duration;
        delay = [image animatedImageDurationAtIndex:_curIndex];
        if (_time < delay) return;
        _time -= delay;
        if (nextIndex == 0) {
            _curLoop++;
            if (_curLoop >= _totalLoop && _totalLoop != 0) {
                _loopEnd = YES;
                [self stopAnimating];
                [self.layer setNeedsDisplay]; // let system call `displayLayer:` before runloop sleep
                return; // stop at last frame
            }
        }
        delay = [image animatedImageDurationAtIndex:nextIndex];
        if (_time > delay) _time = delay; // do not jump over frame
    }
    LOCK(
         // 從緩衝區獲取顯示圖片
         bufferedImage = buffer[@(nextIndex)];
         if (bufferedImage) {
             if ((int)_incrBufferCount < _totalFrameCount) {
                 [buffer removeObjectForKey:@(nextIndex)];
             }
             [self willChangeValueForKey:@"currentAnimatedImageIndex"];
             _curIndex = nextIndex;
             [self didChangeValueForKey:@"currentAnimatedImageIndex"];
             _curFrame = bufferedImage == (id)[NSNull null] ? nil : bufferedImage;
             if (_curImageHasContentsRect) {
                 _curContentsRect = [image animatedImageContentsRectAtIndex:_curIndex];
                 [self setContentsRect:_curContentsRect forImage:_curFrame];
             }
             nextIndex = (_curIndex + 1) % _totalFrameCount;
             _bufferMiss = NO;
             if (buffer.count == _totalFrameCount) {
                 bufferIsFull = YES;
             }
         } else {
             _bufferMiss = YES;
         }
    )//LOCK
    
    // 重繪
    if (!_bufferMiss) {
        // 將需要顯示的圖片記錄到_curFrame中, 呼叫系統的displayLayer:方法, 在displayLayer方法中設定layer.contents = (__bridge id)_curFrame.CGImage
        [self.layer setNeedsDisplay]; // let system call `displayLayer:` before runloop sleep
    }
    
    // 緩衝區未滿, 並且當前操作佇列為空, 就讀取下一幀資料到緩衝區
    if (!bufferIsFull && _requestQueue.operationCount == 0) { // if some work not finished, wait for next opportunity
        _YYAnimatedImageViewFetchOperation *operation = [_YYAnimatedImageViewFetchOperation new];
        operation.view = self;
        operation.nextIndex = nextIndex;
        operation.curImage = image;
        [_requestQueue addOperation:operation];
    }
}
複製程式碼

這部分是顯示動圖的核心程式碼, 所以直接將程式碼copy過來了, 在程式碼中做了相關的註釋.

八. YYImageCoder

YYImageCoder是YYImage的底層編/解碼實現.

在YYImageCoder中提供了UIImage的分類YYImageCoder, yy_imageByDecoded方法對圖片進行解碼.

解碼的處理主要是在YYCGImageCreateDecodedCopy方法中

CGImageRef YYCGImageCreateDecodedCopy(CGImageRef imageRef, BOOL decodeForDisplay) {
    	...
	// 獲取點陣圖上下文
	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;
	...
}
複製程式碼

解碼處理邏輯:

  1. 獲取點陣圖上下文
  2. 將圖片繪製到上下文中
  3. 通過上下文生成圖片

參考文章

ibireme-移動端圖片格式調研

ibireme-iOS 處理圖片的一些小 Tip

ibireme-iOS保持介面流暢的技巧

apple-ImageIO是iOS底層實現的圖片編解碼庫, 負責顏色管理和訪問影像後設資料.

apple-ImageIO Programming Guide

iOS中圖片解壓縮到檔案渲染到螢幕的過程

相關文章