在第13章“高效繪圖”中,我們研究了和Core Graphics
繪圖相關的性能問題,以及如何修復。和繪圖性能相關緊密相關的是圖像性能。在這一章中,我們將研究如何 優化從閃存驅動器或者網絡中加載和顯示圖片。
加載和潛伏
繪圖實際消耗的時間通常并不是影響性能的因素。圖片消耗很大一部分內存,而且不太可能把需要顯示的圖片都保留在內存中,所以需要在應用運行的時候周期性地加載和卸載圖片。
圖片文件加載的速度被CPU
和IO(輸入/輸出)
同時影響。iOS
設備中的閃存已經 比傳統硬盤快很多了,但仍然比RAM
慢將近200
倍左右,這就需要很小心地管理加載,來避免延遲。
只要有可能,試著在程序生命周期不易察覺的時候來加載圖片,例如啟動,或者在屏幕切換的過程中。按下按鈕和按鈕響應事件之間最大的延遲大概是200ms
,這 比動畫每一幀切換的16ms
小得多。你可以在程序首次啟動的時候加載圖片,但是如果20
秒內無法啟動程序的話,iOS
檢測計時器就會終止你的應用(而且如果啟動 大于2,3秒
的話用戶就會抱怨了)。
有些時候,提前加載所有的東西并不明智。比如說包含上千張圖片的圖片傳送帶:用戶希望能夠能夠平滑快速翻動圖片,所以就不可能提前預加載所有圖片;那樣會消耗太多的時間和內存。
有時候圖片也需要從遠程網絡連接中下載,這將會比從磁盤加載要消耗更多的時間,甚至可能由于連接問題而加載失敗(在幾秒鐘嘗試之后)。你不能夠在主線程中加載網絡造成等待,所以需要后臺程。
線程加載
在第12章“性能調優”
我們的聯系人列表例子中,圖片都非常小,所以可以在主線 程同步加載。但是對于大圖來說,這樣做就不太合適了,因為加載會消耗很長時間,造成滑動的不流暢。滑動動畫會在主線程的run loop
中更新,所以會有更多運 行在渲染服務進程中CPU
相關的性能問題。
GCD和 NSOperationQueue
GCD(Grand Central Dispatch)
和NSOperationQueue
很類似,都給我們提供 了隊列閉包塊來在線程中按一定順序來執行。 NSOperationQueue
有一個 Objecive-C
接口(而不是使用GCD
的全局C
函數),同樣在操作優先級和依賴關系 上提供了很好的粒度控制,但是需要更多地設置代碼。
延遲解壓
一旦圖片文件被加載就必須要進行解碼,解碼過程是一個相當復雜的任務,需要消耗非常長的時間。解碼后的圖片將同樣使用相當大的內存。
用于加載的CPU
時間相對于解碼來說根據圖片格式而不同。對于PNG
圖片來說, 加載會比JPEG
更長,因為文件可能更大,但是解碼會相對較快,而且Xcode
會把 PNG
圖片進行解碼優化之后引入工程。JPEG
圖片更小,加載更快,但是解壓的步 驟要消耗更長的時間,因為JPEG
解壓算法比基于zip
的PNG
算法更加復雜。
當加載圖片的時候,iOS
通常會延遲解壓圖片的時間,直到加載到內存之后。這就會在準備繪制圖片的時候影響性能,因為需要在繪制之前進行解壓(通常是消耗 時間的問題所在)。
最簡單的方法就是使用UIImage
的+ imageNamed:
方法避免延時加載。不像+ +imageWithContentsOfFile:
(和其他別的 UIImage
加載方法),這個方法會在加載圖片之后立刻進行解壓(就和本章之前我們談到的好處一樣)。問題在于 +imageNamed:
只對從應用資源束中的圖片有效,所以對用戶生成的圖片內容 或者是下載的圖片就沒法使用了。
另一種立刻加載圖片的方法就是把它設置成圖層內容,或者是UIImageView
的image
屬性。不幸的是,這又需要在主線程執行,所以不會 對性能有所提升。
第三種方式就是繞過 UIKit
,像下面這樣使用ImageIO
框架:
這樣就可以使用 kCGImageSourceShouldCache
來創建圖片,強制圖片立刻解 壓,然后在圖片的生命周期保留解壓后的版本。
最后一種方式就是使用UIKit
加載圖片,但是立刻會知道 CGContext
中去。圖片必須要在繪制之前解壓,所以就強制了解壓的及時性。這樣的好處在于繪制圖片可以在后臺線程(例如加載本身)執行,而不會阻塞UI
。
有兩種方式可以為強制解壓提前渲染圖片:
- 將圖片的一個像素繪制成一個像素大小的
CGContext
。這樣仍然會解壓整張圖片,但是繪制本身并沒有消耗任何時間。這樣的好處在于加載的圖片并不會 在特定的設備上為繪制做優化,所以可以在任何時間點繪制出來。同樣iOS
也 就可以丟棄解壓后的圖片來節省內存了。 - 將整張圖片繪制到
CGContext
中,丟棄原始的圖片,并且用一個從上下文內 容中新的圖片來代替。這樣比繪制單一像素那樣需要更加復雜的計算,但是因 此產生的圖片將會為繪制做優化,而且由于原始壓縮圖片被拋棄了,iOS
就不能夠隨時丟棄任何解壓后的圖片來節省內存了。
需要注意的是蘋果特別推薦了不要使用這些詭計來繞過標準圖片解壓邏輯(所以也是他們選擇用默認處理方式的原因),但是如果你使用很多大圖來構建應用,那如果想提升性能,就只能和系統博弈了。
如果不使用+imageNamed:
,那么把整張圖片繪制到 CGContext
可能是最佳的方式了。盡管你可能認為多余的繪制相較別的解壓技術而言性能不是很高,但是 新創建的圖片(在特定的設備上做過優化)可能比原始圖片繪制的更快。
同樣,如果想顯示圖片到比原始尺寸小的容器中,那么一次性在后臺線程重新繪制到正確的尺寸會比每次顯示的時候都做縮放會更有效(盡管在這個例子中我們加
載的圖片呈現正確的尺寸,所以不需要多余的優化)。
CATiledLayer
如第6章“專用圖層”
中的例子所示,CATiledLayer
可以用來異步加載和顯示大型圖片,而不阻塞用戶輸入。但是我們同樣可以使用 CATiledLayer
在 UICollectionView
中為每個表格創建分離
的CATiledLayer
實例加載傳動器圖片,每個表格僅使用一個圖層。
這樣使用 CATiledLayer
有幾個潛在的弊端:
-
CATiledLayer
的隊列和緩存算法沒有暴露出來,所以我們只能祈禱它能匹配我們的需求 -
CATiledLayer
需要我們每次重繪圖片到CGContext
中,即使它已經解壓縮,而且和我們單元格尺寸一樣(因此可以直接用作圖層內容,而不需要重 繪)。
我們來看看這些弊端有沒有造成不同。
需要解釋幾點:
CATiledLayer
的tileSize
屬性單位是像素,而不是點,所以為了保證瓦片和表格尺寸一致,需要乘以屏幕比例因子。在
- drawLayer: inContext:
方法中,我們需要知道圖層屬于哪一個indexPath
以加載正確的圖片。這里我們利用了CALayer
的KVC
來存儲和檢索任意的值,將圖層和索引打標簽。
結果 CATiledLayer
工作的很好,性能問題解決了,而且和用GCD
實現的代碼 量差不多。僅有一個問題在于圖片加載到屏幕上后有一個明顯的淡入。
我們可以調整CATiledLayer
的fadeDuration
屬性來調整淡入的速度,或者直接將整個漸變移除,但是這并沒有根本性地去除問題:在圖片加載到準備繪制的 時候總會有一個延遲,這將會導致滑動時候新圖片的跳入。這并不是 CATiledLayer
的問題,使用GCD
的版本也有這個問題。
即使使用上述我們討論的所有加載圖片和緩存的技術,有時候仍然會發現實時加載大圖還是有問題。就和13章
中提到的那樣,iPad
上一整個視網膜屏圖片分辨率達 到了2048x1536
,而且會消耗12MB
的RAM
(未壓縮)。第三代iPad
的硬件并不能 支持1/60
秒的幀率加載,解壓和顯示這種圖片。即使用后臺線程加載來避免動畫卡頓,仍然解決不了問題。
我們可以在加載的同時顯示一個占位圖片,但這并沒有根本解決問題,我們可以做到更好。
分辨率交換
視網膜分辨率(根據蘋果市場定義)代表了人的肉眼在正常視角距離能夠分辨的最小像素尺寸。但是這只能應用于靜態像素。當觀察一個移動圖片時,你的眼睛就會對細節不敏感,于是一個低分辨率的圖片和視網膜質量的圖片沒什么區別了。
如果需要快速加載和顯示移動大圖,簡單的辦法就是欺騙人眼,在移動傳送器的 時候顯示一個小圖(或者低分辨率),然后當停止的時候再換成大圖。這意味著我 們需要對每張圖片存儲兩份不同分辨率的副本,但是幸運的是,由于需要同時支持 Retina
和非Retina
設備,本來這就是普遍要做到的。
如果從遠程源或者用戶的相冊加載沒有可用的低分辨率版本圖片,那就可以動態 將大圖繪制到較小的 CGContext
,然后存儲到某處以備復用。
為了做到圖片交換,我們需要利用 UIScrollView
的一些實
現UIScrollViewDelegate
協議的委托方法(和其他類似于UITableView
和UICollectionView
基于滾動視圖的控件一樣):
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate;
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset NS_AVAILABLE_IOS(5_0);
你可以使用這幾個方法來檢測傳送器是否停止滾動,然后加載高分辨率的圖片。只要高分辨率圖片和低分辨率圖片尺寸顏色保持一致,你會很難察覺到替換的過程(確保在同一臺機器使用相同的圖像程序或者腳本生成這些圖片)。
緩存
如果有很多張圖片要顯示,最好不要提前把所有都加載進來,而是應該當移出屏幕之后立刻銷毀。通過選擇性的緩存,你就可以避免來回滾動時圖片重復性的加載了。
緩存其實很簡單:就是存儲昂貴計算后的結果(或者是從閃存或者網絡加載的文 件)在內存中,以便后續使用,這樣訪問起來很快。問題在于緩存本質上是一個權 衡過程 - 為了提升性能而消耗了內存,但是由于內存是一個非常寶貴的資源,所以 不能把所有東西都做緩存。
何時將何物做緩存(做多久)并不總是很明顯。幸運的是,大多情況下,iOS都 為我們做好了圖片的緩存。
+imageNamed: 方法
之前我們提到使用[UIImage imageName:]
加載圖片有個好處在于可以立刻解 壓圖片而不用等到繪制的時候。但是[UIImage imageName:]
方法有另一個非常 顯著的好處:它在內存中自動緩存了解壓后的圖片,即使你自己沒有保留對它的任何引用。
對于iOS
應用那些主要的圖片(例如圖標,按鈕和背景圖片),使用[UIImage imageNamed:]
加載圖片是最簡單最有效的方式。在nib
文件中引用的圖片同樣也是這個機制,所以你很多時候都在隱式的使用它。
但是[UIImage imageNamed:]
并不適用任何情況。它為用戶界面做了優化,但是并不是對應用程序需要顯示的所有類型的圖片都適用。有些時候你還是要實現自 己的緩存機制,原因如下:
[UIImage imageNamed:]
方法僅僅適用于在應用程序資源束目錄下的圖片, 但是大多數應用的許多圖片都要從網絡或者是用戶的相機中獲取,所以[UIImage imageNamed:]
就沒法用了。[UIImage imageNamed:]
緩存用來存儲應用界面的圖片(按鈕,背景等 等)。如果對照片這種大圖也用這種緩存,那么iOS
系統就很可能會移除這些圖片來節省內存。那么在切換頁面時性能就會下降,因為這些圖片都需要重新加載。對傳送器的圖片使用一個單獨的緩存機制就可以把它和應用圖片的生命周期解耦。[UIImage imageNamed:]
緩存機制并不是公開的,所以你不能很好地控制 它。例如,你沒法做到檢測圖片是否在加載之前就做了緩存,不能夠設置緩存 大小,當圖片沒用的時候也不能把它從緩存中移除。
自定義緩存
構建一個所謂的緩存系統非常困難。菲爾 卡爾頓曾經說過:“在計算機科學中只 有兩件難事:緩存和命名”。
如果要寫自己的圖片緩存的話,那該如何實現呢?讓我們來看看要涉及哪些方面:
選擇一個合適的緩存鍵 - 緩存鍵用來做圖片的唯一標識。如果實時創建圖片, 通常不太好生成一個字符串來區分別的圖片。在我們的圖片傳送帶例子中就很 簡單,我們可以用圖片的文件名或者表格索引。
提前緩存 - 如果生成和加載數據的代價很大,你可能想當第一次需要用到的時 候再去加載和緩存。提前加載的邏輯是應用內在就有的,但是在我們的例子 中,這也非常好實現,因為對于一個給定的位置和滾動方向,我們就可以精確 地判斷出哪一張圖片將會出現。
緩存失效 - 如果圖片文件發生了變化,怎樣才能通知到緩存更新呢?這是個非 常困難的問題(就像菲爾 卡爾頓提到的),但是幸運的是當從程序資源加載靜 態圖片的時候并不需要考慮這些。對用戶提供的圖片來說(可能會被修改或者 覆蓋),一個比較好的方式就是當圖片緩存的時候打上一個時間戳以便當文件 更新的時候作比較。
緩存回收 - 當內存不夠的時候,如何判斷哪些緩存需要清空呢?這就需要到你 寫一個合適的算法了。幸運的是,對緩存回收的問題,蘋果提供了一個叫做
NSCache
通用的解決方案
NSCache
NSCache
和NSDictionary
類似。你可以通過- setObject:forKey:
和- object:forKey:
方法分別來插入分別來插入,檢索。和字典不同的是,NSCache
在系統低內存的時候自動丟棄存儲的對象。
NSCache
用來判斷何時丟棄對象的算法并沒有在文檔中給出,但是你可以使用- setCountLimit :
方法設置緩存大小,以及 -setObject:forKey:cost:
來對每個存儲的對象指定消耗的值來提供一些暗示。
指定消耗數值可以用來指定相對的重建成本。如果對大圖指定一個大的消耗值, 那么緩存就知道這些物體的存儲更加昂貴,于是當有大的性能問題的時候才會丟棄 這些物體。你也可以用- setTotalCostLimit:
方法來指定全體緩存的尺寸。
NSCache
是一個普遍的緩存解決方案,我們創建一個比傳送器案例更好的自定 義的緩存類。(例如,我們可以基于不同的緩存圖片索引和當前中間索引來判斷哪些圖片需要首先被釋放)。但是 NSCache
對我們當前的緩存需求來說已經足夠 了;沒必要過早做優化。
文件格式
圖片加載性能取決于加載大圖的時間和解壓小圖時間的權衡。很多蘋果的文檔都 說PNG
是iOS
所有圖片加載的最好格式。但這是極度誤導的過時信息了。
PNG
圖片使用的無損壓縮算法可以比使用JPEG
的圖片做到更快地解壓,但是由 于閃存訪問的原因,這些加載的時間并沒有什么區別。
相對于不友好的PNG
圖片,相同像素的JPEG
圖片總是比PNG
加載更快,除非一些非常小的圖片、但對于友好的PNG
圖片,一些中大尺寸的圖效果還 是很好的。
所以對于之前的圖片傳送器程序來說,JPEG
會是個不錯的選擇。如果用JPEG
的話,一些多線程和緩存策略都沒必要了。
但JPEG
圖片并不是所有情況都適用。如果圖片需要一些透明效果,或者壓縮之 后細節損耗很多,那就該考慮用別的格式了。蘋果在iOS
系統中對PNG
和JPEG
都 做了一些優化,所以普通情況下都應該用這種格式。也就是說在一些特殊的情況下 才應該使用別的格式。
混合圖片
對于包含透明的圖片來說,最好是使用壓縮透明通道的PNG
圖片和壓縮RGB
部分的JPEG
圖片混合起來加載。這就對任何格式都適用了,而且無論從質量還是文 件尺寸還是加載性能來說都和PNG
和JPEG
的圖片相近。
JPEG 2000
除了JPEG
和PNG
之外iOS
還支持別的一些格式,例如TIFF
和GIF
,但是由于他們 質量壓縮得更厲害,性能比JPEG
和PNG
糟糕的多,所以大多數情況并不用考慮。
但是iOS
之后,蘋果低調添加了對JPEG 2000
圖片格式的支持,所以大多數人并 不知道。它甚至并不被Xcode
很好的支持 - JPEG 2000
圖片都沒在Interface Builder
中顯示。
但是JPEG 2000
圖片在(設備和模擬器)運行時會有效,而且比JPEG
質量更好,同樣也對透明通道有很好的支持。但是JPEG 2000
圖片在加載和顯示圖片方面明顯要比PNG
和JPEG
慢得多,所以對圖片大小比運行效率更敏感的時候,使用它是一個不錯的選擇。
但仍然要對JPEG 2000
保持關注,因為在后續iOS
版本說不定就對它的性能做提升,但是在現階段,混合圖片對更小尺寸和質量的文件性能會更好。
PVRTC
當前市場的每個iOS
設備都使用了Imagination Technologies PowerVR
圖像芯片 作為GPU
。PowerVR
芯片支持一種叫做PVRTC(PowerVR Texture Compression
)的標準圖片壓縮。
和iOS
上可用的大多數圖片格式不同,PVRTC
不用提前解壓就可以被直接繪制到 屏幕上。這意味著在加載圖片之后不需要有解壓操作,所以內存中的圖片比其他圖片格式大大減少了(這取決于壓縮設置,大概只有1/60
那么大)。
但是PVRTC仍然有一些弊端:
盡管加載的時候消耗了更少的
RAM
,PVRTC
文件比JPEG
要大,有時候甚至比PNG
還要大(這取決于具體內容),因為壓縮算法是針對于性能,而不是文件尺寸。PVRTC
必須要是二維正方形,如果源圖片不滿足這些要求,那必須要在轉換成PVRTC
的時候強制拉伸或者填充空白空間。質量并不是很好,尤其是透明圖片。通常看起來更像嚴重壓縮的
JPEG
文件。
-
PVRTC
不能用Core Graphics
繪制,也不能在普通的UIImageView
顯示,也 不能直接用作圖層的內容。你必須要用作OpenGL
紋理加載PVRTC
圖片,然后 映射到一對三角板來在CAEAGLLayer
或者GLKView
中顯示。 - 創建一個
OpenGL
紋理來繪制PVRTC
圖片的開銷相當昂貴。除非你想把所有圖 片繪制到一個相同的上下文,不然這完全不能發揮PVRTC
的優勢。 -
PVRTC
使用了一個不對稱的壓縮算法。盡管它幾乎立即解壓,但是壓縮過程相 當漫長。在一個現代快速的桌面Mac
電腦上,它甚至要消耗一分鐘甚至更多來 生成一個PVRTC
大圖。因此在iOS
設備上最好不要實時生成。
如果你愿意使用OpehGL
,而且即使提前生成圖片也能忍受得了,那么PVRTC
將 會提供相對于別的可用格式來說非常高效的加載性能。比如,可以在主線程1/60
秒 之內加載并顯示一張2048×2048
的PVRTC
圖片(這已經足夠大來填充一個視網膜 屏幕的iPad
了),這就避免了很多使用線程或者緩存等等復雜的技術難度。
總結
在這章中,我們研究了和圖片加載解壓相關的性能問題,并延展了一系列解決方案。