YYImage 設計思路,實現細節剖析

Lision發表於2017-12-11

前言

圖片的歷史早於文字,是最原始的資訊傳遞方式。六書中的象形文構造思想就是用文字的線條或筆畫,把要表達物體的外形特徵,具體地勾畫出來。

許慎說文解字》雲:“象形者,畫成其物,隨體詰詘,日、月是也。”

現代社會的資訊傳遞中,圖片仍然是不可或缺的一環,不論是報紙、雜誌、漫畫等實體刊物還是生活中超市地鐵廣告活動,都會有專門的配圖抓人眼球。

在移動端 App 中,圖片通常佔據著重要的視覺空間,作為 iOS 開發來講,所有的 App 都有精心設計的 AppIcon 陳列在 SpringBoard 中,開啟任意一款主流 App 都少不了琳琅滿目的圖片搭配。

YYImage 是一款功能強大的 iOS 影像框架(該專案是 YYKit 元件之一),支援目前市場上所有主流的圖片格式的顯示與編/解碼,並且提供高效的動態記憶體快取管理,以保證高效能低記憶體的動畫播放。

YYKit 的作者 @ibireme 對於 iOS 圖片處理寫有兩篇非常不錯的文章,推薦各位讀者在閱讀本文之前查閱。

本文引用程式碼均為 YYImage v1.0.4 版本原始碼,文章旨在剖析 YYImage 的架構思想以及設計思路並對筆者在閱讀原始碼過程中發現的有趣實現細節探究分享,不會逐行翻譯原始碼,建議對原始碼實現感興趣的同學結合 YYImage v1.0.4 版本原始碼食用本文~

YYImage 設計思路,實現細節剖析

索引

  • YYImage 簡介
  • YYImage, YYFrameImage, YYSpriteSheetImage
  • YYAnimatedImageView
  • YYImageCoder
  • 總結
  • 擴充套件閱讀

YYImage 簡介

YYImage 設計思路,實現細節剖析

YYImage 是一款功能強大的 iOS 影像框架,支援當前市場主流的靜/動態影像編/解碼與動態影像的動畫播放顯示,其具有以下特性:

  • 支援以下型別動畫影像的播放/編碼/解碼: WebP, APNG, GIF。
  • 支援以下型別靜態影像的顯示/編碼/解碼: WebP, PNG, GIF, JPEG, JP2, TIFF, BMP, ICO, ICNS。
  • 支援以下型別圖片的漸進式/逐行掃描/隔行掃描解碼: PNG, GIF, JPEG, BMP。
  • 支援多張圖片構成的幀動畫播放,支援單張圖片的 sprite sheet 動畫。
  • 高效的動態記憶體快取管理,以保證高效能低記憶體的動畫播放。
  • 完全相容 UIImage 和 UIImageView,使用方便。
  • 保留可擴充套件的介面,以支援自定義動畫。
  • 每個類和方法都有完善的文件註釋。

YYImage 架構分析

通過 YYImage 原始碼可以按照其與 UIKit 的對應關係劃分為三個層級:

層級 UIKit YYImage
影像層 UIImage YYImage, YYFrameImage, YYSpriteSheetImage
檢視層 UIImageView YYAnimatedImageView
編/解碼層 ImageIO.framework YYImageCoder
  • 影像層,把不同型別的影像資訊封裝成類並提供初始化和其他便捷介面。
  • 檢視層,負責影像層內容的顯示(包含動態影像的動畫播放)工作。
  • 編/解碼層,提供影像底層支援,使整個框架得以支援市場主流的圖片格式。

Note: ImageIO.framework 是 iOS 底層實現的圖片編/解碼庫,負責管理顏色和訪問影像後設資料。其內部的實現使用了第三方編/解碼庫(如 libpng 等)並對第三方庫進行調整優化。除此之外,iOS 還專門針對 JPEG 的編/解碼開發了 AppleJPEG.framework,實現了效能更高的硬編碼和硬解碼。

YYImage 設計思路,實現細節剖析

YYImage, YYFrameImage, YYSpriteSheetImage

先來介紹 YYImage 庫中影像層的三個類,它們分別是:

  • YYImage
  • YYFrameImage
  • YYSpriteSheetImage

YYImage

YYImage 是一個顯示動態圖片資料的高階別類,其繼承自 UIImage 並對 UIImage 做了擴充套件以支援 WebP,APNG 和 GIF 格式的圖片解碼。它還支援 NSCoding 協議可以對多幀影像資料進行 archive 和 unarchive 操作。

@interface YYImage : UIImage <YYAnimatedImage>

+ (nullable YYImage *)imageNamed:(NSString *)name; // 不同於 UIImage,此方法無快取
+ (nullable YYImage *)imageWithContentsOfFile:(NSString *)path;
+ (nullable YYImage *)imageWithData:(NSData *)data;
+ (nullable YYImage *)imageWithData:(NSData *)data scale:(CGFloat)scale;

@property (nonatomic, readonly) YYImageType animatedImageType; // 影像資料型別
@property (nullable, nonatomic, readonly) NSData *animatedImageData; // 動態影像的後設資料
@property (nonatomic, readonly) NSUInteger animatedImageMemorySize; // 多幀影像記憶體佔用量
@property (nonatomic) BOOL preloadAllAnimatedImageFrames; // 預載入所有幀(到記憶體)

@end
複製程式碼

YYImage 提供了類似 UIImage 的初始化方法,公開了一些屬性便於我們檢測和控制其記憶體使用。

值得一提的是 YYImage 的 imageNamed: 初始化方法並不支援快取。因為其 imageNamed: 內部實現並不同於 UIImage 的 imageNamed: 方法,YYImage 中的實現流程如下:

  • 推測出給定影像資源路徑
  • 拿到路徑中的影像資料(NSData)
  • 呼叫 YYImage 的 initWithData:scale: 方法初始化

YYImage 的私有變數部分也比較簡單,相信大家可以根據上面暴露出的屬性和介面猜得到哈。

@implementation YYImage {
    YYImageDecoder *_decoder; // 解碼器
    NSArray *_preloadedFrames; // 預載入的影像幀
    dispatch_semaphore_t _preloadedLock; // 預載入鎖
    NSUInteger _bytesPerFrame; // 記憶體佔用量
}
複製程式碼

其內部有一把鎖 dispatch_semaphore_t,我們知道 dispatch_semaphore_t 當訊號量為 1 時可以當做鎖來使用,在不阻塞時其作為鎖的效率非常高。這裡使用 _preloadedLock 的主要目的是保證 _preloadedFrames 的讀寫,由於 _preloadedFrames 的讀寫過程是在記憶體中完成的,操作耗時不會太多,所以不會長時間阻塞,這種情況使用 dispatch_semaphore_t 非常合適。

嘛~ _preloadedFrames 對應 preloadAllAnimatedImageFrames 屬性,開啟預載入所有幀到記憶體的話,_preloadedFrames 作為一個陣列會儲存所有幀的影像。_bytesPerFrame 則對應 animatedImageMemorySize 屬性,在初始化 YYImage 時,如果幀總數超過 1 則會計算 _bytesPerFrame 的大小。

if (decoder.frameCount > 1) {
    _decoder = decoder;
    _bytesPerFrame = CGImageGetBytesPerRow(image.CGImage) * CGImageGetHeight(image.CGImage);
    _animatedImageMemorySize = _bytesPerFrame * decoder.frameCount;
}
複製程式碼

其實 YYImage 中還有一些實現也比較有趣,比如 animatedImageDurationAtIndex: 的實現中如果取到 <= 10 ms 的時長會替換為 100 ms,並在 註釋 中解釋了為什麼(一定要點進去看啊,笑~)。

YYFrameImage

YYFrameImage 是專門用來顯示基於幀的動畫影像類,其也是 UIImage 的子類。YYFrameImage 僅支援系統圖片格式例如 png 和 jpeg。

Note: 使用 YYFrameImage 顯示動畫影像同樣要基於 YYAnimatedImageView 播放。

@interface YYFrameImage : UIImage <YYAnimatedImage>

- (nullable instancetype)initWithImagePaths:(NSArray<NSString *> *)paths
                           oneFrameDuration:(NSTimeInterval)oneFrameDuration
                                  loopCount:(NSUInteger)loopCount;
- (nullable instancetype)initWithImagePaths:(NSArray<NSString *> *)paths
                             frameDurations:(NSArray<NSNumber *> *)frameDurations
                                  loopCount:(NSUInteger)loopCount;
- (nullable instancetype)initWithImageDataArray:(NSArray<NSData *> *)dataArray
                               oneFrameDuration:(NSTimeInterval)oneFrameDuration
                                      loopCount:(NSUInteger)loopCount;
- (nullable instancetype)initWithImageDataArray:(NSArray<NSData *> *)dataArray
                                 frameDurations:(NSArray *)frameDurations
                                      loopCount:(NSUInteger)loopCount;

@end
複製程式碼

YYFrameImage 可以把靜態圖片型別如 png 和 jpeg 格式的靜態影像用幀切換的方式以動態圖片的形式顯示,並且提供了 4 個常用的初始化方法方便我們使用。

YYFrameImage 內部有一些基本的變數分別對應於其暴露的 4 個常用初始化介面:

@implementation YYFrameImage {
    NSUInteger _loopCount;
    NSUInteger _oneFrameBytes;
    NSArray *_imagePaths;
    NSArray *_imageDatas;
    NSArray *_frameDurations;
}
複製程式碼

YYFrameImage 的實現程式碼非常簡單,初始化方法大致可以分為以下步驟:

  • 入參校驗
  • 根據入參取到首張圖片
  • 用首圖初始化 _oneFrameBytes ,如入參初始化 _imageDatas_frameDurations_loopCount
  • UIImageinitWithCGImage:scale:orientation: 初始化並返回初始化結果

YYSpriteSheetImage

YYImage 設計思路,實現細節剖析

YYSpriteSheetImage 是用來做 Spritesheet 動畫顯示的影像類,它也是 UIImage 的子類。

關於 Spritesheet 可能做過遊戲開發或者以前鼓搗過簡單網頁遊戲 Demo 的同學會很熟悉,其動畫原理是把一個動畫過程分解為多個動畫幀,按照順序將這些動畫幀排布在一張大的畫布中,播放動畫時只需要按照每一幀影像的尺寸大小以及對應索引去畫布中提取對應的幀替換顯示以達到人眼判定動畫的效果,點選 An Introduction to Spritesheet Animation 或者 What is a sprite sheet? 瞭解更多關於 Spritesheet 動畫的資訊。

Note: 關於 SpriteSheet 素材的製作有一款工具 SpriteSheetMaker 推薦使用。

@interface YYSpriteSheetImage : UIImage <YYAnimatedImage>

// 初始化方法,這個第一次接觸 Spritesheet 的同學可能會覺得比較繁瑣
- (nullable instancetype)initWithSpriteSheetImage:(UIImage *)image
                                     contentRects:(NSArray<NSValue *> *)contentRects
                                   frameDurations:(NSArray<NSNumber *> *)frameDurations
                                        loopCount:(NSUInteger)loopCount;

@property (nonatomic, readonly) NSArray<NSValue *> *contentRects; // 幀位置資訊
@property (nonatomic, readonly) NSArray<NSValue *> *frameDurations; // 幀持續時長
@property (nonatomic, readonly) NSUInteger loopCount; // 迴圈數

// 根據索引找到對應幀 CALayer 的位置
- (CGRect)contentsRectForCALayerAtIndex:(NSUInteger)index;

@end
複製程式碼

其中初始化方法的入參為 SpriteSheet 畫布(包含所有動畫幀的大圖)image,每一幀的位置 contentRects,每一幀對應的持續顯示時間 frameDurations,迴圈次數 loopCount,初始化示例在 YYImage 原始檔 YYSpriteSheetImage.h 註釋中有寫。

Note: 下文中要講的 YYAnimatedImageView 中定義了 YYAnimatedImage 協議,這個協議中有一個可選方法 animatedImageContentsRectAtIndex: 就是為 YYSpriteSheetImage 量身打造的。

這裡需要提一下 contentsRectForCALayerAtIndex: 介面會根據索引找到對應幀的 CALayer 位置,該介面返回一個由 0.0~1.0 之間的數值組成的圖層定位 LayerRect,如果在查詢位置過程中發現異常則返回 CGRectMake(0, 0, 1, 1),其內部實現大體步驟:

  • 校驗入參索引是否超過 SpriteSheet 分割幀總數,超過返回 CGRectMake(0, 0, 1, 1)
  • 沒超過則通過 YYAnimatedImage 協議的 animatedImageContentsRectAtIndex: 方法找到對應索引的真實位置 RealRect
  • 通過真實位置 RealRect 與 SpriteSheet 畫布的比算錯 0.0~1.0 之間的值,得到指定索引幀的邏輯定位 LogicRect
  • 通過 CGRectIntersection 方法計算邏輯定位 LogicRect 與 CGRectMake(0, 0, 1, 1) 的交集,確保邏輯定位沒有超出畫布的部分
  • 將處理後的邏輯定位 LogicRect 作為圖層定位 LayerRect 返回

返回的 LayerRect 作為對應索引幀的畫布內相對位置存在,結合畫布就可以定位到對應幀影像的具體尺寸和位置。

YYAnimatedImageView

YYImage 設計思路,實現細節剖析

人眼中呈現的動畫是由一幅幅內容連貫的影像以較短時間按順序替換形成的,所以要顯示動畫只需要知道動畫順序中每一幀影像以及對應的顯示時間等資訊即可。YYImage 中對應於 UIImage 層級的內容(YYImage, YYFrameImage, YYSpriteSheetImage)在上文已經介紹過了,雖然它們之間存在內容和形式上的差異,但是對於人眼動畫呈現的原理卻是不變的。

YYAnimatedImageView 是 YYImage 的重要組成,它是 UIImageView 的子類,負責 YYImage 影像層中不同的影像類的檢視顯示(包含動態影像的動畫播放),其內部包含 YYAnimatedImage 協議以及 YYAnimatedImageView 自身兩部分。

YYAnimatedImage 協議

上文提到不論是 YYImage, YYFrameImage, YYSpriteSheetImage 還是以後可能會擴充套件的影像類,雖然它們之間存在內容和形式上的差異,但是對於人眼動畫呈現的原理卻是不變的。

YYAnimatedImage 協議就是在不影響原來影像類的情況下把不同影像類之間的共性找出來(求同存異?笑),以統一化的介面將人眼動畫呈現所需的基本資訊輸出給 YYAnimatedImageView 使用的協議。

Note: 作為影像類須遵循 YYAnimatedImage 協議以便可以使用 YYAnimatedImageView 播放動畫。

@protocol YYAnimatedImage <NSObject>

@required
// 動畫幀總數
- (NSUInteger)animatedImageFrameCount;
// 動畫迴圈次數,0 表示無限迴圈
- (NSUInteger)animatedImageLoopCount;
// 每幀位元組數(在記憶體中),可能用於優化記憶體緩衝區大小
- (NSUInteger)animatedImageBytesPerFrame;
// 返回給定特殊索引對應的幀影像,這個方法可能在非同步執行緒中呼叫
- (nullable UIImage *)animatedImageFrameAtIndex:(NSUInteger)index;
// 返回給定特殊索引對應的幀影像對應的顯示持續時長
- (NSTimeInterval)animatedImageDurationAtIndex:(NSUInteger)index;

@optional
// 針對 Spritesheet 動畫的方法,用於顯示某一幀影像在 Spritesheet 畫布中的位置
- (CGRect)animatedImageContentsRectAtIndex:(NSUInteger)index;

@end
複製程式碼

上文提到過可選實現介面 animatedImageContentsRectAtIndex: 是專為 Spritesheet 動畫設計的。

像這樣規定一個協議,使不相關的類遵循此協議擁有統一的功能介面方便另一個類呼叫的設計思想我們在自己日常專案的開發過程中很多場景都可以用到,例如可以封裝一個 TableView,設計一個 TableViewCell 協議,讓所有 TableViewCell 都實現這個協議以擁有統一的功能介面,然後我們封裝的 TableView 類就可以統一的使用這些 TableViewCell 顯示資料啦,省去了反覆寫相同功能 UITableView 的勞動力(實際應用場景很多,這裡只是簡單舉例,拋磚引玉)。

YYAnimatedImageView

上文提到過 YYAnimatedImageView 作為 YYImage 框架中的圖片檢視層,上接影像層,下啟編/解碼底層,是樞紐一般的存在(承上啟下啊有木有?),我們需要重點研究其內部實現:

@interface YYAnimatedImageView : UIImageView

// 如果 image 為多幀組成時,自動賦值為 YES,可以在顯示和隱藏時自動播放和停止動畫
@property (nonatomic) BOOL autoPlayAnimatedImage;
// 當前顯示的幀(從 0 起始),設定新值後會立即顯示對應幀,如果新值無效則此方法無效
@property (nonatomic) NSUInteger currentAnimatedImageIndex;
// 當前是否在播放動畫
@property (nonatomic, readonly) BOOL currentIsPlayingAnimation;
// 動畫定時器所在的 runloop mode,預設為 NSRunLoopCommonModes,關乎動畫定時器的觸發
@property (nonatomic, copy) NSString *runloopMode;
// 內部快取區的最大值(in bytes),預設為 0(動態),如果有值將會把快取區限制為值大小,當收到記憶體警告或者 App 進入後臺時,快取區將會立即釋放並且在適時的時候回覆原狀
@property (nonatomic) NSUInteger maxBufferSize;

@end
複製程式碼

額...出乎意料的簡單呢~ 只有一些屬性暴露出來以便我們在使用過程中實時檢視動畫的播放狀態以及記憶體使用情況。筆者看原始碼總結出一條經驗,即如果某個元件在庫中佔據重要地位,其 .h 檔案中暴露的內容越是簡單,其 .m 內部實現就越是複雜

通過 runloopMode 屬性大家用猜的也應該可以猜出 YYAnimatedImageView 內部實現動畫的原理離不開 RunLoop,而且極有可能是用定時器 NSTimer 或者 CADisplayLink 實現的。下面我們來對 YYAnimatedImageView 的實現剖析,驗證一下我們剛才的猜想。

YYAnimatedImageView 的實現剖析

YYAnimatedImageView 內部實現原始碼很有趣,有很多值得分享的地方。不過為了不把文章寫成 MarkDown 編輯器文(笑~)筆者不會逐行翻譯原始碼。讀者如果想要知道實現的細節建議結合文章去翻閱原始碼。相信有了文章梳理的思路原始碼看起來應該不會有太大的困難,文章還是重在傳播實現思想和一些值得分享的技巧。

我們先簡單看一下 YYAnimatedImageView 的內部結構,方便後面分析實現思路時大家腦中對 YYAnimatedImageView 的結構提前有一個大概的認識。

@interface YYAnimatedImageView() {
    @package
    UIImage <YYAnimatedImage> *_curAnimatedImage; ///< 當前影像
    
    dispatch_once_t _onceToken; ///< 用於確保初始化程式碼只執行一次
    dispatch_semaphore_t _lock; ///< 訊號量鎖(用於 _buffer)
    NSOperationQueue *_requestQueue; ///< 圖片請求佇列,序列
    
    CADisplayLink *_link; ///< 幀轉換
    NSTimeInterval _time; ///< 上一幀之後的時間
    
    UIImage *_curFrame; ///< 當前幀
    NSUInteger _curIndex; ///< 當前幀索引
    NSUInteger _totalFrameCount; ///< 幀總數
    
    BOOL _loopEnd; ///< 是否在迴圈末尾
    NSUInteger _curLoop; ///< 當前迴圈次數
    NSUInteger _totalLoop; ///< 總迴圈次數, 0 表示無窮
    
    NSMutableDictionary *_buffer; ///< 幀緩衝區
    BOOL _bufferMiss; ///< 是否丟幀,在上面 _link 定時執行的 step 函式中從幀緩衝區讀取下一幀圖片時如果沒讀到,則視為丟幀
    NSUInteger _maxBufferCount; ///< 最大緩衝計數
    NSInteger _incrBufferCount; ///< 當前允許的快取區計數(將逐步增加)
    
    CGRect _curContentsRect; ///< 針對 YYSpriteSheetImage
    BOOL _curImageHasContentsRect; ///< 影像類是否實現了 animatedImageContentsRectAtIndex: 介面
}
@property (nonatomic, readwrite) BOOL currentIsPlayingAnimation;
- (void)calcMaxBufferCount; // 動態調節緩衝區最大限制 _maxBufferCount
@end
複製程式碼

可以看到 YYAnimatedImageView 內部結構比 .h 中暴露的屬性要複雜的多,而 CADisplayLink *_link 屬性也證實了我們之前關於 .h 中 runloopMode 屬性的猜想。

YYAnimatedImageView 內部的初始化沒什麼特別之處,初始化函式中會設定圖片,當判定圖片有更改時會依照下面 4 步去處理:

  • 改變圖片
  • 重置動畫
  • 初始化動畫引數
  • 重繪

Note: 這樣可以保證 YYAnimatedImageView 的圖片更改時都會執行上面的步驟為新的圖片初始化配套的新動畫引數並且重繪,而重置動畫實現中會使用到上面的 dispatch_once_t _onceToken; 以確保某些內部變數的建立以及對 App 記憶體警告和進入後臺的通知觀察程式碼只執行一次。

YYAnimatedImageView 使圖片動起來是依靠 CADisplayLink *_link; 變數切換幀影像,其內部的實現邏輯可以簡單理解為:

  • 根據當前幀索引推出下一幀索引
  • 使用下一幀索引去幀緩衝區嘗試獲取對應幀影像
  • 如果找到對應幀影像則使用其重繪
  • 如果沒找到則根據條件向圖片請求佇列加入請求操作(向圖片緩衝區錄入之後的幀影像資料)

嘛~ 這裡面有一些值得一提的實現細節哈!

  • YYAnimatedImageView 實現中當 _curIndex 即當前幀索引修改時在修改程式碼前後加入了 willChangeValueForKey:didChangeValueForKey: 方法以支援 KVO
  • 對幀緩衝區 _buffer 的操作都使用 _lock 上鎖
  • 通過將圖片請求佇列 _requestQueuemaxConcurrentOperationCount 設定為 1 使圖片請求佇列成為序列佇列(最大併發數為 1)
  • 圖片請求佇列中加入的操作均為 _YYAnimatedImageViewFetchOperation
  • 為了避免使用 CADisplayLink 可能造成的迴圈引用設計了 _YYImageWeakProxy

先看一下 _YYAnimatedImageViewFetchOperation 的原始碼:

@interface _YYAnimatedImageViewFetchOperation : NSOperation
@property (nonatomic, weak) YYAnimatedImageView *view;
@property (nonatomic, assign) NSUInteger nextIndex;
@property (nonatomic, strong) UIImage <YYAnimatedImage> *curImage;
@end

@implementation _YYAnimatedImageViewFetchOperation
- (void)main {//...}
@end
複製程式碼

_YYAnimatedImageViewFetchOperation 繼承自 NSOperation 類,是自定義操作類,作者將其操作內容實現寫在了 main 中,程式碼太長而且我覺得貼出來不僅不會幫助讀者理解反而會因為片面的原始碼實現影響讀者對 YYAnimatedImageView 的整體實現思路理解(因為大量貼原始碼會使文章生澀很多,而且會把讀者注意力轉移到某一個實現),這裡簡單描述一下 main 函式內部實現邏輯:

  • 判斷幀緩衝區大小
  • 掃描下一幀以及當前允許緩衝範圍內之後的幀圖片
  • 如果發現丟失的幀則嘗試重新獲取幀影像並加入到幀緩衝

嘛~ 不貼原始碼歸不貼原始碼,該注意的細節還是需要列出來的(笑)。

  • 操作中對於 view 緩衝區的操作也都上了鎖
  • 操作由於是放入圖片請求佇列中進行的,內部有對 isCancelled 做判斷,如果操作已經被取消(發生在更改圖片、停止動畫、手動更改當前幀、收到記憶體警告或 App 進入後臺等)則需要及時跳出
  • 對於新的執行緒優先順序只在 main 方法範圍內有效,所以推薦把操作的實現放在 main 中而非 start(如需覆蓋 start 方法時,需要關注 isExecutingisFinished 兩個 key paths)

YYAnimatedImageView 內部設計了 _YYImageWeakProxy 來避免使用 NSTimer 或者 CADisplayLink 可能造成的迴圈引用問題,_YYImageWeakProxy 內部實現也比較簡單,繼承自 NSProxy,關於 NSProxy 可以檢視官方文件以瞭解更多。

@interface _YYImageWeakProxy : NSProxy
@property (nonatomic, weak, readonly) id target;
- (instancetype)initWithTarget:(id)target;
+ (instancetype)proxyWithTarget:(id)target;
@end

@implementation _YYImageWeakProxy
// ...
- (id)forwardingTargetForSelector:(SEL)selector {
    return _target;
}
- (void)forwardInvocation:(NSInvocation *)invocation {
    void *null = NULL;
    [invocation setReturnValue:&null];
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector {
    return [NSObject instanceMethodSignatureForSelector:@selector(init)];
}
// ...
@end
複製程式碼

上面貼出的原始碼省略了比較基礎的實現部分,_YYImageWeakProxy 內部弱引用一個物件 target,對於 _YYImageWeakProxy 的一些基本操作包含 hashisEqual 這些統統都轉到 target 上,並且使用 forwardingTargetForSelector: 訊息重定向將不能響應的執行時訊息也重定向給 target 來響應。

Emmmmm..那麼問題來了,既然都訊息重定向給 target 了還要訊息轉發幹嘛?因為要避免迴圈引用問題所以對 target 使用弱引用,期間無法保證 target 一定存在,所以 forwardingTargetForSelector: 方法可能返回 nil,接著在 Runtime 訊息轉發中借用 init 訊息返回空以“吞掉”異常。

Note: 訊息轉發產生的開銷要比動態方法解析和訊息重定向大。

YYImageCoder

YYImage 設計思路,實現細節剖析

YYImageCoder 作為 YYImage 的編/解碼器,對應於 iOS 中的 ImageIO.framework 圖片編/解碼庫,正是因為有了 YYImageCoder 的存在,YYImage 才得以支援如此多的圖片格式,所以說 YYImageCoder 是 YYImage 的底層核心。

YYImageCoder 內部定義了許多 YYImage 中用到的核心資料結構:

  • YYImageType,所有的支援的圖片格式做了列舉定義
  • YYImageDisposeMethod,指定在畫布上渲染下一個幀之前如何處理當前幀所使用的區域方法
  • YYImageBlendOperation,指定當前幀的透明畫素如何與前一個畫布的透明畫素混合操作
  • YYImageFrame,一幀影像資料
  • YYImageEncoder,影像編碼器
  • YYImageDecoder,影像解碼器
  • UIImage+YYImageCoder,UIImage 的分類,裡面提供了一些方便使用的方法

其中 YYImageFrame 是對一幀影像資料的封裝,便於在 YYImageCoder 編/解碼過程中使用。

YYImageCoder 內部影像編碼器 YYImageEncoder 和影像解碼器 YYImageDecoder 其實是分開來的,我們下面分別對它們做分析。

YYImageEncoder

先來講一下 YYImageEncoder,其在 YYImageCoder 中擔任編碼器的角色。

@interface YYImageEncoder : NSObject

@property (nonatomic, readonly) YYImageType type; ///< 影像型別
@property (nonatomic) NSUInteger loopCount;       ///< 迴圈次數,0 無限迴圈,僅適用於 GIF/APNG/WebP 格式
@property (nonatomic) BOOL lossless;              ///< 無損標記,僅適用於 WebP.
@property (nonatomic) CGFloat quality;            ///< 壓縮質量,0.0~1.0,僅適用於 JPG/JP2/WebP.

// 禁止適用 init、new 初始化編碼器(我沒忘記我說過這些編碼技巧會在之後統一寫一篇文章彙總)
- (instancetype)init UNAVAILABLE_ATTRIBUTE;
+ (instancetype)new UNAVAILABLE_ATTRIBUTE;

// 根據給定圖片型別建立編碼器
- (nullable instancetype)initWithType:(YYImageType)type NS_DESIGNATED_INITIALIZER;
// 新增影像
- (void)addImage:(UIImage *)image duration:(NSTimeInterval)duration;
// 新增影像資料
- (void)addImageWithData:(NSData *)data duration:(NSTimeInterval)duration;
// 新增檔案路徑
- (void)addImageWithFile:(NSString *)path duration:(NSTimeInterval)duration;
// 開始影像編碼並嘗試返回編碼後的資料
- (nullable NSData *)encode;
// 編碼並將得到的資料儲存到給定路徑檔案中
- (BOOL)encodeToFile:(NSString *)path;
// 便捷方法,對一個單幀影像編碼
+ (nullable NSData *)encodeImage:(UIImage *)image type:(YYImageType)type quality:(CGFloat)quality;
// 便捷方法,從解碼器中編碼影像資料
+ (nullable NSData *)encodeImageWithDecoder:(YYImageDecoder *)decoder type:(YYImageType)type quality:(CGFloat)quality;

@end
複製程式碼

可以看到 YYImageEncoder 內部的一些屬性和介面都比較基本,關於其內部實現我們需要先看一下私有變數:

@implementation YYImageEncoder {
    NSMutableArray *_images; // 已新增到編碼器的圖片(陣列)
    NSMutableArray *_durations; // 對應的圖片幀顯示持續時長(陣列)
}
複製程式碼

YYImageEncoder 的實現思路

YYImageEncoder 的初始化部分沒有多複雜,根據圖片的型別按照編碼最優的引數做初始化而已。關於 YYImageEncoder 對於圖片的編碼工作,其實作者根據要支援的圖片型別和對應圖片型別的編碼方式做了底層封裝,再根據當前圖片的型別選擇對應的底層編碼方法執行。

關於不同圖片型別的圖片編碼格式可以查閱本文文末的擴充套件閱讀章節,結合擴充套件閱讀的內容查閱 YYImage 這部分原始碼可以理解作者對於底層圖片格式資訊的結構封裝以及編/解碼操作具體實現。

關於 YYImageEncoder 的一些簡單使用示例可以檢視 YYImageCoder.h 瞭解。

YYImageDecoder

YYImageDecoder 在 YYImageCoder 中擔任解碼器的角色,其與上述 YYImageEncoder 對應,一個負責影像編碼一個負責影像解碼,不過 YYImageDecoder 的實現比 YYImageEncoder 更為複雜。

@interface YYImageDecoder : NSObject

@property (nullable, nonatomic, readonly) NSData *data;    ///< 影像資料
@property (nonatomic, readonly) YYImageType type;          ///< 影像資料型別
@property (nonatomic, readonly) CGFloat scale;             ///< 影像大小
@property (nonatomic, readonly) NSUInteger frameCount;     ///< 影像幀數量
@property (nonatomic, readonly) NSUInteger loopCount;      ///< 影像迴圈次數,0 無限迴圈
@property (nonatomic, readonly) NSUInteger width;          ///< 影像畫布寬度
@property (nonatomic, readonly) NSUInteger height;         ///< 影像畫布高度
@property (nonatomic, readonly, getter=isFinalized) BOOL finalized;

// 建立一個影像解碼器
- (instancetype)initWithScale:(CGFloat)scale NS_DESIGNATED_INITIALIZER;
// 用新資料增量更新影像
- (BOOL)updateData:(nullable NSData *)data final:(BOOL)final;
// 方便用一個特殊的資料建立對應的解碼器
+ (nullable instancetype)decoderWithData:(NSData *)data scale:(CGFloat)scale;
// 解碼並返回給定索引對應的幀資料
- (nullable YYImageFrame *)frameAtIndex:(NSUInteger)index decodeForDisplay:(BOOL)decodeForDisplay;
// 返回給定索引對應的幀持續顯示時長
- (NSTimeInterval)frameDurationAtIndex:(NSUInteger)index;
// 返回給定索引對應幀的屬性資訊,去 ImageIO.framework 的 "CGImageProperties.h" 檔案中瞭解更多
- (nullable NSDictionary *)framePropertiesAtIndex:(NSUInteger)index;
// 返回圖片的屬性資訊,去 ImageIO.framework 的 "CGImageProperties.h" 檔案中瞭解更多
- (nullable NSDictionary *)imageProperties;

@end
複製程式碼

可以看到 YYImageDecoder 暴露了一些關於解碼影像的屬性並提供了初始化解碼器方法、影像解碼方法以及訪問影像幀資訊的方法。不過上文也說過 YYImageDecoder 的實現比較複雜,我們接著看一下其內部變數結構:

@implementation YYImageDecoder {
    pthread_mutex_t _lock; // 遞迴鎖
    
    BOOL _sourceTypeDetected; // 是否推測影像源型別
    CGImageSourceRef _source; // 影像源
    yy_png_info *_apngSource; // 如果判定影像為 YYImageTypePNG 則會以 APNG 更新影像源
#if YYIMAGE_WEBP_ENABLED
    WebPDemuxer *_webpSource; // 如果判定影像為 YYImageTypeWebP 則會議 WebP 更新影像源
#endif
    
    UIImageOrientation _orientation; // 繪製方向
    dispatch_semaphore_t _framesLock; // 針對於影像幀的鎖
    NSArray *_frames; ///< Array<_YYImageDecoderFrame *>, without image
    BOOL _needBlend; // 是否需要混合
    NSUInteger _blendFrameIndex; // 從幀索引混合到當前幀
    CGContextRef _blendCanvas; // 混合畫布
}
複製程式碼

_YYImageDecoderFrame 繼承自 YYImageFrame 類作為 YYImageCoder 影像解碼器 YYImageDecoder 使用的內部框架類存在,是對於一幀影像的資料封裝提供了便於編/解碼時需要訪問的資料。

YYImageDecoder 內鎖的選擇

可以看到作者在 YYImageDecoder 內部使用了兩種鎖:

  • pthread_mutex_t _lock;
  • dispatch_semaphore_t _framesLock;

pthread_mutex_t 在解碼器初始化過程中被以 PTHREAD_MUTEX_RECURSIVE 型別設定為了遞迴鎖。

pthread_mutexattr_t attr;
pthread_mutexattr_init (&attr);
pthread_mutexattr_settype (&attr, PTHREAD_MUTEX_RECURSIVE);
pthread_mutex_init (&_lock, &attr);
pthread_mutexattr_destroy (&attr);
複製程式碼

Note: 一般情況下一個執行緒只能申請一次鎖,也只能在獲得鎖的情況下才能釋放鎖,多次申請鎖或釋放未獲得的鎖都會導致崩潰。假設在已經獲得鎖的情況下再次申請鎖,執行緒會因為等待鎖的釋放而進入睡眠狀態,因此就不可能再釋放鎖,從而導致死鎖。

然而這種情況經常會發生,比如某個函式申請了鎖,在臨界區內又遞迴呼叫了自己。辛運的是 pthread_mutex 支援遞迴鎖,也就是允許一個執行緒遞迴的申請鎖,只要把 attr 的型別改成 PTHREAD_MUTEX_RECURSIVE 即可。

作者使用 dispatch_semaphore_t 作為影像幀陣列的鎖是因為 dispatch_semaphore_t 更加輕量且對於影像幀陣列的臨界操作比較快,不會造成長時間的阻塞,這種情況下 dispatch_semaphore_t 具有效能優勢(Emmmmmm..老生常談了,熟悉的同學不要抱怨,照顧一下後面的同學)。

YYImageDecoder 內的實現思路

YYImageDecoder 內在初始化時會初始化鎖並更新影像源資料,在更新影像源時呼叫 _updateSource 方法根據當前影像型別以作者對該型別封裝好的底層資料結構和對應影像型別解碼規則做解碼,解碼之後設定對應屬性。

關於作者對不同格式的影像資料的底層封裝原始碼感興趣的讀者可以參考本文文末的擴充套件閱讀章節內容自行查閱。

關於 YYImageDecoder 的一些簡單使用示例可以檢視 YYImageCoder.h 瞭解。

總結

  • 文章系統的分析了 YYImage 原始碼,希望各位讀者在閱讀本文之後可以對 YYImage 整體架構和設計思路有清晰的認識。
  • 文章對 YYImage 的 Image 層級的三類影像(YYImage, YYFrameImage, YYSpriteSheetImage)分別解讀,希望可以對各位讀者關於這三類影像的組成原理和呈現動畫的方式的理解有所幫助。
  • 文章深入剖析了 YYAnimatedImageView 的內部實現,提煉出其設計思路以供讀者探究。
  • 筆者把自己在閱讀原始碼中發現的值得分享的實現細節結合原始碼單獨拎出來分析,希望各位讀者可以在自己平時工作中遇到相似情況時能夠多一些思路,封裝專案元件時可以用到這些技巧。

文章寫得比較用心(是我個人的原創文章,轉載請註明出處 lision.me/),如果發現錯誤會優先在我的 個人部落格 中更新。能力不足,水平有限,如果有任何問題歡迎在我的微博 @Lision 聯絡我,另外我的 GitHub 主頁 裡有很多有趣的小玩意哦~

最後,希望我的文章可以為你帶來價值~

擴充套件閱讀


補充~ 我建了一個技術交流微信群,想在裡面認識更多的朋友!如果各位同學對文章有什麼疑問或者工作之中遇到一些小問題都可以在群裡找到我或者其他群友交流討論,期待你的加入喲~

YYImage 設計思路,實現細節剖析

Emmmmm..由於微信群人數過百導致不可以掃碼入群,所以請掃描上面的二維碼關注公眾號進群。

相關文章