淺談UIView的刷新與繪製

概述

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有UIKitUIView,但是Mac OS有AppKitNSView的原因。他們功能上很相似,但是在實現上有着顯著的區別。把這種功能的邏輯分開並封裝成獨立的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:CALayerDelegatedrawLayer: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有三個比較重要的佈局屬性:frameboundscenter.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就調用CALayerdrawInContext方法,如果有代理,並且你實現了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 AnimationCAShapeLayer代替Core Graphics進行圖形的繪製,具體的方法這裏就不介紹了,感興趣的可以自行去查看。

參考引用:
iOS-Core-Animation-Advanced-Techniques
YYAsyncLayer
https://juejin.cn/post/6844903567610871816

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章