版权声明:本文章原创于 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 上传。
此分析纯属个人见解,如果有不对之处或者欠妥地方,欢迎指出一起讨论。