Android進階:繪製流程

語言: CN / TW / HK

Android View的繪製流程分為三大流程:測量、佈局、繪製。三大流程都開始於ViewRootImpl的performTraversals函數。通過了解三大流程的順序和原理,支撐日常開發工作。繪製是Android進階攔路虎之一。

一、測量流程

三大流程都是始於ViewRootImpl的performTravels函數,先是從調用View的performMeasure函數開始測量流程,再是調用performLayout函數開始佈局流程,進而是調用performDraw函數開始繪製流程。本節從performMeasure函數開始,講View的測量流程。

正式開始測量流程了~

performMeasure函數會調用View的measure函數。 measure函數第一行會調用isLayoutModeOptical函數,用來判斷當前View是否ViewGroup ,是ViewGroup的話,判斷layoutModel屬性是否LAYOUT_MODE_OPTICAL_BOUNDS,即opticalBounds。該屬性默認為clipBounds,還可取值opticalBounds,前者在獲取ViewGroup的四邊(getLeft,getTop,getRight,getBottom)將返回原始的值,而opticalBounds表示給ViewGroup加一些特殊的效果,例如陰影或高亮效果,因為返回的四邊也將比clipBounds小。

measure函數接下來的這一段主要是為了判斷是否需要進行重新測量,畢竟每次測量也不容易。

	//用於存儲上次測量的結果
        long key = (long) widthMeasureSpec << 32 | (long) heightMeasureSpec & 0xffffffffL;
        if (mMeasureCache == null) mMeasureCache = new LongSparseLongArray(2);
	
    	//view是否需要強行刷新,調用froceLayout
        final boolean forceLayout = (mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT;
	
    	//判斷此次的widthMeasureSpec與heightMeasureSpec是否與上次相等
        final boolean specChanged = widthMeasureSpec != mOldWidthMeasureSpec
                || heightMeasureSpec != mOldHeightMeasureSpec;
     	
        //判斷此次測量模式是否精確,不是精確的可能需要重新測量
        final boolean isSpecExactly = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY
                && MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY;
    
    	//判斷此次測量大小是否與已保存的大小一致,不是一致可能需要重新測量         
        final boolean matchesSpecSize = getMeasuredWidth() == MeasureSpec.getSize(widthMeasureSpec)
                && getMeasuredHeight() == MeasureSpec.getSize(heightMeasureSpec);
                
         //如果specChanged為false,即寬高measureSpec與上次都相等,不需要重新測量;true則進一步檢查其他條件
         //sAlwaysRemeasureExactly主要用於判斷LinearLayout在舊版本的不同測量模式都會返回不同的測量結果,小於Android 6.0為true,大於為false;所以但小於Android 6.0需要重新測量
         //如果isSpecExactly測量模式是非精確模式需要重新測量
          //如果matchesSpecSize與已保存大小不一致需要重新測量
        final boolean needsLayout = specChanged
                && (sAlwaysRemeasureExactly || !isSpecExactly || !matchesSpecSize);
複製代碼

needsLayout就是根據上面相關變量的值共同判斷是否需要重新測量的最終結果。也可以通過下圖一覽上面的註釋。

接着measure函數的內容,當調用forceLayoutrequestLayout函數,mPrivalteFlags就會添加PFLAG_FORCE_LAYOUT標記,那麼forceLayout就是true,無論後面其他判斷條件怎麼樣,一定會調用onMeasure函數進行測量。而needsLayout就在上文剛分析了。

mPrivateFlags &= ~PFLAG_MEASURED_DIMENSION_SET;語句重置所有的已設置的測量信息,畢竟要準備重新開始測量了。resolveRtlPropertiesIfNeeded()主要是處理文本從右到左的情況,因為並不是所有國家文字書寫順序都是從左到右。 LongSparseLongArray是key與value都為Long,類似HashMap的數據結構。這裏正是通過這種結構用來存儲測量的寬和高,如果mMeasureCache.indexOfKey(key)返回值小於0,表示不存在對應的寬高,需要測量。

sIgnoreMeasureCache表示為了性能優化而忽略測量緩存,其實是為了兼容舊版本,因為在Android4.4前,APP總是希望onMeasure函數被調用,所以該變量總是true,而Android 4.4和後續版本,該標誌總是false。

因此,如果需要測量,則調用當前View的onMeasure函數;不需要重新測量,則從緩存mMeasureCache獲取已緩存寬高。

measure函數的最後代碼就是保存父View對當前View的寬高要求和往mMeasureCache存值,以供下次測量作為判斷條件使用。

measure函數總結一下:

measure函數主要是為了性能優化,根據緩存(已緩存)、父類約束是不與上次一致,和行為(刷新佈局)來判斷是否重新測量大小。

接下來看看View的onMeasure函數做了什麼事:

看着簡單,其實還是要拆解看看: getSuggestedZMininumWidth函數主要判斷當前是否設置背景,如果沒有設置背景,則取最小寬度;設置了背景,則取最小寬度和背景最小寬度的兩者之間的最大值。最小寬度就是我們設置的minWidth屬性。高度的測量亦是如此。

getDefaultSize函數主要是根據測量模式,計算出默認的尺寸大小。 到這裏,就應該需要對MeasureSpec的大小和測量模式解釋一下,不然有的同學真一臉懵逼。MeasureSpec是View的靜態內部類,代表一個32位的整型,高2位表示測量模式,低30位表示尺寸大小。measure函數的兩個參數widthMeasureSpec,heightMeasureSpec,分別代表着父View對子View的寬高約束。從這裏也可以看出,子View的大小由父View約束和子View自身自身約束共同確定。

通過MeasureSpec提供的一些靜態方法,如int getSize(int measureSpec)int getMode(int measureSpec),可以獲取到測量模式mode和大小size,分別為:

  • EXACTLY:當View的layout_width或者layout_height設置為match_parent或具體的值時,該測量模式就是EXACTLY,表示父View對當前View的尺寸要求大小是size;
  • AT_MOST:當View的layout_width或者layout_height屬性設置為wrap_content,該測量模式就是AT_MOST,表示父View能給予當前View的最大的可用尺寸是size,具體用多少當前View自己決定;
  • UNSPECIFIED:表示父View對當前View沒有任何約束,想要多大的尺寸當前View自己決定。

getDefaultSize函數對測量模式AT_MOSTEXACTLY的處理方式看,自定義View繼承View時,要格外注意layout_widthlayout_height屬性值為wrap_content的情況,因為它的表現就跟match_parent是一樣的,有時需要根據具體情況去更改這種行為。

setMeasureDimension函數開始跟measure函數類似,先判讀一下layoutModel是否optical bound,進行寬高的調整,並調用setMeasureDimensionRaw函數。

setMeasureDimension則是簡單的賦值,設置mPrivateFlags標誌位。這樣就可以通過getMeasuredWidthgetMeasuredHeight函數來獲取測量的寬高了。注意: 重寫onMeasure函數需要調用setMeasureDimension函數進行數據緩存。

測量流程也就到此結束了。但仔細一想,發現不對勁,這裏測量指的是View,那麼ViewGroup呢?

ViewGroup是View的子類,而View的measure函數被被聲明成了final,所以ViewGroup測量自身或者測量子View只能重寫onMeasure函數。但在ViewGroup類仔細尋找,卻沒有發現重寫onMeasure函數的痕跡。具體原因是因為具體的ViewGroup,如LinearLayout和RelativeLayout它們各自的測量方式是不一樣的,onMeasure需要它們具體去實現。但ViewGroup類提供了一些便捷的api,如measureChildrenmeasureChildWithMarginsmeasureChild等等。 翻翻LinearLayout的onMeasure函數,最終也會調用View的measure函數,走View的測量流程。

因此自定義View或者ViewGroup,需要根據自身實現的功能去重寫omMeasure函數,來測量自身或子View的大小

二、佈局流程

上一節分析了測量流程,得知了每個View的寬高大小,這一節緊跟着分析佈局流程,判斷子View如何在父View進行定位。performLayout函數同樣是在ViewRootImpl類的performTraversals函數中,performMeasure函數之後。 可以看到,performLayout函數很快就調用了View的layout函數進行佈局流程。這裏先不跟進去,只需要知道已經進行了一次佈局,然後看performLayout函數的後續內容。

mLayoutRequesters是一個保存了在佈局過程中所有請求佈局的View的列表。當列表不為空時候,需要對這些View進行處理。

在佈局的過程中,可能View請求佈局(即設置了PFLAG_FORCE_LAYOUT),將它們存到列表mLayoutRequesters中,然後在佈局結束後,第一次通過getValidLayoutRequesters函數判斷這些View是否需要重新佈局,判斷條件就是當前View是否可見和設置了PFLAG_FORCE_LAYOUT標誌。 如果返回值validLayoutRequesters不為空,重新設置他們的標誌位PFLAG_FORCE_LAYOUT,並調用measureHierarchy函數,對它們進行View層級的測量,測量流程和整個界面測量流程是一致。然後再跟着重新佈局一次host.layout()

進行第二次判斷是否還有在佈局過程中,有View請求佈局,如果有的話,判斷有效的需要重新佈局的View,這次判斷忽略了PFLAG_FORCE_LAYOUT標誌位,除了不可見的View,其他都列為需要有效的。然後留到下次幀再重新來過。

總結一下

在第一次佈局的過程中,如果有View需要requestLayout函數(一般發生在ListView等的子View),則需要判斷這些View是否可見或已經處理了requestLaout。如果有可見的、未處理requestLayout的View則需要進行View層次級別的測量,然後重新佈局一次。然後進行第二次判斷是否有View需要requestlayout,這次只判斷是否可見。如果還有,這些View就留到下一幀進行吧,老子不管了。

再回到第一次佈局host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight()); setOpticalFrame函數最終也會調用setFrame,只是追加了點效果邊距長度。setFrame函數主要是對當前View在父View的位置進行確定,如果此時定位位置有變(四邊有不一致),則changed返回的是true。在setFrame函數會調用sizeChanged函數,而sizeChanged函數會調用onSizeChanged函數。

onLayout函數在View中是一個空實現,而在ViewGroup未重寫該方法。因為子View在父View位置,在不同的ViewGroup表現也是不同的,所以需要具體的ViewGroup根據自己的特性去重寫。但這裏我們注意到一個時機,onSizeChanged函數的回調在onMeasure函數之後,onLayout函數之前,在尺寸大小發生變化時會回調該方法。

在調用onLaout函數後的主要進行OnLayoutChangeListener的回調和焦點的處理。isLayoutValid函數表示至少已經經歷過一次佈局了或者不會再進行其他佈局了,就返回true。 到這裏,佈局流程基本也就結束了。

本節小結

佈局流程始於ViewRootImpl的performTraversals函數,然後調用自身的performLayout函數,對View進行佈局,佈局結束後對佈局過程有請求佈局的View進行View層級測量和佈局。在View的layout函數中,通過setFrame對自身進行佈局定位,如果位置發生變化則回調onSizeChanged函數。再而是調用onLayout函數。因此自定View無需重寫onLayout函數,自定義ViewGroup則需要重寫onLayout函數進行子View的佈局。

三、繪製流程

經過測量、繪製,已經知道了View的大小,在父View的位置,那麼接下來就是如何將View繪製出來,展現在屏幕。

繪製流程始於ViewRootImpl的performTraversals函數,調用自身的performDraw函數。

//ViewRootImpl.java
performTraversals=>performDraw=>draw=>drawSoftware=>View.draw
複製代碼

draw函數中,主要是繪製區域dirty的確定,例如是否滾動、全部繪製等。

drawSoftware函數就是通過軟件去繪製的地方,主要根據dirty區域,生成並鎖定canvas,而canvas就是繪製內容的區域。

而在View的draw函數,則是View的繪製的開始:

drawBackground=>onDraw=>dispatchDraw=>onDrawForeground=>drawDefaultFocusHighlight
複製代碼

在View的draw流程中,View一般重寫onDraw函數,super.onDraw後繪製自己的內容,表示所繪製內容在系統繪製的內容之後。而在ViewGroup中,如果需要覆蓋在子View之上,應該是重寫dispatchDraw函數,並調用super.dispatchDraw之後,因為dispatchDraw函數會去繪製所有子View的內容,在之前繪製的內容都會被覆蓋。當然,也可以以dispatchDraw作為分界點,根據需要重寫其他函數,繪製內容。

如果重寫ViewGroup的onDraw函數,繪製的內容一般顯示不出來,因為ViewGroup會優化從而跳過onDraw函數,可以通過設置背景或setWillNotDraw(false)來解決這個問題。

四、總結

通過學習Android的繪製流程,需要知道幾點情況:

  1. 自定View時,需要考慮寬高設置wrap_content的情況,因為它的表現在測量階段和match_parent是一致的。
  2. 重寫View的onDraw函數,要避免在onDraw創建對象,因為onDraw會被調用多次,可以考慮在onSizeChanged函數創建。
  3. 如果View或ViewGroup需要改變自身大小,應該在onMeasure函數實現,並通過setMeasureDimension保存下來。
  4. 重寫ViewGroup的onDraw函數時,要注意onDraw函數在整個draw流程的地位,以及它並不是都會被調用。

番外篇

1、MeasureSpec是什麼?作用

MeasureSpec在View中的一個靜態內部類,能將一個32位整型拆分成測量模式和測量大小,代表着父View對子View的約束。32位的整型,高兩位代表着測量模式,低三十位代表測量大小。通過位位運算,可以分別獲取測量模式和大小,而合併成一個32位整型,只需要相加即可。

例如,給寬設置10,此時測量模式是精確模式EXACTLY,即01,用32位的整型表示應該是(暫且用xxx表示中間所有的0)

若求測試模式model,只需要和高兩位都是1,低三十位都是0的MODE_MASK按位與即可。

代碼:

public static int getMode(int measureSpec) {
	return (measureSpec & MODE_MASK);
}
複製代碼

若求測試大小,只需要和取反後MODE_MASK按位與即可。 代碼:

public static int getSize(int measureSpec) {
    return (measureSpec & ~MODE_MASK);
}
複製代碼

2、requestLayout和invalidate,postInvalidate區別?

一般來説,需要重新測量佈局,就調用requestLayout,然後在調用invalidate保證onDraw一定被調用。也就是説requestLayout不一定保證onDraw被調用,但會調用onMeasure和onLayout。而invalidata只會調用到onDraw。invalidate在UI線程刷新界面,postInvalidate表示在子線程刷新界面。