UIImage是用來處理圖像數據的高級類, UIImageView 是 UIKit 提供的用于顯示 UIImage 的類。若采用 MVC 模型進行類比, UIImage 可以看作模型對象( Model ), UIImageView 是一個視圖( View )。它們都肩負著各自的職責:
UIImage負責加載圖片內容, UIImageView 負責顯示和渲染它。
這看似是一個簡單的單向過程,但實際情況卻復雜的多,因為渲染是一個連續的過程,而不是一次性事件。這里還有一個非常關鍵的隱藏階段,對衡量 App 性能至關重要,這個階段被稱為解碼。
圖片解碼
在討論解碼之前,先了解下緩沖區的概念。
緩沖區:是一塊連續的內存區域,用來表示一系列元素組成的內存,這些元素具有相同的尺寸,并通常具有相同的內部結構。
圖像緩沖區:它是一種特定緩沖區,它保存了某些圖像在內存中的表示。此緩沖區的每個元素,描述了圖像中每個像素的顏色和透明度。因此這個緩沖區在內存中的大小與它包含的圖像大小成正比。
幀緩沖區:它保存了 app 中實際渲染后的輸出。因此,當 app 更新其視圖層次結構時, UIKit 將重新渲染 app 的窗口及其所有視圖到幀緩沖區中。幀緩沖區中提供了每個像素的顏色信息,顯示硬件降讀取這些信息用來點亮顯示器上對應的像素。
如果 app 中沒有任何改變,則顯示硬件會從幀緩沖區中取出上次看到的相同數據。但是如果改變了視圖內容,UIKit會重新渲染內容,并將其放入幀緩沖區,下一次顯示硬件從幀緩沖區讀取內容時,就會獲取到新的內容。
數據緩沖區:包含圖像文件的數據緩沖區,通常以某些元數據開頭,這些元數據描述了存儲在數據緩沖區中的圖像大小和圖像數據本身。
下面看下圖像渲染到幀緩沖區的詳細過程:
這塊區域將由圖像視圖進行渲染填充。我們已經為圖像視圖分配一個 UIImage,它有一個表示圖像文件內容的數據緩沖區。我們需要用每個像素的數據來填充幀緩沖區,為了做到這一點,UIImage 將分配一個圖像緩沖區,其大小等于包含在數據緩沖區中的圖像大小,并執行稱為解碼的操作,這就是將 JPEG 或 PNG 或其它編碼的圖像數據轉換為每個像素的圖像信息。然后取決于我們圖像視圖的內容模式,當 UIKit 要求圖像視圖進行渲染時,它會將數據復制到幀緩沖區的過程中對來自圖像緩沖區的數據進行復制和縮放。
解碼階段是 CPU 密集型的,特別是對于大型圖像。因此,不是每次 UIKit 要求圖像視圖渲染時都執行一次這個過程。 UIImage 綁定在圖像緩沖區上,所以它只執行一次這個過程。因此,在你的 app 中,對于每個被解碼的圖像,都可能會持續存在大量的內存分配,這種內存分配與輸入的圖像大小成正比,而與幀緩沖區中實際渲染的圖像視圖大小沒有必然聯系,這會對內存產生相當不利的后果。
減少 CPU 的使用率
我們可以使用一種稱為向下采樣的技術來實現這一目標。
我們可以通過這種下采樣技術來節省一些內存。本質上,我們要做的就是捕捉該縮小操作,并將其放入縮略圖的對象中,最終達到降低內存的目的,因為我們將有一個較小的解碼圖像緩沖區。
這樣,我們設置了一個圖像源,創建了一個縮略圖,然后將解碼緩沖區捕獲到 UIImage 中,并將該 UIImage 分配給我們的圖像視圖。接下來我們就可以丟棄包含圖片數據的數據緩沖區,最終結果就是我們的 app 中將具有一個更小的長期內存占用足跡。
下面看下如何使用代碼來實現這一過程:
- 首先,創建一個 CGImageSource 對象
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)!
KCGImageSourceShouldCache 參數為 false,用來告訴 Core Graphic 框架我們只是在創建一個對象,來表示存儲在該 URL 的文件中的信息,不要立即解碼這個圖像,只需要創建一個表示它的對象,我們需要來自此 URL 的文件信息。
- 然后在水平和垂直軸上進行計算,該計算基于期望的圖片大小以及我們要渲染的像素和點大小:
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as CFDictionary
這里也創建了一個縮略圖選項的字典,最重要的是 CacheImmediately 這個選項,通過這個選項,告訴 Core Graphics,當我們要求你創建縮略圖時,這就是你應該為我創建解碼緩沖區的確切時刻。因此,我們可以確切的控制何時調用 CPU 來進行解碼。
- 最后,我們創建縮略圖,即拿到返回的 CGImage 。
let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)
其完整代碼如下:
func downsample(imageAt imageURL: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage {
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)!
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as CFDictionary
let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)
return UIImage(cgImage: downsampledImage)
}
在 UICollectionView 中的使用
我們可能會在創建單元格時,直接使用下采樣技術來生成的圖片,代碼如下:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! MyCollectionViewCell
cell.layoutIfNeeded()
let imageViewSize = cell.imageView.bounds.size
let scale = collectionView.traitCollection.displayScale
cell.imageView.image = downsample(imageAt: "", to: imageViewSize, scale: scale)
}
這樣確實會減少內存的使用量,但這并不能解決我們的另一個問題。這些問題在可滾動的視圖中是非常常見的。
當我們滾動頁面時,CPU 相對比較空閑或它所做的工作可以在顯示硬件需要幀緩沖的下一個副本之前完成,所以,當幀緩沖被更新時,我們能看到流暢的效果,并且顯示硬件能及時獲得新幀。
但是,如果我們將顯示另一行圖像,將單元格交回 UICollectionView 之前,我們要求 core Graphics 解碼這些圖像,這將會花費很長的 CPU 時間,以至于我們不得不重新渲染幀緩沖區,但顯示器硬件按固定的時間間隔運行,因此,從用戶的角度來看,app 好像卡住了一樣。
這不僅會造成信息粘連,還會有明顯的響應性后果,也對電池壽命有不利的影響。
我們可以使用兩種技術來平滑我們的 CPU 使用率:
第一個是預取,它的基本思想是:預取允許 UICollectionView 告知我們的數據源,它當前不需要一個單元格,但它在不久的將來需要,因此,如果你有任何工作要做,也許現在可以提前開始。這允許我們隨時間的推移,分攤 CPU 的使用率,因此,我們減少了CPU 使用的峰值。
另一種技術是在后臺執行工作,既然我們已經隨時間分散了工作量,我們也可以將這些技術分散到可用的 CPU 上。
這樣做的效果是使你的 app 具有更強的響應性,并且該設備具有更長的電池壽命。
具體代碼如下:
func collectionView(_ collectionView: UICollectionView,
prefetchItemsAt indexPaths: [IndexPath]) {
// Asynchronously decode and downsample every image we are about to show
for indexPath in indexPaths {
DispatchQueue.global(qos: .userInitiated).async {
let downsampledImage = downsample(images[indexPath.row])
DispatchQueue.main.async {
self.update(at: indexPath, with: downsampledImage)
}
}
}
}
我們在全局兵法隊列中來使用下采樣技術,但這里有個潛在的缺陷,就是有可能會引起線程爆炸。當我們要求系統去做比 CPU 能夠做的工作更多的工作時,就會發生這種情況。
為類避免線程爆炸,我們現在不是簡單的將工作分配到全局異步隊列中,而是創建一個串行隊列,并且在預取方法的實現中,異步的將任務分配到該隊列中,實現如下:
let serialQueue = DispatchQueue(label: "Decode queue")
func collectionView(_ collectionView: UICollectionView,
prefetchItemsAt indexPaths: [IndexPath]) {
// Asynchronously decode and downsample every image we are about to show
for indexPath in indexPaths {
serialQueue.async {
let downsampledImage = downsample(images[indexPath.row])
DispatchQueue.main.async { self.update(at: indexPath, with: downsampledImage)
}
}
}