版權聲明:本文章原創於 RamboPan ,未經允許,請勿轉載。
最近做了一個項目,是關於 Unity 使用人臉識別添加一個面具,再將畫面數據傳遞數據給 Android ,然後由 Android 進行繪製。
重點就兩個部分:
- 如果高速的傳輸畫面數據
- 如何在安卓這邊高速顯示。
關於第一個問題,主要是涉及到 Unity 與 Android 之間交互,發送消息,中間也嘗試過一些方案,這個留在後續簡單說下。主要來說下 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 中尋找加載方法。
我開始對 Bitmap 與 BitmapFactory 有點分不清,後面多使用了些方法之後發現,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[] 變大了,而且 height 和 width 也都變大了,那用 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 註解表示該 int 以 ARGB 顏色模式來代表顏色。0xAARRGGBB,每 2 字節代表透明、紅、綠、藍。
查看 Color 最上面部分,可以看到定義了很多常用的顏色,我們可以選一個作爲驗證,比如 RED,GREEN,BLUE。
透明度都是滿的,說明不透明,對應紅、綠、藍位置,爲 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 都填寫 0 ,width ,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 秒這種。看到的效果就是數字來回切換,估計用戶看着也會感覺莫名其妙 …… 有了之前碰到的雙緩存問題經驗,這個處理起來就快了很多。
現在講完了主要的部分,順帶提提 Unity 與 Android 通信時的問題。
常見 Unity 與 android 通信方式是:
-
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 上傳。
此分析純屬個人見解,如果有不對之處或者欠妥地方,歡迎指出一起討論。