1. 搭建自己的流媒體服務器
在實際的開發過程中,我們是可以不用自己來搭建流媒體服務器的,訪問後臺的接口會返回媒體房間和 IM 房間。但現在我們自己測試就無法用公司的接口了,當然也可以去抓一些第三方的直播接口,我強烈不推薦大家這麼做。最好的辦法就是自己搭建一個簡單的流媒體服務器。
sudo wget https://github.com/nginx/nginx/archive/release-1.17.1.tar.gz
sudo wget https://github.com/arut/nginx-rtmp-module/archive/v1.2.1.tar.gz
sudo tar -zxvf release-1.17.1.tar.gz
sudo tar -zxvf v1.2.1.tar.gz
./auto/configure --add-module=/lib/nginx/nginx-rtmp-module-1.2.1
make
make install
最後配置測試流媒體服務器
cd /usr/local/nginx/sbin/
./nginx
.\ffmpeg.exe -re -i 01.mp4 -vcodec libx264 -acodec aac -f flv rtmp://148.70.96.230/myapp/mystream
2. 集成 RTMP 推流的源碼
當我們的流媒體服務器搭建好後,要用 ffmpeg 測試一下,確保流媒體服務器搭建成功後,我們再來集成 RTMP 推流的源碼。
git clone git://git.ffmpeg.org/rtmpdump
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DNO_CRYPTO")
/**
* 初始化連接流媒體服務器
*/
void *initConnectFun(void *context) {
DZLivePush *pLivePush = (DZLivePush *) context;
// 創建 RTMP
pLivePush->pRtmp = RTMP_Alloc();
// 初始化 RTMP
RTMP_Init(pLivePush->pRtmp);
// 設置連接超時
pLivePush->pRtmp->Link.timeout = 10;
pLivePush->pRtmp->Link.lFlags |= RTMP_LF_LIVE;
RTMP_SetupURL(pLivePush->pRtmp, pLivePush->url);
RTMP_EnableWrite(pLivePush->pRtmp);
// 連接失敗回調到 java 層
if (!RTMP_Connect(pLivePush->pRtmp, NULL)) {
LOGE("connect url error");
pLivePush->pJniCall->callConnectError(THREAD_CHILD, RTMP_CONNECT_ERROR_CODE, "connect url error");
return (void *) RTMP_CONNECT_ERROR_CODE;
}
if (!RTMP_ConnectStream(pLivePush->pRtmp, 0)) {
LOGE("connect stream url error");
pLivePush->pJniCall->callConnectError(THREAD_CHILD, RTMP_STREAM_CONNECT_ERROR_CODE, "connect stream url error");
return (void *) RTMP_STREAM_CONNECT_ERROR_CODE;
}
// 連接成功也回調到 Java 層,可以開始推流了
LOGE("connect succeed");
pLivePush->pJniCall->callConnectSuccess(THREAD_CHILD);
return (void *) 0;
}
3. H.264 協議介紹
我們打算採用最常見的 H.264 來編碼推流,那麼現在我們不得不來了解一下 H.264 的協議了,這些東西雖說看似比較枯燥複雜,但這也是最最重要的部分。首先需要明確 H264 可以分爲兩層:1.VCL video codinglayer(視頻編碼層),2.NAL network abstraction layer(網絡提取層)。對於 VCL 具體的編解碼算法這裏暫時先不介紹,只介紹常用的 NAL 層,即網絡提取層,這是解碼的基礎。
SPS:序列參數集
PPS:圖像參數集
I幀:幀內編碼幀,可獨立解碼生成完整的圖片。
P幀: 前向預測編碼幀,需要參考其前面的一個I 或者B 來生成一張完整的圖片。
B幀: 雙向預測內插編碼幀,則要參考其前一個I或者P幀及其後面的一個P幀來生成一張完整的圖片
根據上面所說,現在我們就得思考幾個問題了:
- SPS 和 PPS 到底存的是什麼數據?
- 我們怎麼判斷獲取每一個 NALU ?
- 如何判斷某一個 NALU 是 I 幀、P 幀、 B 幀還是其他?
4. 直播推流視頻數據
關於怎麼預覽相機,怎麼編碼成 H264,怎麼獲取 SPS 和 PPS 大家需要先看看之前的《FFmpeg - 朋友圈錄製視頻添加背景音樂》。爲了確保直播過程中進來的用戶也可以正常的觀看直播,我們需要在每個關鍵幀前先把 SPS 和 PPS 推送到流媒體服務器。
/**
* 發送 sps 和 pps 到流媒體服務器
* @param spsData sps 的數據
* @param spsLen sps 的數據長度
* @param ppsData pps 的數據
* @param ppsLen pps 的數據長度
*/
void DZLivePush::pushSpsPps(jbyte *spsData, jint spsLen, jbyte *ppsData, jint ppsLen) {
// frame type : 1關鍵幀,2 非關鍵幀 (4bit)
// CodecID : 7表示 AVC (4bit) , 與 frame type 組合起來剛好是 1 個字節 0x17
// fixed : 0x00 0x00 0x00 0x00 (4byte)
// configurationVersion (1byte) 0x01版本
// AVCProfileIndication (1byte) sps[1] profile
// profile_compatibility (1byte) sps[2] compatibility
// AVCLevelIndication (1byte) sps[3] Profile level
// lengthSizeMinusOne (1byte) 0xff 包長數據所使用的字節數
// sps + pps 的數據
// sps number (1byte) 0xe1 sps 個數
// sps data length (2byte) sps 長度
// sps data sps 的內容
// pps number (1byte) 0x01 pps 個數
// pps data length (2byte) pps 長度
// pps data pps 的內容
// body 長度 = spsLen + ppsLen + 上面所羅列出來的 16 字節
int bodySize = spsLen + ppsLen + 16;
// 初始化創建 RTMPPacket
RTMPPacket *pPacket = static_cast<RTMPPacket *>(malloc(sizeof(RTMPPacket)));
RTMPPacket_Alloc(pPacket, bodySize);
RTMPPacket_Reset(pPacket);
// 按照上面的協議,開始一個一個給 body 賦值
char *body = pPacket->m_body;
int index = 0;
// CodecID 與 frame type 組合起來剛好是 1 個字節 0x17
body[index++] = 0x17;
// fixed : 0x00 0x00 0x00 0x00 (4byte)
body[index++] = 0x00;
body[index++] = 0x00;
body[index++] = 0x00;
body[index++] = 0x00;
//0x01版本
body[index++] = 0x01;
// sps[1] profile
body[index++] = spsData[1];
// sps[2] compatibility
body[index++] = spsData[2];
// sps[3] Profile level
body[index++] = spsData[3];
// 0xff 包長數據所使用的字節數
body[index++] = 0xff;
// 0xe1 sps 個數
body[index++] = 0xe1;
// sps 長度
body[index++] = (spsLen >> 8) & 0xff;
body[index++] = spsLen & 0xff;
// sps 的內容
memcpy(&body[index], spsData, spsLen);
index += spsLen;
// 0x01 pps 個數
body[index++] = 0x01;
// pps 長度
body[index++] = (ppsLen >> 8) & 0xff;
body[index++] = ppsLen & 0xff;
// pps 的內容
memcpy(&body[index], ppsData, ppsLen);
// 設置 RTMPPacket 的參數
pPacket->m_packetType = RTMP_PACKET_TYPE_VIDEO;
pPacket->m_nBodySize = bodySize;
pPacket->m_nTimeStamp = 0;
pPacket->m_hasAbsTimestamp = 0;
pPacket->m_nChannel = 0x04;
pPacket->m_headerType = RTMP_PACKET_SIZE_MEDIUM;
pPacket->m_nInfoField2 = this->pRtmp->m_stream_id;
// 添加到發送隊列
pPacketQueue->push(pPacket);
}
緊接着發送每一幀的數據
/**
* 發送每一幀的視頻數據到服務器
* @param videoData
* @param dataLen
* @param keyFrame
*/
void DZLivePush::pushVideo(jbyte *videoData, jint dataLen, jboolean keyFrame) {
// frame type : 1關鍵幀,2 非關鍵幀 (4bit)
// CodecID : 7表示 AVC (4bit) , 與 frame type 組合起來剛好是 1 個字節 0x17
// fixed : 0x01 0x00 0x00 0x00 (4byte) 0x01 表示 NALU 單元
// video data length (4byte) video 長度
// video data
// body 長度 = dataLen + 上面所羅列出來的 9 字節
int bodySize = dataLen + 9;
// 初始化創建 RTMPPacket
RTMPPacket *pPacket = static_cast<RTMPPacket *>(malloc(sizeof(RTMPPacket)));
RTMPPacket_Alloc(pPacket, bodySize);
RTMPPacket_Reset(pPacket);
// 按照上面的協議,開始一個一個給 body 賦值
char *body = pPacket->m_body;
int index = 0;
// CodecID 與 frame type 組合起來剛好是 1 個字節 0x17
if (keyFrame) {
body[index++] = 0x17;
} else {
body[index++] = 0x27;
}
// fixed : 0x01 0x00 0x00 0x00 (4byte) 0x01 表示 NALU 單元
body[index++] = 0x01;
body[index++] = 0x00;
body[index++] = 0x00;
body[index++] = 0x00;
// (4byte) video 長度
body[index++] = (dataLen >> 24) & 0xff;
body[index++] = (dataLen >> 16) & 0xff;
body[index++] = (dataLen >> 8) & 0xff;
body[index++] = dataLen & 0xff;
// video data
memcpy(&body[index], videoData, dataLen);
// 設置 RTMPPacket 的參數
pPacket->m_packetType = RTMP_PACKET_TYPE_VIDEO;
pPacket->m_nBodySize = bodySize;
pPacket->m_nTimeStamp = RTMP_GetTime() - startPushTime;
pPacket->m_hasAbsTimestamp = 0;
pPacket->m_nChannel = 0x04;
pPacket->m_headerType = RTMP_PACKET_SIZE_LARGE;
pPacket->m_nInfoField2 = this->pRtmp->m_stream_id;
pPacketQueue->push(pPacket);
}
5. 直播推流音頻數據
最後就是把錄製的聲音數據推到媒體房間,這部分流程跟視頻推流類似。
/**
* 發送音頻數據到服務器
* @param audioData
* @param dataLen
*/
void DZLivePush::pushAudio(jbyte *audioData, jint dataLen) {
// 2 字節頭信息
// 前四位表示音頻數據格式 AAC 10(A)
// 五六位表示採樣率 0 = 5.5k 1 = 11k 2 = 22k 3(11) = 44k
// 七位表示採樣採樣的精度 0 = 8bits 1 = 16bits
// 八位表示音頻類型 0 = mono 1 = stereo
// 我們這裏算出來第一個字節是 0xAF
// 0x01 代表 aac 原始數據
// body 長度 = dataLen + 上面所羅列出來的 2 字節
int bodySize = dataLen + 2;
// 初始化創建 RTMPPacket
RTMPPacket *pPacket = static_cast<RTMPPacket *>(malloc(sizeof(RTMPPacket)));
RTMPPacket_Alloc(pPacket, bodySize);
RTMPPacket_Reset(pPacket);
// 按照上面的協議,開始一個一個給 body 賦值
char *body = pPacket->m_body;
int index = 0;
// 我們這裏算出來第一個字節是 0xAF
body[index++] = 0xAF;
body[index++] = 0x01;
// audio data
memcpy(&body[index], audioData, dataLen);
// 設置 RTMPPacket 的參數
pPacket->m_packetType = RTMP_PACKET_TYPE_AUDIO;
pPacket->m_nBodySize = bodySize;
pPacket->m_nTimeStamp = RTMP_GetTime() - startPushTime;
pPacket->m_hasAbsTimestamp = 0;
pPacket->m_nChannel = 0x04;
pPacket->m_headerType = RTMP_PACKET_SIZE_LARGE;
pPacket->m_nInfoField2 = this->pRtmp->m_stream_id;
pPacketQueue->push(pPacket);
}
萬丈高樓平地起,這些的確都很簡單基礎,後面其實還有很多擴展,比如美顏濾鏡,IM 聊天房間,禮物動畫貼圖等等。
這是 NDK 實戰的最後一篇文章了,週六日我們花了接近半年的時間來學習,我知道很多同學並未從事這方面的開發,最後我再囉嗦囉嗦,給大家打點雞血。我個人是很幸運的,能把學到的東西用到工作中,但在三年前我其實也不知道,自己以後會從事這方面的工作。
這部分知識也是大家從中級到高級進階的一個必經過程,通過學習 NDK 我們能把 Android 的上下層打通,以前只能是閱讀 Java 層的代碼,到現在能閱讀 FrameWorker 的 Native 層源碼,且 Android 很多的核心代碼都是在 C/C++ 中,因此當我們無法看懂這部分代碼時,我們很難說自己理解了 Android,也不能算是一個高級工程師,我希望大家可以從這些方面去花些時間。
從開發佈局上來說,我們已經能做一些別人不能做的東西了,個人的價值在於別人不能做的我們能做,這其實就是一個學習成本的問題,因此目前從事 NDK 開發的工作相比於只是簡單的做 Android 應用的薪資來說要高個 1.5 - 2 倍,至少我們公司是這樣。且現在很多企業招聘崗位也都是要求會 NDK 開發者優先。
爲何我們不繼續接着講 OpenGL 和音視頻的一些高級知識呢?目前講的這些東西都是很基礎的,高級進階內容其實是在我們後面規劃中的,這些知識其實很少有人能跟上來,從最近的上課活躍度就能體現出來,這也是爲什麼在網上我們幾乎找不到系統的學習方案,出了問題也很難查到答案的一個原因,希望大家可以多花些時間。
視頻地址:https://pan.baidu.com/s/19eFV02TyjOD3few0HlZs1w
視頻密碼:6u50