HenCoder Android 開發進階: 自定義 View 1-2 Paint 詳解

扔物線發表於2017-07-17

這期是 HenCoder 自定義繪製的第二期: Paint。如果你沒看過第一期,可以先去看一下第一期:

HenCoder Android 開發進階:自定義 View 1-1 繪製基礎

簡介

上一期我已經簡單說過, CanvasdrawXXX() 方法配合 Paint 的幾個常用方法可以實現最常見的繪製需求;而如果你只會基本的繪製, Paint 的完全功能的掌握,能讓你更進一步,做出一些更加細緻、炫酷的效果。把 Paint 掌握之後,你幾乎不再會遇到「iOS 組可以實現,但你卻實現不了」的繪製效果。

由於依然是講繪製的,所以這期就沒有介紹視訊了。繪製的內容一共需要講大概 5~6 期才能講完,也就是說你要看 5~6 期才能成為自定義繪製的高手。相對於上期的內容,這期的內容更為專項、深度更深。對於沒有深入研究過 Paint 的人,這期是一個對 Paint 的詮釋;而對於嘗試過研究 Paint 但仍然對其中一些 API 有疑惑的人,這期也可以幫你解惑。

另外,也正由於這期的內容是更為專項的,所以建議你在看的時候,不必像上期那樣把所有東西都完全記住,而是隻要把內容理解了就好。這期的內容,只要做到「知道有這麼個東西」,在需要用到的時候能想起來這個功能能不能做、大致用什麼做就好,至於具體的實現,到時候拐回來再翻一次就行了。

好,下面進入正題。

Paint 的 API 大致可以分為 4 類:

  • 顏色
  • 效果
  • drawText() 相關
  • 初始化

下面我就對這 4 類分別進行介紹:

1 顏色

Canvas 繪製的內容,有三層對顏色的處理:

這圖大概看看就行,不用鑽研明白再往下看,因為等這章講完你就懂了。

1.1 基本顏色

畫素的基本顏色,根據繪製內容的不同而有不同的控制方式: Canvas 的顏色填充類方法 drawColor/RGB/ARGB() 的顏色,是直接寫在方法的引數裡,通過引數來設定的(上期講過了); drawBitmap() 的顏色,是直接由 Bitmap 物件來提供的(上期也講過了);除此之外,是圖形和文字的繪製,它們的顏色就需要使用 paint 引數來額外設定了(下面要講的)。

Canvas 的方法 畫素顏色的設定方式
drawColor/RGB/ARGB() 直接作為引數傳入
drawBitmap() bitmap 引數的畫素顏色相同
圖形和文字 (drawCircle() / drawPath() / drawText() ...) paint 引數中設定

Paint 設定顏色的方法有兩種:一種是直接用 Paint.setColor/ARGB() 來設定顏色,另一種是使用 Shader 來指定著色方案。

1.1.1 直接設定顏色

1.1.1.1 setColor(int color)

方法名和使用方法都非常簡單直接,而且這個方法在上期已經介紹過了,不再多說。

paint.setColor(Color.parseColor("#009688"));
canvas.drawRect(30, 30, 230, 180, paint);

paint.setColor(Color.parseColor("#FF9800"));
canvas.drawLine(300, 30, 450, 180, paint);

paint.setColor(Color.parseColor("#E91E63"));
canvas.drawText("HenCoder", 500, 130, paint);複製程式碼

setColor() 對應的 get 方法是 getColor()

1.1.1.2 setARGB(int a, int r, int g, int b)

其實和 setColor(color) 都是一樣一樣兒的,只是它的引數用的是更直接的三原色與透明度的值。實際運用中,setColor()setARGB() 哪個方便和順手用哪個吧。

paint.setARGB(100, 255, 0, 0);
canvas.drawRect(0, 0, 200, 200, paint);
paint.setARGB(100, 0, 0, 0);
canvas.drawLine(0, 0, 200, 200, paint);複製程式碼

1.1.2 setShader(Shader shader) 設定 Shader

除了直接設定顏色, Paint 還可以使用 Shader

Shader 這個英文單詞很多人沒有見過,它的中文叫做「著色器」,也是用於設定繪製顏色的。「著色器」不是 Android 獨有的,它是圖形領域裡一個通用的概念,它和直接設定顏色的區別是,著色器設定的是一個顏色方案,或者說是一套著色規則。當設定了 Shader 之後,Paint 在繪製圖形和文字時就不使用 setColor/ARGB() 設定的顏色了,而是使用 Shader 的方案中的顏色。

在 Android 的繪製裡使用 Shader ,並不直接用 Shader 這個類,而是用它的幾個子類。具體來講有 LinearGradient RadialGradient SweepGradient BitmapShader ComposeShader 這麼幾個:

1.1.2.1 LinearGradient 線性漸變

設定兩個點和兩種顏色,以這兩個點作為端點,使用兩種顏色的漸變來繪製顏色。就像這樣:

Shader shader = new LinearGradient(100, 100, 500, 500, Color.parseColor("#E91E63"),
        Color.parseColor("#2196F3"), Shader.TileMode.CLAMP);
paint.setShader(shader);

...

canvas.drawCircle(300, 300, 200, paint);複製程式碼

設定了 Shader 之後,繪製出了漸變顏色的圓。(其他形狀以及文字都可以這樣設定顏色,我只是沒給出圖。)

注意:在設定了 Shader 的情況下, Paint.setColor/ARGB() 所設定的顏色就不再起作用。

構造方法:
LinearGradient(float x0, float y0, float x1, float y1, int color0, int color1, Shader.TileMode tile)

引數:
x0 y0 x1 y1:漸變的兩個端點的位置
color0 color1 是端點的顏色
tile:端點範圍之外的著色規則,型別是 TileModeTileMode 一共有 3 個值可選: CLAMP, MIRRORREPEATCLAMP (夾子模式???算了這個詞我不會翻)會在端點之外延續端點處的顏色;MIRROR 是映象模式;REPEAT 是重複模式。具體的看一下例子就明白。

CLAMP:

MIRROR:

REPEAT:

1.1.2.2 RadialGradient 輻射漸變

輻射漸變很好理解,就是從中心向周圍輻射狀的漸變。大概像這樣:

Shader shader = new RadialGradient(300, 300, 200, Color.parseColor("#E91E63"),
        Color.parseColor("#2196F3"), Shader.TileMode.CLAMP);
paint.setShader(shader);

...

canvas.drawCircle(300, 300, 200, paint);複製程式碼

構造方法:
RadialGradient(float centerX, float centerY, float radius, int centerColor, int edgeColor, TileMode tileMode)

引數:
centerX centerY:輻射中心的座標
radius:輻射半徑
centerColor:輻射中心的顏色
edgeColor:輻射邊緣的顏色
tileMode:輻射範圍之外的著色模式。

CLAMP:

MIRROR:

REPEAT:

1.1.2.3 SweepGradient 掃描漸變

又是一個漸變。「掃描漸變」這個翻譯我也不知道精確不精確。大概是這樣:

Shader shader = new SweepGradient(300, 300, Color.parseColor("#E91E63"),
        Color.parseColor("#2196F3"));
paint.setShader(shader);

...

canvas.drawCircle(300, 300, 200, paint);複製程式碼

構造方法:
SweepGradient(float cx, float cy, int color0, int color1)

引數:
cx cy :掃描的中心
color0:掃描的起始顏色
color1:掃描的終止顏色

1.1.2.4 BitmapShader

Bitmap 來著色(終於不是漸變了)。其實也就是用 Bitmap 的畫素來作為圖形或文字的填充。大概像這樣:

Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.batman);
Shader shader = new BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
paint.setShader(shader);

...

canvas.drawCircle(300, 300, 200, paint);複製程式碼

嗯,看著跟 Canvas.drawBitmap() 好像啊?事實上也是一樣的效果。如果你想繪製圓形的 Bitmap,就別用 drawBitmap() 了,改用 drawCircle() + BitmapShader 就可以了(其他形狀同理)。

構造方法:
BitmapShader(Bitmap bitmap, Shader.TileMode tileX, Shader.TileMode tileY)

引數:
bitmap:用來做模板的 Bitmap 物件
tileX:橫向的 TileMode
tileY:縱向的 TileMode

CLAMP:

MIRROR:

REPEAT:

1.1.2.5 ComposeShader 混合著色器

所謂混合,就是把兩個 Shader 一起使用。

// 第一個 Shader:頭像的 Bitmap
Bitmap bitmap1 = BitmapFactory.decodeResource(getResources(), R.drawable.batman);
Shader shader1 = new BitmapShader(bitmap1, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);

// 第二個 Shader:從上到下的線性漸變(由透明到黑色)
Bitmap bitmap2 = BitmapFactory.decodeResource(getResources(), R.drawable.batman_logo);
Shader shader2 = new BitmapShader(bitmap2, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);

// ComposeShader:結合兩個 Shader
Shader shader = new ComposeShader(shader1, shader2, PorterDuff.Mode.SRC_OVER);
paint.setShader(shader);

...

canvas.drawCircle(300, 300, 300, paint);複製程式碼

注意:上面這段程式碼中我使用了兩個 BitmapShader 來作為 ComposeShader() 的引數,而 ComposeShader() 在硬體加速下是不支援兩個相同型別的 Shader 的,所以這裡也需要關閉硬體加速才能看到效果。

構造方法:ComposeShader(Shader shaderA, Shader shaderB, PorterDuff.Mode mode)

引數:
shaderA, shaderB:兩個相繼使用的 Shader
mode: 兩個 Shader 的疊加模式,即 shaderAshaderB 應該怎樣共同繪製。它的型別是 PorterDuff.Mode

PorterDuff.Mode

PorterDuff.Mode 是用來指定兩個影像共同繪製時的顏色策略的。它是一個 enum,不同的 Mode 可以指定不同的策略。「顏色策略」的意思,就是說把源影像繪製到目標影像處時應該怎樣確定二者結合後的顏色,而對於 ComposeShader(shaderA, shaderB, mode) 這個具體的方法,就是指應該怎樣把 shaderB 繪製在 shaderA 上來得到一個結合後的 Shader

沒有聽說過 PorterDuff.Mode 的人,看到這裡很可能依然會一頭霧水:「什麼怎麼結合?就……兩個影像一疊加,結合唄?還能怎麼結合?」你還別說,還真的是有很多種策略來結合。

最符合直覺的結合策略,就是我在上面這個例子中使用的 Mode: SRC_OVER。它的演算法非常直觀:就像上面圖中的那樣,把源影像直接鋪在目標影像上。不過,除了這種,其實還有一些其他的結合方式。例如如果我把上面例子中的引數 mode 改為 PorterDuff.Mode.DST_OUT,就會變成挖空效果:

而如果再把 mode 改為 PorterDuff.Mode.DST_IN,就會變成蒙版摳圖效果:

這下明白了吧?

具體來說, PorterDuff.Mode 一共有 17 個,可以分為兩類:

  1. Alpha 合成 (Alpha Compositing)
  2. 混合 (Blending)

第一類,Alpha 合成,其實就是 「PorterDuff」 這個詞所指代的演算法。 「PorterDuff」 並不是一個具有實際意義的片語,而是兩個人的名字(準確講是姓)。這兩個人當年共同發表了一篇論文,描述了 12 種將兩個影像共同繪製的操作(即演算法)。而這篇論文所論述的操作,都是關於 Alpha 通道(也就是我們通俗理解的「透明度」)的計算的,後來人們就把這類計算稱為Alpha 合成 ( Alpha Compositing ) 。

看下效果吧。效果直接盜 Google 的官方文件了。

源影像和目標影像:

Alpha 合成:

第二類,混合,也就是 Photoshop 等製圖軟體裡都有的那些混合模式(multiply darken lighten 之類的)。這一類操作的是顏色本身而不是 Alpha 通道,並不屬於 Alpha 合成,所以和 Porter 與 Duff 這兩個人也沒什麼關係,不過為了使用的方便,它們同樣也被 Google 加進了 PorterDuff.Mode 裡。

效果依然盜 官方文件

結論

從效果圖可以看出,Alpha 合成類的效果都比較直觀,基本上可以使用簡單的口頭表達來描述它們的演算法(起碼對於不透明的源影像和目標影像來說是可以的),例如 SRC_OVER 表示「二者都繪製,但要源影像放在目標影像的上面」,DST_IN 表示「只繪製目標影像,並且只繪製它和源影像重合的區域」。

而混合類的效果就相對抽象一些,只從效果圖不太能看得出它們的著色演算法,更看不出來它們有什麼用。不過沒關係,你如果拿著這些名詞去問你司的設計師,他們八成都能給你說出來個 123。

所以對於這些 Mode,正確的做法是:對於 Alpha 合成類的操作,掌握他們,並在實際開發中靈活運用;而對於混合類的,你只要把它們的名字記住就好了,這樣當某一天設計師告訴你「我要做這種混合效果」的時候,你可以馬上知道自己能不能做,怎麼做。

另外:PorterDuff.Mode 建議你動手用一下試試,對加深理解有幫助。

好了,這些就是幾個 Shader 的具體介紹。

除了使用 setColor/ARGB()setShader() 來設定基本顏色, Paint 還可以來設定 ColorFilter,來對顏色進行第二層處理。

1.2 setColorFilter(ColorFilter colorFilter)

ColorFilter 這個類,它的名字已經足夠解釋它的作用:為繪製設定顏色過濾。顏色過濾的意思,就是為繪製的內容設定一個統一的過濾策略,然後 Canvas.drawXXX() 方法會對每個畫素都進行過濾後再繪製出來。舉幾個現實中比較常見的顏色過濾的例子:

  • 有色光照射:

    w400
    w400

  • 有色玻璃透視:

    w400
    w400

  • 膠捲:

Paint 裡設定 ColorFilter ,使用的是 Paint.setColorFilter(ColorFilter filter) 方法。 ColorFilter 並不直接使用,而是使用它的子類。它共有三個子類:LightingColorFilter PorterDuffColorFilterColorMatrixColorFilter

1.2.1 LightingColorFilter

這個 LightingColorFilter 是用來模擬簡單的光照效果的。

LightingColorFilter 的構造方法是 LightingColorFilter(int mul, int add) ,引數裡的 muladd 都是和顏色值格式相同的 int 值,其中 mul 用來和目標畫素相乘,add 用來和目標畫素相加:

R' = R * mul.R / 0xff + add.R
G' = G * mul.G / 0xff + add.G
B' = B * mul.B / 0xff + add.B複製程式碼

一個「保持原樣」的「基本 LightingColorFilter 」,mul0xffffffadd0x000000(也就是0),那麼對於一個畫素,它的計算過程就是:

R' = R * 0xff / 0xff + 0x0 = R // R' = R
G' = G * 0xff / 0xff + 0x0 = G // G' = G
B' = B * 0xff / 0xff + 0x0 = B // B' = B複製程式碼

基於這個「基本 LightingColorFilter 」,你就可以修改一下做出其他的 filter。比如,如果你想去掉原畫素中的紅色,可以把它的 mul 改為 0x00ffff (紅色部分為 0 ) ,那麼它的計算過程就是:

R' = R * 0x0 / 0xff + 0x0 = 0 // 紅色被移除
G' = G * 0xff / 0xff + 0x0 = G
B' = B * 0xff / 0xff + 0x0 = B複製程式碼

具體效果是這樣的:

ColorFilter lightingColorFilter = new LightingColorFilter(0x00ffff, 0x000000);
paint.setColorFilter(lightingColorFilter);複製程式碼

表情忽然變得陰鬱了

或者,如果你想讓它的綠色更亮一些,就可以把它的 add 改為 0x003000 (綠色部分為 0x30 ),那麼它的計算過程就是:

R' = R * 0xff / 0xff + 0x0 = R
G' = G * 0xff / 0xff + 0x30 = G + 0x30 // 綠色被加強
B' = B * 0xff / 0xff + 0x0 = B複製程式碼

效果是這樣:

ColorFilter lightingColorFilter = new LightingColorFilter(0xffffff, 0x003000);
paint.setColorFilter(lightingColorFilter);複製程式碼

這樣的表情才陽光

至於怎麼修改引數來模擬你想要的某種具體光照效果,你就別問我了,還是跟你司設計師討論吧,這個我不專業……

1.2.2 PorterDuffColorFilter

這個 PorterDuffColorFilter 的作用是使用一個指定的顏色和一種指定的 PorterDuff.Mode 來與繪製物件進行合成。它的構造方法是 PorterDuffColorFilter(int color, PorterDuff.Mode mode) 其中的 color 引數是指定的顏色, mode 引數是指定的 Mode。同樣也是 PorterDuff.Mode ,不過和 ComposeShader 不同的是,PorterDuffColorFilter 作為一個 ColorFilter,只能指定一種顏色作為源,而不是一個 Bitmap

PorterDuff.Mode 前面已經講過了,而 PorterDuffColorFilter 本身的使用是非常簡單的,所以不再展開講。

1.2.3 ColorMatrixColorFilter

這個就厲害了。ColorMatrixColorFilter 使用一個 ColorMatrix 來對顏色進行處理。 ColorMatrix 這個類,內部是一個 4x5 的矩陣:

[ a, b, c, d, e,
  f, g, h, i, j,
  k, l, m, n, o,
  p, q, r, s, t ]複製程式碼

通過計算, ColorMatrix 可以把要繪製的畫素進行轉換。對於顏色 [R, G, B, A] ,轉換演算法是這樣的:

R’ = a*R + b*G + c*B + d*A + e;
G’ = f*R + g*G + h*B + i*A + j;
B’ = k*R + l*G + m*B + n*A + o;
A’ = p*R + q*G + r*B + s*A + t;複製程式碼

ColorMatrix 有一些自帶的方法可以做簡單的轉換,例如可以使用 setSaturation(float sat) 來設定飽和度;另外你也可以自己去設定它的每一個元素來對轉換效果做精細調整。具體怎樣設定會有怎樣的效果,我就不講了(其實是我也不太會?)。如果你有需求,可以試一下程大治同學做的這個庫:StyleImageView

以上,就是 Paint 對顏色的第二層處理:通過 setColorFilter(colorFilter) 來加工顏色。

除了基本顏色的設定( setColor/ARGB(), setShader() )以及基於原始顏色的過濾( setColorFilter() )之外,Paint 最後一層處理顏色的方法是 setXfermode(Xfermode xfermode) ,它處理的是「當顏色遇上 View」的問題。

1.3 setXfermode(Xfermode xfermode)

"Xfermode" 其實就是 "Transfer mode",用 "X" 來代替 "Trans" 是一些美國人喜歡用的簡寫方式。嚴謹地講, Xfermode 指的是你要繪製的內容和 Canvas 的目標位置的內容應該怎樣結合計算出最終的顏色。但通俗地說,其實就是要你以繪製的內容作為源影像,以 View 中已有的內容作為目標影像,選取一個 PorterDuff.Mode 作為繪製內容的顏色處理方案。就像這樣:

Xfermode xfermode = new PorterDuffXfermode(PorterDuff.Mode.DST_IN);

...

canvas.drawBitmap(rectBitmap, 0, 0, paint); // 畫方
paint.setXfermode(xfermode); // 設定 Xfermode
canvas.drawBitmap(circleBitmap, 0, 0, paint); // 畫圓
paint.setXfermode(null); // 用完及時清除 Xfermode複製程式碼

xfermode1
xfermode1

又是 PorterDuff.ModePorterDuff.ModePaint 一共有三處 API ,它們的工作原理都一樣,只是用途不同:

API 用途
ComposeShader 混合兩個 Shader
PorterDuffColorFilter 增加一個單色的 ColorFilter
Xfermode 設定繪製內容和 View 中已有內容的混合計算方式

另外,從上面的示例程式碼可以看出,建立 Xfermode 的時候其實是建立的它的子類 PorterDuffXfermode。而事實上,Xfermode 也只有這一個子類。所以在設定 Xfermode 的時候不用多想,直接用 PorterDuffXfermode 吧。

「只有一個子類???什麼設計?」

其實在更早的 Android 版本中,Xfermode 還有別的子類,但別的子類現在已經 deprecated 了,如今只剩下了 PorterDuffXfermode。所以目前它的使用看起來好像有點囉嗦,但其實是由於歷史遺留問題。

Xfermode 注意事項

Xfermode 使用很簡單,不過有兩點需要注意:

1. 使用離屏緩衝(Off-screen Buffer)

實質上,上面這段例子程式碼,如果直接執行的話是不會繪製出圖中效果的,程式的繪製也不會像上面的動畫那樣執行,而是會像這樣:

為什麼會這樣?

按照邏輯我們會認為,在第二步畫圓的時候,跟它共同計算的是第一步繪製的方形。但實際上,卻是整個 View 的顯示區域都在畫圓的時候參與計算,並且 View 自身的底色並不是預設的透明色,而且是遵循一種迷之邏輯,導致不僅繪製的是整個圓的範圍,而且在範圍之外都變成了黑色。就像這樣:

xfermode2
xfermode2

這……那可如何是好?

要想使用 setXfermode() 正常繪製,必須使用離屏快取 (Off-screen Buffer) 把內容繪製在額外的層上,再把繪製好的內容貼回 View 中。也就是這樣:

xfermode3
xfermode3

通過使用離屏緩衝,把要繪製的內容單獨繪製在緩衝層, Xfermode 的使用就不會出現奇怪的結果了。使用離屏緩衝有兩種方式:

  • Canvas.saveLayer()

    saveLayer() 可以做短時的離屏緩衝。使用方法很簡單,在繪製程式碼的前後各加一行程式碼,在繪製之前儲存,繪製之後恢復:

    int saved = canvas.saveLayer(null, null, Canvas.ALL_SAVE_FLAG);
    
    canvas.drawBitmap(rectBitmap, 0, 0, paint); // 畫方
    paint.setXfermode(xfermode); // 設定 Xfermode
    canvas.drawBitmap(circleBitmap, 0, 0, paint); // 畫圓
    paint.setXfermode(null); // 用完及時清除 Xfermode
    
    canvas.restoreToCount(saved);複製程式碼
  • View.setLayerType()

    View.setLayerType() 是直接把整個 View 都繪製在離屏緩衝中。 setLayerType(LAYER_TYPE_HARDWARE) 是使用 GPU 來緩衝, setLayerType(LAYER_TYPE_SOFTWARE) 是直接直接用一個 Bitmap 來緩衝。

關於 Canvas.saveLayer()View.setLayerType() ,這裡就不細講它們的意義和原理了,後面也許我會專門用一期來講它們。

如果沒有特殊需求,可以選用第一種方法 Canvas.saveLayer() 來設定離屏緩衝,以此來獲得更高的效能。更多關於離屏緩衝的資訊,可以看官方文件中對於硬體加速的介紹。

2. 控制好透明區域

使用 Xfermode 來繪製的內容,除了注意使用離屏緩衝,還應該注意控制它的透明區域不要太小,要讓它足夠覆蓋到要和它結合繪製的內容,否則得到的結果很可能不是你想要的。我用圖片來具體說明一下:

如圖所示,由於透明區域過小而覆蓋不到的地方,將不會受到 Xfermode 的影響。

好,到此為止,前面講的就是 Paint 的第一類 API——關於顏色的三層設定:直接設定顏色的 API 用來給圖形和文字設定顏色; setColorFilter() 用來基於顏色進行過濾處理; setXfermode() 用來處理源影像和 View 已有內容的關係。

再貼一次本章開始處的圖作為回顧:

2 效果

效果類的 API ,指的就是抗鋸齒、填充/輪廓、線條寬度等等這些。

2.1 setAntiAlias (boolean aa) 設定抗鋸齒

抗鋸齒在上一節已經講過了,話不多說,直接上圖:

抗鋸齒預設是關閉的,如果需要抗鋸齒,需要顯式地開啟。另外,除了 setAntiAlias(aa) 方法,開啟抗鋸齒還有一個更方便的方式:構造方法。建立 Paint 物件的時候,構造方法的引數里加一個 ANTI_ALIAS_FLAG 的 flag,就可以在初始化的時候就開啟抗鋸齒。

Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);複製程式碼

2.2 setStyle(Paint.Style style)

setStyle(style) 也在上一節講過了,用來設定圖形是線條風格還是填充風格的(也可以二者並用):

paint.setStyle(Paint.Style.FILL); // FILL 模式,填充
canvas.drawCircle(300, 300, 200, paint);複製程式碼

paint.setStyle(Paint.Style.STROKE); // STROKE 模式,畫線
canvas.drawCircle(300, 300, 200, paint);複製程式碼

paint.setStyle(Paint.Style.FILL_AND_STROKE); // FILL_AND_STROKE 模式,填充 + 畫線
canvas.drawCircle(300, 300, 200, paint);複製程式碼

FILL 模式是預設模式,所以如果之前沒有設定過其他的 Style,可以不用 setStyle(Paint.Style.FILL) 這句。

2.3 線條形狀

設定線條形狀的一共有 4 個方法:setStrokeWidth(float width), setStrokeCap(Paint.Cap cap), setStrokeJoin(Paint.Join join), setStrokeMiter(float miter)

2.3.1 setStrokeWidth(float width)

設定線條寬度。單位為畫素,預設值是 0。

paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(1);
canvas.drawCircle(150, 125, 100, paint);
paint.setStrokeWidth(5);
canvas.drawCircle(400, 125, 100, paint);
paint.setStrokeWidth(40);
canvas.drawCircle(650, 125, 100, paint);複製程式碼

線條寬度 0 和 1 的區別

預設情況下,線條寬度為 0,但你會發現,這個時候它依然能夠畫出線,線條的寬度為 1 畫素。那麼它和線條寬度為 1 有什麼區別呢?

其實這個和後面要講的一個「幾何變換」有關:你可以為 Canvas 設定 Matrix 來實現幾何變換(如放大、縮小、平移、旋轉),在幾何變換之後 Canvas 繪製的內容就會發生相應變化,包括線條也會加粗,例如 2 畫素寬度的線條在 Canvas 放大 2 倍後會被以 4 畫素寬度來繪製。而當線條寬度被設定為 0 時,它的寬度就被固定為 1 畫素,就算 Canvas 通過幾何變換被放大,它也依然會被以 1 畫素寬度來繪製。Google 在文件中把線條寬度為 0 時稱作「hairline mode(髮際線模式)」。

2.3.2 setStrokeCap(Paint.Cap cap)

設定線頭的形狀。線頭形狀有三種:BUTT 平頭、ROUND 圓頭、SQUARE 方頭。預設為 BUTT

放出「平頭」「圓頭」「方頭」這種翻譯我始終有點糾結:既覺得自己翻譯得簡潔清晰盡顯機智,同時又擔心用詞會不會有點太過通俗,讓人覺得我不夠高貴冷豔?

當線條的寬度是 1 畫素時,這三種線頭的表現是完全一致的,全是 1 個畫素的點;而當線條變粗的時候,它們就會表現出不同的樣子:

虛線是額外加的,虛線左邊是線的實際長度,虛線右邊是線頭。有了虛線作為輔助,可以清楚地看出 BUTTSQUARE 的區別。

2.3.3 setStrokeJoin(Paint.Join join)

設定拐角的形狀。有三個值可以選擇:MITER 尖角、 BEVEL 平角和 ROUND 圓角。預設為 MITER

輔助理解:

MITER 在現實中其實就是這玩意:

而 BEVEL 是這玩意:

2.3.4 setStrokeMiter(float miter)

這個方法是對於 setStrokeJoin() 的一個補充,它用於設定 MITER 型拐角的延長線的最大值。所謂「延長線的最大值」,是這麼一回事:

當線條拐角為 MITER 時,拐角處的外緣需要使用延長線來補償:

而這種補償方案會有一個問題:如果拐角的角度太小,就有可能由於出現連線點過長的情況。比如這樣:

所以為了避免意料之外的過長的尖角出現, MITER 型連線點有一個額外的規則:當尖角過長時,自動改用 BEVEL 的方式來渲染連線點。例如上圖的這個尖角,在預設情況下是不會出現的,而是會由於延長線過長而被轉為 BEVEL 型連線點:

至於多尖的角屬於過於尖,尖到需要轉為使用 BEVEL 來繪製,則是由一個屬性控制的,而這個屬性就是 setStrokeMiter(miter) 方法中的 miter 引數。miter 引數是對於轉角長度的限制,具體來講,是指尖角的外緣端點和內部拐角的距離與線條寬度的比。也就是下面這兩個長度的比:

用幾何知識很容易得出這個比值的計算公式:如果拐角的大小為 θ ,那麼這個比值就等於 1 / sin ( θ / 2 ) 。

這個 miter limit 的預設值是 4,對應的是一個大約 29° 的銳角:

預設情況下,大於這個角的尖角會被保留,而小於這個夾角的就會被「削成平頭」

 

所以,這個方法雖然名叫 setStrokeMiter(miter) ,但它其實設定的是「 線條在 Join 型別為 MITER 時對於 MITER 的長度限制」。它的這個名字雖然短,但卻存在一定的迷惑性,如果叫 setStrokeJoinMiterLimit(limit) 就更準確了。 Google 的工程師沒有這麼給它命名,大概也是不想傷害大家的手指吧,畢竟程式設計師何苦為難程式設計師。

以上就是 4 個關於線條形狀的方法: setStrokeWidth(width) setStrokeCap(cap) setStrokeJoint(join)setStrokeMiter(miter)

2.4 色彩優化

Paint 的色彩優化有兩個方法: setDither(boolean dither)setFilterBitmap(boolean filter) 。它們的作用都是讓畫面顏色變得更加「順眼」,但原理和使用場景是不同的。

2.4.1 setDither(boolean dither)

設定影像的抖動。

在介紹抖動之前,先來看一個猥瑣男:

注意毛利小五郎臉上的紅暈,它們並不是使用一片淡紅色塗抹出來的,而是畫了三道深色的紅線。這三道深色紅線放在臉上,給人的視覺效果就成了「淡淡的紅暈」。

抖動的原理和這個類似。所謂抖動(注意,它就叫抖動,不是防抖動,也不是去抖動,有些人在翻譯的時候自作主張地加了一個「防」字或者「去」字,這是不對的),是指把影像從較高色彩深度(即可用的顏色數)向較低色彩深度的區域繪製時,在影像中有意地插入噪點,通過有規律地擾亂影像來讓影像對於肉眼更加真實的做法。

比如向 1 位色彩深度的區域中繪製灰色,由於 1 位深度只包含黑和白兩種顏色,在預設情況下,即不加抖動的時候,只能選擇向上或向下選擇最接近灰色的白色或黑色來繪製,那麼顯示出來也只能是一片白或者一片黑。而加了抖動後,就可以繪製出讓肉眼識別為灰色的效果了:

瞧,像上面這樣,用黑白相間的方式來繪製,就可以騙過肉眼,讓肉眼辨別為灰色了。

嗯?你說你看不出灰色,只看出黑白相間?沒關係,那是因為畫素顆粒太大,我把畫素顆粒縮小,看到完整效果你就會發現變灰了:

這下變灰了吧?

什麼,還沒有變灰?那一定是你看圖的姿勢不對了。

不過,抖動可不只可以用在純色的繪製。在實際的應用場景中,抖動更多的作用是在影像降低色彩深度繪製時,避免出現大片的色帶與色塊。效果盜一下維基百科的圖:

看著很牛逼對吧?確實很牛逼,而且在 Android 裡使用起來也很簡單,一行程式碼就搞定:

paint.setDither(true);複製程式碼

只要加這麼一行程式碼,之後的繪製就是加抖動的了。

不過對於現在(2017年)而言, setDither(dither) 已經沒有當年那麼實用了,因為現在的 Android 版本的繪製,預設的色彩深度已經是 32 位的 ARGB_8888 ,效果已經足夠清晰了。只有當你向自建的 Bitmap 中繪製,並且選擇 16 位色的 ARGB_4444 或者 RGB_565 的時候,開啟它才會有比較明顯的效果。

2.4.2 setFilterBitmap(boolean filter)

設定是否使用雙線性過濾來繪製 Bitmap

影像在放大繪製的時候,預設使用的是最近鄰插值過濾,這種演算法簡單,但會出現馬賽克現象;而如果開啟了雙線性過濾,就可以讓結果影像顯得更加平滑。效果依然盜維基百科的圖:

牛逼吧?而且它的使用同樣也很簡單:

paint.setFilterBitmap(true);複製程式碼

加上這一行,在放大繪製 Bitmap 的時候就會使用雙線性過濾了。

以上就是 Paint 的兩個色彩優化的方法: setDither(dither) ,設定抖動來優化色彩深度降低時的繪製效果; setFilterBitmap(filterBitmap) ,設定雙線性過濾來優化 Bitmap 放大繪製的效果。

2.5 setPathEffect(PathEffect effect)

使用 PathEffect 來給圖形的輪廓設定效果。對 Canvas 所有的圖形繪製有效,也就是 drawLine() drawCircle() drawPath() 這些方法。大概像這樣:

PathEffect pathEffect = new DashPathEffect(new float[]{10, 5}, 10);
paint.setPathEffect(pathEffect);

...

canvas.drawCircle(300, 300, 200, paint);複製程式碼

下面就具體說一下 Android 中的 6 種 PathEffectPathEffect 分為兩類,單一效果的 CornerPathEffect DiscretePathEffect DashPathEffect PathDashPathEffect ,和組合效果的 SumPathEffect ComposePathEffect

2.5.1 CornerPathEffect

把所有拐角變成圓角。

PathEffect pathEffect = new CornerPathEffect(20);
paint.setPathEffect(pathEffect);

...

canvas.drawPath(path, paint);複製程式碼

它的構造方法 CornerPathEffect(float radius) 的引數 radius 是圓角的半徑。

2.5.2 DiscretePathEffect

把線條進行隨機的偏離,讓輪廓變得亂七八糟。亂七八糟的方式和程度由引數決定。

PathEffect pathEffect = new DiscretePathEffect(20, 5);
paint.setPathEffect(pathEffect);

...

canvas.drawPath(path, paint);複製程式碼

DiscretePathEffect 具體的做法是,把繪製改為使用定長的線段來拼接,並且在拼接的時候對路徑進行隨機偏離。它的構造方法 DiscretePathEffect(float segmentLength, float deviation) 的兩個引數中, segmentLength 是用來拼接的每個線段的長度, deviation 是偏離量。這兩個值設定得不一樣,顯示效果也會不一樣,具體的你自己多試幾次就明白了,這裡不再貼更多的圖。

2.5.3 DashPathEffect

使用虛線來繪製線條。

PathEffect pathEffect = new DashPathEffect(new float[]{20, 10, 5, 10}, 0);
paint.setPathEffect(pathEffect);

...

canvas.drawPath(path, paint);複製程式碼

它的構造方法 DashPathEffect(float[] intervals, float phase) 中, 第一個引數 intervals 是一個陣列,它指定了虛線的格式:陣列中元素必須為偶數(最少是 2 個),按照「畫線長度、空白長度、畫線長度、空白長度」……的順序排列,例如上面程式碼中的 20, 5, 10, 5 就表示虛線是按照「畫 20 畫素、空 5 畫素、畫 10 畫素、空 5 畫素」的模式來繪製;第二個引數 phase 是虛線的偏移量。

2.5.4 PathDashPathEffect

這個方法比 DashPathEffect 多一個字首 Path ,所以顧名思義,它是使用一個 Path 來繪製「虛線」。具體看圖吧:

Path dashPath = ...; // 使用一個三角形來做 dash
PathEffect pathEffect = new PathDashPathEffect(dashPath, 40, 0,
        PathDashPathEffectStyle.TRANSLATE);
paint.setPathEffect(pathEffect);

...

canvas.drawPath(path, paint);複製程式碼

它的構造方法 PathDashPathEffect(Path shape, float advance, float phase, PathDashPathEffect.Style style) 中, shape 引數是用來繪製的 Pathadvance 是兩個相鄰的 shape 段之間的間隔,不過注意,這個間隔是兩個 shape 段的起點的間隔,而不是前一個的終點和後一個的起點的距離; phaseDashPathEffect 中一樣,是虛線的偏移;最後一個引數 style,是用來指定拐彎改變的時候 shape 的轉換方式。style 的型別為 PathDashPathEffect.Style ,是一個 enum ,具體有三個值:

  • TRANSLATE:位移
  • ROTATE:旋轉
  • MORPH:變體

2.5.5 SumPathEffect

這是一個組合效果類的 PathEffect 。它的行為特別簡單,就是分別按照兩種 PathEffect 分別對目標進行繪製。

PathEffect dashEffect = new DashPathEffect(new float[]{20, 10}, 0);
PathEffect discreteEffect = new DiscretePathEffect(20, 5); 
pathEffect = new SumPathEffect(dashEffect, discreteEffect);

...

canvas.drawPath(path, paint);複製程式碼

2.5.6 ComposePathEffect

這也是一個組合效果類的 PathEffect 。不過它是先對目標 Path 使用一個 PathEffect,然後再對這個改變後的 Path 使用另一個 PathEffect

PathEffect dashEffect = new DashPathEffect(new float[]{20, 10}, 0);
PathEffect discreteEffect = new DiscretePathEffect(20, 5); 
pathEffect = new ComposePathEffect(dashEffect, discreteEffect);

...

canvas.drawPath(path, paint);複製程式碼

它的構造方法 ComposePathEffect(PathEffect outerpe, PathEffect innerpe) 中的兩個 PathEffect 引數, innerpe 是先應用的, outerpe 是後應用的。所以上面的程式碼就是「先偏離,再變虛線」。而如果把兩個引數調換,就成了「先變虛線,再偏離」。至於具體的視覺效果……我就不貼圖了,你自己試試看吧!

上面這些就是 Paint 中的 6 種 PathEffect。它們有的是有獨立效果的,有的是用來組合不同的 PathEffect 的,功能各不一樣。

注意: PathEffect 在有些情況下不支援硬體加速,需要關閉硬體加速才能正常使用:

  1. Canvas.drawLine()Canvas.drawLines() 方法畫直線時,setPathEffect() 是不支援硬體加速的;
  2. PathDashPathEffect 對硬體加速的支援也有問題,所以當使用 PathDashPathEffect 的時候,最好也把硬體加速關了。

剩下的兩個效果類方法:setShadowLayer()setMaskFilter() ,它們和前面的效果類方法有點不一樣:它們設定的是「附加效果」,也就是基於在繪製內容的額外效果。

2.6 setShadowLayer(float radius, float dx, float dy, int shadowColor)

在之後的繪製內容下面加一層陰影。

paint.setShadowLayer(10, 0, 0, Color.RED);

...

canvas.drawText(text, 80, 300, paint);複製程式碼

效果就是上面這樣。方法的引數裡, radius 是陰影的模糊範圍; dx dy 是陰影的偏移量; shadowColor 是陰影的顏色。

如果要清除陰影層,使用 clearShadowLayer()

注意:

  • 在硬體加速開啟的情況下, setShadowLayer() 只支援文字的繪製,文字之外的繪製必須關閉硬體加速才能正常繪製陰影。

  • 如果 shadowColor 是半透明的,陰影的透明度就使用 shadowColor 自己的透明度;而如果 shadowColor 是不透明的,陰影的透明度就使用 paint 的透明度。

2.7 setMaskFilter(MaskFilter maskfilter)

為之後的繪製設定 MaskFilter。上一個方法 setShadowLayer() 是設定的在繪製層下方的附加效果;而這個 MaskFilter 和它相反,設定的是在繪製層上方的附加效果。

到現在已經有兩個 setXxxFilter(filter) 了。前面有一個 setColorFilter(filter) ,是對每個畫素的顏色進行過濾;而這裡的 setMaskFilter(filter) 則是基於整個畫面來進行過濾。

MaskFilter 有兩種: BlurMaskFilterEmbossMaskFilter

2.7.1 BlurMaskFilter

模糊效果的 MaskFilter

paint.setMaskFilter(new BlurMaskFilter(50, BlurMaskFilter.Blur.NORMAL));

...

canvas.drawBitmap(bitmap, 100, 100, paint);複製程式碼

它的構造方法 BlurMaskFilter(float radius, BlurMaskFilter.Blur style) 中, radius 引數是模糊的範圍, style 是模糊的型別。一共有四種:

  • NORMAL: 內外都模糊繪製
  • SOLID: 內部正常繪製,外部模糊
  • INNER: 內部模糊,外部不繪製
  • OUTER: 內部不繪製,外部模糊(什麼鬼?)

2.7.2 EmbossMaskFilter

浮雕效果的 MaskFilter

paint.setMaskFilter(new EmbossMaskFilter(new float[]{0, 1, 1}, 0.2f, 8, 10));

...

canvas.drawBitmap(bitmap, 100, 100, paint);複製程式碼

它的構造方法 EmbossMaskFilter(float[] direction, float ambient, float specular, float blurRadius) 的引數裡, direction 是一個 3 個元素的陣列,指定了光源的方向; ambient 是環境光的強度,數值範圍是 0 到 1; specular 是炫光的係數; blurRadius 是應用光線的範圍。

不過由於我沒有在專案中使用過 EmbossMaskFilter,對它的每個引數具體調節方式並不熟,你有興趣的話自己研究一下吧。

2.8 獲取繪製的 Path

這是效果類的最後一組方法,也是效果類唯一的一組 get 方法。

這組方法做的事是,根據 paint 的設定,計算出繪製 Path 或文字時的實際 Path

這裡你可能會冒出兩個問題:

  1. 什麼叫「實際 Path」? Path 就是 Path,這加上個「實際」是什麼意思?
  2. 文字的 Path ?文字還有 Path

這兩個問題(咦好像有四個問號)的答案就在後面的內容裡。

2.8.1 getFillPath(Path src, Path dst)

首先解答第一個問題:「實際 Path」。所謂實際 Path ,指的就是 drawPath() 的繪製內容的輪廓,要算上線條寬度和設定的 PathEffect

預設情況下(線條寬度為 0、沒有 PathEffect),原 Path 和實際 Path 是一樣的;而線上條寬度不為 0 (並且模式為 STROKE 模式或 FLL_AND_STROKE ),或者設定了 PathEffect 的時候,實際 Path 就和原 Path 不一樣了:

看明白了嗎?

通過 getFillPath(src, dst) 方法就能獲取這個實際 Path。方法的引數裡,src 是原 Path ,而 dst 就是實際 Path 的儲存位置。 getFillPath(src, dst) 會計算出實際 Path,然後把結果儲存在 dst 裡。

2.8.2 getTextPath(String text, int start, int end, float x, float y, Path path) / getTextPath(char[] text, int index, int count, float x, float y, Path path)

這裡就回答第二個問題:「文字的 Path」。文字的繪製,雖然是使用 Canvas.drawText() 方法,但其實在下層,文字資訊全是被轉化成圖形,對圖形進行繪製的。 getTextPath() 方法,獲取的就是目標文字所對應的 Path 。這個就是所謂「文字的 Path」。

這兩個方法, getFillPath()getTextPath() ,就是獲取繪製的 Path 的方法。之所以把它們歸類到「效果」類方法,是因為它們主要是用於圖形和文字的裝飾效果的位置計算,比如自定義的下劃線效果

到此為止, Paint 的第二類方法——效果類,就也介紹完了。

3 drawText() 相關

Paint 有些設定是文字繪製相關的,即和 drawText() 相關的。

比如設定文字大小:

比如設定文字間隔:

比如設定各種文字效果:

除此之外,Paint 還有很多與文字繪製相關的設定或計算的方法,非常詳細。不過由於太詳細了,相關方法太多了(Paint 超過一半的方法都是 drawText() 相關的,算不算多?),如果放在這裡講它們的話,內容會顯得有點過量。所以這一節我就不講它們了,把它們放在下一節裡單獨講。

4 初始化類

這一類方法很簡單,它們是用來初始化 Paint 物件,或者是批量設定 Paint 的多個屬性的方法。

4.1 reset()

重置 Paint 的所有屬性為預設值。相當於重新 new 一個,不過效能當然高一些啦。

4.2 set(Paint src)

src 的所有屬性全部複製過來。相當於呼叫 src 所有的 get 方法,然後呼叫這個 Paint 的對應的 set 方法來設定它們。

4.3 setFlags(int flags)

批量設定 flags。相當於依次呼叫它們的 set 方法。例如:

paint.setFlags(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);複製程式碼

這行程式碼,和下面這兩行是等價的:

paint.setAntiAlias(true);
paint.setDither(true);複製程式碼

setFlags(flags) 對應的 get 方法是 int getFlags()

好了,這些就是 Paint 的四類方法:顏色類效果類文字繪製相關以及初始化類。其中顏色類、效果類和初始化類都已經在這節裡面講過了,剩下的一類——文字繪製類,下一節單獨講。

最後再強調一遍:這期的內容沒必要全部背會,只要看懂、理解,記住有這麼個東西就行了。以後在用到的時候,再拐回來翻一翻就行了。

練習專案

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

下期預告

下期是文字繪製專場,我將會花一整期的篇幅來詳述文字的繪製。慣例放出部分配圖作為預覽:

感謝

感謝參與這期預釋出內測的讀者:

小邁MadisonRong、小於、戀上你的眸、rubicAndroidmiaoyongjunArchyWang、孫志帥、czwathouTim Aimeecode小生

另外,公開招募內測讀者,願意幫助內測的掃下面的碼加群吧!(已經加過一群的就別加這個了,給別人留個名額,兩個群待遇一樣的。)

讚賞

老規矩,但你的錢換不來任何增值服務,所以真的覺得贊再給錢喲。

相關文章