iOS高效圖片 IO 框架是如何煉成的

原文

當我們使用圖片存儲的時候,難免會涉及到文件IO,GPU渲染等問題,文章注重從計算機操作系統方面深入淺析地講解如何優化圖片IO的速度,提高 iOS 中 UIImageView 的渲染效率和內存優化,這對我們做多圖片相冊等應用會非常有幫助,而且讓我們把閱讀CASPP——進程篇閱讀CSAPP——虛擬內存篇這兩篇文章學到的內容進行實戰應用。

圖像數據拷貝?

當我們使用以下 Object-C 代碼從網絡中獲取圖片并加載到 UIImageView 上

   NSURL* url = [NSURL URLWithString:@"https://img.alicdn.com/bao/uploaded/i2/2836521972/TB2cyksspXXXXanXpXXXXXXXXXX_!!0-paimai.jpg"];
    __weak typeof(self) weakSelf = self;
    NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:[[NSURLRequest alloc] initWithURL:url]
                                                                 completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
                                                                     UIImage* image = [UIImage imageWithData:data];
                                                                     dispatch_async(dispatch_get_main_queue(), ^{
                                                                         [weakSelf.imageView setImage:image];
                                                                     });
                                                                 }];
    [task resume];

運行上面代碼,通過 Instrument 的 TimeProfile 查看 CPU 消耗情況:


image.png

上述圖片中發現了兩個問題:

  1. 應用程序使用了 CA::Render::copy_image, 這是因為 Core Animation 在圖像數據非字節對齊的情況下渲染前會先拷貝一份圖像數據,當我們使用 imageWithContentsOfFile 也會發生這種情況。

  2. 應用程序使用了 CA::Render::create_image_from_provider, 這個方法實際上是對圖片進行解碼,原因是 UIImage 在加載的時候實際上并沒有對圖片進行解碼,而是延遲到圖片被顯示或者其他需要解碼的時候。這種策略節約了內存,但是卻會在顯示的時候占用大量的主線程CPU時間進行解碼,導致界面卡頓。

那么如果解決上述兩個問題,我們使用 FastImageCache 這個第三方庫加載圖片,官方Demo一開始的 FICDPhoto 加載圖片的方法為使用 imageWithContentsOfFile, 這會導致 CA::Render::copy_image 的圖像數據拷貝,所以更改為以下方法:


- (UIImage *)sourceImage {

 __block UIImage *sourceImage = [UIImage imageWithContentsOfFile:[_sourceImageURL path]];

 if (!sourceImage) {

 pthread_mutex_lock(&_mutex);

 NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:[[NSURLRequest alloc] initWithURL:_sourceImageURL]

 completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {

 sourceImage = [UIImage imageWithData:data];

 pthread_cond_signal(&_signal);

 pthread_mutex_unlock(&_mutex);

 }];

 [task resume];

 pthread_cond_wait(&_signal, &_mutex);

 pthread_mutex_unlock(&_mutex);

 }

 return sourceImage;

}

當圖片被渲染到 ImageView 的時候,利用 Instrument 查看并沒有發生 CA::Render::copy_imageCA::Render::create_image_from_provider 的情況。

如何優化?

FastImageCache是如何解決上述兩個問題的?****

先看第一個問題,為何 Core Animation 在渲染之前要拷貝一份數據呢?

在此之前,我們先看一些計算機系統的理論知識,等看完后再回頭看看,答案便更加明朗了。

我們都知道,圖片說白了就是一段字節數據所組成的文件數據而已,也就是說我們把圖片顯示到界面上,不過是把一堆字節加載到 CPU 的寄存器當中,然后通過 GPU 將字節變成紅(R)、綠(G)、藍(B)的三原色的數組,然后通過界面顯示出來(也就是我們所說的渲染)。所以,我們先了解一下,字節數據是如何通過內存加載到 CPU 的。

學過計算機操作系統的我們都知道,所有的字節數據都是通過總線來傳送的,總線連接著 CPU 到主存,主存到磁盤等主要硬件設備的傳輸路線,而主要的傳輸單位就是字(word),由于數字信號分為高頻和低頻,所以我們的計算機信號只有 0 和 1 兩種來區分,所以我們采用了二進制的數據形式來表述數據,因此信號的量化精度一般以比特(bits)來衡量,這就是字節數據在總線中傳輸的本質。

image.png

而 64 位系統當中,****字(word) 是 8 個字節的大小。

內存的存儲單元被稱為塊(Chunk),而塊的大小因硬件設備而定的,大多數文件系統都是基于塊設備,即存取規定數據塊的硬件抽象層。

假如32位的文件系統中,高速緩存(Cache)以4個字節的塊大小為傳送到 CPU 當中,下圖說明了 CPU 如何以4字節內存訪問粒度訪問4字節的數據查找:

image.png

如果我們們獲取的數據為未對齊的4個字節,CPU 就會執行額外的工作去訪問數據:加載兩個字節塊(Chunk)的數據,移出不需要的字節,然后將它們組合在一起。這個過程肯定會降低性能并浪費CPU周期,以便從內存中獲取正確的數據。


image.png

所以我們存儲數據的時候,需要進行 字節對齊(Byte Alignment) ,其實稱為字節塊對齊更為合適,也就是說我們所取的數據,都以上圖第一種的形式在內存中讀寫,避免發生第二種情況,這樣就能節省 CPU 周期,加快存取速度。

再回頭看看當我們從內存中讀取圖片數據的時候,也是一堆的字節塊,如果圖像字節并沒有經過如何處理,那么,就會出現以下情況:

image.png

當我們數據傳輸的時候,由來傳輸,存儲設備中讀取內存卻以為單位, 所以從內存讀取到的圖像數據勢必會帶上其他"雜質字節"。所以便發生了——"通常情況",但是 GPU 所需要的數據,是理想狀態下的數據,因為這些"雜質字節"會影響圖像生成。所以 Core Animation 就需要把"雜質字節"去除,然后變成我們的——"理想狀態"。其實,不是 Core Animation 規定這么做,是我們接粗到圖像處理的時候,都必須這么做,不然圖像數據就亂了,只不過 Core Animation 幫我們封裝了底層處理而已。
通過我們學習到的知識,我們意識到,我們需要對圖片的存入進行字節對齊。
對于高速緩存(Cache)來說,存取都是以字節塊的形式,而塊的大小跟 CPU 的高速緩存存儲器有關,ARMv7是 32 Byte,A9是 64 Byte,在 A9 下CoreAnimation 應該是按 64 Byte(也就是8個字,8Byte/字) 作為一塊數據去讀取、存儲和渲染,讓圖像數據對齊64 Byte 就可以避免CoreAnimation再拷貝一份數據。能節約內存和進行copy的時間。(因為圖片存入的時候已經對齊過了,獲取的時候自然也是字節對齊的),

如何字節塊對齊避免 Core Animation 進行圖像數據復制?
以下是代碼形式進行實操:
計算圖像所需字節大小

/** FICImageTable.m */

CGSize pixelSize = [_imageFormat pixelSize]; // 想要展示的圖片大小
NSInteger bytesPerPixel = [_imageFormat bytesPerPixel]; // 該圖像個是中的字節每像素, 例如 FICImageFormatStyle32BitBGRA 為32位4個字節
_imageRowLength = (NSInteger)FICByteAlignForCoreAnimation((size_t) (pixelSize.width * bytesPerPixel));
_imageLength = _imageRowLength * (NSInteger)pixelSize.height;

通過 FICByteAlignForCoreAnimation 函數對圖片數據進行字節對齊然后計算, 得到 _imageRowLength 圖像每行的字節數, 圖像所需字節 = 圖像的高度 * _imageRowLength(字節塊對齊的圖像每行字節數)

通過實際圖像每行所需的字節進行字節塊對齊

inline size_t FICByteAlignForCoreAnimation(size_t bytesPerRow) {
    return FICByteAlign(bytesPerRow, 64); // 跟 CPU 的高速緩存器有關
}

讓為 width 成為 alignment 的倍數計算


inline size_t FICByteAlign(size_t width, size_t alignment) {
    return ((width + (alignment - 1)) / alignment) * alignment;
}

創建Entry所對應的 Chunk,而 Chunk 是頁對齊的

// 設置每一個 entry 的字節長度,因為除了圖像數據外,fastImageCache 還額外為圖像添加了兩個 UUID 的 32 個字節
_entryLength = (NSInteger)FICByteAlign(_imageLength + sizeof(FICImageTableEntryMetadata), (size_t) [FICImageTable pageSize]);

entryData = [[FICImageTableEntry alloc] initWithImageTableChunk:chunk bytes:mappedEntryAddress length:(size_t) _entryLength];

為什么要進行頁對齊?因為對于磁盤來講,磁盤中的字節塊大小就是頁,因為分頁就是磁盤和物理內存的存儲方式,這樣做就可以節省讀取 entryData時CPU周期, 這是跟字節對齊一樣的道理。

通過_imageRowLength來創建位圖,由于圖像字節塊已經對齊, 避免 CA::Render::copy_image 圖像數據的拷貝發生了.

// 創建 CGDataProviderRef 用于圖像上下文創建,提供圖像數據和數據結構的 Release 函數
CGDataProviderRef dataProvider = CGDataProviderCreateWithData((__bridge_retained void *)entryData, [entryData bytes], [entryData imageLength], _FICReleaseImageData);

CGSize pixelSize = [_imageFormat pixelSize]; // 想要展示的圖片大小
CGBitmapInfo bitmapInfo = [_imageFormat bitmapInfo]; // 位圖數據的信息,例如是大小端,計算位數等
NSInteger bitsPerComponent = [_imageFormat bitsPerComponent]; // 每個組成的位數,32位RGBA、RGB和8位Gray都為8bit,而16位的RGB為5bit
NSInteger bitsPerPixel = [_imageFormat bytesPerPixel] * 8; // bit每個像素
CGColorSpaceRef colorSpace = [_imageFormat isGrayscale] ? CGColorSpaceCreateDeviceGray() : CGColorSpaceCreateDeviceRGB();
 
CGImageRef imageRef = CGImageCreate((size_t) pixelSize.width, (size_t) pixelSize.height, (size_t) bitsPerComponent, (size_t) bitsPerPixel,(size_t) _imageRowLength, colorSpace, bitmapInfo, dataProvider, NULL, false, (CGColorRenderingIntent)0);
CGDataProviderRelease(dataProvider);
CGColorSpaceRelease(colorSpace);

文件讀取,資源消耗?

當我們從磁盤當中獲取圖像數據時,必須調用read()函數來從磁盤中讀取圖像字節數據,那么一起看看 read() 函數的調用過程:

圖3-1


image.png

圖3-1展示了當應用程序調用read()函數的時候:

  1. CPU 接受到中斷信號,進入了內核模式。

  2. 內核模式中利用內核模式程序訪問高速緩存(Cache),查看高速緩存是否存在圖像數據,如果又則返回,沒有則繼續訪問物理內存。

  3. 內核模式程序讀取物理內存,查看是否存在對應的物理頁(由于磁盤跟物理內存的存儲方式都是以分頁的方式劃分數據塊的,通常64位系統為 4KB/頁),如果存在則將物理頁數據,則返回相對應圖像數據的物理頁沒有則發生異常行為(頁錯誤)。

  4. ****缺頁異常處理程序****訪問磁盤,找到磁盤中對應的圖像數據加載成磁盤頁, 并磁盤頁作為新頁替換物理內存中的物理頁,然后將物理頁數據作為字節塊緩存到高速緩存當中

  5. 異常處理程序發出中斷信號將控制返回內核程序,內核程序再次加載高速緩存字節塊返回放置內核緩沖區

  6. 由于內核程序跟用戶程序的內存地址空間是完全不同的,所以對于虛擬內存來講,內核程序要降內核緩沖區中的數據字節進行一次拷貝,才能返回給用戶程序.

(PS: 用戶程序跟內核程序的概念中,可能兩者都為同一程序,只是由于CPU切換模式而轉換,也有可能是兩個不同的程序)

CPU 讀取內存頁過程:


image.png

當然,上面的分析是針對邏輯層面跟硬件層面的講解,實際上軟件層面上,對于一次磁盤請求如下:

圖3-2顯示了 read 系統調用在核心空間中所要經歷的層次模型。從圖中看出, 對于磁盤的一次讀請求:

  • 首先經過虛擬文件系統層(vfs layer)
  • 其次是具體的文件系統層(例如 ext2)
  • 接下來是 cache 層(page cache 層)
  • 通用塊層(generic block layer)
  • IO 調度層(I/O scheduler layer)
  • 塊設備驅動層(block device driver layer)
  • 最后是物理塊設備層(block device layer)

圖3-2 read 系統調用在核心空間中的處理層次


image.png

(對于這部分目前先留個懸念,以后分享在詳細講解文集系統)

通過上面對read()函數的分析,我們知道從磁盤中讀取一次文件的操作是非常繁碎而且非常消耗資源的(特別是大文件),而且由于物理內存和高速緩存的資源是有限的,當我們不再訪問圖像數據的時候,圖像數據就會被當做犧牲頁換出物理內存和高速緩存,當我們應用程序后面再次read()訪問的時候,還得再次重新走上述流程。

那么這個時候我們可以怎么優化來加快我們對圖片的IO呢?

如何優化?

對于我們iOS這種封閉系統來講,優化手段其實很有限,因為我們不能直接操作內核,但是在是不是就無法優化呢?而操作系統為我們提供了一個用戶級的內核函數,mmap/ummap,這是一個實現內存映射做法,那么內存映射能為我們讀寫文件的操作帶來什么?

答案就是優化了上述流程的 1 和 6 中所產生的內存拷貝過程,我們首先來看看內存映射是什么。

操作系統通過將一個虛擬內存區域與一個磁盤上的對象(object)關聯起來,以初始化這個虛擬內存區域的內容,這個過程稱為內存映射(memory mapping).

下圖展示了內存映射的做法:


image.png

下圖展示了內存映射區域在進程中的位置:


image.png

當磁盤文件通過內存映射到應用程序的時候,是直接跟用戶空間的地址相關聯的,也就是說當我們讀取磁盤文件數據的時候,CPU 不用在切換用戶空間和內核空間,隨之****字節拷貝也不會再發生,所有讀取操作都能在用戶空間中進行****。

好了,說了這么多,具體做法怎么做呢?

在 FastImageCache 當中,創建 Chunk 直接文件中的對一個 Chunk 內存區域部分進行內存映射:


// FICImageTableChunk.m

- (instancetype)initWithFileDescriptor:(int)fileDescriptor index:(NSInteger)index length:(size_t)length {

 self = [super init];

 if (self != nil) {

 _index = index;

 _length = length;

 _fileOffset = _index * _length;

  // 通過內存映射設置為共享內存文件

 _bytes = mmap(NULL, _length, (PROT_READ|PROT_WRITE), (MAP_FILE|MAP_SHARED), fileDescriptor, _fileOffset);

 if (_bytes == MAP_FAILED) {

 NSLog(@"Failed to map chunk. errno=%d", errno);

 _bytes = NULL;

 self = nil;

 }

 }

  return  self;

}

這里就有一個疑問了,通過FastImageCache 架構分析文章中知道,一個圖片文件應該對應為一個Entry才對呀,為什么現在內存映射要映射Chunk呢?

因為內存映射文件越大越有效果呀,不然小數據通過read()函數直接進入內核拷貝字節這種做法就跟內存映射沒有對比了。

為了讓映射文件越大, FastImageCache 甚至直接在存儲圖片的時候就直接降圖片解碼了:


- (void)setEntryForEntityUUID:(NSString *)entityUUID sourceImageUUID:(NSString *)sourceImageUUID imageDrawingBlock:(FICEntityImageDrawingBlock)imageDrawingBlock {

 if (entityUUID != nil && sourceImageUUID != nil && imageDrawingBlock != NULL) {

 [_lock lock]; // 遞歸鎖

  // 創建 Entry

 NSInteger newEntryIndex = [self _indexOfEntryForEntityUUID:entityUUID];

 if (newEntryIndex == NSNotFound) {

 newEntryIndex = [self _nextEntryIndex];

 if (newEntryIndex >= _entryCount) {

  // Determine how many chunks we need to support new entry index.

  // Number of entries should always be a multiple of _entriesPerChunk

 NSInteger numberOfEntriesRequired = newEntryIndex + 1;

 NSInteger newChunkCount = _entriesPerChunk > 0 ? ((numberOfEntriesRequired + _entriesPerChunk - 1) / _entriesPerChunk) : 0; 

 NSInteger newEntryCount = newChunkCount * _entriesPerChunk;

 [self _setEntryCount:newEntryCount]; 

 }

 }

 if (newEntryIndex < _entryCount) {

 CGSize pixelSize = [_imageFormat pixelSize];

 CGBitmapInfo bitmapInfo = [_imageFormat bitmapInfo];

 CGColorSpaceRef colorSpace = [_imageFormat isGrayscale] ? CGColorSpaceCreateDeviceGray() : CGColorSpaceCreateDeviceRGB();

 NSInteger bitsPerComponent = [_imageFormat bitsPerComponent];

  // Create context whose backing store *is* the mapped file data

 FICImageTableEntry *entryData = [self _entryDataAtIndex:newEntryIndex]; // 創建內存映射區域

 if (entryData != nil) {

 [entryData setEntityUUIDBytes:FICUUIDBytesWithString(entityUUID)];

 [entryData setSourceImageUUIDBytes:FICUUIDBytesWithString(sourceImageUUID)];

  // Update our book-keeping

 _indexMap[entityUUID] = @((NSUInteger) newEntryIndex);

 [_occupiedIndexes addIndex:(NSUInteger) newEntryIndex];

 _sourceImageMap[entityUUID] = sourceImageUUID;

// 用于內存最近使用策略來裝載和釋放內存

 [self _entryWasAccessedWithEntityUUID:entityUUID];

 [self saveMetadata];

  // Unique, unchanging pointer for this entry's index

 NSNumber *indexNumber = [self _numberForEntryAtIndex:newEntryIndex];

  // Relinquish the image table lock before calling potentially slow imageDrawingBlock to unblock other FIC operations

 [_lock unlock];

  // 利用創建位圖,將圖圖象數據draw到位圖當中,然后再保存位圖字節數據

 CGContextRef context = CGBitmapContextCreate([entryData bytes], (size_t) pixelSize.width, (size_t) pixelSize.height,

 (size_t) bitsPerComponent, (size_t) _imageRowLength, colorSpace, bitmapInfo);

 CGContextTranslateCTM(context, 0, pixelSize.height);

 CGContextScaleCTM(context, _screenScale, -_screenScale);

 @synchronized(indexNumber) {

  // Call drawing block to allow client to draw into the context

 // 解碼

 imageDrawingBlock(context, [_imageFormat imageSize]);

 CGContextRelease(context);

  // Write the data back to the filesystem

 [entryData flush];

 }

 } else {

 [_lock unlock];

 }

 CGColorSpaceRelease(colorSpace);

 } else {

 [_lock unlock];

 }

 }

}

這里涉及到了遞歸鎖,主要是防止多次調用lock造成死鎖,有機會再次跟大家分享遞歸鎖的神奇用法。

代碼中-_entryDataAtIndex方法便創建了Chunk,但此時Chunk并沒有數據,只是做了一個文件的映射區域.

利用-_indexOfEntryForEntityUUID創建了Entry, 分配了圖像所需的字節和UUID等metaData所需的字節內存空間。然后我們使用-CGBitmapContextCreate利用內存空間創建位圖,通過-imageDrawingBlock將圖片字節全部draw到位圖,然后通過Entry flush 就圖像數據同步到磁盤當中, 這就完成了圖片的存儲了。

存在的問題

但是內存映射是不是就沒有缺陷呢?

通過上文學習我們知道,內存映射是直接對應這虛擬內存區域的,也就是說是占用這我們虛擬內存的地址空間的,而且是一塊常駐內存,那么當映射內存非常大的時候,甚至會影響我們程序的堆內存創建反而導致性能更差。所以 FastImageCahce 中甚至給Entry做了內存限制,一個Entry只能存儲兩M的數據.


NSInteger goalChunkLength = 2 * (1024 * 1024);

NSInteger goalEntriesPerChunk = goalChunkLength / _entryLength;

_entriesPerChunk = (NSUInteger) MAX(4, goalEntriesPerChunk); // 最少也要存在 4Entry/Chunk

_chunkLength = (size_t)(_entryLength * _entriesPerChunk); // Chunk 的內存字節大小

實際大小要跟著圖像的大小改變,但是跟 2M 不會相差太多。

未完待續...(后續學到新方法,會持續更新)

通過上述方法,就能有效的加快我們圖片的文件IO,特別當前我們的女性用戶,手機里面有幾十G圖圖片,當我們要做一個圖片相冊的精美應用的時候,這些性能便不可忽視了。根據摩爾定律,計算機性能****約每隔18-24個月便會增加一倍,性能也將提升一倍****,但是用戶的要求和使用方式也會隨著時間不斷提高的!所以,平常培養對計算機原理的深入理解,才能寫出高性能的代碼,才不會在面對高并發高內存的情況下素手無策。

參考文獻:

《Linux驅動mmap內存映射》

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容