Android录屏单帧获取方案

Android录屏单帧获取方案

没啥好说的,还是公司需求,需要直播手机上的画面,麻烦的是目前的产品线都只支持单帧数据编解码,那Android的硬编,只能pass了。老实说离开了硬编码,手机变暖手宝是跑不了了,胜在兼容性强,好在对帧数没有高要求,勉强能看就行,硬着头皮写吧

MediaProjection

MediaProjection是由Android5.0开始提供的录屏方案,不再需要root支持,貌似录屏直播也只能选择这也一种方案了,其余的要么需要root,要么得连接电脑借助adb,与需求相悖。

请求录屏

	@TargetApi(Build.VERSION_CODES.LOLLIPOP)
    private void getMediaProjection(Activity activity) {
        mMediaProjectionManager = (MediaProjectionManager) activity.getSystemService(Context.MEDIA_PROJECTION_SERVICE);
        if (null == mMediaProjectionManager) {
        	//TODO 错误处理 
			return;
		}
        activity.startActivityForResult(mMediaProjectionManager.createScreenCaptureIntent(), REQUEST_CODE);
    }

首先需要发起录屏请求,可以看到这里启动了一个acitivity以获取相关权限,类似6.0以后获取权限的方式,屏幕将会弹出一个窗口要求用户授权,早期这里有个系统安全问题,可以将申请权限的文字描述挤到框外以实现不被用户察觉的情况下后台录屏,当然已经被谷歌修复了。

发起录屏

请求权限之后我们就可以拿到MediaProjectionManager的实例了,然后用它来发起录屏即可,这里的步骤为了保证线程安全,我是在子线程中进行的,之后所有的操作都在子线程执行。

	case StartRecord:   //开始录屏
         if (null == mMediaProjection) break;
         //最少2张
         mImageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2);
         mVirtualDisplay = mMediaProjection.createVirtualDisplay("screen", width, height, dpi,
         DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC, mImageReader.getSurface(), null, null);
         break;

相信看到ImageReader大家已经明白我的意图了,出于无奈我也只能使用它来获取单帧数据了,如果大家有更好的方法不妨也提出来分享一下。这里通过MediaProjection创建了VirtualDisplay并在屏幕数据发生改变时,将数据传入ImagfeReader中。顺带一提,因为软编码的原因,这里不建议设定过高的分辨率,最好维持在720P以下,毕竟你永远无法避免用户使用古董机。

数据处理

ImageReader可以为其设置监听方法,但为了在子线程中书写方便,这里我们主动获取数据,注意错误处理即可。

 if (null != mVirtualDisplay && null != mImageReader) {    
                        //当画面没有差异时不会返回数据
                        Image image = null;
                        try {
                            image = mImageReader.acquireLatestImage();
                        } catch (UnsupportedOperationException e){
                            //TODO 终止线程并反馈错误
                            //MX3 NB!
                        }

                        if (null == image){
                            continue;
                        }
                        
                        //提取数据
                        int width = image.getWidth();
                        int height = image.getHeight();
                        Image.Plane[] planes = image.getPlanes();
                        if (null == planes || planes.length <= 0 || null == planes[0]) {
                            image.close();
                            continue;
                        }
                        
                        ByteBuffer buffer = planes[0].getBuffer();
                        if (null == buffer){
                            image.close();
                            continue;
                        }

                        //在ByteBuffer存放的数据存在像素间隔,如果不做考虑画面会不全
                        int pixelStride = planes[0].getPixelStride();
                        int rowStride = planes[0].getRowStride();
                        int newWidth = width + ((rowStride - pixelStride * width) / pixelStride);

						//转码容器
                        byte[] yuv = new byte[width * height * 3 >> 1 ];

                        //将rgb转为YUV420,此处做了一次裁剪,裁掉ByteBuffer中像素间隔中的数据
                        Convert.ABGRToI420Clip(buffer, newWidth, height, yuvWidth, yuvHeight, yuv);
                        
                        //TODO 转发给编码线程

                        image.close();
                    }

由于是主动获取数据,那数据未准备完全的情况下,有异常是非常正常的事,注意排查即可。值得注意的是,真机测试时捕获到在MX3上,录屏数据格式异常,抛出了UnsupportedOperationException,AZ ScreenRecorder录制本地视频也无法成功,我也没有什么好的办法解决了(日常黑MX3达成)。

另外ByteBuffer的处理要注意像素之间的间隔,计算出数据的宽高并在转码后将数据裁剪为正常的图片宽高,否则侧边将会有异常的彩带。

关于转码也有值得注意的地方,虽然格式是PixelFormat.RGBA_8888,但是看一看注释中的描述可以知道实际其像素排布为ABGR,这里直接把ByteBuffer传入C层即可,其指针直接指向数据可以减少Java层的拷贝次数(C层用的libyuv)。

当然这里还可以对帧率进行自定义控制避免编码队列拥塞,最后别忘了释放Image的实例。

其它需求

  • 切换分辨率:
case ResizeRecord:  
     if (null == mMediaProjection) break;
     if (null == mVirtualDisplay) break;
     if (null != mImageReader){   
         mImageReader.close();
     }
     mImageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2);
     mVirtualDisplay.setSurface(mImageReader.getSurface());
     mVirtualDisplay.resize(width, height, dpi);
     break;

这里直接使用VirtualDisplay提供的Api即可,重建ImageReader并提供新的分辨率

  • 横竖屏切换
    这个有点儿意思,在设备旋转之后,其分辨率将会发生改变,例如720x1280变为1280x720,这时候如果还使用之前的分辨率,系统将会自动适配,其适配规则为CenterInside,没错,就是ImageView那个CenterInside,那么看上去,视频就像变小了一样。

这里我们就要用到以前写过的Context.getResources().getConfiguration().orientation了,它可以描述整个设备最顶端页面的横竖屏状态。在我们的子线程循环中不断访问这个变量,再根据横竖屏状态切换分辨率即可。

值得注意的问题

  1. 分辨率的比例一定要契合屏幕比例,否则黑边警告。
  2. 刘海屏要考虑是否减去刘海的部分,不然不好看,这个见仁见智了。
  3. 帧率限制还是有必要的,但是由于其数据提供规则为数据变更时才产生并且由于性能限制不是很准时,因此乱抛数据可能停在某个操作途中,比如拖动页面,可以考虑保留抛弃的最后一帧,并在一定时间没有接到新数据时添加回处理队列。

你可能感兴趣的:(Android-UI)