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