這是 Core Animation 的系列文章,介紹了 Core Animation 的用法,以及如何進(jìn)行性能優(yōu)化。
上一篇文章圖像IO之圖片加載、解碼,緩存介紹了如何高效加載、繪制圖片,避免影響幀率。這篇文章著重介紹圖層樹,以實(shí)現(xiàn)更好性能。
1. 隱式繪制 Inexplicit Drawing
圖層的 backing image 可以使用 Core Graphics 繪制,或?yàn)?code>contents屬性賦值圖片,或在離屏的CGContext
提前渲染。之前的文章已經(jīng)介紹過了這些場景的優(yōu)化,但還可以通過以下三種方式優(yōu)化:
- 修改圖層屬性。
- 使用特殊視圖。
- 使用特定圖層子類。
應(yīng)當(dāng)了解什么情況下使用何種方式,并盡可能避免使用軟件繪制。
1.1 柵格化 Rasterization
在之前的文章中,為了解決半透明圖層重疊、復(fù)雜圖層樹的性能問題,已經(jīng)使用過CALayer
的shouldRasterize
屬性。shouldRasterize
默認(rèn)值為 false。
1.1.1 性能特點(diǎn)
- 當(dāng)被設(shè)置為 true 時,圖層被 GPU 離屏渲染成圖片,圖片會被緩存并用來替換圖層及其子圖層。但首次使用時需時間來生成圖片,并占用額外內(nèi)存。
- 內(nèi)容更新時,再次離屏渲染圖片。
- 緩存大小被限制為2.5倍屏幕大小,
- 緩存超過100ms沒有使用會被自動丟棄。
1.1.2 用途
- 靜態(tài)內(nèi)容想要避免重復(fù)繪制特殊效果,例如圓角、陰影等。因?yàn)橐坏﹥?nèi)容發(fā)生變化(如 resize、動畫),之前處理得到的緩存就失效了。如果頻繁發(fā)生變化,就又回到了每一幀都需要離屏渲染的場景,緩存占用的內(nèi)存只會讓性能變得更糟。
- 想要避免重復(fù)繪制復(fù)雜層級結(jié)構(gòu)。如果有很多子圖層,或子圖層有復(fù)雜顯示效果,柵格化性能遠(yuǎn)高于每幀重繪。
使用 Debug > View Debugging > Rendering > Color Hits Green and Misses Red 檢測是否使用了緩存圖片。如果緩存的圖片需要不斷重新生成,該選項(xiàng)會使用紅色標(biāo)記重繪部分。
2. 離屏渲染 Offscreen Rendering
在屏幕中顯示內(nèi)容時,需一塊至少與屏幕像素?cái)?shù)據(jù)量一樣大的 frame buffer,作為像素?cái)?shù)據(jù)存儲區(qū)域,而這也是 GPU 存儲渲染結(jié)果的地方。如果因某些限制,無法把渲染結(jié)果直接寫入 frame buffer,需先暫存到單獨(dú)的一塊內(nèi)存區(qū)域,之后再寫入 frame buffer,這個過程被稱為離屏渲染 Offscreen Rendering。
渲染結(jié)果先經(jīng)過了離屏 buffer,再到 frame buffer。如下圖所示:
2.1 CPU“離屏渲染”
如果在UIView
中實(shí)現(xiàn)了draw(_:)
方法,即使函數(shù)體內(nèi)部沒有任何代碼,系統(tǒng)也會為其分配一塊內(nèi)存,等待 Core Graphics 可能的繪制操作。
Core Graphics、CoreText 的任何繪制方法,都會分配單獨(dú)內(nèi)存,不會直接繪制到 frame buffer。因?yàn)?CPU 不擅長渲染,我們就會認(rèn)為需要盡量避免離屏渲染,但根據(jù) Apple 工程師的說法,CPU 渲染并非真正意義上的離屏渲染。開啟 Xcode 中的 Color Offscreen-Rendered Yellow 后,使用draw(_:)
繪制的區(qū)域并不會被標(biāo)記為黃色,從另一方面也說明了 Xcode 也不認(rèn)為這屬于離屏渲染。
2.2 畫家算法
渲染工作主要由獨(dú)立進(jìn)程中的 render server 完成。對于每一層 layer,render server 會遵循畫家算法,把各層按照深度排序,由深到淺依次輸出到 frame buffer,后一次覆蓋前一層。
上層會覆蓋底層,被遮蓋部分像素?cái)?shù)據(jù)永久丟失。此時不能通過修改當(dāng)前層的某一部分,讓底下的層重新顯示出來。
如果能在 frame buffer 之外另開啟一塊內(nèi)存,把待處理的 layer 先畫上去,然后在這塊臨時區(qū)里執(zhí)行擦除、修改工作,處理完畢再寫回到 frame buffer,得到最終結(jié)果。雖然這種方法需要額外空間,但得到了更大的靈活性。
2.3 GPU 離屏渲染
上面提到的 frame buffer 之外的內(nèi)存,稱為離屏 buffer,整個過程就是離屏渲染。對于每一層 layer,我們肯定希望找到單次遍歷就能完成渲染的算法,不然就需要申請一塊離屏 buffer,借助臨時中轉(zhuǎn)區(qū)完成一些修改、剪切操作。單獨(dú)開辟離屏 buffer 是一種很昂貴的操作。
離屏渲染并不意味著軟件繪制,但一定是在離屏 context 由 CPU 或 GPU 渲染。以下圖層屬性會引起離屏渲染:
-
cornerRadius
和masksToBounds
一起使用。 -
mask
。 - 陰影。
離屏渲染和開啟光柵化有些像,但離屏渲染開銷并沒有光柵化那么大,子圖層也不受影響,也不會緩存渲染結(jié)果,因此不會產(chǎn)生長期內(nèi)存開銷。太多圖層離屏渲染會明顯影響性能。
2.4 避免離屏渲染方案
如果圖層需要 offscreen rendering,且圖層內(nèi)容沒有變化,可以開啟光柵化優(yōu)化圖層性能;如果圖層需要 offscreen rendering,且圖層內(nèi)容有變化,可以使用CAShapeLayer
、contentsCenter
或shadowPath
實(shí)現(xiàn)相似效果,同時避免了離屏渲染。
2.4.1 CAShapeLayer
cornerRadius
和masksToBounds
本身不會引起性能問題,只有一起使用時才會引起 offscreen rendering。
有時需要顯示圓角矩形并裁減超出邊界的子圖層,但有時不需要裁減圓角,這時使用CAShapeLayer
可以避免離屏渲染問題。
使用UIBezierPath
的init(roundedRect:byRoundingCorners:cornerRadii:)
創(chuàng)建左上角、右下角圓角的矩形:
let blueLayer = CAShapeLayer()
blueLayer.bounds = CGRect(x: 0, y: 0, width: 100, height: 100)
blueLayer.fillColor = UIColor.blue.cgColor
blueLayer.path = UIBezierPath(roundedRect: CGRect(x: 0, y: 0, width: 100, height: 100), byRoundingCorners: [.topLeft, .bottomRight], cornerRadii: CGSize(width: 10, height: 10)).cgPath
view.layer.addSublayer(blueLayer)
2.4.2 拉伸圖片
另一種創(chuàng)建圓角矩形方式是使用圓角圖片,賦值給contents
屬性,并結(jié)合contentsCenter
創(chuàng)建可拉伸圖片。理論上來說,這種方式渲染速度比CAShapeLayer
速度快。一個可拉伸圖片需要18個三角形(一個圖片是由一個3*3網(wǎng)格渲染而成),一條順滑曲線需要很多三角形。
let blueLayer = CAShapeLayer()
blueLayer.bounds = CGRect(x: 0, y: 0, width: 200, height: 200)
blueLayer.contentsCenter = CGRect(x: 0.5, y: 0.5, width: 0.0, height: 0.0)
blueLayer.contentsScale = UIScreen.main.scale
blueLayer.contents = UIImage(named: "Rounded")?.cgImage
view.layer.addSublayer(blueLayer)
拉伸圖片可以創(chuàng)建任意邊框效果,而無需額外開銷。
2.4.3 shadowPath
如果圖層是矩形或圓角矩形,不含部分透明、子圖層,可以很簡單的創(chuàng)建陰影路徑,進(jìn)而簡化 Core Animation 繪制陰影的計(jì)算工作,也避免了離屏渲染。
如果陰影形狀復(fù)雜,使用圖片作為陰影性能可能更好。
2.4.4 Group Opacity
allowsGroupOpacity
屬性是布爾類型,指示是否允許圖層獨(dú)立于父圖層進(jìn)行合成。
allowsGroupOpacity
值為true
、opacity
值小于1.0時,圖層可以獨(dú)立于父圖層進(jìn)行合成,顯示結(jié)果更逼真,特別是圖層包含不透明組件時,但也更耗費(fèi)性能。
默認(rèn)從 bundle 的 Info.plist 文件讀取UIViewGroupOpacity
屬性。如果沒有讀取到,則使用默認(rèn)值 true。
當(dāng)opacity
小于1.0時,會觸發(fā)離屏渲染。設(shè)置為 false 可以避免這一情況。
將一對紅色、藍(lán)色圖層重疊在一起,設(shè)置父圖層opacity
為0.5,并復(fù)制一份作為對比。左側(cè)開啟 group opacity,右側(cè)關(guān)閉。如下所示:
let redLayer1 = CALayer()
redLayer1.frame = CGRect(x: view.bounds.size.width / 2 - 130, y: 100, width: 100, height: 200)
redLayer1.backgroundColor = UIColor.red.cgColor
redLayer1.opacity = 0.5
redLayer1.allowsGroupOpacity = true
view.layer.addSublayer(redLayer1)
let blueLayer1 = CALayer()
blueLayer1.frame = CGRect(x: 0, y: 0, width: 50, height: 100)
blueLayer1.backgroundColor = UIColor.blue.cgColor
blueLayer1.opacity = 0.8
redLayer1.addSublayer(blueLayer1)
// 關(guān)閉 group opacity
let redLayer2 = CALayer()
redLayer2.frame = CGRect(x: view.bounds.size.width / 2 + 30, y: 100, width: 100, height: 200)
redLayer2.backgroundColor = UIColor.red.cgColor
redLayer2.opacity = 0.5
redLayer2.allowsGroupOpacity = false
view.layer.addSublayer(redLayer2)
let blueLayer2 = CALayer()
blueLayer2.frame = CGRect(x: 0, y: 0, width: 50, height: 100)
blueLayer2.backgroundColor = UIColor.blue.cgColor
blueLayer2.opacity = 0.8
redLayer2.addSublayer(blueLayer2)
打開 Color Offscreen-Rendered Yellow 后如下:
可以看到左側(cè)開啟 group opacity 的圖層進(jìn)行了離屏渲染。
如果用不到 group opacity,永遠(yuǎn)設(shè)置為 false。
3. 混合和重繪
GPU 每幀可渲染像素?cái)?shù)量是有限制的,稱為 fill rate。如果由于圖層重疊,導(dǎo)致同一位置重繪多次,可能導(dǎo)致掉幀。
GPU 不會繪制完全被其他圖層覆蓋的像素,但計(jì)算圖層是否被覆蓋會耗費(fèi)處理器資源。只有在必要的時候才使用透明度。下面方案可以提高性能:
- 設(shè)置
backgroundColor
為固定、不透明顏色。 - 設(shè)置視圖的
opaque
為 true。
圖層不透明后,底層圖層不會對顯示效果有任何作用,避免了混合和重繪。
除非特別需要,應(yīng)避免圖片 alpha 透明度。如果圖片顯示在固定背景色、靜態(tài)圖之上,且背景色、靜態(tài)圖不會隨 foreground 移動,應(yīng)填充圖片背景,避免運(yùn)行時混合。
使用UILabel
時,白色或其他純色背景比透明背景更容易渲染。
通過使用shouldRasterize
屬性,可以把靜態(tài)圖層樹壓縮為一張圖片,無需每幀時混合,避免混合、重繪的開銷。
4. 減少圖層數(shù)量
初始化、預(yù)處理、打包并使用 IPC 發(fā)送到 render server,及轉(zhuǎn)換至 OpenGL/Metal 等都會產(chǎn)生開銷,這些限制了屏幕可顯示圖層數(shù)量上限。
上限數(shù)量隨硬件、圖層類型、內(nèi)容、屬性而已,但一般圖層數(shù)量達(dá)到成百上千后,即使圖層沒進(jìn)行任何設(shè)置也會開始產(chǎn)生性能問題。
4.1 3D 圖層矩陣
對圖層進(jìn)行優(yōu)化前,先確保屏幕上不顯示的圖層不要初始化、添加到屏幕中。圖層可能由于以下原因不顯示:
- 位于屏幕、父圖層可見區(qū)域之外。
- 被其他不透明圖層覆蓋。
- 完全透明。
Core Animation 可以很好的處理不可見圖層,但你的代碼能更早(比如創(chuàng)建前)的解決此類問題,進(jìn)而避免初始化、配置圖層的開銷。
下面代碼創(chuàng)建了滑動的3D圖層矩陣,圖層只簡單設(shè)置了顏色。
let width = 10
let height = 10
let depth = 10
let size = 100
let spacing: CGFloat = 150
let cameraDistance: CGFloat = 500.0
var scrollView = UIScrollView()
private func test3DMatrixLayer() {
scrollView.frame = view.bounds
scrollView.backgroundColor = .gray
scrollView.contentSize = CGSize(width: spacing * CGFloat(width - 1), height: CGFloat(height - 1) * spacing)
view.addSubview(scrollView)
var transform = CATransform3DIdentity
transform.m34 = -1.0 / cameraDistance
scrollView.layer.sublayerTransform = transform
// Create layers
for z in 0...(depth-1) {
for y in 0...height {
for x in 0...width {
// Create layer
let layer = CALayer()
layer.frame = CGRect(x: 0, y: 0, width: size, height: size)
layer.position = CGPoint(x: CGFloat(x) * spacing, y: CGFloat(y) * spacing)
layer.zPosition = -CGFloat(z) * spacing
// Set background color
layer.backgroundColor = UIColor(white: 1-CGFloat(z)*(1.0/CGFloat(depth)), alpha: 1.0).cgColor
// Attach to scroll view
scrollView.layer.addSublayer(layer)
}
}
}
print("Displayed:\(depth * height * width)")
}
效果如下:
width、height、depth常量控制圖層數(shù)量。在這個demo中,有10*10*10=1000個圖層,其中幾百個可見。
如果增加width、height常量為100,即共十萬個圖層,app 性能會立即降下來。但屏幕中可見圖層數(shù)量并未改變,也就是并未進(jìn)行額外繪制工作,app 性能下降是由于初始化圖層、計(jì)算圖層位置導(dǎo)致。
由于圖層是均勻分布在網(wǎng)格中,可以計(jì)算出當(dāng)前顯示的圖層,也就沒有必要初始化、計(jì)算不可見圖層位置。
UITableView
和UICollectionView
使用了類似機(jī)制,只初始化當(dāng)前可見的 cell。此外,還會復(fù)用出隊(duì)的cell。
4.2 對象回收 Object Recycling
處理大量視圖、圖層時,可以通過對象回收減少性能消耗。UITableViewCell
、UICollectionViewCell
和MKMapView
的圖釘都用了回收機(jī)制。
Object recycling 的機(jī)制是創(chuàng)建一個相似對象池,當(dāng)某個實(shí)例不再使用時,添加到回收池;當(dāng)需要新實(shí)例時,從回收池取出,如果回收池沒有實(shí)例,則直接創(chuàng)建。
使用回收池可以避免創(chuàng)建、銷毀對象的開銷,并且可以避免為相似實(shí)例重復(fù)賦值。
使用回收池重構(gòu) demo:
let width = 100
let height = 100
let depth = 10
let size: CGFloat = 100.0
let spacing: CGFloat = 150.0
let cameraDistance: CGFloat = 500.0
var scrollView = UIScrollView()
var recyclePool = NSMutableSet()
override func viewWillLayoutSubviews() {
updateLayers()
}
// 創(chuàng)建3D圖層矩陣
private func test3DMatrixLayer() {
scrollView.frame = view.bounds
scrollView.backgroundColor = .gray
scrollView.contentSize = CGSize(width: spacing * CGFloat(width - 1), height: CGFloat(height - 1) * spacing)
scrollView.delegate = self
view.addSubview(scrollView)
var transform = CATransform3DIdentity
transform.m34 = -1.0 / cameraDistance
scrollView.layer.sublayerTransform = transform
}
// MARK: - 循環(huán)使用已創(chuàng)建的圖層
private func perspective(_ z: CGFloat) -> CGFloat {
return cameraDistance / (z + cameraDistance)
}
func updateLayers() {
// Calculate clipping bounds
var bounds = scrollView.bounds
bounds.origin = scrollView.contentOffset
bounds = bounds.insetBy(dx: -size/2, dy: -size/2)
// Add existing layers to pool
recyclePool.addObjects(from: scrollView.layer.sublayers ?? [])
// Disable animation
CATransaction.begin()
CATransaction.setDisableActions(true)
// Create layers
var recycled = 0
var visibleLayers = [CALayer]()
for z in (0...(depth-1)).reversed() {
// Increase bounds size to compensate for perspective
var adjusted = bounds
adjusted.size.width /= perspective(CGFloat(z)*spacing)
adjusted.size.height /= perspective(CGFloat(z)*spacing)
adjusted.origin.x -= (adjusted.size.height - bounds.size.width) / 2
adjusted.origin.y -= (adjusted.size.height - bounds.size.height) / 2
for y in 0...height {
// Check if vertically outside visible rect
if CGFloat(y) * spacing < adjusted.origin.y || CGFloat(y)*spacing >= adjusted.origin.y + adjusted.size.height {
continue
}
for x in 0...width {
// Check if horizontally outside visible rect
if CGFloat(x)*spacing < adjusted.origin.x || CGFloat(x) * spacing >= adjusted.origin.x + adjusted.size.width {
continue
}
// Recycle layer if available
var layer = recyclePool.anyObject() as? CALayer
if layer != nil {
recycled += 1
recyclePool.remove(layer)
} else {
// Otherwise create a new one
layer = CALayer()
layer?.frame = CGRect(x: 0, y: 0, width: size, height: size)
}
// Set position
layer?.position = CGPoint(x: CGFloat(x)*spacing, y: CGFloat(y)*spacing)
layer?.zPosition = -CGFloat(z)*spacing
// Set background color
layer?.backgroundColor = UIColor(white: 1-CGFloat(z)*(1.0/CGFloat(depth)), alpha: 1).cgColor
// Attach to scroll view
visibleLayers.append(layer!)
}
}
}
// Update layers
scrollView.layer.sublayers = visibleLayers
print("Displayed:\(visibleLayers.count)/\(depth*height*width)")
}
extension LayerPerformanceViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
updateLayers()
}
}
這個示例中只有一種類型圖層對象,系統(tǒng)框架使用標(biāo)志符區(qū)分不同回收池。
使用回收池之前,每次都初始化圖層,無需使用CATransaction
禁用動畫。開啟回收后,對象可能是被回收的,修改圖層屬性會觸發(fā)隱式動畫,因此需先禁用動畫。
4.3 Core Graphics 繪制
除了只創(chuàng)建屏幕中可見圖層,還可以進(jìn)一步減少圖層數(shù)量。例如,如果使用了多個UILabel
、UIImageView
顯示靜態(tài)內(nèi)容,可以使用draw(_:)
將整個視圖層級繪制到一個視圖中。
雖然 GPU 渲染速度比 CPU 快,也無需占用額外內(nèi)存。但當(dāng)性能瓶頸是圖層數(shù)量時,軟件繪制通過減少初始化圖層數(shù)量和圖層樹層級,也能夠提高整體性能。
4.4 render(in:)
雖然有時使用draw(_:)
手動繪制內(nèi)容可以提高性能,但要放棄UIView
的很多便捷性,如 Interface Builder、Auto layout等。
幸運(yùn)的是不是必須使用draw(_:)
。當(dāng)有大量圖層時,只有圖層添加到了圖層樹,才會被發(fā)送給 render tree,此時才會對渲染產(chǎn)生性能影響。
通過CALayer
的render(in:)
方法,可以將圖層樹繪制到 Core Graphics 的 context,并將結(jié)果作為圖片輸出。shouldRasterize
柵格化圖層時,圖層必須添加到圖層樹中。使用render(in:)
繪制時圖層無需添加到圖層樹中。
使用render(in:)
后,圖層內(nèi)容發(fā)生改變時需開發(fā)者進(jìn)行處理,而shouldRasterize
會自動緩存、檢查緩存是否失效。但render(in:)
生成圖片后可以減少后續(xù)工作,Core Animaiton 無需維護(hù)復(fù)雜圖層樹。
總結(jié)
這篇文章介紹了使用 Core Animation 圖層可能遇到的性能瓶頸,如離屏渲染、混合和重繪、圖層數(shù)量太對。并提供了如下多種解決方案。
shouldRasterize
將圖層渲染為一張圖片,緩存起來使用。使用
CAShapeLayer
、圖片創(chuàng)建圓角。使用
shadowPath
創(chuàng)建陰影。盡可能不使用不透明圖層。
只創(chuàng)建當(dāng)前屏幕可見圖層。
添加圖層到回收池,避免不必要的創(chuàng)建、釋放。
使用 Core Graphics 將復(fù)雜視圖繪制到一個視圖中。
使用
render(_:)
將圖層樹繪制為圖片。
Demo名稱:CoreAnimation
源碼地址:https://github.com/pro648/BasicDemos-iOS/tree/master/CoreAnimation
上一篇:圖像IO之圖片加載、解碼,緩存
參考資料:
- 關(guān)于離屏渲染的深入研究
- Advanced Graphics and Animations for iOS Apps Presentation Slides
- Advanced Graphics and Animations for iOS Apps Transcript
- How to make a UIView's subviews' alpha change according to it's parent's alpha?
- A Performance-minded take on iOS design
歡迎更多指正:https://github.com/pro648/tips
本文地址:https://github.com/pro648/tips/blob/master/sources/圖層性能之離屏渲染、柵格化、回收池.md