前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >ShareREC for Android全系统录屏原理解析

ShareREC for Android全系统录屏原理解析

作者头像
LiveVideoStack
发布2021-09-02 12:31:29
1.2K0
发布2021-09-02 12:31:29
举报
文章被收录于专栏:音视频技术音视频技术

本文是Mob开发者平台技术副总监余勋杰基于MediaProjection实现Android全系统录屏功能的原理解析,包括了结合MediaRecorder和MediaCodec两套方案。

文 / 余勋杰

前言

自安卓4.4开始,系统提供了内置的录屏功能,用户可以在adb下执行screenrecord命令,以指定码率、帧率、分辨率和时长来录制屏幕。但这个方案有缺点,普通用户无法直接执行adb命令,只能要么求助于adb终端,比如pc端的android-sdk,又或者在安卓设备上获取root权限,再执行录屏命令。幸而从5.1开始,系统又提供了MediaProjection API,通过再组合MediaRecorder或者MediaCodec API,开发者可以十分轻松地实现一个免root的全系统录屏工具,而ShareREC的全系统录屏功能,正是基于这种组合。

基于MediaProjection来实现录屏有两种方案,如果结合MediaRecorder,则前者为输入,后者为输出,原理清晰,实现简单,代码也很少。但如果结合的是MediaCodec,则由于后者仅仅只是一个编码器,我们要仔细考虑采用什么样子的数据作为编码输入,编码后要将数据输出到什么工具上压制为视频文件等等,原理复杂,实现困难,代码也很多。但相比较而言,第二个方案自由度很高,站在ShareREC的立场,我们除了全系统录屏,还有别的应用内录屏工具,这些工具已经实现了基于MediaCodec的方案;加之我们还要考虑输出的媒体流可能不是存为文件,而是作为流媒体传输,MediaRecorder是很难满足要求的。故而ShareREC使用的是第二套方案。

但本文会将这两套方案都介绍一遍,因此让我们由浅及深一步步来吧。

方案一:使用MediaRecorder作为媒体输出

让我们先来看一下MediaProjection API是个什么东西。顾名思义,它是一套“屏幕镜像”工具,核心类包括:MediaProjectionManager、MediaProjection和VirtualDisplay。

其中MediaProjectionManager用于向用户显示一个弹窗,请求获取屏幕镜像的权限(如下图)。此弹窗的操作结果会通过Activity的onActivityResult返回,RESULT_OK表示用户已经给了权限。

private MediaProjectionManager mpm; private void showDialog() { mpm = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE); Intent captureIntent = mpm.createScreenCaptureIntent(); startActivityForResult(captureIntent, REQUEST_CODE); } public void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == REQUEST_CODE) { // 从此处开始抓屏操作 CreateMediaRecorder(); createVirtualDisplay(data); } }

得到权限后,可以调用MediaProjectionManager的getMediaProjection方法获取MediaProjection实例,并用此实例创建一个VirtualDisplay,这个就是我们的屏幕镜像。

创建VirtualDisplay时需要一个surface做出输出缓存,即存放即将显示在屏幕上的数据。另一方面,自安卓5.1以后,系统为MediaRecorder提供多了一种新的图形输入方式,我们可以通过其实例方法getSurface得到一个surface作为输入缓存。如此结合起来,在录屏的场景中,我们可以先从MediaRecorder中得到一个输入缓存,并将这个缓存当做VirtualDisplay的输出缓存,形成I/O流通、内存共享。

private MediaRecorder mr; private MediaProjection mp; private VirtualDisplay vd; private Callback cb; private void CreateMediaRecorder() { try { mr = new MediaRecorder(); mr.setAudioSource(MediaRecorder.AudioSource.MIC); mr.setVideoSource(MediaRecorder.VideoSource.SURFACE); mr.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); mr.setVideoEncoder(MediaRecorder.VideoEncoder.H264); mr.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); mr.setVideoEncodingBitRate(bitRate); mr.setVideoFrameRate(30); mr.setVideoSize(1280, 720); mr.setOutputFile(“/sdcard/test.mp4”); mr.prepare(); } catch (Throwable t) { t.printStackTrace(); } } private void createVirtualDisplay(Intent data) { MediaProjection mp = mpm.getMediaProjection(RESULT_OK, data); cb = new Callback() { public void onStop() { if (mr != null) { mr.stop(); mr.release(); mr = null; } if (vd != null) { vd.release(); vd = null; } } }; mp.registerCallback(cb, null); int densityDpi = (int) (getResources().getDisplayMetrics().densityDpi + 0.5f); vd = mp.createVirtualDisplay("ShareREC", 1280, 720, densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, mr.getSurface(), null, null); mr.start(); }

经过上面的代码,程序已经进入录屏模式。MediaRecorder将以h264/aac为编码格式,将录制的结果以mp4格式存储在sd卡的test.mp4中。

当录制完毕时,需要关闭MediaRecorder,并释放VirtualDisplay和MediaProjection,上面代码中的MediaProjection.Callback实例正是为了这个而定义的。下面的代码演示了如何停止录制操作:

private void stop() { if (mp != null) { mp.stop(); if (cb != null) { mp.unregisterCallback(cb); } mp = null; } }

方案二:自行实现媒体编码和输出

看完简单的方案,现在来看一下复杂的方案。ShareREC在这个方案上的实现流程如下图:

ShareREC将全系统录屏功能拆分为抓图、编码和输出3部分。在用户授权抓屏之后,抓图模块率先启动,创建虚拟屏幕、创建图形缓存、创建回调等等。这里面的图形缓存是自安卓4.4以后提供的ImageReader。和MediaRecorder一样,它也提供了getSurface方法,返回用于更新缓存的surface实例。并且在缓存发生变更时,通过acquireLatestImage方法来获取最新的图片数据。不过由于我们并不知道什么时候缓存会发生变更,因此需要再调用setOnImageAvailableListener方法设置一个OnImageAvailableListener实例,并通过它的onImageAvailable方法实时得到缓存更新的通知:

private MediaProjectionManager mpm; private ImageReader ir; private MediaProjection mp; private VirtualDisplay vd; /** * @param screenSize 屏幕的实际分辨率 * @param videoSize 抓取图片的分辨率 */ public void startCapturer(final int[] screenSize, final int[] videoSize, final Intent data) { try { float densityDpi = getResources().getDisplayMetrics().densityDpi; int densityDpi = (int) (densityDpi * screenSize[0] / videoSize[0] + 0.5f); ir = ImageReader.newInstance(videoSize[0], videoSize[1], PixelFormat.RGBA_8888, 4); ir.setOnImageAvailableListener(this, null); mp = mpm.getMediaProjection(Activity.RESULT_OK, data); vd = mp.createVirtualDisplay("ShareREC", videoSize[0], videoSize[1], (int) densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, ir.getSurface(), null, null); } catch (Throwable t) { t.printStackTrace(); } } public void onImageAvailable(ImageReader reader) { Image image = reader.acquireLatestImage(); if (image != null) { Image.Plane[] planes = image.getPlanes(); if (planes != null && planes.length > 0) { int rowStride = planes[0].getRowStride(); ByteBuffer rgba = planes[0].getBuffer(); if (rgba != null) { // 将rgba数据输送给编码器 offerFrame(rgba, rowStride); } } image.close(); } }

上面的代码演示了如何通过组合VirtualDisplay和ImageReader来实现连续抓图。需要注意的一点是,根据surface内部的实现原理(超越本文的范畴),我们得到的rgba数据,多数时候不仅包含屏幕上的像素数据,还在图片的右侧包含一条黑边,因此我们在将像素数据发送给编码器之前,还需要告知编码器,每一行有效像素的个数(本例子中用了字节数)。

然后说一下编码器MediaCodec。这东西从安卓4.1开始就有,一般是用来实现音视频编解码的。在它之前,市面上早已经有ffmpeg之类的工具,但MediaCodec的优势在于它还能调起硬件编解码模块,性能更高、效果更好。但它的早期版本功能很弱,只能支持像素数据作为输入源,并且多数是YUV格式数据,故而输入前还需要做一次RGB转YUV的操作。自安卓4.3开始,它支持surface作为输入源,因此这里面临一个看似理所应当的问题:既然我们的全系统抓屏是基于安卓5.1的,而从安卓4.3开始,MediaCodec就支持以surface作为输入,那为什么不直接组合VirtualDisplay和MediaCodec就好,要中间插入一个ImageReader?这个问题怎么说呢,这是由于ShareREC不仅支持全系统录屏,还支持其它的应用内的录屏方式,如基于Cocos2d-x,Unity3D、libGDX等等引擎来做的录屏功能。而这些应用内的录屏方式,其抓取模块只能抓取到像素数据,考虑到编码模块在ShareREC内是一个通用的模块,故而全系统录屏也将抓图输出处理为像素数据输出。

private BufferInfo bufferInfo; private MediaCodec encoder; public void startEncoder() throws Throwable { // 获取硬件编码器支持的颜色格式,一般是I420或者NV12 int pixelFormat = getHWColorFormat(); MediaFormat format = MediaFormat.createVideoFormat(MIME, 1280, 720); format.setInteger(MediaFormat.KEY_BIT_RATE, 1 * 1024 * 1024); format.setInteger(MediaFormat.KEY_FRAME_RATE, 30); format.setInteger(MediaFormat.KEY_COLOR_FORMAT, pixelFormat); format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1); format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, 0); encoder = MediaCodec.createEncoderByType("video/avc"); encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); encoder.start(); bufferInfo = new BufferInfo(); }

上面的代码演示了如何初始化一个MediaCodec实例。需要注意的一点是,虽然我们设置了MediaCodec的帧率,但由于抓图时,图片数据不是匀速输入的,因此这个字段在此处形同虚设,可是又不能不填。上面的例子并不演示如何获取硬件编码器支持的颜色格式类型,具体的实现方式可以搜索一下,不难找。

然后我们来实现上面抓图模块中遗留的offerFrame方法:

public void offerFrame(ByteBuffer frame, int rowStride) throws Throwable { long framePreTimeUs = System.nanoTime() / 1000; ByteBuffer[] inputBuffers = encoder.getInputBuffers(); int inputBufferIndex = encoder.dequeueInputBuffer(-1); if (inputBufferIndex >= 0) { ByteBuffer ibb = inputBuffers[inputBufferIndex]; ibb.position(0); YUVConverter.rgbaToI420(frame, ibb, 1280, 720, rowStride); encoder.queueInputBuffer(inputBufferIndex, 0, ibb.limit(), framePreTimeUs, 0); } ByteBuffer[] outputBuffers = encoder.getOutputBuffers(); int outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, 0); while (outputBufferIndex >= 0) { ByteBuffer obb = outputBuffers[outputBufferIndex]; if (obb != null) { int frameType = 0; if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_SYNC_FRAME) == 1) { frameType = 1; } else if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 2) { frameType = 2; } // 将编码好的H264帧输出给mp4合并模块 offerVideoTrack(obb, bufferInfo.size, bufferInfo.presentationTimeUs, frameType); } encoder.releaseOutputBuffer(outputBufferIndex, false); outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, 0); } }

MediaCodec的输入输出都有缓存队列,我们要给它输入数据,需要先获取其输入缓存队列,然后在空闲的位置复制像素数据。由于我们抓取到的数据是RGBA格式,必须转为YUV格式才能别正确编码,这里ShareREC使用了libYUV,将RGBA转为I420。此外,并不是一输入图片就立刻会有输出h264帧,MediaCodec一般会缓存3-7张图片。

最后是视频合并模块,ShareREC使用了mp4v2来实现。其实在安卓平台同样自4.3以后系统自带了视频合并工具MediaMuxer。但这个东西似乎必须与MediaCodec一同使用,由于的用户要求ShareREC至少支持4.0以上的系统,故除了MediaCodec,其实我们还具备优化过的软件编码器。为了同时兼容两种编码器,我们放弃了MediaMuxer而采用兼容性更好的mp4v2。

本文不介绍mp4v2的使用,因为这超过java代码的范畴(libYUV也是)。但它的工作原理很简单,无非就是打开文件;在内存中保存视频轨道和音频轨道的信息;接着一帧帧写入视频或者音频数据,不用在意写入顺序,可以混在一起;在完成合并时,将内存里面的音视频信息组合为mp4描述信息,追加到文件尾部,之后关闭文件。这个流程网上的文档很多,随便搜索就有了。但使用时有一些可能需要注意的,包括多线程同步和图片呈现时间的问题。

关于多线程同步,是指因为我们在实际录屏时,音频和视频是分开两条线程来编码的,但最后往mp4v2写入时,是写入同一个文件的,但由于mp4v2没有做好同步,因此如果写入音视频帧的时候,不对mp4v2自己做好同步锁,会出现音视频写乱了的问题,导致最后视频无法播放。

至于图片呈现的问题,请回顾一下上面代码例子中的framePreTimeUs,这个是这一张图片被送入编码器的时候,合并视频时,需要将这个字段带给mp4v2。由于mp4v2默认是认为图片匀速输入的,所以它不理会我们这个字段,只在意一开始设置的帧率。但由于抓图不是匀速的,因此如果只依照固定的帧率来显示,将来视频就会时快时慢,甚至声音图片不同步。因此在添加视频帧时,务必要设置呈现的时间偏移。ShareREC以TimeScale为基准,会将framePreTimeUs根据TimeScale做一次转换,然后在MP4WriteSample的时候,renderingOffset参数传递进去。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2017-09-21,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 LiveVideoStack 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档