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 上传。


此分析纯属个人见解,如果有不对之处或者欠妥地方,欢迎指出一起讨论。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值