Android屏幕直播方案

項目需求是實時同步Android手機屏幕畫面至瀏覽器。這裏有兩個挑戰,一是Android如何在應用內獲得屏幕實時視頻流,另一個是如何在瀏覽器上做視頻直播。經過一番折騰,確定瞭如下的實現方案。期間,我們也實現了手機攝像頭的直播。

演示效果:

演示演示

Android獲取實時屏幕畫面

原理與基礎設置

Android 5.0版本之後,支持使用MediaProjection的方式獲取屏幕視頻流。具體的使用方法和原理如下圖所示:

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();
}

數據獲取

MediaCodecMediaCodec

圖片來自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推流

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”,以便字節對齊。

NALNAL

因此,爲了將幀序列變成合法的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項目
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章