FastImageCache是Path團隊開發的一個開源庫,用於提升圖片的加載和渲染速度,讓基於圖片的列表滑動起來更順暢,來看看它是怎麼做的。
優化點
iOS從磁盤加載一張圖片,使用UIImageVIew顯示在屏幕上,需要經過以下步驟:
從磁盤拷貝數據到內核緩沖區
從內核緩沖區復制數據到用戶空間
生成UIImageView,把圖像數據賦值給UIImageView
如果圖像數據為未解碼的PNG/JPG,解碼為位圖數據
CATransaction捕獲到UIImageView layer樹的變化
主線程Runloop提交CATransaction,開始進行圖像渲染
6.1 如果數據沒有字節對齊,Core Animation會再拷貝一份數據,進行字節對齊。
6.2 GPU處理位圖數據,進行渲染。
FastImageCache分別優化了2,4,6.1三個步驟:
使用mmap內存映射,省去了上述第2步數據從內核空間拷貝到用戶空間的操作。
緩存解碼後的位圖數據到磁盤,下次從磁盤讀取時省去第4步解碼的操作。
生成字節對齊的數據,防止上述第6.1步CoreAnimation在渲染時再拷貝一份數據。
接下來具體介紹這三個優化點以及它的實現。
內存映射
平常我們讀取磁盤上的一個文件,上層API調用到最後會使用系統方法read()讀取數據,內核把磁盤數據讀入內核緩沖區,用戶再從內核緩沖區讀取數據復制到用戶內存空間,這裡有一次內存拷貝的時間消耗,並且讀取後整個文件數據就已經存在於用戶內存中,占用了進程的內存空間。
FastImageCache采用了另一種讀寫文件的方法,就是用mmap把文件映射到用戶空間裡的虛擬內存,文件中的位置在虛擬內存中有了對應的地址,可以像操作內存一樣操作這個文件,相當於已經把整個文件放入內存,但在真正使用到這些數據前卻不會消耗物理內存,也不會有讀寫磁盤的操作,只有真正使用這些數據時,也就是圖像准備渲染在屏幕上時,虛擬內存管理系統VMS才根據缺頁加載的機制從磁盤加載對應的數據塊到物理內存,再進行渲染。這樣的文件讀寫文件方式少了數據從內核緩存到用戶空間的拷貝,效率很高。
解碼圖像
一般我們使用的圖像是JPG/PNG,這些圖像數據不是位圖,而是是經過編碼壓縮後的數據,使用它渲染到屏幕之前需要進行解碼轉成位圖數據,這個解碼操作是比較耗時的,並且沒有GPU硬解碼,只能通過CPU,iOS默認會在主線程對圖像進行解碼。很多庫都解決了圖像解碼的問題,不過由於解碼後的圖像太大,一般不會緩存到磁盤,SDWebImage的做法是把解碼操作從主線程移到子線程,讓耗時的解碼操作不占用主線程的時間。
FastImageCache也是在子線程解碼圖像,不同的是它會緩存解碼後的圖像到磁盤。因為解碼後的圖像體積很大,FastImageCache對這些圖像數據做了系列緩存管理,詳見下文實現部分。另外緩存的圖像體積大也是使用內存映射讀取文件的原因,小文件使用內存映射無優勢,內存拷貝的量少,拷貝後占用用戶內存也不高,文件越大內存映射優勢越大。
字節對齊
Core Animation在圖像數據非字節對齊的情況下渲染前會先拷貝一份圖像數據,官方文檔沒有對這次拷貝行為作說明,模擬器和Instrument裡有高亮顯示“copied images”的功能,但似乎它有bug,即使某張圖片沒有被高亮顯示出渲染時被copy,從調用堆棧上也還是能看到調用了CA::Render::copy_image方法:
那什麼是字節對齊呢,按我的理解,為了性能,底層渲染圖像時不是一個像素一個像素渲染,而是一塊一塊渲染,數據是一塊塊地取,就可能遇到這一塊連續的內存數據裡結尾的數據不是圖像的內容,是內存裡其他的數據,可能越界讀取導致一些奇怪的東西混入,所以在渲染之前CoreAnimation要把數據拷貝一份進行處理,確保每一塊都是圖像數據,對於不足一塊的數據置空。大致圖示:(pixel是圖像像素數據,data是內存裡其他數據)
塊的大小應該是跟CPU cache line有關,ARMv7是32byte,A9是64byte,在A9下CoreAnimation應該是按64byte作為一塊數據去讀取和渲染,讓圖像數據對齊64byte就可以避免CoreAnimation再拷貝一份數據進行修補。FastImageCache做的字節對齊就是這個事情。
實現
FastImageCache把同個類型和尺寸的圖像都放在一個文件裡,根據文件偏移取單張圖片,類似web的css雪碧圖,這裡稱為ImageTable。這樣做主要是為了方便統一管理圖片緩存,控制緩存的大小,整個FastImageCache就是在管理一個個ImageTable的數據。整體實現的數據結構如圖:
一些補充和說明:
ImageTable
一個ImageFormat對應一個ImageTable,ImageFormat指定了ImageTable裡圖像渲染格式/大小等信息,ImageTable裡的圖像數據都由ImageFormat規定了統一的尺寸,每張圖像大小都是一樣的。
一個ImageTable一個實體文件,並有另一個文件保存這個ImageTable的meta信息。
圖像使用entityUUID作為唯一標示符,由用戶定義,通常是圖像url的hash值。ImageTable Meta的indexMap記錄了entityUUID->entryIndex的映射,通過indexMap就可以用圖像的entityUUID找到緩存數據在ImageTable對應的位置。
ImageTableEntry
ImageTable的實體數據是ImageTableEntry,每個entry有兩部分數據,一部分是對齊後的圖像數據,另一部分是meta信息,meta保存這張圖像的UUID和原圖UUID,用於校驗圖像數據的正確性。
Entry數據是按內存分頁大小對齊的,數據大小是內存分頁大小的整數倍,這樣可以保證虛擬內存缺頁加載時使用最少的內存頁加載一張圖像。
圖像數據做了字節對齊處理,CoreAnimation使用時無需再處理拷貝。具體做法是CGBitmapContextCreate創建位圖畫布時bytesPerRow參數傳64倍數。
Chunk
ImageTable和實體數據Entry間多了層Chunk,Chunk是邏輯上的數據劃分,N個Entry作為一個Chunk,內存映射mmap操作是以chunk為單位的,每一個chunk執行一次mmap把這個chunk的內容映射到虛擬內存。為什麼要多一層chunk呢,按我的理解,這樣做是為了靈活控制mmap的大小和調用次數,若對整個ImageTable執行mmap,載入虛擬內存的文件過大,若對每個Entry做mmap,調用次數會太多。
緩存管理
用戶可以定義整個ImageTable裡最大緩存的圖像數量,在有新圖像需要緩存時,如果緩存沒有超過限制,會以chunk為單位擴展文件大小,順序寫下去。如果已超過最大緩存限制,會把最少使用的緩存替換掉,實現方法是每次使用圖像都會把UUID插入到MRUEntries數組的開頭,MRUEntries按最近使用順序排列了圖像UUID,數組裡最後一個圖像就是最少使用的。被替換掉的圖片下次需要再使用時,再走一次取原圖—解壓—存儲的流程。
使用
FastImageCache適合用於tableView裡緩存每個cell上同樣規格的圖像,優點是能極大加快第一次從磁盤加載這些圖像的速度。但它有兩個明顯的缺點:一是占空間大。因為緩存了解碼後的位圖到磁盤,位圖是很大的,寬高100*100的圖像在2x的高清屏設備下就需要200*200*4byte/pixel=156KB,這也是為什麼FastImageCache要大費周章限制緩存大小。二是接口不友好,需預定義好緩存的圖像尺寸。FastImageCache無法像SDWebImage那樣無縫接入UIImageView,使用它需要配置ImageTable,定義好尺寸,手動提供的原圖,每種實體圖像要定義一個FICEntity模型,使邏輯變復雜。
FastImageCache已經屬於極限優化,做圖像加載/渲染優化時應該優先考慮一些低代價高回報的優化點,例如CALayer代替UIImageVIew,減少GPU計算(去透明/像素對齊),圖像子線程解碼,避免Offscreen-Render等。在其他優化都做到位,圖像的渲染還是有性能問題的前提下才考慮使用FastImageCache進一步提升首次加載的性能,不過字節對齊的優化倒是可以脫離FastImageCache直接運用在項目上,只需要在解碼圖像時bitmap畫布的bytesPerRow設為64的倍數即可。