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

Stella981
• 阅读 1420

本文是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表示用户已经给了权限。

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

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 for Android全系统录屏原理解析

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参数传递进去。

关于分享者

余勋杰,现任游族Mob开发者平台技术副总监,是其各类SDK Android端的主要设计者和实现者。Java me出身,从事Android开发超过7年。参加过多个lbs项目、流媒体项目和社会化模块项目,知名App“遇见”公司早期成员,在Android Java层和本地代码层都有开发经验。

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

余勋杰 游族Mob开发者平台技术副总监

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

您可在微信公众号中回复以下【关键词】获取相应内容。

资料类:

  • 0422资料】****:LiveVideoStack Meet北京站沙龙

  • 0617资料】****:LiveVideoStack Meet杭州站沙龙

  • 0624资料】****:LiveVideoStack Meet上海站沙龙

  • 0729资料】****:LiveVideoStack Meet广州站沙龙

  • 0805资料】****:LiveVideoStack Meet深圳站沙龙

  • 0909资料】:LiveVideoStack Meet北京站多媒体开发技术沙龙

干货类:

  • 刘歧】:走弯路的“大师兄”(采访)

  • 杨继珩】:全成就的大Boss(采访)

  • 唐賡】:某椒直播APP客户端技术演进之路

  • 董海冰】:TutorMeet+:基于Docker的高品质云课堂

  • 冼牛】:解密实时语音视频互动技术

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

本文分享自微信公众号 - LiveVideoStack(livevideostack)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
待兔 待兔
4个月前
手写Java HashMap源码
HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22
Jacquelyn38 Jacquelyn38
3年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
Wesley13 Wesley13
3年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
3年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Wesley13 Wesley13
3年前
Unity横屏
Android下发现Unity里面的Player设置,并不能完全有效,比如打开了自动旋转,启动的时候还是会横屏,修改XML添加以下代码<applicationandroid:icon"@drawable/ic\_launcher"                    android:label"@string/app\_name"
Stella981 Stella981
3年前
Django中Admin中的一些参数配置
设置在列表中显示的字段,id为django模型默认的主键list_display('id','name','sex','profession','email','qq','phone','status','create_time')设置在列表可编辑字段list_editable
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
Python进阶者 Python进阶者
10个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这