文章目錄
- 1. 子產品分割
- 2. 解碼器實作
- 3. 播放控制
- 4. 音視訊同步
- 5. 總結
之前的部落格中已經使用了FFmpeg進行音頻檔案的解碼,并且基于OpenSLES實作了一個簡單的音樂播放器。最近正在學習《音視訊開發進階指南》,看到了視訊部分。不如就幹脆再寫一個視訊播放器。代碼存放在我的github:Android-VideoPlayer。
1. 子產品分割
首先對這個視訊播放器所采用的一些部件要清楚。這個播放器主要可以拆分為4個部分:
- 解碼:FFmpeg
- 音頻輸出:OpenSLES
- 視訊渲染:OpenGLES
這些架構都是基于C的api,是以這次我們的主要工作将會集中在NDK部分。而關于NDK的一些知識,之前的部落格也有講過,是以這個工程會是對之前知識的一次綜合運用。
按照視訊播放器的功能,我們将分出以下幾個子產品:
- 圖像顯示
- 音頻輸出
- 解碼
- 播放控制
- 音視訊同步
為了提高可移植性,對關鍵部件使用接口來規範其API接口。
1. IAudioPlayer:音頻播放器接口。它規定的接口如下
class IAudioPlayer {
public:
virtual bool create() = 0;
virtual void release() = 0;
virtual void start() = 0;
virtual void stop() = 0;
virtual bool isPlaying() = 0;
virtual void setAudioFrameProvider(IAudioFrameProvider *provider) = 0;
virtual void removeAudioFrameProvider(IAudioFrameProvider *provider) = 0;
};
2. IVideoPlayer:視訊播放接口。
class IVideoPlayer {
public:
virtual bool create() = 0;
virtual void release() = 0;
virtual void refresh() = 0;
virtual void setVideoFrameProvider(IVideoFrameProvider *provider) = 0;
virtual void removeVideoFrameProvider(IVideoFrameProvider *provider) = 0;
virtual void setWindow(void *window) = 0;
virtual void setSize(int32_t width, int32_t height) = 0;
virtual bool isReady() = 0;
};
3. AudioFrame:存儲解碼好的音頻資料。
對于播放器内部,播放的音頻資料格式為16位PCM,44.1kHz采樣率,雙聲道。為了避免每一段音頻資料都要重新申請記憶體,我們将會複用AudioFrame,是以要給它設定一個最大音頻資料存儲空間。
struct AudioFrame{
// present time stamp
int64_t pts;
int16_t *data;
int32_t sampleCount;
int32_t maxDataSizeInByte = 0;
AudioFrame(int32_t dataLenInByte)
{
this->maxDataSizeInByte = dataLenInByte;
pts = 0;
sampleCount = 0;
data = (int16_t *)malloc(maxDataSizeInByte);
memset(data, 0, maxDataSizeInByte);
}
~AudioFrame(){
if(data != NULL)
{
free(data);
}
}
};
4. VideoFrame:存儲解碼好的視訊資料:
對于播放器内部使用的視訊資料格式,分辨率為1920*1080,像素格式RGB888,每種顔色一個位元組,一個像素占3個位元組。對于VideoFrame同樣會複用。
struct VideoFrame
{
int64_t pts;
uint8_t *data;
int32_t width;
int32_t height;
int32_t maxDataSizeInByte = 0;;
VideoFrame(int32_t dataLenInByte)
{
this->maxDataSizeInByte = dataLenInByte;
data = (uint8_t *)malloc(maxDataSizeInByte);
memset(data, 0, maxDataSizeInByte);
}
~VideoFrame()
{
if(data != NULL)
{
free(data);
}
}
};
5. IAudioFrameProvider:面向IAudioPlayer的音頻資料的提供源,它為IAudioPlayer提供解碼好的音頻資料
由于要複用AudioFrame,是以要設定一個接口,讓IAudioPlayer将使用完的AudioFrame歸還給我們。
class IAudioFrameProvider {
public:
virtual AudioFrame* getAudioFrame() = 0;
virtual void putBackUsed(AudioFrame *data) = 0;
};
6. IVideoFrameProvider:和IAudioFrameProvider一樣。
class IVideoFrameProvider {
public:
virtual VideoFrame* getVideoFrame() = 0;
virtual void putBackUsed(VideoFrame* data) = 0;
};
7. IMediaDataReceiver:用于接收解碼好的音視訊資料的接口。
它是用來維護并存儲已經解碼好的音視訊資料和使用過的音視訊資料。
class IMediaDataReceiver {
public:
virtual void receiveAudioFrame(AudioFrame *audioData) = 0;
virtual void receiveVideoFrame(VideoFrame *videoData) = 0;
virtual AudioFrame* getUsedAudioFrame() = 0;
virtual VideoFrame* getUsedVideoFrame() = 0;
virtual void putUsedAudioFrame(AudioFrame *audioData) = 0;
virtual void putUsedVideoFrame(VideoFrame *videoData) = 0;
};
8. BlockRecyclerQueue:同步複用隊列。
c++内并沒有線程安全的隊列模型。是以我們自己實作一個。并且由于播放器内很多的資料都會需要複用,是以給這個隊列加一個複用功能。這樣,這個類内部會有兩個隊列,一個存儲未使用的資料,一個存儲已使用的資料。使用兩把鎖,分别對兩個隊列進行線程保護。當然,實際上你也可以以更小的粒度來考慮這件事,隻要使用一個隊列,然後對隊列進行線程保護即可,至于裡面存儲的到底是用過的資料還是沒用過的資料,完全可以由上層來決定。
播放器中的多線程都使用c++11自帶的thread。
這個同步複用隊列實際上就是生産者消費者模式中的管道。它有以下幾個特點:
- 如果設定capacity=-1,那麼這個隊列是不限大小的。如果限制了大小,當内部存儲的資料滿的時候,put操作就會等待,這是為了防止解碼器過快導緻記憶體占用過高。
- 對于get操作和put操作,你可以通過設定wait來決定當資料空或滿的時候是否等待。對于get操作,隊列空時,如果wait = true,那麼它就會一直等待直到有資料;如果wait = false,那麼它就會立刻傳回NULL。對于put操作,隊列滿時,如果wait = true,它就會一直等待到隊列不滿;如果wait = false,那麼它就不會顧及capacity,而直接向隊列中存儲,導緻size > capacity。
- 為了防止播放結束時發生死鎖,設定兩個接口來解除所有的get和put操作的等待。這一點考慮到解碼器解碼完畢後,播放器卻一直等待。
- 以上所有情況都是是對于有用的資料。而對于回收資料隊列,所有的put和get操作隻保證線程安全,而不會等待。它沒有最大容量,所有的put操作都會在得到鎖之後立刻執行。所有的get操作也會在得到線程鎖之後立刻執行,如果沒有回收資料,立刻傳回NULL。
- 通過
方法可以将所有的有用資料一次性放到回收資料中,并且還可以傳遞一個函數指針,對所有的有用資料進行回收處理,之後再放入回收隊列。這是為了seek操作考慮的,因為seek時要放棄所有已經解碼好的資料。discardAll(void (*discardCallback)(T))
template <class T>
class BlockRecyclerQueue {
public:
// if size == -1, then we don't limit the size of data queue, and all the put option will not wait.
BlockRecyclerQueue(int capacity = -1);
~BlockRecyclerQueue();
int getCapacity();
int getSize();
// put a element, if wait = true, put option will wait until the length of data queue is less than specified size.
void put(T t, bool wait = true);
// get a element, if wait = true, it will wait until the data queue is not empty. If wait = false, it will return NULL if the data queue is empty.
// It will still return NULL even wait = true, in this case, it must be someone call notifyWaitGet() but the data queue is still empty.
T get(bool wait = true);
void putToUsed(T t);
T getUsed();
void discardAll(void (*discardCallback)(T));
// notify all the put option to not wait. This will cause put option succeed immediately
void notifyWaitPut();
// notify all the get option to return immediately. if data queue is still empty, get option will return a NULL.
void notifyWaitGet();
private:
int capacity = 0;
mutex queueMu;
mutex usedQueueMu;
condition_variable notFullSignal;
condition_variable notEmptySignal;
list<T> queue;
list<T> usedQueue;
bool allowNotifyPut = false;
bool allowNotifyGet = false;
};
2. 解碼器實作
解碼部分還是使用FFmpeg。解碼過程和解碼音頻過程大同小異。
首先,我們肯定需要兩個線程來分别解碼音頻和視訊。
其次,還需要一個線程來讀取檔案,之前我們在解碼音頻時将從檔案中讀取packet和将packet解碼為frame的過程放在同一個線程中執行,因為音頻檔案我們隻關注音頻流。現在我們要将讀packet這個操作單獨放在一個線程裡,然後解碼器要維護兩個隊列,來分别存放音頻的AVPacket和視訊的AVPacket,這兩個隊列就可以使用之前的
BlockRecyclerQueue
。這相當于,讀檔案線程是生産者,而音頻解碼線程和視訊解碼線程都是消費者。具體代碼可以檢視
VideoFileDecoder.cpp
。
需要注意的是,seek操作也是放在解碼器中進行的,因為seek需要對媒體檔案進行操作。在seek時,同樣要将之前所有已經讀出的AVPacket抛棄。
由于檔案解碼出的編碼格式會不一樣,是以我們需要FFmpeg的
swr_convert
來轉碼音頻資料,用
sws_scale
轉碼視訊資料。
3. 播放控制
我向外提供了一個播放器的統一操作接口:
VideoPlayController.cpp
,同時它還負責通知上層播放進度、管理音視訊播放器和解碼器、管理已解碼好的資料等。是以它的聲明如下:
class VideoPlayController: public IMediaDataReceiver, public IAudioFrameProvider, public IVideoFrameProvider
它實作了三個接口,可以接受解碼器解碼好的資料,并且向音視訊播放器分别提供音頻資料和視訊資料。
4. 音視訊同步
由于通常音頻幀率要比視訊幀率高很多,一般視訊中的音頻采樣率多為44.1kHz或48kHz,而視訊一般是25fps。
音視訊同步通常有兩種方式:
- 以音頻時間基準播放視訊,這是由于音頻幀率更高。
- 以額外的時鐘對音視訊進行同步。
一般來說,額外時鐘的方式會更好一些,一是因為它的精度高;二是這樣一來,如果出現檔案中隻有視訊或者隻有音頻的情況,适用性也會更高些;三是如果你的音頻播放器不是主動請求音頻資料的,那麼你無論如何都需要一個額外時鐘來向音頻播放器和視訊播放器定時發送資料。不過它的缺點在于多占資源。
我這裡使用的是以音頻時間為基準,因為OpenSLES是主動請求音頻資料的。這樣一來每次音頻播放器請求資料時,我們可以拿到目前AudioFrame的pts,就可以得知目前的播放進度,也可以以這個播放進度來判斷是否向視訊播放器發送重新整理指令。
自然而然,播放和暫停功能也是通過控制音頻播放器的播放暫停來實作的。
音視訊同步也放在
VideoPlayController.cpp
中。音視訊同步部分的代碼放在
AudioFrame *VideoPlayController::getAudioFrame()
方法中。
5. 總結
至此,這個播放器的關鍵部分就理清了。代碼請上我的github上檢視,連結在部落格頂部。不過它仍然有很多問題:
- 某些情況下,退出視訊播放會ANR,可能是某個線程進入了死鎖或者死等待。
- 現在隻能正常播放分辨率較低的視訊,因為沒有針對硬體加速做優化,導緻解碼視訊過于耗時。測試得出解碼一幀1920*1080的視訊解碼需要差不多70ms。