面試系列之View相關知識點

lvzishen發表於2019-05-13

目錄介紹

  • 1.View測量佈局繪製整體流程
    • 1.1 MeasureSpec是什麼?
    • 1.2 LayoutParams是什麼?
    • 1.3 View的測量流程(Measure)
    • 1.4 在getChildMeasureSpec方法中都做了什麼?
    • 1.5 Layout佈局過程
    • 1.6 Draw過程
  • 2.getWidth,getMeasureWidth的區別
  • 3.requestLayout()、invalidate()與postInvalidate()有什麼區別?
  • 4.自定義View整體思想和型別
  • 5.什麼時候可以獲取到View的寬高,為什麼?
  • 6.獲取控制元件寬高的幾種方法
  • 7.子執行緒中真的不能更新UI嗎?
  • 8.常用佈局測量流程
    • 8.1 LinearLayout
    • 8.2 FrameLayout
    • 8.3 RelativeLayout

此文為我個人總結學習的View相關知識點和常考知識點,文章中說的每一個點都需要你個人去理解而不是去背,如果這些你都搞懂那麼恭喜你與View相關的知識點應該是難不住你了。

1. View測量佈局繪製整體流程

首先明確兩個概念:

1.1 MeasureSpec是什麼?

MeasureSpec是一個大小跟模式的組合值,MeasureSpec中的值是一個整型(32位)將size和mode打包成一個Int型,其中高兩位是mode,後面30位存的是size,為了減少物件的分配開支所以使用了int型別去進行儲存。要注意的是一般的int值是十進位制的數,而MeasureSpec 是二進位制儲存的。一定要注意的是MeasureSpec是父View對子View的期望寬高要求,可以認為是父View傳遞給子View的。

SpecMode有三類,每一類都表示特殊的含義,如下所示:

  1. UNSPECIFIED: 父容器不對View有任何限制,要多大給多大,這種情況一般用於系統內部,表示一種測量的狀態。 (如ListView或ScrollView)
  2. EXACTLY :一個明確的大小值,如多少多少dp或matchparent
  3. AT_MOST :對應於LayoutParams中的wrap_content。

1.2 LayoutParams是什麼?

其實其中儲存的就是我們XML檔案對View的賦值。

<View    
	android:layout_width="100dp"    
	android:layout_height="100dp"   />
複製程式碼

比如上面這種情況layoutParams.width和layoutParams.height就是100dp

具體分為三種: 1. LayoutParams.MATCH_PARENT:精確模式,大小就是視窗的大小; 2. LayoutParams.WRAP_CONTENT:最大模式,大小不定,但是不能超過視窗的大小; 3. 具體的大小值(比如100dp):精確模式,大小為LayoutParams中指定的大小。

1.3 View的測量流程(Measure):

首先由一段程式碼來說明 程式碼所示:

protected void onMeasure(int parentWidthMeasureSpec, int parentHeightMeasureSpec) {
    for (int i = 0; i < getChildCount(); i++) {
        View child = getChildAt(i);
        //獲取子View的LayoutParams
        final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
        //根據子View自身的LayoutParams和父View的MeasureSpec和可用空間獲取子View自身的MeasureSpec
        //獲取寬度MeasureSpec
        final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
                mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
                        + widthUsed, lp.width);
        //獲取高度MeasureSpec
        final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
                mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin
                        + heightUsed, lp.height);
        //根據父View對子View的期望MeasureSpec結合自身的規則進行最終的測量得出自身的期望寬高
        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
        widthUsed+=child.getMeasuredWidth();
        heightUsed+=child.getMeasuredHeight();
    }
    //給父View設定上最終的期望寬高
    setMeasuredDimension(widthUsed, heightUsed);
}
複製程式碼

以ViewGroup為例

1.首先會遍歷所有子View。(for迴圈)

2.根據子View自身的LayoutParams和父View自身的MeasureSpec以及父View的可用空間獲取子View自身的MeasureSpec,這個MeasureSpec是父View對子View的期望寬高。(對應getChildMeasureSpec方法,最終在getChildMeasureSpec方法中使用MeasureSpec.makeMeasureSpec(size, mode) 來求得結果)

(有這一步的原因是因為我們在XML中定義的View寬高比如說是match_parent或wrap_content這種格式,那麼我們其實並不知道他具體應該被賦值多大,google就要幫我們計算你match_parent的時候是多大,wrap_content的是多大,這個計算過程,就是計算出來的父View的MeasureSpec不斷往子View傳遞,結合子View的LayoutParams 一起再算出子View的MeasureSpec,然後繼續傳給子View,不斷計算每個View的MeasureSpec,子View有了MeasureSpec才能測量自己和自己的子View。)

3.子View根據父View對其的期望寬高和自身的規則算出其最終的期望寬高。(child.measure(childWidthMeasureSpec, childHeightMeasureSpec)) (這裡的自身規則指的是其在OnMeasure中的邏輯,比如TextView會根據其中字串的長度高度確定最終的大小值)。

MeasureSpec中的值既然是父View對子View的期望值,那麼最外層的View是如何設定的?

在最外層的DecorView中,有這樣一段程式碼:

private void performTraversals() {
......
int childWidthMeasureSpec = getRootMeasureSpec(mWidth, lp.width);
int childHeightMeasureSpec = getRootMeasureSpec(mHeight, lp.height);
......
mView.measure(childWidthMeasureSpec, childHeightMeasureSpec);
......
mView.layout(0, 0, mView.getMeasuredWidth(), mView.getMeasuredHeight());
......
mView.draw(canvas);
......
}

private static int getRootMeasureSpec(int windowSize, int rootDimension) {
   int measureSpec;
   switch (rootDimension) {
   case ViewGroup.LayoutParams.MATCH_PARENT:
   measureSpec = MeasureSpec.makeMeasureSpec(windowSize,MeasureSpec.EXACTLY);
   break;
   ......
  }
return measureSpec;
}
複製程式碼

可以看到我們最外層的View也就是DecorView中根據getRootMeasureSpec這個方法獲取的MeasureSpec的Mode是EXACTLY,size是螢幕的寬高。 也就是說我們最外層的DecorView中預設的寬高就是螢幕的寬高,EXACTLY代表固定大小。

1.4 在getChildMeasureSpec方法中都做了什麼?

在這個方法中子View根據自身的LayoutParams和父View自身的MeasureSpec及可用空間獲取子View自身的MeasureSpec。

面試系列之View相關知識點
可以看到當我們定義子View為match_parent或wrap_content的時候,最終生成的MeasureSpec的Size為父View的大小,而在View的預設實現中當呼叫measure開始測量後走到onMearsure設定最終期望寬高的時候預設實現為直接使用MeasureSpec中的Size值。

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {    
    setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
                     getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
 }

public static int getDefaultSize(int size, int measureSpec) {
    int result = size;
    int specMode = MeasureSpec.getMode(measureSpec);
    int specSize = MeasureSpec.getSize(measureSpec);
    switch (specMode) {
    case MeasureSpec.UNSPECIFIED:
        result = size;
        break;
    case MeasureSpec.AT_MOST:
    case MeasureSpec.EXACTLY:
        result = specSize;
        break;
    }
    return result;
}
複製程式碼

也就是說當我們自定義View的時候如果我們需要使自己的View支援wrap_content,那麼就必須重寫OnMeasure方法並對wrap_content做一個特殊的測量,否則在wrap_content的情況下我們自定義View的大小就會和父View的大小相同。

1.5 Layout佈局過程

Layout的作用是ViewGroup用來確定子元素的位置,當ViewGroup的位置被確定後,它在onLayout中會遍歷所有的子元素並呼叫其layout方法,在layout方法中的onLayout方法又會被呼叫。 layout方法中會呼叫setFrame方法儲存其在ViewGroup中的位置,自定義ViewGroup的時候必須重寫OnLayout方法,在其中進行子View位置的設定。

  • 在View中onLayout預設是一個空實現
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {  
}  
複製程式碼
  • 在ViewGroup中是抽象方法,所以重寫ViewGroup的時候必須去實現OnLayout方法。
@Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            child.layout(l, t, r,b);
        } 
複製程式碼

具體的計算過程可以看下最簡單FrameLayout 的onLayout 函式的原始碼,每個不同的ViewGroup 的實現都不一樣。 MeasuredWidth和MeasuredHeight這兩個引數為layout過程提供了一個很重要的依據(如果不知道View的大小,你怎麼固定四個點的位置呢),但是這兩個引數也不是必須的,layout過程中的4個引數l, t, r, b完全可以由我們任意指定,而View的最終的佈局位置和大小(mRight - mLeft=實際寬或者mBottom-mTop=實際高)完全由這4個引數決定,但通常情況下用的就是第一步在measure過程中計算出來的期望寬高。

從measure和layout方法中可以看出的另一點是measure只是進行一些初始化引數的工作,真正的測量邏輯是在OnMeasure中進行的。而layout方法直接對你的View進行了位置和大小的確定,真正的邏輯不是在OnLayout中進行的。

1.6 Draw過程

View的繪製主要分為四部分:

  1. 繪製背景background.draw(canvas)。
  2. 繪製自己(onDraw)。
  3. 繪製children(dispatchDraw)。
  4. 繪製裝飾(onDrawScrollBars)。

OnDraw

onDraw(canvas) 方法是view用來draw 自己的,具體如何繪製,顏色線條什麼樣式就需要子View自己去實現,View.java 的onDraw(canvas) 是空實現,ViewGroup 也沒有實現,每個View的內容是各不相同的,所以需要由子類去實現具體邏輯。

dispatchDraw

dispatchDraw(canvas) 方法是用來繪製子View的,View.java 的dispatchDraw()方法是一個空方法,因為View沒有子View,不需要實現dispatchDraw ()方法,ViewGroup就不一樣了,它實現了dispatchDraw ()方法並在其中遍歷子View然後呼叫子View的draw()方法。

當我們自定義ViewGroup的時候預設是不會執行OnDraw方法的(ViewGroup預設呼叫了setWillNotDraw(true),因為系統預設認為我們不會在ViewGroup中繪製內容),我們如果需要進行繪製可以在dispatchDraw中去進行或者呼叫setWillNotDraw(false)方法。

從setWillNotDraw這個方法的註釋中可以看出,如果一個View不需要繪製任何內容,那麼設定這個標記位為true以後,系統會進行相應的優化。預設情況下,View沒有啟用這個優化標記位,但是ViewGroup會預設啟用這個優化標記位。這個標記位對實際開發的意義是:當我們的自定義控制元件繼承於ViewGroup並且本身不具備繪製功能時,就可以開啟這個標記位從而便於系統進行後續的優化。當然,當明確知道一個ViewGroup需要通過onDraw來繪製內容時,我們需要顯式地關閉WILL_NOT_DRAW這個標記位。

/**
* If this view doesn't do any drawing on its own,set this flag to
* allow further optimizations. By default,this flag is not set on
* View,but could be set on some View subclasses such as ViewGroup.
*
* Typically,if you override {@link #onDraw(android.graphics.Canvas)}
* you should clear this flag.
*
* @param willNotDraw whether or not this View draw on its own
*/
public void setWillNotDraw(boolean willNotDraw) {
setFlags(willNotDraw ? WILL_NOT_DRAW : 0,DRAW_MASK);
}
複製程式碼

2.getWidth,getMeasureWidth的區別

首先要明確一點,測量得到的寬高並不一定是View的最終寬高,當measure執行完畢後(準確的是我們在onMeasure中呼叫setMeasuredDimension(width,height)方法後)我們就可以得到View的一個期望寬高,通常情況下期望寬高是和最終的寬高相同的,但是也有特殊情況(比如在layout方法最終賦值View寬高的時候手動的修改值而不用測量得到的值)。

  • getMeasureWidth()方法在measure()過程結束後就可以獲取到了,另外,getMeasureWidth()方法中的值是通過setMeasuredDimension()方法來進行設定的。
  • getWidth()方法要在layout()過程結束後才能獲取到,當在layout方法中呼叫setFrame()後就可以獲取此值了,這個值是View的真實寬高。
    • getWidth()方法中的值則是通過檢視右邊的座標減去左邊的座標計算出來的。
public final int getWidth() {
    return mRight - mLeft;
}
複製程式碼

3.requestLayout()、invalidate()與postInvalidate()有什麼區別?

invalidate和postInvalidate都是呼叫onDraw()方法,然後去達到重繪view的目的。 invalidate()用於主執行緒,postInvalidate()用於子執行緒, postInvalidate的原理其實就是通過主執行緒的handler完成執行緒的排程最終在主執行緒中呼叫invalidate方法。 requestLayout()會呼叫measure和layout方法,當View的大小位置需要改變的時候呼叫。如果view的大小發生了變化那麼requestlayout也會呼叫draw()方法。

4.自定義View整體思想和型別

自定義View

1.繼承自系統View(ImageView,TextView等)

一般重寫OnMearsure方法,因為系統View再其自身的OnMearsure,OnDraw中都處理好了內容,我們一般不需要進行修改,複寫的時候通常直接super父類方法然後實現自己的邏輯即可。 比如實現一個正方形的ImageView

2.繼承View

如果你的View是定義了明確寬高的話,那麼通常不需要我們重寫OnMeasure的,如果寬高定義為了wrap_content的話我們需要早OnMeasure中針對wrap_content這種模式進行一個修改並設定最終寬高,因為預設情況下View的wrap_content和match_parent大小是相同的(在getChildMeasureSpec方法計算得出)。 如果我們的一些用到的屬性是跟View的大小變化相關的話,那麼我們可以通過OnSizeChanged去進行監聽(OnSizeChanged在layout方法中的setFrame執行時會被呼叫,也就是說當我們呼叫requestLayout時可以通過OnSizeChanged去獲取新的控制元件寬高等值)。 我們可以在OnDraw中進行內容的繪製,onDraw不要進行過多的耗時操作,如頻繁的建立物件。

3.繼承自ViewGroup

需要重寫OnMeasure並且對子View進行遍歷測量,然後自身去呼叫setMeasureDimens設定自身寬高。 onLayout必須重寫並遍歷子View呼叫其layout方法進行佈局和大小的確定。(如果不呼叫會沒有子View顯示) onDraw預設不執行,如果需要進行繪製可以呼叫setWillNotDraw(false)取消onDraw的禁用或者在dispatchDraw中進行繪製。 TagLayout(流式佈局)佈局思路: 需要定義一個已使用寬度(widthUsed)和高度(heightUsed),在OnMeasure執行完對所有子View測量後,OnLayout方法中根據自身定義的規則如果widthUsed+view.getMeasureWidth>viewGroup.getMeasureWidth的話需要進行換行,widthUsed清零且heightUsed+=view.getMeasureHeight,子View呼叫layout時傳入的四個點座標就是(widthUsed,heightUsed,widthUsed+view.getMeasureWidth,heightUsed+view.getMeasureHeight),以此類推完成所有子View的佈局;

4.繼承自系統ViewGroup

這種情況不需要我們重寫OnMearsure和OnLayout,因為系統已經幫我們寫好了,通常這種情況下是我們將自己定義的佈局新增到ViewGroup中,對整個的View進行一個封裝複用。

5.什麼時候可以獲取到View的寬高,為什麼?

在OnResume執行完後可以獲取寬高,因為View的測繪流程是由ViewRootImpl的performTraversals開始的。當Activity建立時執行到handleResumeActivity方法中先會執行OnResume方法然後WindowManager會呼叫addView將DecorView新增進去,之後ViewRootImpl才會被建立出來從而呼叫performTraversals開始View的測繪流程。

final void handleResumeActivity( ... ... ) {
     // 最終會執行到 onResume(),不是重點
     r = performResumeActivity(token, clearHide, reason);

     if (r != null) {
         final Activity a = r.activity;

         if (r.window == null && !a.mFinished && willBeVisible) {
             r.window = r.activity.getWindow();
             View decor = r.window.getDecorView();
             ViewManager wm = a.getWindowManager();
             // 5. 執行到 WindowManagerImpl 的 addView()
             // 然後會跳轉到 WindowManagerGlobal 的 addView()
             if (a.mVisibleFromClient) {
                 if (!a.mWindowAdded) {
                     a.mWindowAdded = true;
                     wm.addView(decor, l);
                 }
             }
         }
     }
}

public void addView( ... ... ) {
     ViewRootImpl root;
     synchronized (mLock) {
         // 初始化一個 ViewRootImpl 的例項
         root = new ViewRootImpl(view.getContext(), display);
         try {
             // 呼叫 setView,為 root 佈局 setView
             // 其中 view 為傳下來的 DecorView 物件
             // 也就是說,實際上根佈局並不是我們認為的 DecorView,而是 ViewRootImpl
             root.setView(view, wparams, panelParentView);
         }
     }
}

// 6. 將 DecorView 載入到 WindowManager, View 的繪製流程從此刻才開始public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
    // 請求對 View 進行測量和繪製
    // 與 setContentView() 不同,此處的方法是 ViewRootImpl 的方法
    requestLayout();
}

@Overridepublic void requestLayout() {
    if (!mHandlingLayoutInLayoutRequest) {
        checkThread();
        mLayoutRequested = true;
        // 7. 此方法內部有一個 post 了一個 Runnable 物件
        // 在其中又呼叫一個 doTraversal() 方法;
        // 再之後又會呼叫到 performTraversals() 方法,然後 View 的測繪流程就從此處開始了
        scheduleTraversals();
    }
}

private void performTraversals() {
    ... ...
    // Ask host how big it wants to be
    performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
    ... ...
    performLayout(lp, mWidth, mHeight);
    ... ...
    performDraw();
    ... ...
}
複製程式碼

6.獲取控制元件寬高的幾種方法

1.onWindowFocusChanged 這個方法會被呼叫多次,在View初始化完畢後會呼叫,當Activity的視窗得到焦點和失去焦點都會被呼叫一次(Activity繼續執行和暫停執行時)。

2.ViewTreeObserver 當View樹的狀態發生改變或者View樹內部的View可見性發現改變時,onGlobalLayout方法將被回撥。

3.View.post(new Runnble) 內部分兩種情況: 第一種View已經完成測繪(這種直接呼叫主執行緒handler.post(new Runnable)傳送一個Message並回撥給Runnble處理) 第二種View沒有完成測繪,這種會先將Runnble任務通過陣列儲存下來,當View開始測繪時(ViewRootImpl.performTraversals())會將包存下來的Runnble任務通過主執行緒handler進行傳送訊息,由於訊息在messagequeue中是序列處理的,所以view.post的Runnble任務會在view的測繪完成後在開始執行其自身的訊息,這時View已經完成測繪,自然就可以獲取到寬高了。 更詳細的可參考: www.cnblogs.com/dasusu/p/80…

7.子執行緒中真的不能更新UI嗎?

眾所周知安卓不允許在非UI執行緒中去更新UI,每當我們對View狀態做出改變的時候(如呼叫requestLayout()或invalidate()等方式時)都會去檢查當前執行緒是否是主執行緒,而**檢查執行緒的判斷是在ViewRootImpl的checkThread()方法中去執行的。**也就是說在ViewRootImpl沒有建立出來的時候(OnResume執行完後ViewRootImpl才建立出來的)checkThread()這一步檢測是不會執行的,在這種情況下我們在子執行緒中是可以更新UI的。

ViewRootImpl.java
void checkThread() {
           if (mThread != Thread.currentThread()) {
              throw new CalledFromWrongThreadException(
                "Only the original thread that created a view hierarchy can touch its views.");
             }
}
複製程式碼

8.常用佈局測量流程

8.1 LinearLayout設定權重測量流程

詳細分析可參考https://toutiao.io/posts/08f9tz/preview 垂直佈局分析 設定了權重的View會被測量兩次,沒有隻會測量一次。(特殊情況:如果子View的lp.weight>0且lp.height==0且LinearLayout設定了明確寬高的(mode==MeasureSpec.EXACTLY)情況下子View也只會測量一次。)

1.LinearLayout中的第一個迴圈會遍歷所有的子View計算其高度並將高度進行累加。

  • 如果子View的lp.weight>0且lp.height==0且LinearLayout設定了明確寬高的(mode==MeasureSpec.EXACTLY)情況下子View只會測量一次。

第一次測量完成後會根據LinearLayout總高度-累加高度算出剩餘高度,剩餘高度有可能是負值,最後根據剩餘高度和總權重算出每一份權重的佔比。 2.第二個迴圈會對所有設定了權重weight的子View進行測量,並根據子View設定的權重值分配子View最終的高度。

結論:簡而言之就是第一次迴圈算出所有子View的高度和,然後用Linearlayout自身高度-已用高度算出剩餘高度並根據剩餘高度/總權重算出每一份權重的大小,第二次迴圈給設定了權重的View根據權重設定的值分配大小。

8.2 FrameLayout測量過程

FrameLayout只會測量一次,計算出所有子View的寬高之後,如果FrameLayout自身MeasureSpec.MODE=EXACTLY,那麼它最終寬高就是設定的值,如果是MeasureSpec.MODE=AT_MOST(wrap_content)的話那麼最終寬高會選取所有子View中的最大寬和最大高作為最終寬高。

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
...
for (int i = 0; i < count; i++) {
    final View child = getChildAt(i);
    if (mMeasureAllChildren || child.getVisibility() != GONE) {
        //子View測量自身寬高,因為Framelayout內部View可重疊放置所以當前可用寬高都傳的0    
        measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);
        final LayoutParams lp = (LayoutParams) child.getLayoutParams();
        //記錄最大寬高
        maxWidth = Math.max(maxWidth,
                child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin);
        maxHeight = Math.max(maxHeight,
                child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin);
        childState = combineMeasuredStates(childState, child.getMeasuredState());
        if (measureMatchParentChildren) {
            if (lp.width == LayoutParams.MATCH_PARENT ||
                    lp.height == LayoutParams.MATCH_PARENT) {
                mMatchParentChildren.add(child);
            }
        }
    }
}
   //修正最大寬高
// Account for padding too
maxWidth += getPaddingLeftWithForeground() + getPaddingRightWithForeground();
maxHeight += getPaddingTopWithForeground() + getPaddingBottomWithForeground();
// Check against our minimum height and width
maxHeight = Math.max(maxHeight, getSuggestedMinimumHeight());
maxWidth = Math.max(maxWidth, getSuggestedMinimumWidth());
// Check against our foreground's minimum height and width
final Drawable drawable = getForeground();
if (drawable != null) {
    maxHeight = Math.max(maxHeight, drawable.getMinimumHeight());
    maxWidth = Math.max(maxWidth, drawable.getMinimumWidth());
}
//設定最終FrameLayou寬高
setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, childState),
        resolveSizeAndState(maxHeight, heightMeasureSpec,
                childState << MEASURED_HEIGHT_STATE_SHIFT));
}

複製程式碼

8.3 RelativeLayout測量過程

在OnMeasure中會測量兩次子View,第一次水平方向根據水平方向規則(toLeft,toBottom等)測量獲取子View左右值(mLeft,mRight),高度可認為設定為最大值。第二次測量根據豎直方向的規則(Above,Bottom等)測量獲取子View上下值(mTop,mBottom)。

為什麼需要測量兩次?

因為RelativeLayout子View之前既可以是水平依賴也可以是豎直依賴,所以水平豎直方向都需要去進行一次測量。 這裡需要注意的一點是在規則的處理上alignParentLeft的優先順序是高於toLeft的。 詳情可見:www.jianshu.com/p/87bc61b8a…

面試系列之View相關知識點

相關文章