概述
UIView
是我們在做iOS開發時每天都會接觸到的類,幾乎所有跟頁面顯示相關的控件也都繼承自它。但是關於UIView
的佈局、顯示、以及繪製原理等方面筆者一直一知半解,只有真正瞭解了它的原理才能更好的服務我們的開發。並且在市場對iOS開發者要求越來越高的大環境下,對App頁面流暢度的優化也是對高級及以上開發者必問的面試題,這就需要我們要對UIView
有更深的認知。
一.UIView 與 CALayer
UIView
:一個視圖(UIView)就是在屏幕上顯示的一個矩形塊(比如圖片,文字或者視頻),它能夠攔截類似於鼠標點擊或者觸摸手勢等用戶輸入。視圖在層級關係中可以互相嵌套,一個視圖可以管理它的所有子視圖的位置,在iOS當中,所有的視圖都從一個叫做UIView的基類派生而來,UIView可以處理觸摸事件,可以支持基於Core Graphics繪圖,可以做仿射變換(例如旋轉或者縮放),或者簡單的類似於滑動或者漸變的動畫。
CALayer
:CALayer
類在概念上和UIView
類似,同樣也是一些被層級關係樹管理的矩形塊,同樣也可以包含一些內容(像圖片,文本或者背景色),管理子圖層的位置。它們有一些方法和屬性用來做動畫和變換。和UIView最大的不同是CALayer不處理用戶的交互。
CALayer
並不清楚具體的響應鏈(iOS通過視圖層級關係用來傳送觸摸事件的機制),於是它並不能夠響應事件,即使它提供了一些方法來判斷一個觸點是否在圖層的範圍之內。
1. UIView 與 CALayer的關係
每一個UIView
都有一個CALayer
實例的圖層屬性,也就是所謂的backing layer
,視圖的職責就是創建並管理這個圖層,以確保當子視圖在層級關係中添加或者被移除的時候,他們關聯的圖層也同樣對應在層級關係樹當中有相同的操作.
兩者的關係:實際上這些背後關聯的圖層(Layer)纔是真正用來在屏幕上顯示和做動畫,UIView僅僅是對它的一個封裝,提供了一些iOS類似於處理觸摸的具體功能,以及Core Animation底層方法的高級接口。
這裏引申出面試常問的一個問題:爲什麼iOS要基於UIView和CALayer提供兩個平行的層級關係呢?爲什麼不用一個簡單的層級來處理所有事情呢?
原因在於要做職責分離(單一職責原則),這樣也能避免很多重複代碼。在iOS和Mac OS兩個平臺上,事件和用戶交互有很多地方的不同,基於多點觸控的用戶界面和基於鼠標鍵盤有着本質的區別,這就是爲什麼iOS有UIKit
和UIView
,但是Mac OS有AppKit
和NSView
的原因。他們功能上很相似,但是在實現上有着顯著的區別。把這種功能的邏輯分開並封裝成獨立的Core Animation框架,蘋果就能夠在iOS和Mac OS之間共享代碼,使得對蘋果自己的OS開發團隊和第三方開發者去開發兩個平臺的應用更加便捷。
2. CALayer的一些常用屬性
contents
屬性
CALayer
的contents屬性可以讓我們爲layer圖層設置一張圖片,我們看下它的定義
/* An object providing the contents of the layer, typically a CGImageRef,
* but may be something else. (For example, NSImage objects are
* supported on Mac OS X 10.6 and later.) Default value is nil.
* Animatable. */
@property(nullable, strong) id contents;
這個屬性的類型被定義爲id,意味着它可以是任何類型的對象。在這種情況下,你可以給contents屬性賦任何值,你的app都能夠編譯通過。但是,如果你給contents賦的不是CGImage,那麼你得到的圖層將是空白的。事實上,你真正要賦值的類型應該是CGImageRef,它是一個指向CGImage結構的指針,UIImage有一個CGImage屬性,它返回一個CGImageRef,但是要使用它還需要進行強轉:
layer.contents = (__bridge id _Nullable)(image.CGImage);
contentGravity
屬性
/* A string defining how the contents of the layer is mapped into its
* bounds rect. Options are `center', `top', `bottom', `left',
* `right', `topLeft', `topRight', `bottomLeft', `bottomRight',
* `resize', `resizeAspect', `resizeAspectFill'. The default value is
* `resize'. Note that "bottom" always means "Minimum Y" and "top"
* always means "Maximum Y". */
@property(copy) CALayerContentsGravity contentsGravity;
如果我們爲圖層layer
設置contents爲一張圖片,那麼可以使用這個屬性來讓圖片自適應layer的大小,它類似於UIView的contentMode
屬性,但是它是一個NSString類型,而不是像對應的UIKit部分,那裏面的值是枚舉。contentsGravity可選的常量值有以下一些:
kCAGravityCenter
kCAGravityTop
kCAGravityBottom
kCAGravityLeft
kCAGravityRight
kCAGravityTopLeft
kCAGravityTopRight
kCAGravityBottomLeft
kCAGravityBottomRight
kCAGravityResize
kCAGravityResizeAspect
kCAGravityResizeAspectFill
例如,如果要讓圖片等比例拉伸去自適應layer的大小可以直接這樣設置
layer.contentsGravity = kCAGravityResizeAspect;
contentsScale
屬性
/* Defines the scale factor applied to the contents of the layer. If
* the physical size of the contents is '(w, h)' then the logical size
* (i.e. for contentsGravity calculations) is defined as '(w /
* contentsScale, h / contentsScale)'. Applies to both images provided
* explicitly and content provided via -drawInContext: (i.e. if
* contentsScale is two -drawInContext: will draw into a buffer twice
* as large as the layer bounds). Defaults to one. Animatable. */
@property CGFloat contentsScale
contentsScale
屬性定義了contents
設置圖片的像素尺寸和視圖大小的比例,默認情況下它是一個值爲1.0的浮點數。這個屬性其實屬於支持Retina屏幕機制的一部分,它的值等於當前設備的物理尺寸與邏輯尺寸的比值。如果contentsScale設置爲1.0,將會以每個點1個像素繪製圖片,如果設置爲2.0,則會以每個點2個像素繪製圖片。當用代碼的方式來處理contents
設置圖片的時候,一定要手動的設置圖層的contentsScale屬性,否則圖片在Retina設備上就顯示得不正確啦。代碼如下:
layer.contentsScale = [UIScreen mainScreen].scale;
maskToBounds
屬性
maskToBounds
屬性的功能類似於UIView的clipsToBounds
屬性,如果設置爲YES,則會將超出layer範圍的圖片進行裁剪.
contentsRect
屬性
contentsRect
屬性在我們的日常開發中用的不多,它的主要作用是可以讓我們顯示contents
所設置圖片的一個子區域。它是單位座標取值在0到1之間。默認值是{0, 0, 1, 1},這意味着整個圖片默認都是可見的,如果我們指定一個小一點的矩形,比如{0,0,0.5,0.5},那麼layer顯示的只有圖片的左上角,也就是1/4的區域。
實際上給layer的contents賦CGImage的值不是唯一的設置其寄宿圖的方法。我們也可以直接用Core Graphics直接繪製。通過繼承UIView並實現-drawRect:方法來自定義繪製,如果單獨使用
CALayer
那麼可以實現其代理(CALayerDelegate)方法- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx;
在這裏面進行自主繪製。實際的方法繪製流程我們在下面進行探討。
二.View的佈局與顯示
1.圖像顯示原理
在開始介紹圖像的佈局與顯示之前,我們有必要先了解下圖像的顯示原理,也就是我們創建一個顯示控件是怎麼通過CPU與GPU的運算顯示在屏幕上的。這個過程大體分爲六個階段:
- 佈局 :首先一個視圖由CPU進行Frame佈局,準備視圖(view)和圖層(layer)的層級關係,以及設置圖層屬性(位置,背景色,邊框)等等。
-
顯示:view的顯示圖層(layer),它的寄宿圖片被繪製的階段。所謂的寄宿圖,就是上面我們提到過的layer所顯示的內容。它有兩種設置形式:一種是直接設置
layer.contents
,賦值一個CGImageRef
;第二種是重寫UIView的drawRect:
或CALayerDelegate
的drawLayer:inContext:
方法,實現自定義繪製。注意:如果實現了這兩個方法,會額外的消耗CPU的性能。 - 準備:這是Core Animation準備發送數據到渲染服務的階段。這個階段主要對視圖所用的圖片進行解碼以及圖片的格式轉換。PNG或者JPEG壓縮之後的圖片文件會比同質量的位圖小得多。但是在圖片繪製到屏幕上之前,必須把它擴展成完整的未解壓的尺寸(通常等同於圖片寬 x 長 x 4個字節)。爲了節省內存,iOS通常直到真正繪製的時候纔去解碼圖片。
- 提交:CPU會將處理視圖和圖層的層級關係打包,通過IPC(內部處理通信)通道提交給渲染服務,渲染服務由OpenGL ES和GPU組成。
- 生成幀緩存:渲染服務首先將圖層數據交給OpenGL ES進行紋理生成和着色,生成前後幀緩存。再根據顯示硬件的刷新頻率,一般以設備的VSync信號和CADisplayLink爲標準,進行前後幀緩存的切換。
- 渲染 :將最終要顯示在畫面上的後幀緩存交給GPU,進行採集圖片和形狀,運行變換,應用紋理和混合,最終顯示在屏幕上。
注意:當圖層被成功打包,發送到渲染服務器之後,CPU仍然要做如下工作:爲了顯示屏幕上的圖層,Core Animation必須對渲染樹種的每個可見圖層通過OpenGL循環轉換成紋理三角板。由於GPU並不知曉Core Animation圖層的任何結構,所以必須要由CPU做這些事情。
前四個階段都在軟件層面處理(通過CPU),第五階段也有CPU參與,只有最後一個完全由GPU執行。而且,你真正能控制只有前兩個階段:佈局和顯示,Core Animation框架在內部處理剩下的事務,你也控制不了它。所以接下來我們來重點分析佈局與顯示階段。
2.佈局
佈局
:佈局就是一個視圖在屏幕上的位置與大小。UIView有三個比較重要的佈局屬性:frame
,bounds
和center
.UIView提供了用來通知系統某個view佈局發生變化的方法,也提供了在view佈局重新計算後調用的可重寫的方法。
layoutSubviews()
方法
layoutSubviews()
:當一個視圖“認爲”應該重新佈局自己的子控件時,它便會自動調用自己的layoutSubviews方法,在該方法中“刷新”子控件的佈局.這個方法並沒有系統實現,需要我們重新這個方法,在裏面實現子控件的重新佈局。這個方法很開銷很大,因爲它會在每個子視圖上起作用並且調用它們相應的layoutSubviews
方法.系統會根據當前run loop
的不同狀態來觸發layoutSubviews
調用的機制,並不需要我們手動調用。以下是他的觸發時機:
- 直接修改 view 的大小時會觸發
- 調用
addSubview
會觸發子視圖的layoutSubviews
- 用戶在 UIScrollView 上滾動(layoutSubviews 會在
UIScrollView
和它的父view
上被調用) - 用戶旋轉設備
- 更新視圖的 constraints
這些方式都會告知系統view
的位置需要被重新計算,繼而會調用layoutSubviews
.當然也可以直接觸發layoutSubviews
的方法。
setNeedsLayout()
方法
setNeedsLayout()
方法的調用可以觸發layoutSubviews
,調用這個方法代表向系統表示視圖的佈局需要重新計算。不過調用這個方法只是爲當前的視圖打了一個髒標記
,告知系統需要在下一次run loop
中重新佈局這個視圖。也就是調用setNeedsLayout()
後會有一段時間間隔,然後觸發layoutSubviews
.當然這個間隔不會對用戶造成影響,因爲永遠不會長到對界面造成卡頓。
layoutIfNeeded()
方法
layoutIfNeeded()
方法的作用是告知系統,當前打了髒標記
的視圖需要立即更新,不要等到下一次run loop
到來時在更新,此時該方法會立即觸發layoutSubviews
方法。當然但如果你調用了layoutIfNeeded
之後,並且沒有任何操作向系統表明需要刷新視圖,那麼就不會調用layoutsubview
.這個方法在你需要依賴新佈局,無法等到下一次 run loop
的時候會比setNeedsLayout
有用。
3.顯示
和佈局的方法類似,顯示也有觸發更新的方法,它們由系統在檢測到更新時被自動調用,或者我們可以手動調用直接刷新。
drawRect:
方法
在上面我們提到過,如果要設置視圖的寄宿圖,除了直接設置view.layer.contents
屬性,還可以自主進行繪製。繪製的方法就是實現view的drawRect:
方法。這個方法類似於佈局的layoutSubviews
方法,它會對當前View的顯示進行刷新,不同的是它不會觸發後續對視圖的子視圖方法的調用。跟layoutSubviews
一樣,我們不能直接手動調用drawRect:
方法,應該調用間接的觸發方法,讓系統在 run loop
中的不同結點自動調用。具體的繪製流程我們在本文第三節進行介紹。
setNeedsDisplay()
方法
這個方法類似於佈局中的setNeedsLayout
。它會給有內容更新的視圖設置一個內部的標記,但在視圖重繪之前就會返回。然後在下一個run loop
中,系統會遍歷所有已標記的視圖,並調用它們的drawRect:
方法。大部分時候,在視圖中更新任何 UI 組件都會把相應的視圖標記爲“dirty”,通過設置視圖“內部更新標記”,在下一次run loop
中就會重繪,而不需要顯式的調用setNeedsDisplay
.
三.UIView的系統繪製與異步繪製流程
UIView的繪製流程
接下來我們看下UIView
的繪製流程
- UIView調用setNeedsDisplay,這個方法我們已經介紹過了,它並不會立即開始繪製。
- UIView 調用
setNeedsDisplay
,實際會調用其layer屬性的同名方法,此時相當於給layer打上繪製標記。 - 在當前
run loop
將要結束的時候,纔會調用CALayer的display方法進入到真正的繪製當中 - 在CALayer的display方法中,會判斷
layer
的代理方法displayLayer:
是否被實現,如果代理沒有實現這個方法,則進入系統繪製流程,否則進入異步繪製入口。
系統繪製
在系統繪製開始時,在CALayer內部會創建一個繪製上下文,這個上下文可以理解爲
CGContextRef
,我們在drawRect:
方法中獲取到的currentRef
就是它。-
然後layer會判斷是否有delegate,沒有delegate就調用
CALayer
的drawInContext
方法,如果有代理,並且你實現了CALayerDelegate協議中的-drawLayer:inContext:
方法或者UIView中的-drawRect:
方法(其實就是前者的包裝方法),那麼系統就會調用你實現的這兩個方法中的一個。關於這裏的代理我的理解是:如果你直接使用的UIView,那麼layer的代理就是當前view,你直接實現
-drawRect:
,然後在這個方法裏面進行自主繪製; 如果你用的是單獨創建的CALayer
,那麼你需要設置layer.delegate = self;
當然這裏的self就是持有layer的視圖或是控制器了,這時你需要實現-drawLayer:inContext:
方法,然後在這個方法裏面進行繪製。 最後CALayer把位圖傳給GPU去渲染,也就是將生成的 bitmap 位圖賦值給 layer.content 屬性。
注意:使用CPU進行繪圖的代價昂貴,除非絕對必要,否則你應該避免重繪你的視圖。提高繪製性能的祕訣就在於儘量避免去繪製。
異步繪製
什麼是異步繪製?
通過上面的介紹我們熟悉了系統繪製流程,系統繪製就是在主線程中進行上下文的創建,控件的自主繪製等,這就導致了主線程頻繁的處理UI繪製的工作,如果要繪製的元素過多,過於頻繁,就會造成卡頓。而異步繪製就是把複雜的繪製過程放到後臺線程中執行,從而減輕主線程負擔,來提升UI流暢度。
異步繪製流程
上面很明顯的展示了異步繪製過程:
- 從上圖看,異步繪製的入口在
layer
的代理方法displayLayer:
,如果要進行異步繪製,我們必須在自定義view中實現這個方法 - 在
displayLayer:
方法中我們開闢子線程 - 在子線程中我們創建繪製上下文,並藉助
Core Graphics
相關API完成自主繪製 - 完成繪製後生成Image圖片
- 最後回到主線程,把Image圖片賦值給layer的contents屬性。
當然我們在日常開發中還要考慮線程的管理與繪製時機等問題,使用第三方庫YYAsyncLayer
可以讓我們把注意力放在具體的繪製上,具體的使用流程可以點這裏去查看.
四.總結
我們知道,當我們實現了CALayerDelegate
協議中的-drawLayer:inContext:
方法或者UIView中的-drawRect:
方法,圖層就創建了一個繪製上下文,這個上下文需要的大小的內存可從這個算式得出:圖層寬X圖層高X4字節,寬高的單位均爲像素。對於一個在Retina iPad上的全屏圖層來說,這個內存量就是 2048X15264字節,相當於12MB內存,圖層每次重繪的時候都需要重新抹掉內存然後重新分配。可見使用Core Graphics
利用CPU進行繪製代價是很高的,那麼如何進行高效的繪圖呢?iOS-Core-Animation-Advanced-Techniques給出了答案,我們在日常開發中完全可以使用Core Animation
的CAShapeLayer
代替Core Graphics
進行圖形的繪製,具體的方法這裏就不介紹了,感興趣的可以自行去查看。
參考引用:
iOS-Core-Animation-Advanced-Techniques
YYAsyncLayer
https://juejin.cn/post/6844903567610871816