這是一個跨平臺的播放器ijkplayer,iOS上集成看【如何快速的開發一個完整的iOS直播app】(原理篇)。
為了學習ijkplayer的代碼,最好的還是使用workspace來集成,關于worksapce我有一篇簡單介紹iOS使用Workspace來管理多項目。這樣可以點擊函數名查看源碼,也可以設置斷點,修改源碼測試等等。
主架構
每個類型的數據流構建各自的packet和frame緩沖區,讀取packet后根據類型分到不同的流程里。在最后顯示的時候,根據pts同步音視頻播放。
prepareToPlay
使用ijkPlayer只需兩部:用url構建IJKFFMoviePlayerController
對象,然后調用它的prepareToPlay
。因為init時構建的一些東西得到使用的時候才明白它們的意義,所以先從prepareToPlay
開始看。
上圖是調用棧的流程,最終起關鍵作用的就是read_thread
和video_refresh_thread
這兩個函數。
-
read_thread
負責解析URL,把數據拿到 -
video_refresh_thread
負責把內容顯示出來 - 中間步驟在
read_thread
運行后逐漸開啟
上述兩個方法都是在新的線程里運行的,使用了
SDL_CreateThreadEx
這個方法,它開啟一個新的pthread,在里面運行指定的方法。
解碼部分
- 淡黃色背景的是ffmpeg庫的函數,這幾個是解碼流程必須的函數。
- 先獲取分析流,對每個類型流開啟解碼器。
stream_component_open
函數這里做了一點封裝。 - 讀取packet之后,根據packet類型分到不同的packetQueue里面去:
if (pkt->stream_index == is->audio_stream && pkt_in_play_range) { packet_queue_put(&is->audioq, pkt); } else if (pkt->stream_index == is->video_stream && pkt_in_play_range && !(is->video_st && (is->video_st->disposition & AV_DISPOSITION_ATTACHED_PIC))) { packet_queue_put(&is->videoq, pkt); } else if (pkt->stream_index == is->subtitle_stream && pkt_in_play_range) { packet_queue_put(&is->subtitleq, pkt); } else { av_packet_unref(pkt); }
packet_queue_put
是ijkplayer封裝的一個緩沖區PacketQueue
的
的入隊方法。PacketQueue是采用鏈表構建的循環隊列,每個節點循環使用,一部分節點空閑,一部分使用中。就像一隊卡車來回運貨,有些車是空的,有些是裝滿貨的,循環使用。 -
video_thread
和audio_thread
的作用把packet緩沖區里的packet解碼成frame,裝入frame緩沖區。 -
video_thread
那里有個大坑,就是執行到node->func_run_sync(node)
之后就是函數指針,要繞一大圈才能找到真正的函數。因為解碼涉及到平臺不同,iOS里還有VideoToolbox的解碼,所以這里做了個解耦處理。-
node->func_run_sync(node)
的node來源于ffp->node_vdec
-
ffp->node_vdec
來源于pipeline->func_open_video_decoder(pipeline, ffp)
- 而
pipeline
來源于最開始init播放器的時候的_mediaPlayer = ijkmp_ios_create(media_player_msg_loop);
安卓平臺可能就不是這個函數了。也就是pipeline從播放器初始化的時候就不一樣了,然后node就不同了,node的func_run_sync
也跟著不同了。 - iOS里面做了VideoToolbox和ffmpeg解碼的區分。
-
解碼packet得到frame并入隊frameQueue
-
get_video_frame
實際調用decoder_decode_frame
,而這個函數在其他流解碼時也是走這個。 -
decoder_decode_frame
調用packetQueue的方法獲取最早的packet,然后調用各自的解碼函數得到frame - packetQueue里面是空的時候,如果立馬返回,然后又來一次循環,很可能循環了N多次還沒有得到新的packet。視頻幀率一般好的也就60幀,一秒60次,而
get_video_frame
這種while循環一秒能幾次?這里如果不阻塞線程等待新的packet入隊,會占用大量cpu。 -
queue_picture
把得到的frame加入到frameQueue里面去。
這里不僅僅是做了入隊處理,還把AVFrame轉化成了一個自定義類型SDL_VoutOverlay
:
當關系到播放的時候,又回到了上層,所以這里又要做平臺適配問題。struct SDL_VoutOverlay { int w; /**< Read-only */ int h; /**< Read-only */ Uint32 format; /**< Read-only */ int planes; /**< Read-only */ Uint16 *pitches; /**< in bytes, Read-only */ Uint8 **pixels; /**< Read-write */ int is_private; int sar_num; int sar_den; SDL_Class *opaque_class; SDL_VoutOverlay_Opaque *opaque; void (*free_l)(SDL_VoutOverlay *overlay); int (*lock)(SDL_VoutOverlay *overlay); int (*unlock)(SDL_VoutOverlay *overlay); void (*unref)(SDL_VoutOverlay *overlay); int (*func_fill_frame)(SDL_VoutOverlay *overlay, const AVFrame *frame); };
SDL_VoutOverlay
在我的理解里,更像是一個中間件,把AVFrame里面的視頻信息都轉化到這個中間件里,不管之后你用什么方式去播放,不必對AVFrame造成依賴。這樣將播放功能和解碼功能接觸聯系了。我發現在c編程里,函數指針幾乎起著protocol+delegate/interface的作用。
到這,數據流開啟了,packet得到了,frame得到了。read_thread的任務結束了。然后就是把frame顯示出來的事了。
顯示部分
- 視頻的顯示函數是
video_refresh_thread
,在第一張圖里 - 音頻的播放函數是
audio_open
,在第二張圖里,這時是已經解析到了音頻流并且獲得了音頻的格式后。這么做,我的理解是:音頻和視頻不同,視頻播放最終都是要轉成rgb,得到樣顯示的時候再轉;而音頻采樣率、格式、數這些可以設置成和源數據一樣,就不需要轉換了,所以需要先獲取了音頻源數據的格式,再開啟音頻播放。
視頻播放
video_refresh_thread
video_refresh_thread
就是不斷的顯示下一幀,如果距離下一幀還有時間,就用av_usleep
先暫停會。-
video_refresh
內部:- 先獲取最早的frame,計算和下一幀的時間(vp_duration)
- 紅色部分就是重點,用來處理音視頻同步的。根據同步的方式,獲取修正過后的下一幀時間。
- 如果時間沒到直接跳過退出
video_refresh
,進入av_usleep
暫停下。代碼里有點坑的是,這里寫的是goto display;
,而display代碼塊需要is->force_refresh
這個為true才真的起作用,所以實際上是結束了,看起來有些誤導。 - 確定要顯示下一幀之后,才調用
frame_queue_next
把下一幀推到隊列關鍵位,即索引rindex
指定的位置。 -
video_display2
最終到了SDL_Vout
的display_overlay
函數。和前面一樣,到了顯示層,在這做了解耦處理,SDL_Vout
對象是ffp->vout
,也是在IJKFFMoviePlayerController
init里構建的。
對iOS,
SDL_Vout
的display_overlay
最終到了IJKSDLGLView
的- (void)display: (SDL_VoutOverlay *) overlay
函數。下面的內容就是在iOS里如何用OpenGLES顯示視頻的問題了。可以看看用OpenGLES實現yuv420p視頻播放界面, ijkplayer里的顯示代碼有點復雜,貌似是為了把OpenGL ES部分再拆分出來做多平臺共用。
音頻播放
音頻和視頻的邏輯不一樣,視頻是搭建好播放層(OpenGLES的view等)后,把一幀幀的數據 主動 推過去,而音頻是開啟了音頻隊列后,音頻隊列會過來跟你要數據,解碼層這邊是 被動 的把數據裝載進去。
- 音頻Buffer就像卡車一樣,用完了,就再回來裝載音頻數據,然后回去再播放。如此循環。
- 音頻有兩部分:1. 開啟音頻,這部分函數是
audio_open
2. 裝載音頻數據,這部分函數是sdl_audio_callback
。 - 不管開啟音頻還是填充音頻buffer,都做了解耦處理,音頻隊列的部分是iOS里的AudioQueue的運行邏輯。
audio_open
必須先說幾個結構體,它們是連接界面層和顯示層的紐帶。
- SDL_Aout
typedef struct SDL_Aout_Opaque SDL_Aout_Opaque;
typedef struct SDL_Aout SDL_Aout;
struct SDL_Aout {
SDL_mutex *mutex;
double minimal_latency_seconds;
SDL_Class *opaque_class;
SDL_Aout_Opaque *opaque;
void (*free_l)(SDL_Aout *vout);
int (*open_audio)(SDL_Aout *aout, const SDL_AudioSpec *desired, SDL_AudioSpec *obtained);
......
一些操作函數,開始、暫停、結束等
};
SDL_Aout_Opaque
在這(通用模塊)只是typedef
重定義了下,并沒有實際的結構,實際定義根據各平臺而不同,這也是解耦的手段之一。open_audio
之后它的值才設置,在iOS里:
struct SDL_Aout_Opaque {
IJKSDLAudioQueueController *aoutController;
};
.....
//aout_open_audio函數里
opaque->aoutController = [[IJKSDLAudioQueueController alloc] initWithAudioSpec:desired];
這個值用來存儲IJKSDLAudioQueueController
對象,是負責音頻播放的音頻隊列的控制器。這樣,一個重要的連接元素就被合理的隱藏起來了,對解碼層來說不用關系它具體是誰,因為使用它的操作也是函數指針,實際被調用的函數平臺各異,到時這個函數內部就可以使用SDL_Aout_Opaque
的真身了。
- SDL_AudioSpec
typedef struct SDL_AudioSpec
{
int freq; /**采樣率 */
SDL_AudioFormat format; /**音頻格式*/
Uint8 channels; /**聲道 */
Uint8 silence; /**空白聲音,沒數據時用來播放 */
Uint16 samples; /**一個音頻buffer的樣本數,對齊成2的n次方,用來做音視頻同步處理 */
Uint16 padding; /**< NOT USED. Necessary for some compile environments */
Uint32 size; /**< 一個音頻buffer的大小*/
SDL_AudioCallback callback;
void *userdata;
} SDL_AudioSpec;
- 這個結構體基本就是對音頻格式的描述,拿到了我們要播放的音頻格式,顯示層可能可以播也可能不能播,所以需要溝通一下:把源音頻格式傳給顯示層,然后顯示層根據自身情況返回一個兩者都接受的格式回來。所以也就需要一個用于描述音頻格式的通用結構,
SDL_AudioSpec
就出來了。 - 音頻3大樣:采樣率(freq)、格式(format)、聲道(channels)。格式是自定義復合類型,這個后面使用到的時候再說。
- samples 這個其實是用來做音視頻頻同步的,因為音頻隊列的這種架構,填充音頻Buffer的時候,音頻還沒有播放,而解碼層只接觸到填充任務,所以它得不到正確的播放時間和音頻pts的差值。有了一個音頻Buffer的樣本數,除以采樣率就是一個音頻Buffer的播放時間,就可以估算正在裝載的音頻Buffer在未來的播放時間。音視頻同步的問題以后單獨說,比較復雜(如果我能都摸清楚的話-_-)。
- callback 這個是填充音頻Buffer的回調方法,前面說填充任務是被動的,所以顯示層得知道如何告訴解碼層要填充音頻數據了,這個callback就負擔著這個任務。顯示層一個音頻Buffer空了,就只管調用這個callback就好了。
IJKSDLAudioQueueController
再說audio_open
,它實際調用了SDL_Aout
的open_audio
函數,在iOS里,最終到了ijksdl_aout_ios_audiounit.m文件的aout_open_audio
,關鍵就是這句:
opaque->aoutController = [[IJKSDLAudioQueueController alloc] initWithAudioSpec:desired];
重點轉到IJKSDLAudioQueueController
這個類。如果對上圖里audioQueue的處理邏輯理解了,就比較好辦。
-
AudioQueueNewOutput 構建一個用于播放音頻的audioQueue
extern OSStatus AudioQueueNewOutput( const AudioStreamBasicDescription *inFormat, AudioQueueOutputCallback inCallbackProc, void * __nullable inUserData, CFRunLoopRef __nullable inCallbackRunLoop, CFStringRef __nullable inCallbackRunLoopMode, UInt32 inFlags, AudioQueueRef __nullable * __nonnull outAQ)
- inCallbackProc 這個就是用來指定audioQueue需要裝載AudioBuffer的時候的回調函數。
- inUserData 就是傳給回調函數的參數,這種東西比較常見。
- inCallbackRunLoop 和inCallbackRunLoopMode就是回調函數觸發的runloop和mode。
- inFlags無意義保留位置。
- AudioQueueRef 就是新構建的audioQueue
- AudioStreamBasicDescription是音頻格式的描述,這個就是前面說的
SDL_AudioSpec
對應的東西,需要根據傳入的SDL_AudioSpec
生成AudioQueue的格式:
extern void IJKSDLGetAudioStreamBasicDescriptionFromSpec(const SDL_AudioSpec *spec, AudioStreamBasicDescription *desc) { desc->mSampleRate = spec->freq; desc->mFormatID = kAudioFormatLinearPCM; desc->mFormatFlags = kLinearPCMFormatFlagIsPacked; desc->mChannelsPerFrame = spec->channels; desc->mFramesPerPacket = 1; desc->mBitsPerChannel = SDL_AUDIO_BITSIZE(spec->format); if (SDL_AUDIO_ISBIGENDIAN(spec->format)) desc->mFormatFlags |= kLinearPCMFormatFlagIsBigEndian; if (SDL_AUDIO_ISFLOAT(spec->format)) desc->mFormatFlags |= kLinearPCMFormatFlagIsFloat; if (SDL_AUDIO_ISSIGNED(spec->format)) desc->mFormatFlags |= kLinearPCMFormatFlagIsSignedInteger; desc->mBytesPerFrame = desc->mBitsPerChannel * desc->mChannelsPerFrame / 8; desc->mBytesPerPacket = desc->mBytesPerFrame * desc->mFramesPerPacket; }
格式是默認的kAudioFormatLinearPCM,但是采樣率(mSampleRate)和聲道(mChannelsPerFrame)是直接從
SDL_AudioSpec
拷貝過來。mFormatFlags
比較復雜,有許多參數需要設置,比如是否有符號數:#define SDL_AUDIO_MASK_SIGNED (1<<15) #define SDL_AUDIO_ISSIGNED(x) (x & SDL_AUDIO_MASK_SIGNED)
就是在
spec->format
的第16位上保存這個標識,使用&
的方式取出來。使用標識位的方式,把多個信息存在一個值里面,其實在枚舉里面也是這個手段。位運算和枚舉這里有我對枚舉的一點認識。SDL_AUDIO_BITSIZE
是計算這個音頻格式下單個樣本的大小,注意單位是比特,這個宏也就是把spec->format
最低的8位取了出來,在之前構建spec->format
的時候這個值就被存進去了。默認是使用s16也就是“有符號16比特”的格式,定義是這樣的:
#define AUDIO_S16LSB 0x8010 /**< Signed 16-bit samples */
,16位上是1,所以是有符號,低8位是0x10
,也就是16。這只是ijkplayer自定義的一種復合類型,或說傳遞手段,并不是一種標準。 AudioQueueStart 開啟audioQueue沒啥好說的
-
分配幾個AudioBuffer
for (int i = 0;i < kIJKAudioQueueNumberBuffers; i++) { AudioQueueAllocateBuffer(audioQueueRef, _spec.size, &_audioQueueBufferRefArray[i]); _audioQueueBufferRefArray[i]->mAudioDataByteSize = _spec.size; memset(_audioQueueBufferRefArray[i]->mAudioData, 0, _spec.size); AudioQueueEnqueueBuffer(audioQueueRef, _audioQueueBufferRefArray[i], 0, NULL); }
這里的重點是size的問題,AudioBuffer該多大?假如你希望1s裝載m次AudioBuffer,那么1s裝載的音頻數據就是m x AudioBuffer.size。而音頻播放1s需要多少數據?有個公式:1s數據大小 = 采樣率 x 聲道數 x 每個樣本大小。所以AudioBuffer.size = (采樣率 x 聲道數 x 每個樣本大小)/ m。再看ijkplayer里求
_spec.size
的代碼//2 << av_log2這么搞一下是為了對齊成2的n次方吧。 wanted_spec.samples = FFMAX(SDL_AUDIO_MIN_BUFFER_SIZE, 2 << av_log2(wanted_spec.freq / SDL_AoutGetAudioPerSecondCallBacks(ffp->aout))); ..... void SDL_CalculateAudioSpec(SDL_AudioSpec * spec) { ..... spec->size = SDL_AUDIO_BITSIZE(spec->format) / 8; spec->size *= spec->channels; spec->size *= spec->samples; }
spec->samples
是單個AudioBuffer的樣本數,而不是采樣率,注意除以了SDL_AoutGetAudioPerSecondCallBacks
,這個就是1s中裝載AudioBuffer的次數。 -
裝載回調
IJKSDLAudioQueueOuptutCallback
和裝載之后AudioQueueEnqueueBuffer
static void IJKSDLAudioQueueOuptutCallback(void * inUserData, AudioQueueRef inAQ, AudioQueueBufferRef inBuffer) { @autoreleasepool { .... (*aqController.spec.callback)(aqController.spec.userdata, inBuffer->mAudioData, inBuffer->mAudioDataByteSize); AudioQueueEnqueueBuffer(inAQ, inBuffer, 0, NULL); } }
在這里接力,調用
spec.callback
,從這又回到解碼層,那邊把AudioBuffer裝滿,AudioQueueEnqueueBuffer
在把AudioBuffer推給AudioQueue入隊播放。
sdl_audio_callback填充AudioBuffer
整體邏輯是:循環獲取音頻的AVFrame,填充AudioBuffer。比較麻煩的兩個點是:
- 裝載AudioBuffer的時候,裝滿了但是音頻幀數據還省一點的,這一點不能丟
- 獲取音頻幀數據的時候可能需要轉格式
因為剩了一截,那么下一次填充就不能從完整的音頻幀數據開始了。所以需要一個索引,指示AudioFrame的數據上次讀到哪了,然后從這開始讀。
while (len > 0) {
//獲取新的audioFrame
if (is->audio_buf_index >= is->audio_buf_size) {
audio_size = audio_decode_frame(ffp);
if (audio_size < 0) {
is->audio_buf = NULL;
is->audio_buf_size = SDL_AUDIO_MIN_BUFFER_SIZE / is->audio_tgt.frame_size * is->audio_tgt.frame_size;
} else {
is->audio_buf_size = audio_size;
}
is->audio_buf_index = 0;
}
len1 = is->audio_buf_size - is->audio_buf_index;
if (len1 > len)
len1 = len;
if (!is->muted && is->audio_buf && is->audio_volume == SDL_MIX_MAXVOLUME)
memcpy(stream, (uint8_t *)is->audio_buf + is->audio_buf_index, len1);
else {
memset(stream, 0, len1);
}
len -= len1;
stream += len1;
is->audio_buf_index += len1;
}
-
len
代表當前AudioBuffer剩余的填充量,因為len -= len1;
每次循環都會減去這次的填充量 -
audio_buf_size
是當前的AudioFrame總的數據大小,audio_buf_index
是這個AudioFrame上次讀到的位置。所以memcpy(stream, (uint8_t *)is->audio_buf + is->audio_buf_index, len1);
這句填充函數里,從audio_buf+audio_buf_index的位置開始讀。 -
len1
開始是當前的AudioFrame剩余的數據大小,如果超過了AudioBuffer剩余的填充量(len
),就只填充len
這么多。 - 如果不可填充,就插入0:
memset(stream, 0, len1)
。
audio_decode_frame獲取音頻幀數據
-
frame_queue_nb_remaining
沒有數據,先睡會av_usleep (1000);
-
frame_queue_peek_readable
讀取新的音頻幀,frame_queue_next
調到下一個位置。 - 轉換分3部:
- 設置轉換參數
3個轉換前的音頻參數,3個轉換后的參數,都是3大樣:聲道、格式、采樣率。is->swr_ctx = swr_alloc_set_opts(NULL, is->audio_tgt.channel_layout, is->audio_tgt.fmt, is->audio_tgt.freq, dec_channel_layout, af->frame->format, af->frame->sample_rate, 0, NULL);
- 初始化轉換
swr_init(is->swr_ctx) < 0
- 轉換操作
const uint8_t **in = (const uint8_t **)af->frame->extended_data; uint8_t **out = &is->audio_buf1; int out_count = (int)((int64_t)wanted_nb_samples * is->audio_tgt.freq / af->frame->sample_rate + 256); int out_size = av_samples_get_buffer_size(NULL, is->audio_tgt.channels, out_count, is->audio_tgt.fmt, 0); ..... av_fast_malloc(&is->audio_buf1, &is->audio_buf1_size, out_size); ...... len2 = swr_convert(is->swr_ctx, out, out_count, in, af->frame->nb_samples);
in
是frame->extended_data
,就是原始的音頻數據,音頻數據時存放在extended_data
里而不是data
里。
out
是is->audio_buf1
的地址,轉換完后,數據直接寫到了is->audio_buf1
里。
av_fast_malloc
是重用之前的內存來快速分配內存,audio_buf1
是一個重復利用的中間量,在這里裝載數據,在播放的時候用掉數據。所以可以利用好之前的內存,不必每次都構建。 - 不管是否轉換,數據都到了
is->audio_buf
,填充AudioBuffer的時候就是用的這個。
最后
把主流程和某些細節寫了一遍,這個項目還有一些值得說的問題:
- 音視頻同步
- AVPacket和AVFrame的內存管理和坑
- 緩沖區的構建和操作,這應該是有一種最佳實踐的,而且這是一個很通用的問題。
- 平臺各異的顯示層和核心的解碼層是怎么連接到一起又互相分離的。