本節書摘來自異步社群《android開發進階:從小工到專家》一書中的第2章,第2.2節必須掌握的最重要的技能——自定義控件,作者 何紅輝,更多章節内容可以通路雲栖社群“異步社群”公衆号檢視
2.2 必須掌握的最重要的技能——自定義控件
雖然android已經自帶了很多強大的ui控件,但是依舊不能滿足所有開發人員的需求。通常開發人員需要實作設計師精心設計的視覺效果,這種情況下可能現有的控件就不能滿足需求或者說使用現有的控件實作起來成本很高,此時我們隻能尋找是否有類似的開源庫,如果沒有人實作過類似的效果,我們隻能通過自定義view實作。是以,自定義view就成了開發人員必須掌握的最重要技能之一。
自定義view也有幾種實作類型,分别為繼承自view完全自定義、繼承自現有控件(如imageview)實作特定效果、繼承自viewgroup實作布局類,在這其中比較重要的知識點是view的測量與布局、view的繪制、處理觸摸事件、動畫等,也就是本章我們要學習的重要知識點。
2.2.1 最為自由的一種實作——自定義view
繼承自view完全實作自定義控件是最為自由的一種實作,也是相對來說比較複雜的一種。因為你通常需要正确地測量view的尺寸,并且需要手動繪制各種視覺效果,是以,它的工作量相對來說比較大,但是,能夠自由地控制整個view的實作。
下面我們就繼承view來實作一個簡單的imageview,它能夠根據使用者設定的大小将圖檔縮放,使得圖檔在任何尺寸下都能夠正确顯示。
對于繼承自view類的自定義控件來說,核心的步驟分别為尺寸測量與繪制,對應的函數是onmeasure、ondraw。因為view類型的子類也是視圖樹的葉子節點,是以,它隻負責繪制好自身内容即可,而這兩步就是完成它職責的所有工作。
下面我們來簡單實作一個顯示圖檔的imageview,第一版控件的核心代碼如下:
首先我們建立了一個繼承自view的simpleimageview類,在含有構造函數中我們會擷取該控件的屬性,并且進行初始化要繪制的圖檔及畫筆。在values/attr.xml中定義了這個view的屬性,為了便于後續的圓形imageview使用,我們命名為circleimageview,attr.xml中的内容如下:
該屬性集的名字為simpleimageview,裡面隻有一個名為src的整型屬性。我們通過這個屬性為simpleimageview設定圖檔的資源id。代碼如下所示:
注意,在使用自定義的屬性時,我們需要将該屬性所在的命名空間引入到xml檔案中,命名空間實際上就是該工程的應用包名,如上述代碼中的加粗部分。因為自定義的屬性集最終會編譯為r類,r類的完整路徑是應用的包名.“r”,我們的示例應用包名為com.book.jtm,是以,我們引入了一個名為img的命名控件,它的格式為 :
xmlns:名字="http://schemas.android.com/apk/res/應用包名"
此時我們在xml檔案中定義了一個simpleimageview,并且指定它的圖檔資源為drawable目錄下的icon_400,這是values/drawable目錄下的一張圖檔。當應用啟動時會從這個xml布局中解析simpleimageview的屬性,例如寬度、高度都為wrap_content,src屬性為drawable目錄下的icon_400。進入simpleimageview構造函數後會調用initattrs函數進行初始化。
在initattrs函數中,我們首先讀取circleimageview的屬性集typedarray;再從該對象中讀取simpleimageview_src屬性值,該屬性是一個drawable的資源id值;然後我們根據這個id從該typedarray對象中擷取到該id對應的drawable;最後我們調用measuredrawable函數測量該圖檔drawable的大小。代碼如下:
我們在simpleimageview中定義了兩個字段mwidth、mheight,分别表示該視圖的寬度、高度。在measuredrawable函數中,我們通過在xml檔案中指定。資源id對應的drawable得到圖檔的高度和高度,并且把它們當作simpleimageview的寬和高,也就是說圖檔多大,simpleimageview就多大。在simpleimageview被加載時,首先會調用onmeasure函數測量simpleimageview的大小,然後再将圖檔繪制出來。代碼如下:
運作示例,結果如圖2-9所示。

我們總結一下這個過程:
(1)繼承自view建立自定義控件;
(2)如有需要自定義view屬性,也就是在values/attrs.xml中定義屬性集;
(3)在xml中引入命名控件,設定屬性;
(4)在代碼中讀取xml中的屬性,初始化視圖;
(5)測量視圖大小;
(6)繪制視圖内容。
實作起來并不難,但是,這隻是最簡單的imageview而已。simpleimageview的寬、高設定為match_parent會怎麼樣,設定為指定大小的值又會正常顯示嗎?
2.2.2 view的尺寸測量
我們都知道android的視圖數在建立時會調用根視圖的measure、layout、draw三個函數,分别對應尺寸測量、視圖布局、繪制内容。但是,對于非viewgroup類型來說,layout這個步驟是不需要的,因為它并不是一個視圖容器。它需要做的工作隻是測量尺寸與繪制自身内容,上述simpleimageview就是這樣的例子。
但是,simpleimageview的尺寸測量隻能夠根據圖檔的大小進行設定,如果使用者想支援match_parent和具體的寬高值則不會生效,simpleimageview的寬高還是圖檔的寬高。是以,我們需要根據使用者設定的寬高模式來計算simpleimageview的尺寸,而不是一概地使用圖檔的寬高值作為視圖的寬高。
在視圖樹渲染時view系統的繪制流程會從viewroot的performtraversals()方法中開始,在其内部調用view的measure()方法。measure()方法接收兩個參數:widthmeasurespec和heightmeasurespec,這兩個值分别用于确定視圖的寬度、高度的規格和大小。measurespec的值由specsize和specmode共同組成,其中specsize記錄的是大小,specmode記錄的是規格。在支援match_parent、具體寬高值之前,我們需要了解specmode的3種類型,如表2-1所示。
那麼這兩個measurespec又是從哪裡來的呢?其實這是從整個視圖樹的控制類viewrootimpl建立的,在viewrootimpl的measurehierarchy函數中會調用如下代碼擷取measurespec:
從上述程式中可以看到,這裡調用了getrootmeasurespec()方法來擷取widthmeasurespec和heightmeasurespec的值。注意,方法中傳入的參數,參數1為視窗的寬度或者高度,而lp.width和lp.height在建立viewgroup執行個體時就被指派了,它們都等于match_parent。然後看一下getrootmeasurespec()方法中的代碼,如下所示:
從上述程式中可以看到,這裡使用了measurespec.makemeasurespec()方法來組裝一個measurespec,當rootdimension參數等于match_parent時,measurespec的specmode就等于exactly,當rootdimension等于wrap_content時,measurespec的specmode就等于at_most;并且match_parent和wrap_content的specsize都是等于windowsize的,也就意味着根視圖總是會充滿全屏的。
當建構完根視圖的measurespec之後就會執行performmeasure函數從根視圖開始一層一層測量視圖的大小。最終會調用每個view的onmeasure函數,在該函數中使用者需要根據measurespec測量view的大小,最終調用setmeasureddimension函數設定該視圖的大小。下面我們看看simpleimageview根據measurespec設定大小的實作,修改的部分隻有測量視圖的部分,代碼如下:
在onmeasure函數中我們擷取寬、高的模式與大小,然後分别調用measurewidth、measureheight函數根據measurespec的mode與大小計算view的具體大小。在measurespec.unspecified與measurespec.at_most類型中,我們都将view的寬高設定為圖檔的寬高,而使用者指定了具體的大小或match_parent時,它的模式則為exactly,它的值就是measurespec中的值。最後在繪制圖檔時,會根據view的大小重新建立一個圖檔,得到一個與view大小一緻的bitmap,然後繪制到view上。
圖2-10、圖2-11和圖2-12分别為寬高設定為wrap_content、match_parent、具體值的顯示效果。
view的測量是自定義view中最為重要的一步,如果不能正确地測量視圖的大小,那麼将會導緻視圖顯示不完整等情況,這将嚴重影響view的顯示效果。是以,了解measurespec以及正确的測量方法對于開發人員來說是必不可少的。
2.2.3 canvas與paint(畫布與畫筆)
在上一節中我們自定義了一個simpleimageview,該視圖的作用就是用于顯示一張圖檔。圖檔并不是自動顯示在simpleimageview上的,而是我們在ondraw函數中通過canvas和paint繪制到視圖上的,這就引入了canvas和paint這兩個概念。
對于android來說,整個view就是一張畫布,也就是canvas。開發人員可以通過畫筆paint在這張畫布上繪制各種各樣的圖形、元素,例如矩形、圓形、橢圓、文字、圓弧、圖檔等,通過修改畫筆的屬性則可以将同一個元素繪制出不同的效果,例如設定畫筆的顔色為紅色,那麼通過該畫筆繪制一個矩形時,該矩形的顔色則為紅色。
canvas和paint的重要函數如表2-2和表2-3所示。
canvas和paint的函數較多,但了解起來都比較簡單,是以我們不過多贅述。在ondraw方法裡我們經常會看到調用canvas的save和restore方法,這兩個函數很重要,那麼它們的作用是什麼呢?
有的時候我們需要使用canvas來繪制一些特殊的效果,在做一些特殊效果之前,我們希望不儲存原來的canvas狀态,此時需要調用canvas的save函數。執行save之後,可以調用canvas的平移、放縮、旋轉、skew(傾斜)、裁剪等操作,然後再進行其他的繪制操作。當繪制完畢之後,我們需要調用restore函數來恢複canvas之前儲存的狀态。save和restore要配對使用,但需要注意的是,restore函數的調用次數可以比save函數少,不能多,否則會引發異常。
例如,需要在simpleimageview中繪制一個豎向的文本,我們知道 drawtext函數預設是橫向繪制的,如果直接在ondraw函數中繪制文本,那麼得到的效果如圖2-13所示。
實作代碼如下:
但是我們的需求是将文字豎向顯示,那麼如何實作呢?
通常的思路是在繪制文本之前将畫布旋轉一定的角度,使得畫布的角度發生變化,此時再在畫布上繪制文字,得到的效果就是文字被繪制為豎向的。實作代碼如下:
得到的效果如圖2-14所示。
實作思路是在繪制文本之前将畫布旋轉90°,即順時針方向旋轉90°,然後再在畫布上繪制文字,最後将畫布restore到save之前的狀态。整個過程如圖2-15所示。
首先将畫布選擇90°之後畫布大緻如圖2-16所示的第二幅圖,此時原點到了左下角,向右的方向x遞增,向下則為y軸遞增。此時我們在該畫布上繪制文本,假設simpleimageview的left和top都為0,那麼繪制文本的起始坐标為(50,−50),x越大越靠右,y值越小越向上偏移。繪制完文本之後将畫布再還原,此時得到的效果就是文本被豎向顯示了。
2.2.4 自定義viewgroup
自定義viewgroup是另一種重要的自定義view形式,當我們需要自定義子視圖的排列方式時,通常需要通過這種形式實作。例如,最常用的下拉重新整理元件,實作下拉重新整理、上拉加載更多的原理就是自定義一個viewgroup,将header view、content view、footer view從上到下依次布局,如圖2-16所示(紅色區域為螢幕的顯示區域運作時可看到色彩)。然後在初始時通過scroller滾動使得該元件在y軸方向上滾動headerview的高度,這樣當依賴該viewgroup顯示在使用者眼前時headerview就被隐藏掉了,如圖2-17所示。而content view的寬度和高度都是match_parent的,是以,此時螢幕上隻顯示content view,headerview和footerview都被隐藏在螢幕之外。當content view被滾動到頂部,此時如果使用者繼續下拉,那麼該下拉重新整理元件将攔截觸摸事件,然後根據使用者的觸摸事件擷取到手指滑動的y軸距離,并通過scroller将該下拉重新整理元件在y軸上滾動手指滑動的距離,實作headerview顯示與隐藏,進而到達下拉的效果,如圖2-18所示。當使用者滑動到最底部時會觸發加載更多的操作,此時會通過scroller滾動該下拉重新整理元件,将footer view顯示出來,實作加載更多的效果。
)
通過使用scroller使得整個滾動效果更加平滑,使用margin來實作則需要自己來計算滾動時間和margin值,滾動效果不是很流暢,且頻繁地修改布局參數效率也不高。使用scroller隻是滾動位置,而沒有修改布局參數,是以,使用scroller是最好的選擇。