整理一些老生常談的問題.
一.顯示流程
1.繪制
當(dāng)然是邏輯上的繪制,此時各種框架的代碼開始工作,iOS并不完全由GPU負(fù)責(zé)渲染,比如當(dāng)需要Core Graphics參與時,CPU會做更多的事,這一點很重要;比如當(dāng)重寫drawRect方法時使用了很多Core Graphics的api,此時就由CPU來繪制bitmap,并且還需要內(nèi)存來存儲bitmap,當(dāng)然只是繪制一張圖并不會消耗什么,但是如果頻繁的繪制就不一樣了.
繪制包含很多動作,比如UIKit中UIView的布局,Core Animation中CALayer的顯示和動畫,還有上面說的core Graphics繪制,以及OpenGL,Metal的繪制,這部分可能是CPU的工作也可能直接由GPU處理.
2.畫面渲染
從渲染流程的角度,不同的框架控制不同的步驟,除了OpenGL或者Metal更多設(shè)計GPU的工作,其他框架基本都是在編程CPU的工作.編程以外的事,就是GPU渲染管線,計算過程,緩存策略等等了.
cpu和gpu處理完成之后,得到一張bitmap,也就是像素點陣,接下來視頻控制器將bitmap顯示到屏幕上.
但是由于計算需要時間不一,視頻控制器不能去等bitmap生成出來,因此需要一個緩存來存儲已經(jīng)生成好的bitmap,也就是幀緩沖Framebuffer,
3.掃描顯示
當(dāng)cpu和GPU的工作完成時,視頻控制器就應(yīng)該將bitmap轉(zhuǎn)換為模擬信號,給顯示元件發(fā)送指令了.
iOS屏幕的顯示仍然類似CRT電子掃描顯示,從左上角開始,從左到右以此控制顯示元件,一行顯示完成后開始第二行,這樣從上到下的刷新屏幕.
GPU隨時可能會進行渲染,視頻控制器隨時可能會給顯示元件發(fā)送指令,那么自然就會有這種情況,當(dāng)掃描顯示進行到一半的時候收到了新的指令,如果畫面變化不大,那就感覺不出來什么區(qū)別,如果畫面變化很大,就產(chǎn)生了畫面撕裂現(xiàn)象.
4.垂直同步和二級緩沖
iOS采用使用垂直同步信號 Vsync 與雙緩沖機制 Double Buffering來解決這個問題.
當(dāng)元件開始讀取幀數(shù)據(jù)的時候,加一個鎖,在當(dāng)前幀發(fā)送完畢之前,不發(fā)送下一幀的顯示指令;當(dāng)完成一行的顯示時,發(fā)送一個水平同步信號(horizonal synchronization),當(dāng)完成一幀的顯示時,發(fā)送一個垂直同步信號(vertical synchronization).當(dāng)視頻控制器收到垂直信號時,再從幀緩沖中取出需要顯示的下一張bitmap,進行顯示.
5.卡頓
解決畫面撕裂的方案其實就一個字,等.但是這會引起別的問題.
視圖控制器有預(yù)定的發(fā)送頻率,也就是預(yù)定幀率,在支持高刷(ProMotion displays)的iPhone13之前,是60fps,之后是120fps.如果是60Fps,那就是16.7毫秒發(fā)送一次bitmap,假如cpu和gpu的復(fù)載很大,收到Vsync的時候framebuffer還沒有新的一幀,就發(fā)送舊的,畫面就沒有變化,對應(yīng)到屏幕刷新,就是卡頓現(xiàn)象.
二級緩沖就是GPU 會預(yù)先渲染一幀放入一個緩沖區(qū)中妓美,用于視頻控制器的讀取。當(dāng)下一幀渲染完畢后,GPU 會直接把視頻控制器的指針指向第二個緩沖器帖旨。
二.離屏渲染
1.Core Animation的工作流程
這張有名的圖是Core Animation的工作流程,可以看到既參與Application的部分,又要負(fù)責(zé)Rander server的部分.
Commit Transaction階段指的是對布局進行計算,構(gòu)建和繪制,以及圖片解碼等等工作;
首先是構(gòu)建視圖(layout),包括初始化各種UIView對象,addsubveiw, 計算frame布局和autolayout等;在這一階段,影響性能的就是圖層數(shù)量,布局關(guān)系,減少視圖層級,避免不必要的初始化可以提升性能.
其次是繪制(display),構(gòu)建視圖時,用于交付 draw calls的bitmap還沒有生成,但是如果在drawrect中使用core Graphics的api繪制圖形,CPU就會直接開始生成bitmap,并且還要申請內(nèi)存來存儲.此時就需要注意不能頻繁的繪制.
Rander server階段, Core Animation對圖層解碼,得到每個layer的bitmap;
當(dāng)完成解碼后,等待下一次runloop,core Animation調(diào)用OpenGL或者Metal的接口,開始draw calls階段;
OpenGL或者Metal拿到具體的任務(wù),此時,工作已經(jīng)到了GPU這邊,開始執(zhí)行渲染;
當(dāng)渲染完成,下一次runloop循環(huán)開始,就是顯示元件的工作了.
2.offscreen buffer
前面說到GPU的frame buffer緩存將要顯示的bitmap,但是有些情況下,core Animation交付的bitmap并不是最終版本,GPU需要暫存一些中間狀態(tài)的bitmap,當(dāng)這些中間bitmap都到位之后,再生出最終的bitmap放到frame buffer,而offscreen buffer就負(fù)責(zé)管理這些中間狀態(tài)的bitmap.也就是常說的離屏渲染.
因此offscreen是GPU的工作部分產(chǎn)生的,所以CPU負(fù)責(zé)的部分,雖然也可能申請內(nèi)存去存儲中間狀態(tài),比如core Graphics繪制就是典型的開辟一個cgcontext來畫,除此之外還有文字繪制,圖片解碼視頻軟解等,但是這些不屬于離屏渲染,重寫drawrect并不會被Color offscreen rendered標(biāo)記黃色.
3.畫家算法
OpenGL/Metal輸出的bitmap會像畫油畫一樣一層層覆蓋,bitmap作為位圖,像素點陣,包含的信息很有限,一個位置的像素信息,被后來的信息覆蓋,前面的就丟失了,當(dāng)修改完一整張bitmap后,再想回頭修改之前的bitmap是做不到的.
對于一個layer,如果它能夠確定自己的內(nèi)容,一步到位,就可以直接到frame buffer中去疊加, 如果不能一步到位,比如說cornerRadius+clipsToBounds,就得把自己和子layer都先疊加畫好好,然后再裁剪,再然后才能去frame buffer,在畫子layer之前,自己就會先在offscreen buffer中渲染.
4.離屏渲染的場景
那么什么時候會產(chǎn)生離屏渲染呢,模擬器打開Color offscreen rendered觀察一下.
- 圓角
這是一個UIImageView,它的clipsToBounds為true,layer.cornerRadius是10.0,backgroundcolor是clear,
此時并沒有產(chǎn)生離屏渲染
使用layer的contents也是一樣的
let l = CALayer.init()
l.frame = .init(x: 0, y: 0, width: 20, height: 20)
l.masksToBounds = true
l.contents = UIImage.init(named:"avatar")?.cgImage
l.contentsGravity = .resizeAspectFill
l.cornerRadius = 5
// l.backgroundColor = UIColor.blue.cgColor
contentView.layer.addSublayer(l)
但是如果backgroundcolor不是clear,比如設(shè)置成black,就會產(chǎn)生離屏渲染
此外border也會影響離屏渲染,即便背景是透明,設(shè)置了border和圓角和clipstobound,也會產(chǎn)生離屏渲染
avatar.backgroundColor = .clear
avatar.layer.borderWidth = 1
avatar.layer.borderColor = UIColor.black.cgColor
主要是因為layer的三層結(jié)構(gòu)
這是一個UIView,同樣設(shè)置了clipsToBounds=true,layer.cornerRadius=10.0,backgroundcolor是灰色,它沒有離屏渲染,
當(dāng)給他添加一個subView的時候,就產(chǎn)生了離屏渲染,此時如果把backgroundcolor設(shè)置為clear,又沒有離屏渲染了.
此時如果改成超出父視圖的這種布局,會發(fā)現(xiàn)離屏渲染又回來了;
這是由core Animation的算法決定的,子層沒有超過父層的部分,那么就可以按照畫家算法正常的去畫.
因此在clipsToBounds圓角方面,視圖層級,視圖布局,背景色,contents,都是會影響離屏渲染的因素.
- 陰影
給一個UIView添加陰影,結(jié)果整個都是黃色的.
singleV.layer.shadowColor = UIColor.black.cgColor
singleV.layer.shadowOffset = .init(width: 2, height: 2)
singleV.layer.shadowRadius = 0.8
singleV.layer.shadowOpacity = 1.0
甚至當(dāng)設(shè)置clipsToBounds= true時,關(guān)閉圓角,此時陰影被裁剪了,依然是離屏渲染
而clearcolor的情況下陰影不會生效,也不會有離屏渲染,不過如果有子視圖,陰影是會對子視圖生效的,此時就有離屏渲染
- mask
這個都不用看效果了,mask的本質(zhì)就是層的合并,只要layer本身不是透明的,用了肯定離屏渲染.
let be = UIBezierPath.init()
be.move(to: .init(x: 10, y: 10))
be.addLine(to: .init(x: 50, y: 10))
be.addLine(to: .init(x: 50, y: 50))
be.addLine(to: .init(x: 10, y: 50))
be.close()
let layer = CAShapeLayer.init()
layer.path = be.cgPath
layer.backgroundColor = UIColor.red.cgColor
singleV.layer.mask = layer
let l = CALayer.init()
l.backgroundColor = UIColor.black.cgColor
l.opacity = 0.5
l.frame = .init(x: 0, y: 0, width: 80, height: 80)
avatar.layer.mask = l
- 光柵化
shouldRasterize默認(rèn)是false, 設(shè)置為true時會緩存該layer的bitmap在offscreen buffer,因此屬于離屏渲染
-
組透明度
layer.allowsGroupOpacity默認(rèn)是true,開啟和關(guān)閉時的渲染效果不同,并且開啟時如果存在子layer,會引起離屏渲染.
它指的是不單獨對層進行透明度處理,等層和子層渲染完了之后,再應(yīng)用透明度.
另外透明度是是渲染比較靠后的一步,光柵化緩存的bitmap也不包含透明度信息.
- 文本
CATextLayer和帶有contents的普通layer性質(zhì)相同
let l = CATextLayer.init()
l.frame = .init(x: 320, y: 10, width: 100, height: 40)
l.foregroundColor = UIColor.white.cgColor
l.contentsScale = UIScreen.main.scale
let font = UIFont.systemFont(ofSize: 14)
l.font = CGFont.init(font.fontName as CFString)
l.fontSize = font.pointSize
l.string = "text layer"
l.backgroundColor = UIColor.black.cgColor
l.masksToBounds = true
l.cornerRadius = 10
contentView.layer.addSublayer(l)
UILabel具有其特殊性,不管是普通的繪制文字,clipsToBounds,背景顏色,圓角,都不會產(chǎn)生離屏渲染;
UILabel的layer,類型是_UILabelLayer,并非CATextLayer. _UILabelLayer繼承自CALayer.
label.textColor = .white
label.backgroundColor = .black
label.clipsToBounds = true
label.layer.cornerRadius = 10
三:關(guān)于性能優(yōu)化
1.CPU和GPU都要關(guān)照
通常大部分的渲染工作由GPU來完成,從框架角度來說基本是由Core Animation來做,Core Animation實現(xiàn)硬件加速,也就是把渲染工作轉(zhuǎn)換成適合GPU處理的形式.但是也有一些工作Core Animation做不到的,他們屬于core Graphics的工作范圍,比如繪制文字,圖形以及ImageIO的圖片解碼等等,這些必須由CPU完成,然后再把數(shù)據(jù)傳給GPU.
CPU的有些渲染其實也可以叫做CPU專屬的"離屏渲染",畢竟表現(xiàn)還是很像的,就是更多的消耗性能和占用內(nèi)存
比如要避免頻繁的core Graphics繪制,大量的解碼圖片會引起內(nèi)存暴漲等這些情況要及時釋放內(nèi)存,core Graphics和core image的api都有對應(yīng)的release方法,要合理使用.
前面說到CPU還需要負(fù)責(zé)視圖的構(gòu)建和布局計算,因此頻繁的調(diào)整圖層結(jié)構(gòu),頻繁的重置布局都是需要注意的地方.
對于GPU方面,要用color offscreen rendered來觀察,而不是靠經(jīng)驗和猜測.
2.處理圓角
處理圓角要考慮前面的那些情況,如果子視圖能單獨處理,就單獨處理,分別對層進行切圓角,同時背景能clear的就clear;
另外可以考慮用腦洞來實現(xiàn)圓角效果,比如用圖片遮擋.
3.處理陰影
使用shadowPath可以避免離屏渲染
singleV.layer.shadowColor = UIColor.black.cgColor
singleV.layer.shadowOffset = .init(width: 3, height: 3)
singleV.layer.shadowRadius = 0.8
singleV.layer.shadowOpacity = 1.0
//singleV.layer.shadowPath = UIBezierPath.init(rect: singleV.bounds).cgPath
那么為什么呢:
首先陰影是添加在其他層的效果,A的陰影顯然不會在A自己身上,會在下面的BCD...上,從畫家算法思考,B畫了一半,畫A,然后在B上畫A的陰影,這才算完.
shadowPath會預(yù)先定義好陰影的位置,在core Animation的階段就可以確定陰影畫在哪,現(xiàn)在當(dāng)畫B的時候,直接把陰影畫上,即使A的bitmap還沒有被發(fā)送到GPU,因為在生成B的bitmap時core Animation已經(jīng)預(yù)先計算好了陰影.
4.使用光柵化的情況
光柵化是計算機圖形學(xué)的必要流程,是把采樣結(jié)果隱射到bitmap上,所以這里的shouldRasterize并不是這個意思;
shouldRasterize指的是會把層以及子層整個生成好的bitmap緩存在offscreen buffer,之后如果能夠重用,就可以直接發(fā)送到frame buffer.
當(dāng)界面上的內(nèi)容在頻繁的快速的變化時,比如滑動一個列表,對于60fps的機型,理論上渲染流程每秒要走60次;
但是一秒內(nèi)一個單元格可能從最底下到最上面,它的內(nèi)容并沒有發(fā)生變化;
這種情況下使用shouldRasterize就可以提升性能,當(dāng)然前提是單元格本身就存在不可避免的離屏渲染,或者存在復(fù)雜繁多的圖層結(jié)構(gòu),因為shouldRasterize本身就會產(chǎn)生離屏渲染,簡單的布局使用了它之后得不償失.
除此之外,CPU的部分還要考慮多線程問題,UI的刷新只會在主隊列進行,主隊列只有一個線程就是主線程Thread 0,耗時的操作放在主隊列會影響UI的刷新.
這是apple的解決方案,單線程雖然效率低,但是線程安全;
不過實際上UIKit和core Graphics的繪制也是可以在主線程之外進行的,相關(guān)的庫也有一些比如Facebook的Texture等