Android屏幕直播方案

项目需求是实时同步Android手机屏幕画面至浏览器。这里有两个挑战,一是Android如何在应用内获得屏幕实时视频流,另一个是如何在浏览器上做视频直播。经过一番折腾,确定了如下的实现方案。期间,我们也实现了手机摄像头的直播。

演示效果:

演示

Android获取实时屏幕画面

原理与基础设置

Android 5.0版本之后,支持使用MediaProjection的方式获取屏幕视频流。具体的使用方法和原理如下图所示:

MediaProjection原理

参考ScreenRecorder项目3的实现,我们了解到VirtualDisplay可以获取当前屏幕的视频流,创建VirtualDisplay只需通过MediaProjectionManager获取MediaProjection,然后通过MediaProjection创建VirtualDisplay即可。

那么视频数据的流向是怎样的呢?

  • 首先,Display 会将画面投影到 VirtualDisplay中;
  • 接着,VirtualDisplay 会将图像渲染到 Surface中,而这个Surface是由MediaCodec所创建的;
  • 最后,用户可以通过MediaCodec获取特定编码的视频流数据。

经过我们的尝试发现,在这个场景下,MediaCodec只允许使用video/avc编码类型,也就是RAW H.264的视频编码,使用其他的编码会出现应用Crash的现象(不知是否与硬件有关?)。由于这个视频编码,后面我们与它“搏斗”了好一段时间。

以下是关键部分的代码(来自ScreenRecorder项目3):

codec = MediaCodec.createEncoderByType(MIME_TYPE);
mSurface = codec.createInputSurface();
mVirtualDisplay = mMediaProjection.createVirtualDisplay(
        name,
        mWidth,
        mHeight,
        mDpi,
        DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC,
        mSurface,    // 图像会渲染到Surface中
        null,
        null);

在编码之前,我们还需要设置视频编码的一些格式信息,这里我们通过MediaFormat进行编码格式设置,代码如下(来自ScreenRecorder项目3)。

private static final String MIME_TYPE = "video/avc"; // H.264编码
private static final int FRAME_RATE = 30;            // 30 FPS
private static final int IFRAME_INTERVAL = 10;       // I-frames间隔时间
private static final int TIMEOUT_US = 10000;

private void prepareEncoder() throws IOException {
    MediaFormat format = MediaFormat.createVideoFormat(MIME_TYPE, mWidth, mHeight);
    format.setInteger(MediaFormat.KEY_COLOR_FORMAT,
            MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
    format.setInteger(MediaFormat.KEY_BIT_RATE, mBitRate);
    format.setInteger(MediaFormat.KEY_FRAME_RATE, FRAME_RATE);
    format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, IFRAME_INTERVAL);

    codec = MediaCodec.createEncoderByType(MIME_TYPE);
    codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
    codec.start();
}

数据获取

MediaCodec

图片来自Android官方文档

紧接着,我们需要实时获取视频流了,我们可以直接从MediaCodec中获取视频数据。

根据官方文档,获取视频流有两种做法。一种是通过异步的方式获取数据,使用回调来获取OutputBuffer,具体代码详见Android文档

这里我们了解一下同步获取的方式,由于是同步执行,为了不阻塞主线程,必然需要启动一个新线程来处理。首先,程序会进入一个循环(可以设置变量进行停止),我们通过codec.dequeueOutputBuffer()方法获取到outputBufferId,接着通过ID获取buffer。这个buffer即是我们需要用到的实时视频帧数据了。代码如下(来自Android官方文档):

 MediaFormat outputFormat = codec.getOutputFormat(); // 方式二
 codec.start();
 for (;;) {
   int outputBufferId = codec.dequeueOutputBuffer(mBufferInfo, 10000);
   if (outputBufferId >= 0) {
     ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);
     // 方式一
     MediaFormat bufferFormat = codec.getOutputFormat(outputBufferId);
     codec.releaseOutputBuffer(outputBufferId,);

   } else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
     outputFormat = codec.getOutputFormat(); // 方式二
   }
 }
 codec.stop();
 codec.release();

按照ScreenRecorder项目3的做法,接着他使用MediaMuxerMuxer.writeSampleData()方法,直接将视频流outputBuffer写入了文件。

然而,我们需要的是实时推流至服务器。那么,接下去应该如何实现呢?

视频推流

这里有一个小插曲,为了完成这个项目,我和同学查阅了不少资料和源码。其中有一个RtmpRecoder项目2使用FFmpeg进行实时摄像头的RTMP推流,推流的原理如下图所示。

FFmpeg推流

FFmpeg是一个大名鼎鼎的音视频转码库,它由C语言实现,因此在Java中,我们需要通过JNI进行调用,这里,我们使用了JavaCV1的FFmpeg转码功能。


注意:如果使用JavaCV并采用mpeg1video格式推流至服务器,切记将声道调为0,recorder.setAudioChannels(0),否则视频会残缺不全。

说到这里,不得不吐槽一下JavaCV1,它没有文档,没有文档是件很可怕的事情,编程基本靠猜。而且它也没有实现FFmpeg的全部功能!!!我们为了把获取到的视频帧流数据传给JavaCV费了好大功夫,曾经一度想通过调用C语言函数来完成这项工作,但没有成功!

到最后黔驴技穷,只好去项目中开Issue寻求帮助,然而作者表示尚未实现该功能,WTF。好吧,毕竟开源项目,别人也没有义务去做这件事。所以最后也只能自己来解决这个问题了。

废话不多说,既然JavaCV1无法完成这项工作,那么我们只好另辟蹊径。

现在,有两种做法。

  • 自己编写FFmpeg类库。我尝试直接使用CLI接入stream的方式实现实时推流。方法也很简单,只需要在Java中启动FFmpeg进程,然后pipe输入流,再由FFmpeg推流至服务器。但实践之后发现一些奇怪的问题,只好作罢。
  • 另一个方案就是徒手来处理视频帧数据,将转码的工作放到服务器端去实现,最后我们使用这个方案成功完成了任务。下面来看看H.264编码:

H.264编码

众所周知,视频编码格式种类繁多,H.264也是其中一种编码,每一种编码都有其特点和适用场景,更多信息请自行搜索,这里不多做赘述。期间,我们尝试过将上面获取到的视频帧数据保存为文件,想研究视频文件为什么会呈现为绿屏的画面。经过翻阅资料和试验我们发现,H.264编码有着特殊的分层结构。

H.264 的功能分为两层:视频编码层(VCL, Video Coding Layer)和网络提取层(NAL, Network Abstraction Layer)。VCL 数据即编码处理的输出,它表示被压缩编码后的视频数据 序列。在 VCL 数据传输或存储之前,这些编码的 VCL 数据,先被映射或封装进 NAL 单元中。每个 NAL 单元包括一个原始字节序列负荷(RBSP, Raw Byte Sequence Payload)、一组对应于视频编码的 NAL 头信息。RBSP 的基本结构是:在原始编码数据的后面填加了结尾比特。一个bit“1”若干比特“0”,以便字节对齐。

NAL

因此,为了将帧序列变成合法的H.264编码,我们需要手动构建NAL单元。H.264的帧是以NAL单元为单位进行封装的,NAL单元的结构如上图所示。H.264分为AnnexbRTP两种格式,RTP格式更适合用于网络传输,因为其结构更加节省空间,但由于Android系统提供的数据本身就是Annexb格式的,因此我们采用Annexb格式进行传输。

按照Annexb格式的要求,我们需要将数据封装为如下格式:

0000 0001 + SPS + 0000 0001 + PPS + 0000 0001 + 视频帧(IDR帧)

H.264的SPS和PPS串,包含了初始化H.264解码器所需要的信息参数,包括编码所用的profile,level,图像的宽和高,deblock滤波器等。

然后不断重复以上格式即可输出正确的H.264编码的视频流了。这里的SPS和PPS在每一个NAL单元中重复存在,主要是适用于流式传播的场景,设想一下如果流式传播过程中漏掉了开头的SPS和PPS,那么整个视频流将永远无法被正确解码。

我们在实践过程中,SPS和PPS只传递了一次,这样的方式比较适合我们的项目场景,也比较省流量。因此在我们的方案中,格式变为如下形式:

0000 0001 + SPS + 0000 0001 + PPS + 0000 0001 + 视频帧(IDR帧)+ 0000 0001 + 视频帧 + ...

H.264编码比较复杂,我也只是在做项目期间查阅一些资料才有一点大概的了解,然后在项目完成之后才去反思和理解背后的原理。如果要深入学习,可以查阅相关的资料(H.264视频压缩标准5)。

介绍完H.264的基本原理,下面看看Android上具体的实现。其实Android系统的MediaCodec类库已经帮助我们完成了较多的工作,我们只需要在开始录制时(或每一次传输视频帧前)在视频帧之前写入SPS和PPS信息即可。MediaCodec已经默认在数据流(视频帧和SPS、PPS)之前添加了start code(0x01),我们不需要手动填写。

SPS和PPS分别对应了bufferFormat中的csd-0csd-1字段。

...

} else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
    MediaFormat outputFormat = codec.getOutputFormat();
    outputFormat.getByteBuffer("csd-0");    // SPS
    outputFormat.getByteBuffer("csd-1");    // PPS
    /* 然后直接写入传输流 */
}

服务器端

实时的数据流通过Socket(tcp)传输到服务器端,服务器端采用Node.js实现视频流转码和WebSocket转播。为了使Web前端可以播放实时的视频,我们必须将格式转换为前端支持的视频格式,这里解码使用FFmpeg的Node.js封装(stream-transcoder项目6)。以下是Socket通讯和转码的关键代码:

var Transcoder = require('stream-transcoder');
var net = require('net');

net.createServer(function(sock) {

    sock.on('close', function(data) {
        console.log('CLOSED: ' +
            sock.remoteAddress + ' ' + sock.remotePort);
    });

    sock.on('error', (err) => {
        console.log(err)
    });

    // 转码  H.264 => mpeg1video
    new Transcoder(sock)
      .size(width, height)
      .fps(30)
      .videoBitrate(500 * 1000)
      .format('mpeg1video')
      .channels(0)
      .stream()
      .on('data', function(data) {
        // WebSocket转播
        socketServer.broadcast(data, {binary:true});
      })

}).listen(9091);

Web直播

紧接着,Web前端与服务器建立WebSocket连接,使用jsmpeg项目7对mpeg1video的视频流进行解码并呈现在Canvas上。

var client = new WebSocket('ws://127.0.0.1:9092/');

var canvas = document.getElementById('videoCanvas');
var player = new jsmpeg(client, {canvas:canvas});

后续还可以做一些灵活的配置以及错误处理,可以让整个直播的流程更加稳定。至于视频方面的优化,也可以继续尝试各种参数的调节等等。

为了完成这个项目,我和我的另一个同学前后花费了四五天的时间,进行各种摸索和尝试,所以我决定记录下这个方案,希望可以帮助到有需要的人。

其他

  1. 参考JavaCV项目
  2. 参考RtmpRecoder开源项目的实现
  3. 参考ScreenRecorder开源项目的实现
  4. 参考Android文档
  5. 文献H.264视频压缩标准
  6. 使用stream-transcoder项目
  7. 使用jsmpeg项目