做者:單輝,聲網 Agora 高級開發工程師。android
衆所周知,Qt 是一個跨平臺的 C++ 圖形用戶界面應用程序開發框架,它具備跨平臺、豐富的 API、支持 2D/3D 圖形渲染、支持 OpenGL、開源等優秀的特性。不少市面上常見的應用或者遊戲,例如說 VLC、WPS Office、極品飛車等,都是基於 Qt 開發。canvas
本文將介紹如何使用 Qt 開發一個音視頻通話應用。bash
Qt 目前有兩種建立用戶界面的方式:app
其中 Qt Widgets 是傳統的桌面界面庫,而 Qt Quick 是新一代的高級用戶界面技術,能夠輕鬆的用於移動端、嵌入式設備等界面開發。框架
目前 Qt Widgets 已經基本處於維護階段,已經很是穩定且成熟。而 Qt Quick 是將來發展的主要方向,其開發更加簡捷方便,用戶體驗更加好。less
因此本文選擇 Qt Quick 做爲建立用戶界面的方式,開發環境以下:ide
首先,咱們設計一個簡單的視頻通話 UI 交互流程。函數
有 2 個主要 UI 界面:ui
以及 3 個輔助 UI 界面:this
UI 之間的交互邏輯,已經用對應紅色線框標記出來。
打開 Qt Creator,選擇建立新的項目。
咱們先將準備好的圖標等資源,導入到項目中。
在 Qt Quick 中使用按鈕等控件時,有兩種方式:
咱們這裏使用事先準備的一些控件,因此先按照步驟導入到項目中。
須要注意的是,默認狀況下控件是沒有導入的,須要開發者在要使用的 UI 中導入,例如:
使用音視頻通話功能,須要導入 Agora.io 對應的 SDK,能夠註冊 Agora.io 的開發者帳號,並從 SDK 下載地址中獲取對應平臺的 SDK。
下載後將對應的頭文件拷貝到項目的 include 文件夾中,靜態庫拷貝到項目中的 lib 文件夾中,動態庫則拷貝到項目中的 dll 文件夾中。
以後則修改 Qt 的工程文件,指定連接的動態庫,打開 AgoraVideoCall.pro 文件,並添加如下內容:
INCLUDEPATH += $$PWD/lib win32: LIBS += -L$$PWD/lib/ -lagora_rtc_sdk
完成項目建立和資源導入後,咱們首先須要實現前面設計的 5 個 UI。
完成 UI 後,對應的按鈕所觸發的業務邏輯須要對應添加。在建立 QtQuick UI File 的時候,例如說建立 Splash UI 時,默認會建立兩個 qml 文件:
因此,例如說 Button 的點擊事件、鼠標事件等,都經過對應控件的 id 進行關聯處理。
例如在 SplashForm.ui.qml 中,咱們期待用戶若是點擊任何地方,則返回到登陸界面,則在 SplashForm.ui.qml 中增長鼠標事件監聽區域:
MouseArea {
id: mouseArea
anchors.fill: parent
}
複製代碼
在 Splash.qml 中增長業務邏輯:
mouseArea.onClicked: main.joinRoom()
複製代碼
最後在 main.qml 增長 joinRoom 的響應函數:
Loader {
id: loader
focus: true
anchors.fill: parent
}
function joinRoom() {
loader.setSource(Qt.resolvedUrl("JoinRoom.qml"))
}
複製代碼
這樣就完成了一個基本的 UI 業務邏輯。其餘例如打開設置窗口、登陸到頻道中等 UI 業務邏輯相似,就再也不一一列舉。
固然,實際觸發的核心業務邏輯,例如說登陸頻道進行音視頻通話、設置參數生效等能夠先留空,在完成全部的 UI 交互響應後,再將該部分邏輯填充進去。
基本 UI 業務邏輯完成後,通常須要 QML 與 C++ 之間的邏輯交互。例如按下進入頻道的 Join 按鈕後,咱們須要在 C++ 中調用 Agora 的音視頻相關邏輯,來進入頻道進行通話。
在 QML 中使用 C++ 的類和對象,通常有兩種方式:
這裏使用第二種方式,定義 MainWindow
類,用來做爲核心窗體加載 main.qml,並在其構造函數中將自己設置爲 QML 的上下文屬性:
setWindowFlags(Qt::Window | Qt::FramelessWindowHint);
resize(600, 600);
m_contentView = new QQuickWidget(this);
m_contentView->rootContext()->setContextProperty("containerWindow", this);
m_contentView->setResizeMode(QQuickWidget::SizeRootObjectToView);
m_contentView->setSource(QUrl("qrc:///main.qml"));
QVBoxLayout *layout = new QVBoxLayout;
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
layout->addWidget(m_contentView);
setLayout(layout);
複製代碼
Agora SDK 提供接口,使得用戶能夠本身定義渲染方式。接口以下:
agora::media::IExternalVideoRender *
AgoraRtcEngine::createRenderInstance(
const agora::media::ExternalVideoRenerContext &context) {
if (!context.view)
return nullptr;
return new VideoRenderImpl(context);
}
複製代碼
VideoRenderImpl
須要繼承 agora::media::IExternalVideoRender
類,並實現相關接口:
virtual void release() override {
delete this;
}
virtual int initialize() override {
return 0;
}
virtual int deliverFrame(const agora::media::IVideoFrame &videoFrame, int rotation, bool mirrored) override {
std::lock_guard<std::mutex> lock(m_mutex);
if (m_view)
return m_view->deliverFrame(videoFrame, rotation, mirrored);
return -1;
}
複製代碼
咱們將會使用 OpenGL 來進行渲染,定義 renderFrame
:
int VideoRendererOpenGL::renderFrame(const agora::media::IVideoFrame &videoFrame) {
if (videoFrame.IsZeroSize())
return -1;
int r = prepare();
if (r) return r;
QOpenGLFunctions *f = renderer();
f->glClear(GL_COLOR_BUFFER_BIT);
if (m_textureWidth != (GLsizei)videoFrame.width() ||
m_textureHeight != (GLsizei)videoFrame.height()) {
setupTextures(videoFrame);
m_resetGlVert = true;
}
if (m_resetGlVert) {
if (!ajustVertices())
m_resetGlVert = false;
}
updateTextures(videoFrame);
f->glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_BYTE, g_indices);
return 0;
}
複製代碼
具體描繪部分,在 updateTextures
中實現以下:
void VideoRendererOpenGL::updateTextures(
const agora::media::IVideoFrame &frameToRender) {
const GLsizei width = frameToRender.width();
const GLsizei height = frameToRender.height();
QOpenGLFunctions *f = renderer();
f->glActiveTexture(GL_TEXTURE0);
f->glBindTexture(GL_TEXTURE_2D, m_textureIds[0]);
glTexSubImage2D(width, height,
frameToRender.stride(IVideoFrame::Y_PLANE),
frameToRender.buffer(IVideoFrame::Y_PLANE));
f->glActiveTexture(GL_TEXTURE1);
f->glBindTexture(GL_TEXTURE_2D, m_textureIds[1]);
glTexSubImage2D(width / 2, height / 2,
frameToRender.stride(IVideoFrame::U_PLANE),
frameToRender.buffer(IVideoFrame::U_PLANE));
f->glActiveTexture(GL_TEXTURE2);
f->glBindTexture(GL_TEXTURE_2D, m_textureIds[2]);
glTexSubImage2D(width / 2, height / 2,
frameToRender.stride(IVideoFrame::V_PLANE),
frameToRender.buffer(IVideoFrame::V_PLANE));
}
複製代碼
這樣就能夠將 Agora SDK 回調中的 Frame,繪製在具體的 Widget 上了。
咱們須要簡單封裝 Agora SDK 的相關邏輯,以提供音視頻通話的功能。
Agora SDK 會提供不少事件的回調信息,例如遠端用戶加入頻道、遠端用戶退出頻道等,咱們須要繼承 agora::rtc::IRtcEngineEventHandler
事件回調類,並重寫部分須要的函數,來進行事件的響應。
class AgoraRtcEngineEvent : public agora::rtc::IRtcEngineEventHandler {
public:
AgoraRtcEngineEvent(AgoraRtcEngine &engine)
:m_engine(engine) {}
virtual void onVideoStopped() override {
emit m_engine.videoStopped();
}
virtual void onJoinChannelSuccess(const char *channel, uid_t uid, int elapsed) override {
emit m_engine.joinedChannelSuccess(channel, uid, elapsed);
}
virtual void onUserJoined(uid_t uid, int elapsed) override {
emit m_engine.userJoined(uid, elapsed);
}
virtual void onUserOffline(uid_t uid, USER_OFFLINE_REASON_TYPE reason) override {
emit m_engine.userOffline(uid, reason);
}
virtual void onFirstLocalVideoFrame(int width, int height, int elapsed) override {
emit m_engine.firstLocalVideoFrame(width, height, elapsed);
}
virtual void onFirstRemoteVideoDecoded(uid_t uid, int width, int height, int elapsed) override {
emit m_engine.firstRemoteVideoDecoded(uid, width, height, elapsed);
}
virtual void onFirstRemoteVideoFrame(uid_t uid, int width, int height, int elapsed) override {
emit m_engine.firstRemoteVideoFrameDrawn(uid, width, height, elapsed);
}
private:
AgoraRtcEngine &m_engine;
};
複製代碼
這裏咱們將事件從 AgoraRtcEngine
的信號函數發出,並在 UI 中進行響應,不作複雜的處理邏輯。
定義 AgoraRtcEngine
類,並在構造函數中,初始化音視頻通話引擎: agora::rtc::IRtcEngine
:
AgoraRtcEngine::AgoraRtcEngine(QObject *parent)
: QObject(parent), m_rtcEngine(createAgoraRtcEngine()),
m_eventHandler(new AgoraRtcEngineEvent(*this)) {
agora::rtc::RtcEngineContext context;
context.eventHandler = m_eventHandler.get();
// Specify your APP ID here
context.appId = "";
if (*context.appId == '\0') {
QMessageBox::critical(nullptr, tr("Agora QT Demo"),
tr("You must specify APP ID before using the demo"));
}
m_rtcEngine->initialize(context);
agora::util::AutoPtr<agora::media::IMediaEngine> mediaEngine;
mediaEngine.queryInterface(m_rtcEngine.get(), agora::AGORA_IID_MEDIA_ENGINE);
if (mediaEngine) {
mediaEngine->registerVideoRenderFactory(this);
}
m_rtcEngine->enableVideo();
}
複製代碼
注意: 有關如何獲取 Agora APP ID,請參閱 Agora 官方文檔。
在 App 退出時,應當在 AgoraRtcEngine
類的析構函數中,釋放音視頻通話引擎資源,這裏咱們經過指定 unique_ptr 的釋放函數來自動管理::
struct RtcEngineDeleter {
void operator()(agora::rtc::IRtcEngine *engine) const {
if (engine != nullptr) engine->release();
}
};
std::unique_ptr<agora::rtc::IRtcEngine, RtcEngineDeleter> m_rtcEngine;
複製代碼
大部分的邏輯基本上處理好了,接下來就是最重要的一步了。
在 MainWindow
增長 AgoraRtcEngine
的 QML 上下文屬性設置:
AgoraRtcEngine *engine = m_engine.get();
m_contentView->rootContext()->setContextProperty("agoraRtcEngine", engine);
複製代碼
用戶輸入頻道名,點擊 Join 按鈕,觸發登陸邏輯時,咱們在 JoinRoom.qml 中增長事件處理:
btnJoin.onClicked: main.joinChannel(txtChannelName.text)
複製代碼
在 main.qml 中,調用 AgoraRtcEngine
的 joinChannel
函數,若是成功則切換到 InRoom 界面:
function joinChannel(channel) {
if (channel.length > 0 && agoraRtcEngine.joinChannel("", channel, 0) === 0) {
channelName = channel
loader.setSource(Qt.resolvedUrl("InRoom.qml"))
}
}
複製代碼
進入 InRoom 界面後,須要進行本地流(通常是攝像頭採集的圖像)的渲染。在 InRoom.qml 的 onCompleted 中增長:
Component.onCompleted: {
inroom.views = [localVideo, remoteVideo1, remoteVideo2, remoteVideo3, remoteVideo4]
channelName.text = main.channelName
agoraRtcEngine.setupLocalVideo(localVideo.videoWidget)
}
複製代碼
在 AgoraRtcEngine
中,將本地流渲染 Widget 設置爲描繪的畫布:
int AgoraRtcEngine::setupLocalVideo(QQuickItem *view) {
agora::rtc::view_t v =
reinterpret_cast<agora::rtc::view_t>(static_cast<AVideoWidget *>(view));
VideoCanvas canvas(v, RENDER_MODE_HIDDEN, 0);
return m_rtcEngine->setupLocalVideo(canvas);
}
複製代碼
當收到 onUserJoined 和 onUserOffline 的事件時, AgoraRtcEngine
會將該事件拋出:
virtual void onUserJoined(uid_t uid, int elapsed) override {
emit m_engine.userJoined(uid, elapsed);
}
複製代碼
此時,在 InRoom 界面中,捕獲該事件,並進行處理:
Connections {
target: agoraRtcEngine
onUserJoined: {
inroom.handleUserJoined(uid)
}
onUserOffline: {
var view = inroom.findRemoteView(uid)
if (view)
inroom.unbindView(uid, view)
}
}
function findRemoteView(uid) {
for (var i in inroom.views) {
var v = inroom.views[i]
if (v.uid === uid && v !== localVideo)
return v
}
}
function bindView(uid, view) {
if (view.uid !== 0)
return false
view.uid = uid
view.showVideo = true
view.visible = true
return true
}
function unbindView(uid, view) {
if (uid !== view.uid)
return false
view.showVideo = false
view.visible = false
view.uid = 0
return true
}
function handleUserJoined(uid) {
//check if the user is already binded
var view = inroom.findRemoteView(uid)
if (view !== undefined)
return
//find a free view to bind
view = inroom.findRemoteView(0)
if (view && agoraRtcEngine.setupRemoteVideo(uid, view.videoWidget) === 0) {
inroom.bindView(uid, view)
}
}
複製代碼
咱們在 UI 中設計最多隻能顯示 4 個遠端流,因此超過 4 個時,就再也不進行 bindView 處理。
在 AgoraRtcEngine
中,將遠端流渲染 Widget 設置爲描繪的畫布:
int AgoraRtcEngine::setupRemoteVideo(unsigned int uid, QQuickItem* view) {
agora::rtc::view_t v =
reinterpret_cast<agora::rtc::view_t>(static_cast<AVideoWidget *>(view));
VideoCanvas canvas(v, RENDER_MODE_HIDDEN, uid);
return m_rtcEngine->setupRemoteVideo(canvas);
}
複製代碼
至此,基本的核心業務邏輯完成,通話效果以下:
Qt 做爲一個很成熟的圖形界面庫,使用起來很是簡單,而且具有大量的文檔和解決方案,我的認爲是桌面下開發圖形界面庫首選的方案之一。這個 Demo 的開發,但願能夠幫到那些,想要爲本身的應用增長了音視頻通話功能的場景的同窗。