深入淺出Android系列之從ViewToBitmap延伸到View的繪製全過程

山有木xi發表於2023-11-28

前言:

最近遇到的一個 Bug 當我們應用將 View 生成圖片後 佈局會出現佈局錯亂的現象, 檢查程式碼發現 之前的工程師的 ui和程式碼邏輯是沒什麼問題的 那問題應該就是出在生成圖片的操作上面,先來看看程式碼

view.measure(
    View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
    View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
)
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
view.draw(canvas)

(生成圖片的程式碼)

整個程式碼的邏輯很簡單,執行了一遍 view measure() layout () draw() 然後在 draw 中放入我們自己的畫布 最後生成 bitmap

經過測試 問題就出現在measure() layout() 先後執行這兩個方法 導致佈局的寬度和高度出現問題 簡單來說 原來一個 TextView 的寬度是 match_conten 當我們呼叫完這兩個方法後就變成了 TextView 文字的寬度 而這個時候如果有其他佈局依賴於這個 TextView 的位置就會導致佈局出現問題

這個時候就在想 這三個方法其實就是 View 的繪製過程啊 但是在我們執行之前 View 生成時應該是已經執行過一次的 所以我把 measure() layout () 註釋掉 單純執行 draw () 發現程式碼是可以正常執行的 同時佈局沒有出現問題

我又試了註釋一個 measure() 執行另外兩個方法 發現也是正常執行 註釋 layout () 執行另外兩個方法 發現執行也是正常的 那麼為什麼先後執行 measure() layout () 佈局就會有問題呢

我開始思考View的繪製過程


DecorView 被載入到 Window 開始

我們都知道, PhoneWindow Android 系統中最基本的視窗系統,每個 Activity 會建立一個。同時, PhoneWindow 也是 Activity View 系統互動的介面。 DecorView 本質上是一個 FrameLayout ,是 Activity 中所有 View 的祖先。

Activity startActivity 開始,最終呼叫到 ActivityThread handleLaunchActivity 方法來建立 Activity ,相關核心程式碼如下:

private void handleLaunchActivity(ActivityClientRecord r, Intent customIntent) {
 
    ....
    // 建立Activity,會呼叫Activity的onCreate方法
    // 從而完成DecorView的建立
    Activity a = performLaunchActivity(r, customIntent);
    if (a != null) {
        r.createdConfig = new Configuration(mConfiguration);
        Bundle oldState = r.state;
        handleResumeActivity(r.tolen, false, r.isForward, !r.activity..mFinished && !r.startsNotResumed);
    }
}
 
final void handleResumeActivity(IBinder token, boolean clearHide, boolean isForward, boolean reallyResume) {
    unscheduleGcIdler();
    mSomeActivitiesChanged = true;
    // 呼叫Activity的onResume方法
    ActivityClientRecord r = performResumeActivity(token, clearHide);
    if (r != null) {
        final Activity a = r.activity;
        ...
        if (r.window == null &&& !a.mFinished && willBeVisible) {
            r.window = r.activity.getWindow();
            // 得到DecorView
            View decor = r.window.getDecorView();
            decor.setVisibility(View.INVISIBLE);
            // 得到了WindowManager,WindowManager是一個介面
            // 並且繼承了介面ViewManager
            ViewManager wm = a.getWindowManager();
            WindowManager.LayoutParams l = r.window.getAttributes();
            a.mDecor = decor;
            l.type = WindowManager.LayoutParams.TYPE_BASE_APPLICATION;
            l.softInputMode |= forwardBit;
            if (a.mVisibleFromClient) {
                a.mWindowAdded = true;
                // WindowManager的實現類是WindowManagerImpl,
                // 所以實際呼叫的是WindowManagerImpl的addView方法
                wm.addView(decor, l);
            }
        }
    }
}


在這裡面出現了兩個概念 ViewRoot DecorView, 應該簡單的提兩句 因為 View 的三大流程均是透過 ViewRoot 來完成的 ViewRoot 對應於 ViewRootImpl 類,它是連線 WindowManager DecorView 的紐帶。 Activity 物件被建立完畢後,會將 DecorView 新增到 Window 中,同時會建立 ViewRootImpl 物件,並將 ViewRootImpl 物件和 DecorView 建立關聯。


View 繪製的流程

Android View 的繪製主要是 3 大流程 分別是 measure layout draw 分別對應著測量 位置 和繪製

同時,在Android 中,主要有兩種檢視: View ViewGroup View 就是一個獨立的檢視, ViewGroup 一個容器元件,該容器可容納多個子檢視,即ViewGroup 可容納多個 View ViewGroup ,且支援巢狀。 雖然ViewGroup 繼承於View ,但是在 View 繪製三大流程中,View ViewGroup 它們之間的操作並不完全相同,比如:

  • View ViewGroup 都需要進行 measure ,確定各自的測量寬 / 高。 View 只需直接測量自身即可,而 ViewGroup 通常都必須先測量所有子 View ,最後才能測量自己

  • 通常ViewGroup 先定位自己的位置( layout ),然後再定位其子 View 位置( onLayout

  • View 需要進行 draw 過程,而 ViewGroup 通常不需要(當然也可以進行繪製),因為 ViewGroup 更多作為容器存在,起儲存放置功能


measure:

對於 View 的測量主要分為兩個步驟

  • 求取 View 的測量規格 MeasureSpec

  • 依據上一步求得的MeasureSpec ,對 View 進行測量,求取得到 View 的最終測量寬 / 高。

那什麼是 MeasureSpec呢?MeasureSpec 表示的是一個32 位的整形值,它的高2 位表示測量模式SpecMode ,低30 位表示某種測量模式下的規格大小SpecSize

public static class MeasureSpec {
    private static final int MODE_SHIFT = 30;
    private static final int MODE_MASK = 0X3 << MODE_SHIFT;
 
    // 不指定測量模式, 父檢視沒有限制子檢視的大小,子檢視可以是想要
    // 的任何尺寸,通常用於系統內部,應用開發中很少用到。
    public static final int UNSPECIFIED = 0 << MODE_SHIFT;
 
    // 精確測量模式,檢視寬高指定為match_parent或具體數值時生效,
    // 表示父檢視已經決定了子檢視的精確大小,這種模式下View的測量
    // 值就是SpecSize的值。
    public static final int EXACTLY = 1 << MODE_SHIFT;
 
    // 最大值測量模式,當檢視的寬高指定為wrap_content時生效,此時
    // 子檢視的尺寸可以是不超過父檢視允許的最大尺寸的任何尺寸。
    public static final int AT_MOST = 2 << MODE_SHIFT;
 
    // 根據指定的大小和模式建立一個MeasureSpec
    public static int makeMeasureSpec(int size, int mode) {
        if (sUseBrokenMakeMeasureSpec) {
            return size + mode;
        } else {
            return (size & ~MODE_MASK) | (mode & MODE_MASK);
        }
    }
 
    // 微調某個MeasureSpec的大小
    static int adjust(int measureSpec, int delta) {
        final int mode = getMode(measureSpec);
        if (mode == UNSPECIFIED) {
            // No need to adjust size for UNSPECIFIED mode.
            return make MeasureSpec(0, UNSPECIFIED);
        }
        int size = getSize(measureSpec) + delta;
        if (size < 0) {
            size = 0;
        }
        return makeMeasureSpec(size, mode);
    }
}

MeasureSpec核心程式碼)

一個MeasureSpec 表達的是:該 View 在該種測量模式( SpecMode )下對應的測量尺寸( SpecSize )。其中, SpecMode 有三種型別:

  • UNSPECIFIED :表示父容器對子 View 未施加任何限制,子 View 尺寸想多大就多大。

  • EXACTLY :如果子 View 的模式為 EXACTLY ,則表示子 View 已設定了確切的測量尺寸,或者父容器已檢測出子 View 所需要的確切大小。 這種模式對應於LayoutParams.MATCH_PARENT 和子View 設定具體數值兩種情況。

  • AT_MOST :表示自適應內容,在該種模式下, View 的最大尺寸不能超過父容器的 SpecSize ,因此也稱這種模式為 最大值模式。 這種模式對應於LayoutParams.WRAP_CONTENT


Measure 的基本流程

public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
    ...
    // ViewGroup沒有定義測量的具體過程,因為ViewGroup是一個
    // 抽象類,其測量過程的onMeasure方法需要各個子類去實現
    onMeasure(widthMeasureSpec, heightMeasureSpec);
    ...
}
 
// 不同的ViewGroup子類有不同的佈局特性,這導致它們的測量細節各不相同,如果需要自定義測量過程,則子類可以重寫這個方法
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    // setMeasureDimension方法用於設定View的測量寬高
    setMeasureDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
    getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
 
// 如果View沒有重寫onMeasure方法,則會預設呼叫getDefaultSize來獲得View的寬高
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 = sepcSize;
            break;
    }
    return result;
}

(View . measure()原始碼

View.measure(int, int) 中引數 widthMeasureSpec heightMeasureSpec 是由父容器傳遞進來的,具體的測量過程請參考後文內容。

需要注意的是,View.measure(int, int) 是一個 final 方法,因此其不可被覆寫,實際真正測量 View 自身使用的是 View.onMeasure(int, int) 方法 .

onMeasure 中主要做了三件事

第一件事 透過getSuggestedMinimumWidth()/getSuggestedMinimumHeight() 方法獲取得到 View 的推薦最小測量寬 /

protected int getSuggestedMinimumWidth() {
    return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth());
}
 
protected int getSuggestedMinimumHeight() {
    return (mBackground == null) ? mMinHeight : max(mMinHeight, mBackground.getMinimumHeight());
}

(View.java)

可以很清晰看出,當 View 沒有設定背景時,它的寬度就為 mMinWidth mMinWidth 就是 android:minWidth 這個屬性對應設定的值(未設定 android:minWidth 時,其值預設為 0 ),當 View 設定了背景時,它的寬度就是 mMinWidth mBackground.getMinimumWidth() 之中的較大值

getSuggestedMinimumWidth()/getSuggestedMinimumHeight() 其實就是用於獲取 View 的最小測量寬 / 高,其具體邏輯為:當 View 沒有設定背景時,其最小寬 / 高為 android:minWidth/android:mMinHeight 所指定的值,當 View 設定了背景時,其最小測量寬 / 高為 android:minWidth/android:minHeight 與其背景圖片寬 / 高的較大值。

簡而言之,View 的最小測量寬 / 高為 android:minWidth/android:minHeight 和其背景寬 / 高之間的較大值。


第二件事 透過getDefaultSize() 獲取到 View 的預設測量寬 /

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;
}

getDefaultSize()原始碼)

這裡的size 是透過 getSuggestedMinimumWidth()/getSuggestedMinimumHeight() 方法獲取得到系統建議 View 的最小測量寬 / 高。

引數measureSpec 是經由 View.measure()==>View.onMeasure()==>View.getDefaultSize() 呼叫鏈傳遞進來的,表示的是當前 View MeasureSpec

getDefaultSize() 內部首先會獲取 View 的測量模式和測量大小,然後當 View 的測量模式為 UNSPECIFIED 時,也即未限制 View 的大小,因此此時 View 的大小就是其原生大小(也即 android:minWidth 或背景圖片大小),當 View 的測量模式為 AT_MOST EXACTLY 時,此時不對這兩種模式進行區分,一律將 View 的大小設定為測量大小(即 SpecSize


第三件事 獲取到 View 的測量寬 / 高後,透過 setMeasuredDimension() 記錄 View 的測量寬 / 高:

protected final void setMeasuredDimension(int measuredWidth, int measuredHeight) {
    ...
    setMeasuredDimensionRaw(measuredWidth, measuredHeight);
}
 
// 記錄測量寬/高
private void setMeasuredDimensionRaw(int measuredWidth, int measuredHeight) {
    mMeasuredWidth = measuredWidth;
    mMeasuredHeight = measuredHeight;
 
    mPrivateFlags |= PFLAG_MEASURED_DIMENSION_SET;
}

setMeasuredDimension()原始碼)

無論是對View 的測量還是 ViewGroup 的測量,都是由 View#measure(int widthMeasureSpec, int heightMeasureSpec) 方法負責,然後真正執行 View 測量的是 View onMeasure(int widthMeasureSpec, int heightMeasureSpec) 方法。

 

具體來說,View 直接在 onMeasure() 中測量並設定自己的最終測量寬 / 高。在預設測量情況下, View 的測量寬 / 高由其父容器的 MeasureSpec 和自身的 LayoutParams 共同決定,當 View 自身的測量模式為 LayoutParams.UNSPECIFIED 時,其測量寬 / 高為 android:minWidth/android:minHeight 和其背景寬 / 高之間的較大值,其餘情況皆為自身 MeasureSpec 指定的測量尺寸。

 

而對於ViewGroup 來說,由於佈局特性的豐富性,只能自己手動覆寫 onMeasure() 方法,實現自定義測量過程,但是總的思想都是先測量 子 View 大小,最終才能確定自己的測量大小。


layout:

當確定了 View 的測量大小後,接下來就可以來確定 View 的佈局位置了,也即將 View 放置到螢幕具體哪個位置。 這也是 layout 乾的事情

View.layout() 主要就做了兩件事:

  • setFrame() :首先透過 View.setFrame() 來確定自己的佈局位置

  • onLayout() setFrame() 是用於確定 View 自身的佈局位置,而 onLayout() 主要用於確定 子 View 的佈局位置

protected boolean setFrame(int left, int top, int right, int bottom) {
    ...
    // Invalidate our old position
    invalidate(sizeChanged);
 
    mLeft = left;
    mTop = top;
    mRight = right;
    mBottom = bottom;
}

(setFrame()原始碼 )


draw

View 的測量大小,佈局位置都確定後,就可以最終將該 View 繪製到螢幕上了。 這也就是 draw 乾的事情

public void draw(Canvas canvas) {
    ...
    /*
     * Draw traversal performs several drawing steps which must be executed
     * in the appropriate order:
     *
     *      1. Draw the background
     *      2. If necessary, save the canvas' layers to prepare for fading
     *      3. Draw view's content
     *      4. Draw children
     *      5. If necessary, draw the fading edges and restore layers
     *      6. Draw decorations (scrollbars for instance)
     */
 
    // Step 1, draw the background, if needed
    drawBackground(canvas);
 
    // skip step 2 & 5 if possible (common case)
    ...
    // Step 2, save the canvas' layers
    if (drawTop) {
        canvas.saveLayer(left, top, right, top + length, null, flags);
    }
 
    if (drawBottom) {
        canvas.saveLayer(left, bottom - length, right, bottom, null, flags);
    }
 
    if (drawLeft) {
        canvas.saveLayer(left, top, left + length, bottom, null, flags);
    }
 
    if (drawRight) {
        canvas.saveLayer(right - length, top, right, bottom, null, flags);
    }
    ...
    // Step 3, draw the content
    if (!dirtyOpaque) onDraw(canvas);
 
    // Step 4, draw the children
    dispatchDraw(canvas);
 
    // Step 5, draw the fade effect and restore layers
    ...
    if (drawTop) {
        ...
        canvas.drawRect(left, top, right, top + length, p);
    }
 
    if (drawBottom) {
        ...
        canvas.drawRect(left, bottom - length, right, bottom, p);
    }
 
    if (drawLeft) {
        ...
        canvas.drawRect(left, top, left + length, bottom, p);
    }
 
    if (drawRight) {
        ...
        canvas.drawRect(right - length, top, right, bottom, p);
    }
    ...
    // Step 6, draw decorations (foreground, scrollbars)
    onDrawForeground(canvas);
}

(View.draw()原始碼)

View.draw() 主要做了以下 6 件事:

  • 繪製背景:drawBackground()

  • 如果有必要的話,儲存畫布圖層:Canvas.saveLayer()

  • 繪製自己:onDraw()

  • 繪製子View dispatchDraw()

  • 如果有必要的話,繪製淡化效果並恢復圖層:Canvas.drawRect()

  • 繪製裝飾:onDrawForeground()


總結:

View 的繪製主要 三大流程

measure :測量流程,主要負責對 View 進行測量,其核心邏輯位於 View.measure() ,真正的測量處理由 View.onMeasure() 負責。預設的測量規則為:如果 View 的佈局引數為 LayoutParams.WRAP_CONTENT LayoutParams.MATCH_PARENT ,那麼其測量大小為 SpecSize ;如果其佈局引數為 LayoutParams.UNSPECIFIED ,那麼其測量大小為 android:minWidth/android:minHeight 和其背景之間的較大值。

自定義View 通常覆寫 onMeasure() 方法,在其內一般會對 WRAP_CONTENT 預設一個預設值,區分 WARP_CONTENT MATCH_PARENT 效果,最終完成自己的測量寬 / 高。而 ViewGroup onMeasure() 方法中,通常都是先測量子 View ,收集到相應資料後,才能最終測量自己。

 

layout :佈局流程,主要完成對 View 的位置放置,其核心邏輯位於 View.layout() ,該方法內部主要透過 View#setFrame() 記錄自己的四個頂點座標(記錄與對應成員變數中即可),完成自己的位置放置,最後會回撥 View.onLayout() 方法,在其內完成對 子 View 的佈局放置。

不同於 measure 流程首先對 子 View 進行測量,最後才測量自己, layout 流程首先是先定位自己的佈局位置,然後才處理放置 子 View 的佈局位置。

 

draw :繪製流程,就是將 View 繪製到螢幕上,其核心邏輯位於 View#draw() ,主要就是對 背景、自身內容( onDraw() )、子 View dispatchDraw() )、裝飾(捲軸、前景等) 進行繪製。

通常自定義View 覆寫 onDraw() 方法,完成自己的繪製即可, ViewGroup 一般充當容器使用,因此通常無需覆寫 onDraw()

 

Activity 的根檢視(即 DecorView )最終是繫結到 ViewRootImpl ,具體是由 ViewRootImpl#setView(...) 進行繫結關聯的,後續 View 繪製的三大流程都是均有 ViewRootImpl 負責執行的。

View 的測量流程中,最關鍵的一步是求取 View MeasureSpec View MeasureSpec 是在其父容器 MeasureSpec 的約束下,結合自己的 LayoutParams 共同測量得到的,具體的測量邏輯由 ViewGroup.getChildMeasureSpec() 負責。

DecorView MeasureSpec 取決於自己的 LayoutParams 和螢幕尺寸,具體的測量邏輯位於 ViewRootImpl.getRootMeasureSpec()


用邏輯過一遍流程:

1 首先,當 Activity 啟動時,會觸發呼叫到 ActivityThread#handleResumeActivity() ,其內部會經歷一系列過程,生成 DecorView ViewRootImpl 等例項,最後透過 ViewRootImpl#setView(decor,MATCH_PARENT) 設定 Activity View

ViewRootImpl.setView() 內容透過將其成員屬性 ViewRootImpl.mView 指向 DecorView ,完成兩者之間的關聯。)

 

2 ViewRootImpl 成功關聯 DecorView 後,其內部會設定同步屏障併傳送一個 CALLBACK_TRAVERSAL 非同步渲染訊息,在下一次 VSYNC 訊號到來時, CALLBACK_TRAVERSAL 就會得到響應,從而最終觸發執行 ViewRootImpl.performTraversals() ,真正開始執行 View 繪製流程。

 

3 ViewRootImpl.performTraversals() 內部會依次呼叫 ViewRootImpl#performMeasure() ViewRootImpl#performLayout() ViewRootImpl#performDraw() 三大繪製流程,其中:

 

4 performMeasure() :內部主要就是對 DecorView 執行測量流程: DecorView#measure() DecorView 是一個 FrameLayout ,其佈局特性是層疊佈局,所佔的空間就是其 子 View 佔比最大的寬 / 高,因此其測量邏輯( onMeasure() )是先對所有 子 View 進行測量,具體是透過 ViewGroup.measureChildWithMargins(...) 方法對 子 View 進行測量,子 View 測量完成後,記錄最大的寬 / 高,設定為自己的測量大小(透過 View#setMeasuredDimension() ),如此便完成了 DecorView 的測量流程。

 

5 performLayout() :內部其實就是呼叫 DecorView.layout() ,如此便完成了 DecorView 的佈局位置,最後會回撥 DecorView.onLayout() ,負責 子 View 的佈局放置,核心邏輯就是計算出各個 子 View 的座標位置,最後透過 child.layout() 完成 子 View 佈局。

 

6、 performDraw() :內部最終呼叫到的是 DecorView.draw() ,該方法內部並未對繪製流程做任何修改,因此最終執行的是 View.draw() ,所以主要就是依次完成對 DecorView 的 背景、子 View dispatchDraw() ) 和 檢視裝飾(捲軸、前景等) 的繪製


回到開頭 Bug 其原因就是在重新呼叫 measure 後對佈局的位置進行重新測量 ,因為子View已經有了真實的寬度,所以子View的寬度就沿用了真實寬度,又 呼叫了 layout 重新進行佈局導致後續有依賴到這個子View的佈局都出現了問題

解決方案有 2

1、 只呼叫 draw () 方法將當前佈局繪製到畫布上即可

2、 使用 view .drawToBitmap() 方法也可以直接生成 view 的圖片




來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69917874/viewspace-2997821/,如需轉載,請註明出處,否則將追究法律責任。

相關文章