SurfaceView 繪製與背景適配記錄

版權聲明:本文章原創於 RamboPan ,未經允許,請勿轉載。

最近做了一個項目,是關於 Unity 使用人臉識別添加一個面具,再將畫面數據傳遞數據給 Android ,然後由 Android 進行繪製。

重點就兩個部分:

  • 如果高速的傳輸畫面數據
  • 如何在安卓這邊高速顯示。

關於第一個問題,主要是涉及到 UnityAndroid 之間交互,發送消息,中間也嘗試過一些方案,這個留在後續簡單說下。主要來說下 Android 這邊關於顯示過程中碰到一些問題,順便記錄遇到的問題。

因爲需要快速並且長時間的繪製視頻幀(攝像頭抓取的畫面),所以不能考慮在主線程上繪製,那我們第一反應就是,找 SurfaceView 來解決這個問題,單獨的線程繪製,不影響主線程的使用,播放視頻使用 MediaPlayer 也是採用的這個思路。

確定了使用的方案之後,我們先來看看大概的 UI 界面。

在這裏插入圖片描述

先來簡單說下界面,需要我們頻繁更新的就是中間的圓與右下側的圓。中間的圓顯示爲捕捉到人臉並且戴上面具的畫面,而右下方爲攝像頭原始捕捉的畫面。

先說明一個情況:所有控件放入屏幕內時給定的區域都是正方形的,如果最後顯示爲圓形或者其他圖形,那麼只能說明有些區域沒有繪製,而該處又有其他控件繪製的圖片,所以有一種感覺是控件區域不是方形的感覺。但使用了 SurfaceView ,那個方形區域主線程都不會進行繪製,所以需要自己填充該位置對應的背景圖,如果不填充就是一個黑色的區域,這樣肯定是不行。

說明了這個情況之後我們來分析,如果這兩個圓相隔比較遠,那麼按照面向對象的想法,我們是可以考慮做一個 SurfaceView 控件類,然後在這個界面放入兩個控件, 每個通過各自數據來更新自己的圖像。但此處距離太近,那麼肯定得換個思路,就是把右側兩個圓做成一個整體的控件,或者三個圓做成一個控件。

簡單畫個圖來說明下前一種情況。忽略下靈魂畫技 …… 加上文字說明應該不難理解,這種方式不好解決紅色區域的衝突問題。
在這裏插入圖片描述
我們採用後一種方式,再來畫一個圖。這樣沒有了紅色衝突部分,這樣就好操作了。
在這裏插入圖片描述
既然我們確定了思路,那就分析下大概需要幾個步驟。

  • 使用 ImageView 填充背景圖。
  • SurfaceView 控件反向裁剪三個小圓(避免過度繪製),畫背景圖片。
  • 三個小圓分別進行各自的繪製。

第一步,使用 ImageView ,對大家來說都不是事,所以此處直接跳過。

第二步,我們需要考慮下動態適配背景,就是如果這個控件放在不同的位置(比如左挪一點,上挪一點),還是從該背景圖對應位置取圖片來進行繪製。如果我們寫死了取一個背景圖區域,那麼調整了控件的位置,也要重新更新,那就不太符合通用這個出發點。

那麼肯定是需要拿到這個 SurfaceView 控件的區域大小,這裏採用 addViewTreeObserver() 來監聽尺寸。


	//我採用的控件名稱爲 RenderView
	mRenderHandle.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                int handleWidth = mRenderHandle.getWidth();
                int handleHeight = mRenderHandle.getHeight();
                int handleXOffSet = mRenderHandle.getLeft();
                int handleYOffSet = mRenderHandle.getTop();
                //添加邏輯傳入這四個值。
                mRenderHandle.getViewTreeObserver().removeOnGlobalLayoutListener(this);
            }
        });

  • 提醒:4 個 get 方法是獲取到與其父類的距離,並不是整個界面,所以如果該控件在多個 ViewGroup 中,需要找一個方式去計算該控件到最外層 ViewGroup 的距離,就是到屏幕的距離。方法有很多,這裏就不貼出答案了哈。

算出了控件的大小,那麼我們需要從背景圖片中,裁出對應控件的背景圖的數據,那麼肯定需要在 Bitmap 或者 BitmapFactory 中尋找加載方法。

我開始對 BitmapBitmapFactory 有點分不清,後面多使用了些方法之後發現,BitmapFactory 一般是用來生成 Bitmap 對象的,而 Bitmap 則是用來對 Bitmap 進行轉換或者其他處理的。

好了,繼續剛纔的分析,那我們應該第一步生成一個完整的背景圖 Bitmap 對象,我這裏使用的是 drawable 所以使用 BitmapFactory.decodeResource()

接下來到裁剪部分,在 Bitmap 中尋找裁剪對應的方法。能看到一個 createBitmap,因爲我們這裏暫時不用矩陣,所以就這個好了。


    /**
     * Returns an immutable bitmap from the specified subset of the source
     * bitmap. The new bitmap may be the same object as source, or a copy may
     * have been made. It is initialized with the same density and color space
     * as the original bitmap.
     *
     * @param source   The bitmap we are subsetting
     * @param x        The x coordinate of the first pixel in source
     * @param y        The y coordinate of the first pixel in source
     * @param width    The number of pixels in each row
     * @param height   The number of rows
     * @return A copy of a subset of the source bitmap or the source bitmap itself.
     * @throws IllegalArgumentException if the x, y, width, height values are
     *         outside of the dimensions of the source bitmap, or width is <= 0,
     *         or height is <= 0
     */
    public static Bitmap createBitmap(@NonNull Bitmap source, int x, int y, int width, int height) {
        return createBitmap(source, x, y, width, height, null, false);
    }
    

- 此處 x 就是需要截取的第一個像素橫向從原圖像哪個位置開始截取。
- 此處 y 就是需要截取的第一個像素縱向從原圖像哪個位置開始截取。
- 此處 width 就是你需要在這行中取多少數據,也就是裁剪圖的寬。
- 此處 height 就是你需要在這列中取多少數據,也就是裁剪圖的高。

再畫個圖說明下,尺寸我是估的,背景尺寸爲 1920 x 1080 。
在這裏插入圖片描述
我們想從原圖的左數 90 像素位置開始取背景,此處 x 就爲 90 ,裁剪的寬度爲 900 ,那麼 width 就爲 900 。高也類似, y 爲180 ,height 爲 800 。

先說完了結論,我們來分析下有沒有不太一樣的情況,或者是說爲什麼是這個數字。

在代碼中 Bitmap bitmap = BitmapFactory.decodeResource(); 這句話加上斷點,然後進行調試。
在這裏插入圖片描述
能看到 mBuffer 這個參數,是一個 byte[] 類型,數字看起來很大,我們來算算 1080 * 1920 是多大。
2073600 看起來有點像是 8294400 / 4 ,驗證一下,剛好符合。那麼說明這個 byte[] 應該就是那個讀取的背景圖在內存中的數據,再通過 ARGB 這種顏色模式,每個顏色佔 8 位, 4 個字節,剛好符合。

可以推測出,我們讀取到原圖像的所有 byte 數據,再根據偏移從中選出了需要裁減的 byte[] ,最後在把數據生成一個裁剪的 Bitmap 對象,至於之前傳入 x 是 90,而不是 90 * 4 ,從註釋中也可以看出是依據像素作爲單位。而一像素剛好用 1 int 表示了。

之前還說有沒有其他情況 ?其實也有這種情況,就是把圖片放在不同的像素密度文件夾中,比如我這裏是 xxdpi 的圖片,手機的像素密度也在這個區間,那我們放入 xdpi 中看看之前的 byte[] 有什麼變化。

在這裏插入圖片描述
不光是 byte[] 變大了,而且 heightwidth 也都變大了,那用 1620 * 2880 * 4 = 18662400 。還是符合剛纔那個結論,只是相對剛纔爲 1.5 * 1.5 倍了,那麼可以得出高像素密度設備加載低像素密度圖片時,會將原圖進行放大。那麼可以先用矩陣縮放,或者取了對應大的圖片再對應來縮小,當然我們就不過多討論了。畢竟最好方式的還是把圖片放在最正確的位置 ……

拿到裁剪後的圖片,我們就要在 SurfaceView 控件上先反向剪裁出 3 個圓(因爲我們需要繪製除圓外的區域)。

先生成一個 Path 對象,去添加出需要的區域,然後用 canvas.clipPath()

canvas.clipPath() 有兩個重載,都有 Path 參數,不同點是有一個是帶 Region.Op 類型參數 ,有一個不帶,當然不帶那個默認是調用了 Region.Op.INTERSECT



    public boolean clipPath(@NonNull Path path, @NonNull Region.Op op) {
        checkValidClipOp(op);
        return nClipPath(mNativeCanvasWrapper, path.readOnlyNI(), op.nativeInt);
    }

    /**
     * Intersect the current clip with the specified path.
     *
     * @param path The path to intersect with the current clip
     * @return     true if the resulting clip is non-empty
     */
    public boolean clipPath(@NonNull Path path) {
        return clipPath(path, Region.Op.INTERSECT);
    }

Region.Op 類型參數,是問當畫布剪裁時,是取交集還是差集,如果我們要對三個圓位置進行操作時,那麼此處應該使用 Region.Op.INTERSECT ,而我們此時是想剪裁除了圓的背景,那麼就選擇 Region.Op.DIFFERENCE,貼個大概思路代碼。

	
	//先生成 Path 對象添加需要剪裁的圓
	//計算中心圓尺寸
    float radius = ……
    float centerX = ……
    float centerY = ……
    //添加中心圓路徑
    mCropPath.addCircle(centerX,centerY,radius,Path.Direction.CW);

    //計算右下圓尺寸
    radius = ……
    centerX = ……
    centerY = ……
    //添加右下圓路徑
    mCropPath.addCircle(centerX,centerY,radius,Path.Direction.CW);

	//計算左下圓尺寸
    radius = ……
    centerX = ……
    centerY = ……
	//添加左下圓路徑
    mCropPath.addCircle(centerX,centerY,radius,Path.Direction.CW);
    
	……
	
	//裁剪差集
	mCanvas.clipPath(cropPath,Region.Op.DIFFERENCE);
    

拿到了剪裁後的區域以及圖片時就可以直接繪製了。


	//保存畫布
    int saveCount = mCanvas.save();
    //獲取剪裁圖
    Bitmap tempBitmap = ……
    //剪裁路徑
    mCanvas.clipPath(cropPath,Region.Op.DIFFERENCE);
    //繪製圖片
    if(tempBitmap != null){
        mCanvas.drawBitmap(tempBitmap,0,0,mPaint);
    }
    //恢復畫布
    mCanvas.restoreToCount(saveCount);
    

這樣繪製好背景後,就可以分別進行三個小圓的繪製,左下角的小圓是 30 秒倒數計時。這裏也不是重點,就忽略繪製邏輯了。

中間圓和右下圓是通過 Unity 部分拿到 int[] 數組,使用 Bitmap.createBitmap() 方法生成一個 Bitmap 對象。


    /**
     * Returns a immutable bitmap with the specified width and height, with each
     * pixel value set to the corresponding value in the colors array.  Its
     * initial density is as per {@link #getDensity}. The newly created
     * bitmap is in the {@link ColorSpace.Named#SRGB sRGB} color space.
     *
     * @param colors   Array of sRGB {@link Color colors} used to initialize the pixels.
     * @param offset   Number of values to skip before the first color in the
     *                 array of colors.
     * @param stride   Number of colors in the array between rows (must be >=
     *                 width or <= -width).
     * @param width    The width of the bitmap
     * @param height   The height of the bitmap
     * @param config   The bitmap config to create. If the config does not
     *                 support per-pixel alpha (e.g. RGB_565), then the alpha
     *                 bytes in the colors[] will be ignored (assumed to be FF)
     * @throws IllegalArgumentException if the width or height are <= 0, or if
     *         the color array's length is less than the number of pixels.
     */
    public static Bitmap createBitmap(@NonNull @ColorInt int[] colors, int offset, int stride,
            int width, int height, @NonNull Config config) {
        return createBitmap(null, colors, offset, stride, width, height, config);
    }
    

第一個參數就是 int[] 類型。 @ColorInt 註解表示該 intARGB 顏色模式來代表顏色。0xAARRGGBB,每 2 字節代表透明、紅、綠、藍。

查看 Color 最上面部分,可以看到定義了很多常用的顏色,我們可以選一個作爲驗證,比如 REDGREENBLUE
透明度都是滿的,說明不透明,對應紅、綠、藍位置,爲 0xFF,爲 255 ,其餘爲 0 ,說明不帶其他顏色。


	public class Color {
	    @ColorInt public static final int BLACK       = 0xFF000000;
	    @ColorInt public static final int DKGRAY      = 0xFF444444;
	    @ColorInt public static final int GRAY        = 0xFF888888;
	    @ColorInt public static final int LTGRAY      = 0xFFCCCCCC;
	    @ColorInt public static final int WHITE       = 0xFFFFFFFF;
	    @ColorInt public static final int RED         = 0xFFFF0000;
	    @ColorInt public static final int GREEN       = 0xFF00FF00;
	    @ColorInt public static final int BLUE        = 0xFF0000FF;
	    @ColorInt public static final int YELLOW      = 0xFFFFFF00;
	    @ColorInt public static final int CYAN        = 0xFF00FFFF;
	    @ColorInt public static final int MAGENTA     = 0xFFFF00FF;
	    @ColorInt public static final int TRANSPARENT = 0;
	    ……
    }
    

後面的 offset ,stride ,width ,height 與之前說的 createBitmap() 參數類似。也是從中剪裁一部分,或者 offset ,stride 都填寫 0width ,height 都填寫最大值,那麼就是獲取的 color[] 的原始圖片,如果需要剪裁的話,就調整參數。

這裏需要提醒的是,繪製三個小圓,需要分別剪裁 3 個小圓,然後繪製,每次剪裁後需要調用 path.reset()。如果不調用的話,之前的剪裁是存在的,所以會干擾到其他的圓,比如可能是這種情況。
在這裏插入圖片描述
正常的操作就應該是如下類似代碼。


	//畫背景圖
	//保存畫布
    int saveCount = mCanvas.save();
    //獲取剪裁圖
    Bitmap tempBitmap = ……
    //剪裁路徑,取差集
    cropPath.reset();
    mCanvas.clipPath(cropPath,Region.Op.DIFFERENCE);
    //繪製圖片
    if(tempBitmap != null){
        mCanvas.drawBitmap(tempBitmap,0,0,mPaint);
    }
    //恢復畫布
    mCanvas.restoreToCount(saveCount);

	//畫中心圓
	//保存畫布
    saveCount = mCanvas.save();
    //獲取中心圓圖
    tempBitmap = ……
    //剪裁中心圓路徑,取交集
    cropPath.reset();
    mCanvas.clipPath(cropPath,Region.Op.INTERSECT);
    //繪製圖片
    if(tempBitmap != null){
        mCanvas.drawBitmap(tempBitmap,0,0,mPaint);
    }
    //恢復畫布
    mCanvas.restoreToCount(saveCount);

	//畫右下
	//保存畫布
    saveCount = mCanvas.save();
    //獲取右下圓圖
    tempBitmap = ……
    //剪裁右下圓路徑,取交集
    cropPath.reset();
    mCanvas.clipPath(cropPath,Region.Op.INTERSECT);
    //繪製圖片
    if(tempBitmap != null){
        mCanvas.drawBitmap(tempBitmap,0,0,mPaint);
    }
    //恢復畫布
    mCanvas.restoreToCount(saveCount);

這樣基本就算完成了 SurfaceView 的繪製圖形與主背景適配的過程。


接下來還有一些可以優化的環節:

  • 比如這個剪裁的背景,如果在 SurfaceView 控件沒有變化的情況下,剪裁背景只需要取一次,然後一直保存着,每次繪製都用同一份,就可以。

  • 而我們通過 init[] 拿到的攝像頭畫面因爲更新過快,所以可以手動調用 if(!bitmap.isRecycled()) bitmap.recycle() 加快回收。

  • 之前我繪製的邏輯是, Unity 需要給我兩種數據,一個是攝像頭畫面,另一個是加上面具後的畫面,兩個數據可能大概率不是同時的,我是等兩種數據都準備好了再進行繪製。如果我改爲哪個數據先到了我就更新哪一邊,這樣理論上來說肯定是會使畫面更流暢。

  • 然後我按照這個邏輯進行修改,進行播放時,發現畫面有點不對勁 …… 畫面雖然更新快了點,但是感覺兩個圓的位置一直在閃爍,想了挺久沒有頭緒,在繪製時打了斷點,才發現繪製過程中,一個圓是正常圖片,另一個圓是黑色。下一次繪製時,正常圖片的圓又是黑色,而黑色圓又是正常。

類似這種情況,不過真實情況播放時比這個 gif 更快,所以沒有立刻察覺問題來源。
在這裏插入圖片描述

突然想起之前瞭解 SurfaceView 原理時看到是雙緩存的結構,那麼猜測可能是因爲兩個數據分開畫時,先到的那個數據 A 觸發繪製,就單獨繪製 A 數據的圓,本來想的是 B 數據到時,A 已經畫過了,那麼此時單獨畫 B 就沒有問題,圖就是正常的。忘了畫 A 時是在 SurfaceView 第一層緩存,畫 B 時在 SurfaceView 第二層緩存,剛好錯開了,如果 A B 數據速度一直保持一前一後,那麼就會一直出現這種黑色交替的情況。

修改時就對用過一次的數據進行緩存,每次繪製都畫所有,如果沒有更新數據就用上一次數據,如果更新了用這一次數據,就解決了這個問題。

  • 同樣碰到緩存不一樣的情況還有左邊那個圓,因爲倒計時是數字,我們邏輯是點了之後數字停止那個計時小圓的繪製,偶然會出現 SurfaceView 一層畫的是 05 秒,另一層畫的是 06 秒這種。看到的效果就是數字來回切換,估計用戶看着也會感覺莫名其妙 …… 有了之前碰到的雙緩存問題經驗,這個處理起來就快了很多。

現在講完了主要的部分,順帶提提 UnityAndroid 通信時的問題。


常見 Unityandroid 通信方式是:

  • Android -> Unity : UnityPlayer.UnitySendMessage()

  • Unity -> Android : new AndroidJavaClass(packageName).Call()

我們需要 Unity 發送 大的 int[] 類型數據,頻率也很高,所以嘗試下來發現並不合適,這種通信只適合對時間要求不高,只是發個消息這樣的輕量級處理。

也嘗試過 Socket 通信,速度還算可以,不過我們後期把圖片稍微調大一點之後,還是感覺到速度有點降低;最後還是採用了同時讀寫內存的方式,Android 使用 jni 開了兩個 int[] 類型變量, Unity 使用 C# 寫到變量中,jni 這邊再去讀取,果然走大數據高頻率時還是內存靠譜。

之前第一反應是讀寫內存,結果沒有找到合適的參考,反而走 Socket 這條彎路花了點時間。

參考1:http://www.xuanyusong.com/archives/1129
參考2:https://blog.csdn.net/crasheye/article/details/51993370

因爲原本是一個雙面屏的項目,所以跑在手機上還是有點問題,稍微晚一點的話考慮整理成一個簡單的 demo 上傳。


此分析純屬個人見解,如果有不對之處或者欠妥地方,歡迎指出一起討論。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章