這是 Core Animation 的系列文章,介紹了 Core Animation 的用法纽疟,以及如何進行性能優(yōu)化憾赁。
上一篇文章介紹了 Core Animation 如何進行渲染,可能的性能瓶頸點毁涉,以及如何使用 Instruments 檢測修復锈死。這一篇文章介紹如何優(yōu)化加載、渲染磁盤待牵、網(wǎng)絡中的圖片其屏。
1. 加載和延遲
圖片繪制通常不是最影響性能的部分缨该。圖片很消耗內(nèi)存,因此不太可能把所有需要顯示的圖片都保留在內(nèi)存中蛤袒,app 運行時需不斷加載汗盘、釋放圖片询一。
圖片加載速度不僅受 CPU 制約,還受 IO(Input/Output)影響菱阵。磁盤讀取速度比 RAM 慢很多缩功,需謹慎管理加載嫡锌,減少延遲琳钉。點擊按鈕到收到響應之間最好不超過200ms蛛倦,否則會有卡頓的感覺溯壶。
通常,不能把所有圖片加載到內(nèi)存中验烧。例如又跛,輪播的圖片可能有很多張效扫,都加載到內(nèi)存中太占用內(nèi)存直砂。此外,很多時候圖片來自網(wǎng)絡济丘,下載會很耗時摹迷,甚至失敗郊供。
1.1 加載線程
上一篇文章的圖片很小,直接在主線程進行了加載鲫寄。如果直接在主線程加載大圖疯淫,會導致主界面失去響應熙掺。滑動動畫在主線程的UITrackingRunLoopMode
執(zhí)行蜡秽,比在 render tree 執(zhí)行的CAAnimation
更容易出現(xiàn)卡頓。
下面代碼使用UICollectionView實現(xiàn)了圖片播放肌似,在collectionView(_:cellForItemAt:)
方法中使用主線程同步加載圖片川队。如下所示:
class ImageIOViewController: BaseViewController {
let flowLayout: UICollectionViewFlowLayout = {
let flowLayout = UICollectionViewFlowLayout()
flowLayout.scrollDirection = .horizontal
flowLayout.itemSize = UIScreen.main.bounds.size
return flowLayout
}()
var collectionView: UICollectionView?
private let cellIdentifier = "cellIdentifier"
var imagePaths = [String]()
override func viewDidLoad() {
super.viewDidLoad()
imagePaths = Bundle.main.paths(forResourcesOfType: "png", inDirectory: "Vacation Photos")
collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)
collectionView?.register(UICollectionViewCell.self, forCellWithReuseIdentifier: cellIdentifier)
collectionView?.dataSource = self
view.addSubview(collectionView!)
collectionView?.translatesAutoresizingMaskIntoConstraints = false
collectionView?.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
collectionView?.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
collectionView?.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
collectionView?.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
}
}
extension ImageIOViewController: UICollectionViewDataSource {
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return imagePaths.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier, for: indexPath)
// Add image view
let imageTag = 99
var imageView = cell.viewWithTag(imageTag) as? UIImageView
if imageView == nil {
imageView = UIImageView(frame: cell.contentView.bounds)
imageView!.tag = imageTag
cell.contentView.addSubview(imageView!)
}
// Set image
let imagePath = imagePaths[indexPath.item]
imageView?.image = UIImage(contentsOfFile: imagePath)
return cell
}
}
運行后如下:
使用 Instruments 分析可以發(fā)現(xiàn)瓶頸在于collectionView(_:cellForItemAt:)
中的UIImage(contentsOfFile: )
方法,如下所示:
把加載圖片任務轉(zhuǎn)移到其他線程斗躏,這樣主線程就可以處理交互任務昔脯,滑動時就不會出現(xiàn)卡頓。
使用后臺線程加載圖片雖然可以解決卡頓問題云稚,但圖片加載時長并沒有減少静陈,甚至可能因為后臺線程優(yōu)先級低圖片加載時長變長,但可以充分發(fā)揮設備多核優(yōu)勢拐格。點擊多線程簡述了解更多刑赶。
可以使用 GCD撞叨、OperationQueue等多線程方案后臺加載圖片,也可以使用CATiledLayer
異步加載圖片谒所。如果是網(wǎng)絡圖片劣领,可以使用URLSession異步加載。
1.2 GCD
在collectionView(_:cellForItemAt:)
方法中使用GCD的后臺線程加載圖片奕锌,圖片加載完成后切換至主線程更新UI。UICollectionView
會回收復用 cell饼丘,圖片加載完成時 cell 可能已經(jīng)被復用肄鸽。為避免圖片顯示到錯誤 cell油啤,顯示圖片前為視圖添加 tag,設置圖片前檢查 tag 是否存在逮诲。更新后如下:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier, for: indexPath)
// Add image view
let imageTag = 99
var imageView = cell.viewWithTag(imageTag) as? UIImageView
if imageView == nil {
imageView = UIImageView(frame: cell.contentView.bounds)
imageView!.tag = imageTag
cell.contentView.addSubview(imageView!)
}
// Tag cell with index and clear current image
cell.tag = indexPath.row
imageView?.image = nil
// Switch to background thread
DispatchQueue.global(qos: .utility).async {
// Load image
let idx = indexPath.item
let imagePath = self.imagePaths[idx]
let img = UIImage(contentsOfFile: imagePath)
// Set image on main thread, but only if index still matches up
DispatchQueue.main.async {
if idx == cell.tag {
imageView?.image = img
}
}
}
return cell
}
再次使用 Instruments 分析梅鹦,已經(jīng)看不到UIImage(contentsOfFile:)
方法了齐唆。性能有了一定程度提升蒿讥,但仍有掉幀抛腕。
1.3 延遲解碼
圖片加載后還需解碼担敌,解碼過程可能需要復雜計算,會耗費一定時間马昙。解碼后的圖片也會占用更多內(nèi)存資源刹悴。
加載和解碼所需 CPU 時間因圖片格式而異土匀。png 文件大,加載時間長证杭,但解碼速度快,Xcode 會對項目內(nèi) png 圖片進行壓縮優(yōu)化镇饺。JPEG 文件小送讲,加載快哼鬓,但因編碼算法復雜,解碼速度慢秸侣。
為了減少內(nèi)存占用宠互,系統(tǒng)會延遲解碼予跌,直到需要繪制時才解碼,但這樣也容易引起性能問題频轿。
有以下三種立即解碼的方案:
- 使用
UIImage(named:)
方法加載圖片烁焙。與UIImage(contentsOfFile:)
不同骄蝇,UIImage(named:)
加載后立即解碼。UIImage(named:)
加載圖片有以下特點:-
UIImage(named:)
只加載 bundle 內(nèi)圖片赚窃,不適用于用戶生成的岔激、網(wǎng)絡獲取的圖片虑鼎。 -
UIImage(named:)
方法會自動緩存圖片冀惭,后續(xù)使用時直接從內(nèi)存讀取散休。系統(tǒng)的按鈕乐尊、背景等都是使用UIImage(named:)
方法加載的圖片。如果使用UIImage(named:)
加載大圖限府,系統(tǒng)可能移除界面控件圖片緩存胁勺,當導航回這些界面時独旷,需重新加載這些圖片嵌洼。使用單獨緩存可以解耦圖片緩存與 app 生命周期。 -
UIImage(named:)
緩存是私有的褐啡,不能查詢圖片是否在緩存中鳖昌,也不能從緩存中移除不再使用的圖片许昨。
-
- 為圖層的
contents
屬性設置圖片,或為UIImageview
的image
屬性賦值允粤,但這些操作都必須在主線程進行翼岁,也就是不能用來解決性能問題琅坡。 - 繞過
UIKit
框架残家,直接使用ImageIO
框架。 - 使用
UIKit
框架加載圖片陪捷,加載后立即在CGContext
中繪制诺擅。
使用ImageIO
解碼方案如下:
// Load image
let idx = indexPath.item
let imageUrl = URL(fileURLWithPath: self.imagePaths[idx])
let options = [kCGImageSourceShouldCache : true]
let source = CGImageSourceCreateWithURL(imageUrl as CFURL, nil)
var img = UIImage()
if source != nil {
let imageRef = CGImageSourceCreateImageAtIndex(source!, 0, options as CFDictionary)
if imageRef != nil {
img = UIImage(cgImage: imageRef!)
}
}
kCGImageSourceShouldCache
選項會緩存解碼的圖片烁涌,直到圖片被銷毀。
圖片繪制前必須解碼微峰,繪制可以像加載一樣在后臺線程進行蜓肆,避免堵塞主線程谋币。為了實現(xiàn)立即解碼瑞信,有以下兩種方案:
- 在一像素的
CGContext
中繪制一像素的圖片。這樣繪制速度極快逼友,也會對整個圖片進行解碼秤涩。缺點在于繪制不能針對設備優(yōu)化筐眷,后續(xù)繪制時耗時可能較長。系統(tǒng)為了優(yōu)化內(nèi)存可能釋放掉已解碼的圖片照棋。 - 把整張圖片繪制到
CGContext
烈炭,使用 context 的 content 替換原始圖片宝恶。這會比繪制一像素耗費性能,但繪制會針對設備進行優(yōu)化霹疫。由于原始圖片已經(jīng)被舍棄丽蝎,系統(tǒng)無法決定是否釋放掉內(nèi)存中解碼的圖片征峦。
系統(tǒng)并不推薦使用這些技巧立即解碼圖片,但如果你的 app 有大量圖片类腮,你可能用得到這些技巧蛉加。
如果圖片顯示大小與自身大小不一致针饥,使用后臺線程繪制為顯示大小更有利于性能,而非每次顯示時進行縮放筷凤。更新collectionView(_:cellForItemAt:)
方法藐守,顯示圖片前進行重繪蹂风,更新后如下:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
...
// Switch to background thread
DispatchQueue.global(qos: .utility).async {
// Load image
let idx = indexPath.item
let imagePath = self.imagePaths[idx]
var img = UIImage(contentsOfFile: imagePath)
// Redraw image using device context
UIGraphicsBeginImageContextWithOptions(imgSize ?? CGSize.zero, true, 0)
img?.draw(in: imgBounds ?? CGRect.zero)
img = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
// Set image on main thread, but only if index still matches up
DispatchQueue.main.async {
if idx == cell.tag {
imageView?.image = img
}
}
}
return cell
}
再次滑動 collection view惠啄,滑動已經(jīng)非常流暢撵渡。
2. NSCache
創(chuàng)建自定義的緩存系統(tǒng)是必要的。創(chuàng)建自定義緩存涉及以下幾方面:
- 選擇緩存鍵丹鸿。緩存鍵用來標記緩存中的資源靠欢。在上述示例中铜跑,可以使用圖片名稱锅纺、cell index作為 key。
- 提前緩存坦弟。如果加載官地、生成數(shù)據(jù)比較慢岁疼,可以在使用前提前加載缎岗。在上述示例中莺褒,可以根據(jù)當前滑動位置遵岩、方向判斷將要顯示哪個圖片巡通,提前加載扁达。
- 緩存失效。緩存的圖片更新后炉旷,如何更新緩存中圖片窘行?可以在緩存圖片時添加時間戳图仓,提取時比較文件修改日期救崔。
- 緩存回收捏顺。緩存將要耗盡時幅骄,先移除哪些緩存本今?這需要一套算法冠息,根據(jù)使用頻率、加載耗費資源等決定躏碳。幸運的是 Apple 提供的
NSCache
類已經(jīng)自動處理了這些問題唐断。
NSCache
與NSDictionary
有很多相似之處脸甘,可以通過setObject(_:forKey:)
偏灿、object(forKey:)
方法設置翁垂、獲取緩存中的資源,但NSCache
在內(nèi)存不足時會自動釋放緩存中資源枚荣。
NSCache
文檔并沒有介紹管理緩存的算法,但可以使用countLimit
設置緩存對象數(shù)量上限啼肩,使用totalCostLimit
設置緩存大小限制橄妆。
NSCache
是通用緩存解決方案。雖然我們可以創(chuàng)建自定義類祈坠,針對滑動圖片進行優(yōu)化害碾,但NSCache
已經(jīng)能夠滿足當前需求,無需過早優(yōu)化赦拘。
下面代碼使用NSCache
提前加載圖片慌随,查看滾動效果是否更好。
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier, for: indexPath)
var imageView = cell.contentView.subviews.last as? UIImageView
if imageView == nil {
imageView = UIImageView(frame: cell.contentView.bounds)
imageView?.contentMode = .scaleAspectFit
cell.contentView.addSubview(imageView!)
}
// Set or load iamge for this index
imageView?.image = loadImage(at: indexPath.item)
// Preload image for previous and next index.
if indexPath.item < self.imagePaths.count - 1 {
loadImage(at: indexPath.item + 1)
}
if indexPath.item > 0 {
loadImage(at: indexPath.item - 1)
}
return cell
}
@discardableResult
func loadImage(at index: Int) -> UIImage? {
let image: UIImage? = cache.object(forKey: CustomKey(int: index, string: String(index))) as? UIImage
if image != nil {
if image!.isKind(of: NSNull.self) {
return nil
} else {
return image
}
}
// Set placeholder to avoid reloading image multiple times
cache.setObject(NSNull.self, forKey: CustomKey(int: index, string: String(index)))
// Switch to background thread
DispatchQueue.global().async {
// Load Image
let imagePath = self.imagePaths[index]
var image = UIImage(contentsOfFile: imagePath)
// Redraw image using device context
UIGraphicsBeginImageContextWithOptions(image?.size ?? CGSize.zero, true, 0)
image?.draw(at: CGPoint.zero)
image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
// Set image for correct image view
DispatchQueue.main.async {
self.cache.setObject(image ?? NSNull.self, forKey: CustomKey(int: index, string: String(index)))
// Display the image
let indexPath = NSIndexPath(item: index, section: 0) as IndexPath
let cell = self.collectionView?.cellForItem(at: indexPath)
let imageView = cell?.contentView.subviews.last as? UIImageView
imageView?.image = image
}
}
return nil
}
再次滑動 collection view阁猜,效果非常好丸逸。這里提前加載邏輯非常粗暴,其實還可以把滑動方向和速度考慮進來蹦漠。
總結(jié)
這篇文章介紹了圖片加載椭员、解碼可能涉及到的性能問題车海,并提供了一些解決方案笛园。下一篇文章圖層性能之離屏渲染、柵格化侍芝、回收池將介紹圖層渲染和圖層組合相關的性能問題研铆。
Demo名稱:CoreAnimation
源碼地址:https://github.com/pro648/BasicDemos-iOS/tree/master/CoreAnimation
歡迎更多指正:https://github.com/pro648/tips
本文地址:https://github.com/pro648/tips/blob/master/sources/圖像IO之圖片加載棵红、解碼,緩存.md