Android中Canvas繪圖之PorterDuffXfermode使用及工作原理詳解

孫群發表於2016-01-11

概述

android.graphics.PorterDuffXfermode繼承自android.graphics.Xfermode。在用Android中的Canvas進行繪圖時,可以通過使用PorterDuffXfermode將所繪製的圖形的畫素與Canvas中對應位置的畫素按照一定規則進行混合,形成新的畫素值,從而更新Canvas中最終的畫素顏色值,這樣會建立很多有趣的效果。當使用PorterDuffXfermode時,需要將將其作為引數傳給Paint.setXfermode(Xfermode xfermode)方法,這樣在用該畫筆paint進行繪圖時,Android就會使用傳入的PorterDuffXfermode,如果不想再使用Xfermode,那麼可以執行Paint.setXfermode(null)

PorterDuffXfermode這個類中的Porter和Duff是兩個人名,這兩個人在1984年一起寫了一篇名為《Compositing Digital Images》的論文,點選可檢視該論文。我們知道,一個畫素是由RGBA四個分量組成的,該論文就論述瞭如何實現不同數字影象的畫素之間是如何進行混合的,該論文提出了多種畫素混合的模式。如果做過影象處理開發,會對其比較瞭解,該技術也和OpenGL中的Alpha混合技術異曲同工。

PorterDuffXfermode支援以下十幾種畫素顏色的混合模式,分別為:CLEAR、SRC、DST、SRC_OVER、DST_OVER、SRC_IN、DST_IN、SRC_OUT、DST_OUT、SRC_ATOP、DST_ATOP、XOR、DARKEN、LIGHTEN、MULTIPLY、SCREEN。

我們下面會分析幾個程式碼片段研究PorterDuffXfermode使用及工作原理詳解。


示例一

我們在演示如何使用PorterDuffXfermode之前,先看一下下面這個例子,程式碼如下所示:

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //設定背景色
        canvas.drawARGB(255, 139, 197, 186);

        int canvasWidth = canvas.getWidth();
        int r = canvasWidth / 3;
        //繪製黃色的圓形
        paint.setColor(0xFFFFCC44);
        canvas.drawCircle(r, r, r, paint);
        //繪製藍色的矩形
        paint.setColor(0xFF66AAFF);
        canvas.drawRect(r, r, r * 2.7f, r * 2.7f, paint);
    }

我們重寫了View的onDraw方法,首先將View的背景色設定為綠色,然後繪製了一個黃色的圓形,然後再繪製一個藍色的矩形,效果如下所示:
這裡寫圖片描述

上面演示就是Canvas正常的繪圖流程,沒有使用PorterDuffXfermode。我們簡單分析一下上面這段程式碼:

  1. 首先,我們呼叫了canvas.drawARGB(255, 139, 197, 186)方法將整個Canvas都繪製成一個顏色,在執行完這句程式碼後,canvas上所有畫素的顏色值的ARGB顏色都是(255,139,197,186),由於畫素的alpha分量是255而不是0,所以此時所有畫素都不透明。

  2. 當我們執行了canvas.drawCircle(r, r, r, paint)之後,Android會在所畫圓的位置用黃顏色的畫筆繪製一個黃色的圓形,此時整個圓形內部所有的畫素顏色值的ARGB顏色都是0xFFFFCC44,然後用這些黃色的畫素替換掉Canvas中對應的同一位置中顏色值ARGB為(255,139,197,186)的畫素,這樣就將黃色圓形繪製到Canvas上了。

  3. 當我們執行了canvas.drawRect(r, r, r * 2.7f, r * 2.7f, paint)之後,Android會在所畫矩形的位置用藍色的畫筆繪製一個藍色的矩形,此時整個矩形內部所有的畫素顏色值的ARGB顏色都是0xFF66AAFF,然後用這些藍色的畫素替換掉Canvas中對應的同一位置中的畫素,這樣黃色的圓中的右下角部分的畫素與其他一些背景色畫素就被藍色畫素替換了,這樣就將藍色矩形繪製到Canvas上了。

上述過程雖然簡單,但是瞭解Canvas繪圖時具體的畫素更新過程是真正理解PorterDuffXfermode的工作原理的基礎。


示例二

下面我們使用PorterDuffXfermode對上面的程式碼進行一下修改,修改後的程式碼如下所示:

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //設定背景色
        canvas.drawARGB(255, 139, 197, 186);

        int canvasWidth = canvas.getWidth();
        int r = canvasWidth / 3;
        //正常繪製黃色的圓形
        paint.setColor(0xFFFFCC44);
        canvas.drawCircle(r, r, r, paint);
        //使用CLEAR作為PorterDuffXfermode繪製藍色的矩形
        paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
        paint.setColor(0xFF66AAFF);
        canvas.drawRect(r, r, r * 2.7f, r * 2.7f, paint);
        //最後將畫筆去除Xfermode
        paint.setXfermode(null);
    }

效果如下所示:
這裡寫圖片描述

下面我們對以上程式碼進行一下分析:

  1. 首先,我們呼叫了canvas.drawARGB(255, 139, 197, 186)方法將整個Canvas都繪製成一個顏色,此時所有畫素都不透明。

  2. 然後我們通過呼叫canvas.drawCircle(r, r, r, paint)繪製了一個黃色的圓形到Canvas上面。

  3. 然後我們執行程式碼paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)),將畫筆的PorterDuff模式設定為CLEAR。

  4. 然後呼叫canvas.drawRect(r, r, r * 2.7f, r * 2.7f, paint)方法繪製藍色的矩形,但是最終介面上出現了一個白色的矩形。

  5. 在繪製完成後,我們呼叫paint.setXfermode(null)將畫筆去除Xfermode。

我們具體分析一下白色矩形出現的原因。一般我們在呼叫canvas.drawXXX()方法時都會傳入一個畫筆Paint物件,Android在繪圖時會先檢查該畫筆Paint物件有沒有設定Xfermode,如果沒有設定Xfermode,那麼直接將繪製的圖形覆蓋Canvas對應位置原有的畫素;如果設定了Xfermode,那麼會按照Xfermode具體的規則來更新Canvas中對應位置的畫素顏色。就本例來說,在執行canvas.drawCirlce()方法時,畫筆Paint沒有設定Xfermode物件,所以繪製的黃色圓形直接覆蓋了Canvas上的畫素。當我們呼叫canvas.drawRect()繪製矩形時,畫筆Paint已經設定Xfermode的值為PorterDuff.Mode.CLEAR,此時Android首先是在記憶體中繪製了這麼一個矩形,所繪製的圖形中的畫素稱作源畫素(source,簡稱src),所繪製的矩形在Canvas中對應位置的矩形內的畫素稱作目標畫素(destination,簡稱dst)。源畫素的ARGB四個分量會和Canvas上同一位置處的目標畫素的ARGB四個分量按照Xfermode定義的規則進行計算,形成最終的ARGB值,然後用該最終的ARGB值更新目標畫素的ARGB值。本例中的Xfermode是PorterDuff.Mode.CLEAR,該規則比較簡單粗暴,直接要求目標畫素的ARGB四個分量全置為0,即(0,0,0,0),即透明色,所以我們通過canvas.drawRect()在Canvas上繪製了一個透明的矩形,由於Activity本身螢幕的背景時白色的,所以此處就顯示了一個白色的矩形。


示例三

我們在對示例二中的程式碼進行一下修改,將繪製圓形和繪製矩形相關的程式碼放到canvas.saveLayer()和canvas.restoreToCount()之間,程式碼如下所示:

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //設定背景色
        canvas.drawARGB(255, 139, 197, 186);

        int canvasWidth = canvas.getWidth();
        int canvasHeight = canvas.getHeight();
        int layerId = canvas.saveLayer(0, 0, canvasWidth, canvasHeight, null, Canvas.ALL_SAVE_FLAG);
            int r = canvasWidth / 3;
            //正常繪製黃色的圓形
            paint.setColor(0xFFFFCC44);
            canvas.drawCircle(r, r, r, paint);
            //使用CLEAR作為PorterDuffXfermode繪製藍色的矩形
            paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
            paint.setColor(0xFF66AAFF);
            canvas.drawRect(r, r, r * 2.7f, r * 2.7f, paint);
            //最後將畫筆去除Xfermode
            paint.setXfermode(null);
        canvas.restoreToCount(layerId);
    }

效果如下所示:
這裡寫圖片描述

下面對上述程式碼進行一下分析:

  1. 首先,我們呼叫了canvas.drawARGB(255, 139, 197, 186)方法將整個Canvas都繪製成一個顏色,此時所有畫素都不透明。

  2. 然後我們將主要的程式碼都放到了canvas.saveLayer()以及canvas.restoreToCount()之間,並且我將程式碼縮排了一下。當我們把程式碼寫在canvas.saveXXX()與canvas.restoreXXX()之間時,建議把裡面的程式碼縮排,這樣的寫法便於程式碼可讀,當然程式碼縮排與否不是強制性的,也不會影響執行效果。

關於canvas繪圖中的layer有以下幾點需要說明:

  • canvas是支援圖層layer渲染這種技術的,canvas預設就有一個layer,當我們平時呼叫canvas的各種drawXXX()方法時,其實是把所有的東西都繪製到canvas這個預設的layer上面。

  • 我們還可以通過canvas.saveLayer()新建一個layer,新建的layer放置在canvas預設layer的上部,當我們執行了canvas.saveLayer()之後,我們所有的繪製操作都繪製到了我們新建的layer上,而不是canvas預設的layer。

  • 用canvas.saveLayer()方法產生的layer所有畫素的ARGB值都是(0,0,0,0),即canvas.saveLayer()方法產生的layer初始時時完全透明的。

  • canvas.saveLayer()方法會返回一個int值,用於表示layer的ID,在我們對這個新layer繪製完成後可以通過呼叫canvas.restoreToCount(layer)或者canvas.restore()把這個layer繪製到canvas預設的layer上去,這樣就完成了一個layer的繪製工作。

那你可能感覺到很奇怪,我們只是將繪製圓形與矩形的程式碼放到了canvas.saveLayer()和canvas.restoreToCount()之間,為什麼不再像示例二那樣顯示白色的矩形了?

我們在分析示例二程式碼時知道了最終矩形區域的目標顏色都被重置為透明色(0,0,0,0)了,最後只是由於Activity背景色為白色,所以才最終顯示成白色矩形。在本例中,我們在新建的layer上面繪製完成後,其實矩形區域的目標顏色也還是被重置為透明色(0,0,0,0)了,這樣整個新建layer只有圓的3/4不是透明的,其餘畫素全是透明的,然後我們呼叫canvas.restoreToCount()將該layer又繪製到了Canvas上面去了。在將一個新建的layer繪製到Canvas上去時,Android會用整個layer上面的畫素顏色去更新Canvas對應位置上畫素的顏色,並不是簡單的替換,而是Canvas和新layer進行Alpha混合,可參見此處連結。由於我們的layer中只有兩種畫素:完全透明的和完全不透明的,不存在部分透明的畫素,並且完全透明的畫素的顏色值的四個分量都為0,所以本例就將Canvas和新layer進行Alpha混合的規則簡化了,具體來說:

  • 如果新建layer上面某個畫素的Alpha分量為255,即該畫素完全不透明,那麼Android會直接用該畫素的ARGB值作為Canvas對應位置上畫素的顏色值。
  • 如果新建layer上面某個畫素的Alpha分量為0,即該畫素完全透明,在本例中Alpha分量為0的畫素,其RGB分量也都為0,那麼Android會保留Canvas對應位置上畫素的顏色值。

這樣當將新layer繪製到Canvas上時,完全不透明的3/4黃色圓中的畫素會完全覆蓋Canvas對應位置的畫素,而由於在新layer上面繪製的矩形區域的畫素ARGB都為(0,0,0,0),所以最終Canvas上對應矩形區域還是保持之前的背景色,這樣就不會出現白色的矩形了。

大部分情況下,我們想要本例中實現的效果,而不是想要示例二中形成的白色矩形,所以大部分情況下在使用PorterDuffXfermode時都是結合canvas.saveLayer()、canvas.restoreToCount()的,將關鍵程式碼寫在這兩個方法之間。


一張被不經大腦瘋傳的神圖

如果大家Google或百度PorterDuffXfermode相關的博文,大家肯定會看到下面這張神圖,如下所示:
這裡寫圖片描述

這張圖是Android的sdk下自帶的API的Demo示例中的一個,其原始碼對應的物理路徑是C:\Users\iSpring\AppData\Local\Android\sdk\samples\android-23\legacy\ApiDemos\src\com\example\android\apis\graphics\Xfermodes.java。

這張圖演示了先繪製黃色的圓形,然後將畫筆paint設定為16種不同的PorterDuffXfermode,然後再繪製藍色矩形的效果。

上面的效果看起來貌似很正常,但是我想說的是這張被瘋傳了的上圖對開發者極具誤導性,該圖的出發點是好的,其想直觀表達多種PorterDuffXfermode的效果,為了實現這個目的其在該程式碼中對所繪製的黃色圖形和藍色圖形都做了手腳。

其程式碼中建立黃色圓形的程式碼如下所示:

// create a bitmap with a circle, used for the "dst" image
    static Bitmap makeDst(int w, int h) {
        Bitmap bm = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
        Canvas c = new Canvas(bm);
        Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);

        p.setColor(0xFFFFCC44);
        c.drawOval(new RectF(0, 0, w*3/4, h*3/4), p);
        return bm;
    }

上面的程式碼首先通過指定尺寸,new了一個Bitmap物件,這樣的Bitmap物件預設所有的畫素的顏色都是(0,0,0,0),由於每個畫素的alpha分量都預設為0,所以整個Bitmap都是透明的。然後用該Bitmap構造了一個Canvas物件,這樣當執行c.drawOval()時,會將黃色的圓形繪製到Canvas上,並自動將Canvas上的圖形更新到Canvas所繫結的之前建立的Bitmap上。這樣,Bitmap中的畫素有兩種,一種是位於圓形範圍內的畫素,其畫素值為0xFFFFCC44,另一種是位於圓形範圍外的畫素,其畫素值為0x00000000,也就是說該Bitmap中的黃色圓形區域是不透明的,其餘範圍是透明的。最後將這個Bitmap物件返回,這樣可以在onDraw()方法中通過canvas.drawBitmap()以繪製黃色的圓形。大家注意看c.drawOval()中的RecF引數,right是w*3/4,而不是w,bottom是h*3/4,而不是h。這說明什麼呢?這說明該Bitmap實際的大小要比你在上圖中看到的黃色圓形區域要大,兩者尺寸不一致。

建立藍色矩形的程式碼如下所示:

// create a bitmap with a rect, used for the "src" image
    static Bitmap makeSrc(int w, int h) {
        Bitmap bm = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
        Canvas c = new Canvas(bm);
        Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);

        p.setColor(0xFF66AAFF);
        c.drawRect(w/3, h/3, w*19/20, h*19/20, p);
        return bm;
    }

建立藍色矩形的程式碼與建立黃色圓形的程式碼很相似。大家注意觀察c.drawRect()中的引數,left的值為w/3,而不是0,top的值是h/3,而不是0,right的值為w*19/20,而不是w,bottom的值是h*19/20,而不是h。這說明什麼呢?這說明該Bitmap實際的大小要比你在上圖中看到的藍色矩形區域要大,兩者尺寸不一致。

經過上面的分析我們知道,API Demo中所繪製的Bitmap的實際大小都比我們肉眼看到的實際大小要大,這導致了上圖結果會給開發者帶來很大困惑。我舉個例子,比如當設定的PorterDuffXfermode中的值為CLEAR時,API Demo中肉眼看到的結果是整個圓形都不可見了,其實這是不對的,因為如果makeDst()、makeSrc()方法所得到的Bitmap的實際大小與所畫的圓、矩形實際大小相同,那麼效果應該是隻有圓與矩形相交的圓的右下角的部分被裁剪成透明的了,圓的其他3/4的部分都應該還可見;再比如當設定的PorterDuffXfermode中的值為SRC時,API Demo中肉眼看到的結果是繪製的黃色的圓形完全不可見,繪製的藍色的矩形完全可見,其實這是不對的,因為如果makeDst()、makeSrc()方法所得到的Bitmap的實際大小與所畫的圓、矩形實際大小相同,那麼效果應該是所繪製的黃色的圓形可見,所繪製的藍色的矩形也可見,只不過圓形和矩形相交的區域是藍色的,即正確的效果應該是藍色矩形壓蓋了黃色圓形。


不同混合模式的計算規則

我參考API Demo修改了程式碼,讓makeDst()、makeSrc()方法所得到的Bitmap的實際大小與所畫的圓、矩形實際大小相同,並且為了方便觀察對比,我將整個View的背景設定為綠色,最終執行效果如下所示:
這裡寫圖片描述

上面的例子演示了了16種混合模式的效果,並且關鍵程式碼都放在了canvas.saveLayer()與canvas.restoreToCount()之間,程式碼放在了CSDN上面,是一個Android Studio工程,點此下載

Android在類android.graphics.PorterDuff中定義了18種源畫素與目標畫素進行顏色混合的規則,並且在程式碼中通過註釋的形式簡明介紹了各種混合規則的計算方式,可參考GitHub上的原始碼連結

我們知道一個畫素的顏色由四個分量組成,即ARGB,第一個分量A表示的是Alpha值,後面三個分量RGB表示了顏色。我們用S代表源畫素,源畫素的顏色值可表示為[Sa, Sc],Sa中的a是alpha的縮寫,Sa表示源畫素的Alpha值,Sc中的c是顏色color的縮寫,Sc表示源畫素的RGB。我們用D代表目標畫素,目標畫素的顏色值可表示為[Da, Dc],Da表示目標畫素的Alpha值,Dc表示目標畫素的RGB。

源畫素與目標畫素在不同混合模式下計算顏色的規則如下所示:

  • CLEAR:[0, 0]

  • SRC:[Sa, Sc]

  • DST:[Da, Dc]

  • SRC_OVER:[Sa + (1 - Sa)*Da, Rc = Sc + (1 - Sa)*Dc]

  • DST_OVER:[Sa + (1 - Sa)*Da, Rc = Dc + (1 - Da)*Sc]

  • SRC_IN:[Sa * Da, Sc * Da]

  • DST_IN:[Sa * Da, Sa * Dc]

  • SRC_OUT:[Sa * (1 - Da), Sc * (1 - Da)]

  • DST_OUT:[Da * (1 - Sa), Dc * (1 - Sa)]

  • SRC_ATOP:[Da, Sc * Da + (1 - Sa) * Dc]

  • DST_ATOP:[Sa, Sa * Dc + Sc * (1 - Da)]

  • XOR:[Sa + Da - 2 * Sa * Da, Sc * (1 - Da) + (1 - Sa) * Dc]

  • DARKEN:[Sa + Da - Sa*Da, Sc*(1 - Da) + Dc*(1 - Sa) + min(Sc, Dc)]

  • LIGHTEN:[Sa + Da - Sa*Da, Sc*(1 - Da) + Dc*(1 - Sa) + max(Sc, Dc)]

  • MULTIPLY:[Sa * Da, Sc * Dc]

  • SCREEN:[Sa + Da - Sa * Da, Sc + Dc - Sc * Dc]

  • ADD:Saturate(S + D)

  • OVERLAY:Saturate(S + D)

我們本文以CLEAR為例詳細介紹了PorterDuffXfermode的工作原理,其他的混合模式的工作原理是一樣的,只不過是源畫素與目標畫素的顏色混合計算規則不同。大家可以根據各種模式的計算規則來驗證一下上面的16中規則的效果圖。

最後需要說明一下,DARKEN、LIGHTEN、OVERLAY等幾種混合規則在GPU硬體加速下不起效,如果你覺得混合模式沒有正確使用,可以讓呼叫View.setLayerType(View.LAYER_TYPE_SOFTWARE, null)方法,把我們的View禁用掉GPU硬體加速,切換到軟體渲染模式,這樣所有的混合模式都能正常使用了,具體可參見博文《Android中GPU硬體加速控制及其在2D圖形繪製上的侷限》

最後總結一下,PorterDuffXfermode用於實現新繪製的畫素與Canvas上對應位置已有的畫素按照混合規則進行顏色混合

希望本文對大家正確理解PorterDuffXfermode的工作原理有所幫助!

相關閱讀:
我的Android博文整理彙總
Android中Canvas繪圖基礎詳解(附原始碼下載)

相關文章