鴻蒙開源全場景應用開發—視頻編解碼
背景
面對鴻蒙這一全新的生態,廣大消費者在積極嘗鮮的同時,家中不可避免會出現安卓設備和鴻蒙設備并存的現象,短期內可能不會形成全鴻蒙的生態環境。因此,在未來的一段時間內,鴻蒙設備和安卓設備共存的現象會比較普遍。那么為了給用戶帶來更加流暢的全場景體驗,鴻蒙和安卓設備之間的交互就顯得格外重要。
家庭合影美顏相機
家庭合影美顏相機應用是同時基于鴻蒙和安卓設備的應用,可以實現鴻蒙大屏借助安卓手機的能力進行美顏拍照的功能,其中安卓端使用了GitHub上的開源項目。具體來說,此應用能夠將鴻蒙大屏拍攝的視頻數據實時傳輸到安卓手機上;并在安卓端為其添加濾鏡,再將處理后的視頻數據傳回到鴻蒙大屏進行渲染顯示,從而達到鴻蒙大屏進行美顏拍照的功能,其效果可以參考下圖1:

圖1 家庭合影美顏相機應用的效果示意圖
應用運行后的動態場景效果可以參考圖2,圖中上方橫屏顯示的是鴻蒙手機,下方豎屏顯示的是安卓手機。此處需要說明的是,由于實驗環境缺少搭載鴻蒙系統的大屏設備,因此我們使用鴻蒙手機替代大屏設備模擬實驗場景。

圖2 應用運行后的效果
應用成功運行后的效果如下:
- 在鴻蒙大屏設備上開啟攝像頭訪問權限,點擊主菜單界面的“點擊發送大屏數據”按鈕,即可將大屏拍攝到的視頻數據通過RTP協議發送到安卓手機端。
- 在安卓手機端點擊主菜單界面的“GLCAMERAVIEW”按鈕,即可接收上述鴻蒙大屏傳來的視頻數據,并將視頻數據顯示在手機屏幕上。
- 安卓端在接收到視頻后,會將數據實時渲染到手機屏幕上,用戶可以選擇給視頻添加各種風格的濾鏡;
- 安卓端會通過RTP協議將添加濾鏡后的視頻數據傳輸到鴻蒙端進行顯示。
上述已經介紹過,此應用是同時基于鴻蒙和安卓設備的,因此在講解此應用時不僅會包含鴻蒙相關知識,同時也會涉及到一些安卓方面的知識。此應用包含4個功能模塊,可參考圖3,分別是:視頻編解碼、通訊協議、美顏濾鏡和視頻渲染。其中每個模塊都會涉及到不同的技術點,如視頻編解碼會涉及視頻流格式和編解碼器參數設置;通訊協議會涉及UDP、RTP協議等。后續我們的文章將會按不同模塊進行講解和發布,敬請期待!

圖3 家庭合影美顏相機功能模塊圖
視頻編解碼應用案例解析
本期文章將介紹視頻編解碼模塊,視頻編解碼是視頻處理的基礎,鴻蒙平臺為我們提供了強大的視頻處理能力。為了更具體地講解該模塊功能,我們將家庭合影美顏相機應用中涉及實現視頻編解碼的代碼獨立拆分出來形成了一個視頻編解碼Demo,將在后續進行效果展示和實現原理講解。
下面以拆分出來的視頻編解碼模塊Demo為例,先向大家講解鴻蒙視頻編解碼的具體實現原理,再對鴻蒙和安卓兩者視頻編解碼的原理差異進行分析。
1、運行效果和代碼結構
視頻編解碼Demo的運行效果如圖4所示。開始運行后,會獲取攝像頭的權限,然后會在界面中間的矩形區域顯示攝像頭拍攝到的畫面,此時用戶可以點擊界面上的“開始編解碼”按鈕,即可在原始視頻的下方的矩形區域中看到編解碼后視頻的渲染效果。


圖4 視頻編解碼Demo運行效果圖
接著介紹一下視頻編解碼Demo的代碼結構,如圖5所示。其中MainAbilitySlice類用于頁面布局和實例化編解碼器;我們還構建了VDEecoder類和VDDecoder類,前者用于視頻編碼并對編碼過程進行監聽,將編碼后的數據送去解碼,后者用于視頻解碼,對解碼過程進行監聽,輸出解碼后的數據。

圖5 視頻編解碼Demo代碼結構
2、實現流程解析
下面講解此Demo實現視頻編解碼效果的具體實現流程,共分為7個步驟:
步驟1. 創建整體顯示布局。
步驟2. 實例化編碼類VDEncoder的對象并初始化編碼器。
步驟3. 獲取相機數據并將其加入編碼隊列。
步驟4. 初始化解碼器。
步驟5. 設置Button監聽事件,執行編碼操作。
步驟6. 監聽編碼器,獲取編碼后的數據并送去解碼。
步驟7. 執行解碼操作。
(1)創建整體顯示布局
在MainAbilitySlice中,定義用于控制編解碼的Button按鈕控件、用于顯示編解碼狀態的Text文本控件、兩個分別用于顯示攝像頭拍攝的視頻和編解碼后視頻的SurfaceProvider畫面渲染控件,并設置上述控件的相關屬性,如圖4效果所示。
(2)實例化編碼類VDEncoder的對象并初始化編碼器
實例化編碼VDEncoder類對象,使用帶有參數framerate的構造函數,其中framerate代表幀速率,此處設為15。
- VDEncoder vdEncoder = new VDEncoder(15);// 創建編碼類對象
在構造函數中,需要進行編碼器初始化操作,如設置編碼器格式如圖像大小、比特率、顏色格式、幀率、關鍵幀間隔時間、比特率模式等。需要注意的是,要選擇合適的比特率、幀率等參數,不然極有可能出現編解碼后視頻顯示不出來或顯示效果異常的情況。在設置完各屬性參數后,通過set()方法將上述格式屬性配置到編碼器對象中;再設置監聽用于獲取編碼輸出數據;使用start()方法控制編碼器開始執行;并初始化自定義的單例線程池用于編碼線程,由于攝像頭獲取到的數據會被按順序放入視頻隊列YUVQueue中,因此需要使用線程來提高處理效率。
- public VDEncoder(int framerate){
- Format fmt = new Format();// 創建編碼器格式
- fmt.putStringValue("mime", "video/avc");
- fmt.putIntValue("width", 640);// 視頻圖像寬度
- fmt.putIntValue("height", 480);// 視頻圖像高度
- fmt.putIntValue("bitrate", 392000);// 比特率
- fmt.putIntValue("color-format", 21);// 顏色格式
- fmt.putIntValue("frame-rate", framerate);// 幀率
- fmt.putIntValue("i-frame-interval", 1);// 關鍵幀間隔時間
- fmt.putIntValue("bitrate-mode", 1);// 比特率模式
- mCodec.setCodecFormat(fmt);// 設置編碼器格式
- mCodec.registerCodecListener(encoderlistener);// 設置監聽
- mCodec.start();// 編碼器開始執行
- singleThreadExecutor = new SingleThreadExecutor();// 初始化自定義單例線程池
- }
(3)獲取相機數據并將其加入編碼隊列
在正式開始編碼之前,需要通過相機的圖像監聽事件ImageReceiver.IImageArrivalListener,獲取實時返回的原生視頻數據并將其存放在ByteBuffer類對象中,再逐個讀取成byte數組的形式,存儲在YUV_DATA中。
- private final ImageReceiver.IImageArrivalListener imagerArivalListener = new ImageReceiver.IImageArrivalListener() {
- @Override
- public void onImageArrival(ImageReceiver imageReceiver) {// 當相機開始運行后,用于監聽,實時返回視頻原始數據
- mLog.log("imagearival", "arrival");
- Image mImage = imageReceiver.readNextImage();// 用于讀取視頻畫面
- if(mImage != null){
- ByteBuffer mBuffer;
- byte[] YUV_DATA = new byte[VIDEO_HEIGHT * VIDEO_WIDTH * 3 / 2];// 存放從相機獲取的原始 YUV 視頻數據
- ...
- // 從相機獲取實時拍攝的視頻數據,并將 Image 讀取到的視頻流數據存放在 mBuffer
- mBuffer = mImage.getComponent(ImageFormat.ComponentType.YUV_Y).getBuffer();
- // 從視頻流mBuffer逐個讀取成 byte 數組的形式,并存儲在 YUV_DATA 中
- for(i=0;i< VIDEO_WIDTH * VIDEO_HEIGHT;i++){
- YUV_DATA[i] = mBuffer.get(i);
- }
- ...
- vdEncoder.addFrame(YUV_DATA);// 將視頻數據 YUV_DATA 加入到隊列等待編解碼
- mImage.release();// 獲取完視頻數據之后及時釋放
- return;
- }
- }
- };
(4)初始化解碼器
通過VDEncoder類對象調用prepareDecoder()方法,初始化解碼器,并將用于顯示編解碼后視頻的SurfaceProvider對象作為入參傳入方法中。
- vdEncoder.prepareDecoder(surfaceview2);
在VDEncoder類的prepareDecoder()方法中,先實例化解碼VDDecoder類對象,并使用SurfaceProvider類對象surfaceview顯示編解碼后的視頻,再調用start()方法控制開始解碼。
- public void prepareDecoder(SurfaceProvider surfaceview){
- vdDecoder = new VDDecoder(surfaceview);// 創建解碼類對象,并使用surfaceview顯示解碼后的視頻
- vdDecoder.start();// 開始解碼
- }
視頻解碼的初始化方法beginCodec()與編碼初始化實現原理類似,也需要對各種格式進行配置,此處不再進行贅述,唯一不同之處是幀率和關鍵幀間隔時間的設置,具體含義可參考下面代碼中的注釋信息。
- private synchronized void beginCodec() {//初始化解碼器各參數
- System.out.println("isSurfaceCreated = " + Boolean.toString(isSurfaceCreated));
- if (isSurfaceCreated) {
- isSurfaceCreated = false;
- Format fmt = new Format();// 創建解碼器格式
- fmt.putStringValue("mime", "video/avc");
- fmt.putIntValue("width", 640);// 視頻圖像寬度
- fmt.putIntValue("height", 480);// 視頻圖像高度
- fmt.putIntValue("bitrate", 392000);// 比特率
- fmt.putIntValue("color-format", 21);// 顏色格式
- fmt.putIntValue("frame-rate", 30);// 幀率
- fmt.putIntValue("i-frame-interval", -1);// 關鍵幀間隔時間
- fmt.putIntValue("bitrate-mode", 1);// 比特率模式
- mCodec.setCodecFormat(fmt);// 設置解碼器格式
- mCodec.registerCodecListener(decoderlistener);// 設置監聽
- mCodec.start();// 解碼器開始執行
- isMediaCodecInit = true;
- }
- }
(5)設置Button監聽事件,執行編碼操作
為整體顯示布局中用于控制是否開始編解碼的Button按鈕設置onCilick()點擊事件,調用VDEncoder類對象的start()方法控制開始編碼。判斷如果編碼正在進行,則顯示當前編碼狀態。
- button.setClickedListener(component -> {// 按鈕被點擊
- mLog.log("button", "start");
- vdEncoder.start();// 開始編碼
- if(vdEncoder.isRuning){// 如果編碼正在進行,顯示當前編碼狀態
- text.setText("成功進行編解碼,并顯示在下方");
- }
- });
在具體執行編碼操作的線程中,會先調用Codec類的getAvailableBuffer()方法在指定索引處獲取編碼時的可用緩沖區ByteBuffer,其中參數timeout表示用于填充有效數據的緩沖區索引;再創建緩沖區信息BufferInfo,注意ByteBuffer和BufferInfo要成對使用,調用setInfo()方法設置相關信息如偏移量、數據長度、時間戳和緩沖區類型;接著將數據通過put()方法放入緩沖區ByteBuffer中;并通過Codec類的WriteBuffer()方法將傳入的ByteBuffer和BufferInfo進行處理。
- private void startEncoderThread() {
- singleThreadExecutor.execute(new Runnable() {
- @Override
- public void run() {
- byte[] data;
- while (isRuning) {
- try {
- data = YUVQueue.take();// 從隊列中獲取原相機得到的原生視頻數據
- } catch (InterruptedException e) {
- e.printStackTrace();
- break;
- }
- // 將數據以 Buffer 和 BufferUnfo 的形式通過 Codec 類進行編碼
- ByteBuffer buffer = mCodec.getAvailableBuffer(-1);
- BufferInfo bufferInfo = new BufferInfo();//與ByteBuffer成對使用
- buffer.put(data);//將數據放入緩沖區
- bufferInfo.setInfo(0, data.length, System.currentTimeMillis(), 0);//設置數據相關信息
- mCodec.writeBuffer(buffer, bufferInfo);//對緩沖區數據進行處理
- }
- }
- });
- }
(6)監聽編碼器,獲取編碼后的數據并送去解碼
設置編碼器監聽事件,監聽編碼器行為。重寫onReadBuffer()方法獲取編碼后的輸出緩沖區ByteBuffer和緩沖區信息BufferInfo,通過ByteBuffer類對象調用get()方法獲得輸出數據,并存放在byte數組data中;再通過當前解碼類對象vdDecoder調用toDecoder()方法,即可將完成編碼后的視頻數據送去解碼。
- private Codec.ICodecListener encoderlistener = new Codec.ICodecListener() {
- // 用于監聽編碼器,獲取編碼完成后的數據
- @Override
- public void onReadBuffer(ByteBuffer byteBuffer, BufferInfo bufferInfo, int i) {
- byte[] data = new byte[bufferInfo.size];
- byteBuffer.get(data);// 從編碼器的 byteBuffer 中獲取數據
- mLog.log("pushdata", "encoded data:" + data.length);
- vdDecoder.toDecoder(data);// 通過解碼類的 toDecoder()方法,將編碼完成的視頻數據送去解碼
- }...
- };
(7)執行解碼操作
通過decoder()方法執行解碼操作,其原理和上述講解過的編碼原理相同。使用Codec類的getAvailableBuffer()方法在指定索引處獲取解碼時的可用緩沖區ByteBuffer;創建緩沖區信息BufferInfo,調用setInfo()方法設置相關信息并將數據放入緩沖區ByteBuffer、WriteBuffer()方法處理傳入的ByteBuffer和BufferInfo。在完成解碼之后,通過事件監聽類獲得輸出數據,并按需對解碼后的視頻數據進行畫面渲染顯示等相關操作。
- private void decoder(byte[] video) {//解碼器具體執行流程
- ByteBuffer mBuffer = mCodec.getAvailableBuffer(-1);
- BufferInfo info = new BufferInfo();//與ByteBuffer成對使用
- info.setInfo(0, video.length, 0, 0);//設置數據相關信息
- mBuffer.put(video);//將數據放入緩沖區
- mCodec.writeBuffer(mBuffer, info);//對緩沖區數據進行處理
- }
鴻蒙編解碼器Codec和安卓編解碼器MediaCodec的區別
MediaCodec類作為安卓多媒體基礎框架的一部分,通過訪問底層的媒體編解碼器,即編解碼器組件,來實現對于音視頻的編解碼功能。MediaCodec共支持4種數據類型,分別是原始的音、視頻數據,和壓縮后的音、視頻數據。鴻蒙平臺的Codec編解碼類同樣支持上述提到的4種數據類型,相比較安卓平臺的MediaCodec類,二者區別主要體現在使用方式上,即輸出數據的獲取方式和Index緩沖區索引的使用。先來對比觀察一下鴻蒙和安卓編解碼實現原理的代碼:
- //鴻蒙Codec編解碼:
- private void decoder(byte[] video) {//將數據以 Buffer 和 BufferUnfo 的形式通過 Codec 類進行解碼
- ByteBuffer mBuffer = mCodec.getAvailableBuffer(-1);
- BufferInfo info = new BufferInfo();
- info.setInfo(0, video.length, 0, 0);
- mBuffer.put(video);//數據放入
- mCodec.writeBuffer(mBuffer, info);
- }
- //鴻蒙監聽類
- private Codec.ICodecListener decoderlistener = new Codec.ICodecListener() {
- // 用于監聽編碼器,獲取解碼完成后的數據
- @Override
- public void onReadBuffer(ByteBuffer byteBuffer, BufferInfo bufferInfo, int i) {
- byte[] bytes = new byte[bufferInfo.size];//自定義數組用來存放輸出數據
- byteBuffer.get(bytes);// 從緩沖區的 byteBuffer 中獲取數據
- }
- };
- //安卓MediaCodec編解碼:
- ByteBuffer[] inputBuffers = mediaCodec.getInputBuffers();
- ByteBuffer[] outputBuffers = mediaCodec.getOutputBuffers();
- //放入處理數據
- int inputBufferIndex = mediaCodec.dequeueInputBuffer(-1);
- ByteBuffer inputBuffer = mediaCodec.getInputBuffer(inputBufferIndex);//獲取編碼器傳入數據ByteBuffer
- inputBuffer.clear();//清除以前數據
- inputBuffer.put(PCMbuffer);//PCMbuffer需要編碼器處理數據
- mediaCodec.queueInputBuffer(inputBufferIndex, 0, inputBuffer.limit(), 0, 0);//通知編碼器,數據放入
- //處理完成數據
- int outputBufferIndex = mediaCodec.dequeueOutputBuffer(timeoutUs);
- while (outputBufferIndex >= 0) {
- outputBuffers = mediaCodec.getOutputBuffer(outputBufferIndex );//獲取編碼數據
- //outputBuffer 編碼器處理完成的數據
- mediaCodec.releaseOutputBuffer(outputBufferIndex , false);//告訴編碼器數據處理完成
- outputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 1000);//可能一次放入的數據處理會輸出多個數據
- }
(1)獲取輸出數據的方式
先簡單解釋一下安卓中編解碼器的原理,可結合圖6理解。當請求(或接收)一個空的輸入緩沖區(input buffers)時,先將待處理的數據填充到這個緩沖區,并將其發送到編解碼器進行處理;然后編解碼器將處理完成后的數據填充到空的輸出緩沖區(output buffers);這樣就可以請求(或接收)輸出緩沖區中的數據,在數據獲取完成之后再將緩沖區釋放掉。 在請求輸出緩沖區數據的過程中,通過while循環驗證輸出緩沖區的索引(outputBufferIndex)是否大于等于0,當滿足上述條件時,表示可以讀取輸出緩沖區的數據,否則一直等待輸出緩沖區的數據。

圖6 安卓編解碼器原理圖(來源于網絡,侵權必刪)
在鴻蒙中,也需要使用輸入緩存區(mBuffer)和輸出緩存區(byteBuffer)來裝載數據,但是在請求輸出緩沖區數據的過程中,鴻蒙采用了編解碼監聽的方式。通過ICodecListener類來監聽編解碼器的數據輸出,當可以從輸出緩存區獲取輸出數據時,可在重寫方法onReadBuffer(ByteBuffer byteBuffer, BufferInfo bufferInfo, int i)中獲取數據,在數據獲取完成之后再將緩沖區釋放掉。
(2)Index緩沖區索引的使用
安卓與鴻蒙端的另一區別是,安卓在處理輸入數據時還使用了一組對應的dequeueInputBuffer()和queueInputBuffer()方法用來處理輸入數據流,標記緩沖區索引。其中,dequeueInputBuffer()用于返回輸入緩沖區的索引;queueInputBuffer()用于告知編碼器數據已經被放入指定的輸入緩沖區中,這樣才可以正確釋放輸入緩沖區。同理,在處理輸出數據時也會使用一組實現原理相同的方法dequeueOutputBuffer()和queueOutputBuffer(),此處不進行贅述。
鴻蒙端并未采用上述緩沖區索引的概念,因此視頻編解碼的過程更加流暢精簡。