HenCoder Android 自定義 View 1-5: 繪製順序

扔物線發表於2017-08-14

這期是 HenCoder 自定義繪製的第 1-5 期:繪製順序

之前的內容在這裡:
HenCoder Android 開發進階 自定義 View 1-1 繪製基礎
HenCoder Android 開發進階 自定義 View 1-2 Paint 詳解
HenCoder Android 開發進階 自定義 View 1-3 文字的繪製
HenCoder Android 開發進階 自定義 View 1-4 Canvas 對繪製的輔助

如果你沒聽說過 HenCoder,可以先看看這個:
HenCoder:給高階 Android 工程師的進階手冊

簡介

前面幾期講的是「術」,是「用哪些 API 可以繪製什麼內容」。到上一期為止,「術」已經講完了,接下來要講的是「道」,是「怎麼去安排這些繪製」。

這期是「道」的第一期:繪製順序。

Android 裡面的繪製都是按順序的,先繪製的內容會被後繪製的蓋住。比如你在重疊的位置先畫圓再畫方,和先畫方再畫圓所呈現出來的結果肯定是不同的:

而在實際的專案中,繪製內容相互遮蓋的情況是很普遍的,那麼怎麼實現自己需要的遮蓋關係,就是這期要講的內容。

1 super.onDraw() 前 or 後?

前幾期我寫的自定義繪製,全都是直接繼承 View 類,然後重寫它的 onDraw() 方法,把繪製程式碼寫在裡面,就像這樣:

public class AppView extends View {
    ...

    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        ... // 自定義繪製程式碼
    }

    ...
}複製程式碼

這是自定義繪製最基本的形態:繼承 View 類,在 onDraw() 中完全自定義它的繪製。

在之前的樣例中,我把繪製程式碼全都寫在了 super.onDraw() 的下面。不過其實,繪製程式碼寫在 super.onDraw() 的上面還是下面都無所謂,甚至,你把 super.onDraw() 這行程式碼刪掉都沒關係,效果都是一樣的——因為在 View 這個類裡,onDraw() 本來就是空實現:

// 在 View.java 的原始碼中,onDraw() 是空的
// 所以直接繼承 View 的類,它們的 super.onDraw() 什麼也不會做
public class View implements Drawable.Callback,
        KeyEvent.Callback, AccessibilityEventSource {
    ...

    /**
     * Implement this to do your drawing.
     *
     * @param canvas the canvas on which the background will be drawn
     */
    protected void onDraw(Canvas canvas) {
    }

    ...
}複製程式碼

然而,除了繼承 View 類,自定義繪製更為常見的情況是,繼承一個具有某種功能的控制元件,去重寫它的 onDraw() ,在裡面新增一些繪製程式碼,做出一個「進化版」的控制元件:

基於 EditText,在它的基礎上增加了頂部的 Hint Text 和底部的字元計數。

而這種基於已有控制元件的自定義繪製,就不能不考慮 super.onDraw() 了:你需要根據自己的需求,判斷出你繪製的內容需要蓋住控制元件原有的內容還是需要被控制元件原有的內容蓋住,從而確定你的繪製程式碼是應該寫在 super.onDraw() 的上面還是下面。

1.1 寫在 super.onDraw() 的下面

把繪製程式碼寫在 super.onDraw() 的下面,由於繪製程式碼會在原有內容繪製結束之後才執行,所以繪製內容就會蓋住控制元件原來的內容。

這是最為常見的情況:為控制元件增加點綴性內容。比如,在 Debug 模式下繪製出 ImageView 的影像尺寸資訊:

public class AppImageView extends ImageView {
    ...

    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        if (DEBUG) {
            // 在 debug 模式下繪製出 drawable 的尺寸資訊
            ...
        }
    }
}複製程式碼

這招很好用的,試過嗎?

當然,除此之外還有其他的很多用法,具體怎麼用就取決於你的需求、經驗和想象力了。

1.2 寫在 super.onDraw() 的上面

如果把繪製程式碼寫在 super.onDraw() 的上面,由於繪製程式碼會執行在原有內容的繪製之前,所以繪製的內容會被控制元件的原內容蓋住。

相對來說,這種用法的場景就會少一些。不過只是少一些而不是沒有,比如你可以通過在文字的下層繪製純色矩形來作為「強調色」:

public class AppTextView extends TextView {
    ...

    protected void onDraw(Canvas canvas) {
        ... // 在 super.onDraw() 繪製文字之前,先繪製出被強調的文字的背景

        super.onDraw(canvas);
    }
}複製程式碼

2 dispatchDraw():繪製子 View 的方法

講了這幾期,到目前為止我只提到了 onDraw() 這一個繪製方法。但其實繪製方法不是隻有一個的,而是有好幾個,其中 onDraw() 只是負責自身主體內容繪製的。而有的時候,你想要的遮蓋關係無法通過 onDraw() 來實現,而是需要通過別的繪製方法。

例如,你繼承了一個 LinearLayout,重寫了它的 onDraw() 方法,在 super.onDraw() 中插入了你自己的繪製程式碼,使它能夠在內部繪製一些斑點作為點綴:

public class SpottedLinearLayout extends LinearLayout {
    ...

    protected void onDraw(Canvas canvas) {
       super.onDraw(canvas);

       ... // 繪製斑點
    }
}複製程式碼

看起來沒問題對吧?

但是你會發現,當你新增了子 View 之後,你的斑點不見了:

<SpottedLinearLayout
    android:orientation="vertical"
    ... >

    <ImageView ... />

    <TextView ... />

</SpottedLinearLayout>複製程式碼

造成這種情況的原因是 Android 的繪製順序:在繪製過程中,每一個 ViewGroup 會先呼叫自己的 onDraw() 來繪製完自己的主體之後再去繪製它的子 View。對於上面這個例子來說,就是你的 LinearLayout 會在繪製完斑點後再去繪製它的子 View。那麼在子 View 繪製完成之後,先前繪製的斑點就被子 View 蓋住了。

具體來講,這裡說的「繪製子 View」是通過另一個繪製方法的呼叫來發生的,這個繪製方法叫做:dispatchDraw()。也就是說,在繪製過程中,每個 View 和 ViewGroup 都會先呼叫 onDraw() 方法來繪製主體,再呼叫 dispatchDraw() 方法來繪製子 View。

注:雖然 ViewViewGroup 都有 dispatchDraw() 方法,不過由於 View 是沒有子 View 的,所以一般來說 dispatchDraw() 這個方法只對 ViewGroup(以及它的子類)有意義。

回到剛才的問題:怎樣才能讓 LinearLayout 的繪製內容蓋住子 View 呢?只要讓它的繪製程式碼在子 View 的繪製之後再執行就好了。

2.1 寫在 super.dispatchDraw() 的下面

只要重寫 dispatchDraw(),並在 super.dispatchDraw() 的下面寫上你的繪製程式碼,這段繪製程式碼就會發生在子 View 的繪製之後,從而讓繪製內容蓋住子 View 了。

public class SpottedLinearLayout extends LinearLayout {
    ...

    // 把 onDraw() 換成了 dispatchDraw()
    protected void dispatchDraw(Canvas canvas) {
       super.dispatchDraw(canvas);

       ... // 繪製斑點
    }
}複製程式碼

好萌的蝙蝠俠啊

2.2 寫在 super.dispatchDraw() 的上面

同理,把繪製程式碼寫在 super.dispatchDraw() 的上面,這段繪製就會在 onDraw() 之後、 super.dispatchDraw() 之前發生,也就是繪製內容會出現在主體內容和子 View 之間。而這個……

其實和前面 1.1 講的,重寫 onDraw() 並把繪製程式碼寫在 super.onDraw() 之後的做法,效果是一樣的。

能想明白為什麼吧?圖就不上了。

3 繪製過程簡述

繪製過程中最典型的兩個部分是上面講到的主體和子 View,但它們並不是繪製過程的全部。除此之外,繪製過程還包含一些其他內容的繪製。具體來講,一個完整的繪製過程會依次繪製以下幾個內容:

  1. 背景
  2. 主體(onDraw()
  3. 子 View(dispatchDraw()
  4. 滑動邊緣漸變和滑動條
  5. 前景

一般來說,一個 View(或 ViewGroup)的繪製不會這幾項全都包含,但必然逃不出這幾項,並且一定會嚴格遵守這個順序。例如通常一個 LinearLayout 只有背景和子 View,那麼它會先繪製背景再繪製子 View;一個 ImageView 有主體,有可能會再加上一層半透明的前景作為遮罩,那麼它的前景也會在主體之後進行繪製。需要注意,前景的支援是在 Android 6.0(也就是 API 23)才加入的;之前其實也有,不過只支援 FrameLayout,而直到 6.0 才把這個支援放進了 View 類裡。

這其中的第 2、3 兩步,前面已經講過了;第 1 步——背景,它的繪製發生在一個叫 drawBackground() 的方法裡,但這個方法是 private 的,不能重寫,你如果要設定背景,只能用自帶的 API 去設定(xml 佈局檔案的 android:background 屬性以及 Java 程式碼的 View.setBackgroundXxx() 方法,這個每個人都用得很 6 了),而不能自定義繪製;而第 4、5 兩步——滑動邊緣漸變和滑動條以及前景,這兩部分被合在一起放在了 onDrawForeground() 方法裡,這個方法是可以重寫的。

滑動邊緣漸變和滑動條可以通過 xml 的 android:scrollbarXXX 系列屬性或 Java 程式碼的 View.setXXXScrollbarXXX() 系列方法來設定;前景可以通過 xml 的 android:foreground 屬性或 Java 程式碼的 View.setForeground() 方法來設定。而重寫 onDrawForeground() 方法,並在它的 super.onDrawForeground() 方法的上面或下面插入繪製程式碼,則可以控制繪製內容和滑動邊緣漸變、滑動條以及前景的遮蓋關係。

4 onDrawForeground()

首先,再說一遍,這個方法是 API 23 才引入的,所以在重寫這個方法的時候要確認你的 minSdk 達到了 23,不然低版本的手機裝上你的軟體會沒有效果。

onDrawForeground() 中,會依次繪製滑動邊緣漸變、滑動條和前景。所以如果你重寫 onDrawForeground()

4.1 寫在 super.onDrawForeground() 的下面

如果你把繪製程式碼寫在了 super.onDrawForeground() 的下面,繪製程式碼會在滑動邊緣漸變、滑動條和前景之後被執行,那麼繪製內容將會蓋住滑動邊緣漸變、滑動條和前景。

public class AppImageView extends ImageView {
    ...

    public void onDrawForeground(Canvas canvas) {
       super.onDrawForeground(canvas);

       ... // 繪製「New」標籤
    }
}複製程式碼
<!-- 使用半透明的黑色作為前景,這是一種很常見的處理 -->
<AppImageView
    ...
    android:foreground="#88000000" />複製程式碼

左上角的標籤並沒有被黑色遮罩蓋住,而是保持了原有的顏色。

4.2 寫在 super.onDrawForeground() 的上面

如果你把繪製程式碼寫在了 super.onDrawForeground() 的上面,繪製內容就會在 dispatchDraw()super.onDrawForeground() 之間執行,那麼繪製內容會蓋住子 View,但被滑動邊緣漸變、滑動條以及前景蓋住:

public class AppImageView extends ImageView {
    ...

    public void onDrawForeground(Canvas canvas) {
       ... // 繪製「New」標籤

       super.onDrawForeground(canvas);
    }
}複製程式碼

由於被半透明黑色遮罩蓋住,左上角的標籤明顯變暗了。

這種寫法,和前面 2.1 講的,重寫 dispatchDraw() 並把繪製程式碼寫在 super.dispatchDraw() 的下面的效果是一樣的:繪製內容都會蓋住子 View,但被滑動邊緣漸變、滑動條以及前景蓋住。

4.3 想在滑動邊緣漸變、滑動條和前景之間插入繪製程式碼?

很簡單:不行。

雖然這三部分是依次繪製的,但它們被一起寫進了 onDrawForeground() 方法裡,所以你要麼把繪製內容插在它們之前,要麼把繪製內容插在它們之後。而想往它們之間插入繪製,是做不到的。

5 draw() 總排程方法

除了 onDraw() dispatchDraw()onDrawForeground() 之外,還有一個可以用來實現自定義繪製的方法: draw()

draw() 是繪製過程的總排程方法。一個 View 的整個繪製過程都發生在 draw() 方法裡。前面講到的背景、主體、子 View 、滑動相關以及前景的繪製,它們其實都是在 draw() 方法裡的。

// View.java 的 draw() 方法的簡化版大致結構(是大致結構,不是原始碼哦):

public void draw(Canvas canvas) {
    ...

    drawBackground(Canvas); // 繪製背景(不能重寫)
    onDraw(Canvas); // 繪製主體
    dispatchDraw(Canvas); // 繪製子 View
    onDrawForeground(Canvas); // 繪製滑動相關和前景

    ...
}複製程式碼

從上面的程式碼可以看出,onDraw() dispatchDraw() onDrawForeground() 這三個方法在 draw() 中被依次呼叫,因此它們的遮蓋關係也就像前面所說的——dispatchDraw() 繪製的內容蓋住 onDraw() 繪製的內容;onDrawForeground() 繪製的內容蓋住 dispatchDraw() 繪製的內容。而在它們的外部,則是由 draw() 這個方法作為總的排程。所以,你也可以重寫 draw() 方法來做自定義的繪製。

5.1 寫在 super.draw() 的下面

由於 draw() 是總排程方法,所以如果把繪製程式碼寫在 super.draw() 的下面,那麼這段程式碼會在其他所有繪製完成之後再執行,也就是說,它的繪製內容會蓋住其他的所有繪製內容。

它的效果和重寫 onDrawForeground(),並把繪製程式碼寫在 super.onDrawForeground() 時的效果是一樣的:都會蓋住其他的所有內容。

當然了,雖說它們效果一樣,但如果你既重寫 draw() 又重寫 onDrawForeground() ,那麼 draw() 裡的內容還是會蓋住 onDrawForeground() 裡的內容的。所以嚴格來講,它們的效果還是有一點點不一樣的。

但這屬於抬槓……

5.2 寫在 super.draw() 的上面

同理,由於 draw() 是總排程方法,所以如果把繪製程式碼寫在 super.draw() 的上面,那麼這段程式碼會在其他所有繪製之前被執行,所以這部分繪製內容會被其他所有的內容蓋住,包括背景。是的,背景也會蓋住它。

是不是覺得沒用?覺得怎麼可能會有誰想要在背景的下面繪製內容?別這麼想,有的時候它還真的有用。

例如我有一個 EditText

它下面的那條橫線,是 EditText 的背景。所以如果我想給這個 EditText 加一個綠色的底,我不能使用給它設定綠色背景色的方式,因為這就相當於是把它的背景替換掉,從而會導致下面的那條橫線消失:

<EditText
    ...
    android:background="#66BB6A" />複製程式碼

EditText:我到底是個 EditText 還是個 TextView?傻傻分不清楚。

在這種時候,你就可以重寫它的 draw() 方法,然後在 super.draw() 的上方插入程式碼,以此來在所有內容的底部塗上一片綠色:

public AppEditText extends EditText {
    ...

    public void draw(Canvas canvas) {
        canvas.drawColor(Color.parseColor("#66BB6A")); // 塗上綠色

        super.draw(canvas);
    }
}複製程式碼

當然,這種用法並不常見,事實上我也並沒有在專案中寫過這樣的程式碼。但我想說的是,我們作為工程師,是無法預知將來會遇到怎樣的需求的。我們能做的只能是儘量地去多學習一些、多掌握一些,儘量地瞭解我們能夠做什麼、怎麼做,然後在需求到來的時候,就可以多一些自如,少一些束手無策。

注意

關於繪製方法,有兩點需要注意一下:

  1. 出於效率的考慮,ViewGroup 預設會繞過 draw() 方法,換而直接執行 dispatchDraw(),以此來簡化繪製流程。所以如果你自定義了某個 ViewGroup 的子類(比如 LinearLayout)並且需要在它的除 dispatchDraw() 以外的任何一個繪製方法內繪製內容,你可能會需要呼叫 View.setWillNotDraw(false) 這行程式碼來切換到完整的繪製流程(是「可能」而不是「必須」的原因是,有些 ViewGroup 是已經呼叫過 setWillNotDraw(false) 了的,例如 ScrollView)。
  2. 有的時候,一段繪製程式碼寫在不同的繪製方法中效果是一樣的,這時你可以選一個自己喜歡或者習慣的繪製方法來重寫。但有一個例外:如果繪製程式碼既可以寫在 onDraw() 裡,也可以寫在其他繪製方法裡,那麼優先寫在 onDraw() ,因為 Android 有相關的優化,可以在不需要重繪的時候自動跳過 onDraw() 的重複執行,以提升開發效率。享受這種優化的只有 onDraw() 一個方法。

總結

今天的內容就是這些:使用不同的繪製方法,以及在重寫的時候把繪製程式碼放在 super.繪製方法() 的上面或下面不同的位置,以此來實現需要的遮蓋關係。下面用一張圖和一個表格總結一下:

嗯,上面這張圖在前面已經貼過了,不用比較了完全一樣的。

另外別忘了上面提到的那兩個注意事項:

  1. ViewGroup 的子類中重寫除 dispatchDraw() 以外的繪製方法時,可能需要呼叫 setWillNotDraw(false)
  2. 在重寫的方法有多個選擇時,優先選擇 onDraw()

練習專案

為了避免轉頭就忘,強烈建議你趁熱打鐵,做一下這個練習專案:HenCoderPracticeDraw5

這裡放上這期練習專案的圖

下期預告

下期是「道」的第二期:動畫。

本來沒想講動畫的,因為動畫其實不屬於自定義 View 的範疇。不過最近從各個渠道的反饋裡發現有很多人對動畫的掌握都比較模糊,而動畫如果掌握得不好,自定義 View 的開發肯定也會受到限制。所以好吧,增加一期動畫詳解。

順便說一下,「道」一共有三期。在這三期過後,自定義 View 的第一部分:自定義繪製就結束了。

預告圖?什麼預告圖?不存在的。

覺得贊?

如果你看完覺得有收穫,把文章轉發到你的微博、微信群、朋友圈、公眾號,讓其他需要的人也看到吧。

相關文章