掌握聚合最新动态了解行业最新趋势
API接口,开发服务,免费咨询服务

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

前言

自安卓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是个什么东西。顾名思义,它是一套“屏幕镜像”工具,核心类包括:MediaProjectionManagerMediaProjectionVirtualDisplay

其中MediaProjectionManager用于向用户显示一个弹窗,请求获取屏幕镜像的权限(如下图)。此弹窗的操作结果会通过ActivityonActivityResult返回,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);
}
}

得到权限后,可以调用MediaProjectionManagergetMediaProjection方法获取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,并释放VirtualDisplayMediaProjection,上面代码中的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();
	}
}

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

然后说一下编码器MediaCodec。这东西从安卓4.1开始就有,一般是用来实现音视频编解码的。在它之前,市面上早已经有ffmpeg之类的工具,但MediaCodec的优势在于它还能调起硬件编解码模块,性能更高、效果更好。但它的早期版本功能很弱,只能支持像素数据作为输入源,并且多数是YUV格式数据,故而输入前还需要做一次RGBYUV的操作。自安卓4.3开始,它支持surface作为输入源,因此这里面临一个看似理所应当的问题:既然我们的全系统抓屏是基于安卓5.1的,而从安卓4.3开始,MediaCodec就支持以surface作为输入,那为什么不直接组合VirtualDisplayMediaCodec就好,要中间插入一个ImageReader?这个问题怎么说呢,这是由于ShareREC不仅支持全系统录屏,还支持其它的应用内的录屏方式,如基于Cocos2d-xUnity3DlibGDX等等引擎来做的录屏功能。而这些应用内的录屏方式,其抓取模块只能抓取到像素数据,考虑到编码模块在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默认是认为图片匀速输入的,所以它不理会我们这个字段,只在意一开始设置的帧率。但由于抓图不是匀速的,因此如果只依照固定的帧率来显示,将来视频就会时快时慢,甚至声音图片不同步。因此在添加视频帧时,务必要设置呈现的时间偏移。ShareRECTimeScale为基准,会将framePreTimeUs根据TimeScale做一次转换,然后在MP4WriteSample的时候,renderingOffset参数传递进去。

作者:Mob开发者平台 技术副总监 余勋杰

原文来自:Mob开发者平台

声明:所有来源为“聚合数据”的内容信息,未经本网许可,不得转载!如对内容有异议或投诉,请与我们联系。邮箱:marketing@think-land.com

  • 全球天气预报

    支持全球约2.4万个城市地区天气查询,如:天气实况、逐日天气预报、24小时历史天气等

    支持全球约2.4万个城市地区天气查询,如:天气实况、逐日天气预报、24小时历史天气等

  • 购物小票识别

    支持识别各类商场、超市及药店的购物小票,包括店名、单号、总金额、消费时间、明细商品名称、单价、数量、金额等信息,可用于商品售卖信息统计、购物中心用户积分兑换及企业内部报销等场景

    支持识别各类商场、超市及药店的购物小票,包括店名、单号、总金额、消费时间、明细商品名称、单价、数量、金额等信息,可用于商品售卖信息统计、购物中心用户积分兑换及企业内部报销等场景

  • 涉农贷款地址识别

    涉农贷款地址识别,支持对私和对公两种方式。输入地址的行政区划越完整,识别准确度越高。

    涉农贷款地址识别,支持对私和对公两种方式。输入地址的行政区划越完整,识别准确度越高。

  • 人脸四要素

    根据给定的手机号、姓名、身份证、人像图片核验是否一致

    根据给定的手机号、姓名、身份证、人像图片核验是否一致

  • 个人/企业涉诉查询

    通过企业关键词查询企业涉讼详情,如裁判文书、开庭公告、执行公告、失信公告、案件流程等等。

    通过企业关键词查询企业涉讼详情,如裁判文书、开庭公告、执行公告、失信公告、案件流程等等。

0512-88869195
数 据 驱 动 未 来
Data Drives The Future