如何使用 Qt 開發音視頻通話應用

做者:單輝,聲網 Agora 高級開發工程師。android

衆所周知,Qt 是一個跨平臺的 C++ 圖形用戶界面應用程序開發框架,它具備跨平臺、豐富的 API、支持 2D/3D 圖形渲染、支持 OpenGL、開源等優秀的特性。不少市面上常見的應用或者遊戲,例如說 VLC、WPS Office、極品飛車等,都是基於 Qt 開發。canvas

本文將介紹如何使用 Qt 開發一個音視頻通話應用。bash

1 使用 Qt Quick

Qt 目前有兩種建立用戶界面的方式:app

  • Qt Widgets
  • Qt Quick

其中 Qt Widgets 是傳統的桌面界面庫,而 Qt Quick 是新一代的高級用戶界面技術,能夠輕鬆的用於移動端、嵌入式設備等界面開發。框架

目前 Qt Widgets 已經基本處於維護階段,已經很是穩定且成熟。而 Qt Quick 是將來發展的主要方向,其開發更加簡捷方便,用戶體驗更加好。less

因此本文選擇 Qt Quick 做爲建立用戶界面的方式,開發環境以下:ide

  • Qt:5.12.0
  • Qt Creator:4.8.2
  • Agora Video SDK:2.4.0

2 設計交互流程

首先,咱們設計一個簡單的視頻通話 UI 交互流程。函數

有 2 個主要 UI 界面:ui

  • JoinRoom:登陸頻道界面;
  • InRoom:視頻通話界面;

以及 3 個輔助 UI 界面:this

  • Splash:歡迎界面;
  • VideoSetting:視頻參數設置界面;
  • DeviceSetting:設備設置界面;

UI 之間的交互邏輯,已經用對應紅色線框標記出來。

3 建立 Qt 項目

打開 Qt Creator,選擇建立新的項目。

  1. 選擇 Qt Quick Application - Empty;

  1. 輸入項目名稱 AgoraVideoCall,並選擇項目路徑;

  1. 選擇 qmake 編譯;

  1. 選擇最小支持的 Qt 版本,這裏默認爲 Qt 5.9;

  1. 選擇本地 Qt 版本,這裏使用 5.12.0;

6. 選擇版本控制系統;

4 導入資源

4.1 導入 images 資源

咱們先將準備好的圖標等資源,導入到項目中。

  1. 將 images 文件夾拷貝到工程目錄中;
  2. 在 Qt Creator 的項目視圖中,右鍵點擊 Resources/qml.qrc 文件;
  3. 選擇添加現有路徑;
  4. 選擇 images 文件夾;
  5. images 文件夾下的全部資源,會自動添加到 qml.qrc 文件中;

4.2 導入 controls 資源

在 Qt Quick 中使用按鈕等控件時,有兩種方式:

  1. 使用 Qt Quick 定義的控件;優勢是不用本身開發,能夠快速集成使用。
  2. 使用用戶自定義控件;優勢是樣式能夠本身定義,且能夠定義更多官方不提供的控件。

咱們這裏使用事先準備的一些控件,因此先按照步驟導入到項目中。

  1. 將 controls 文件夾拷貝到工程目錄中;
  2. 在 Qt Creator 的項目視圖中,右鍵點擊 Resources/qml.qrc 文件;
  3. 選擇添加現有路徑;
  4. 選擇 controls 文件夾;
  5. controls 文件夾下的全部控件,會自動添加到 qml.qrc 文件中;

須要注意的是,默認狀況下控件是沒有導入的,須要開發者在要使用的 UI 中導入,例如:

4.3 導入 Agora.io 音視頻通話 SDK

使用音視頻通話功能,須要導入 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 業務邏輯

完成項目建立和資源導入後,咱們首先須要實現前面設計的 5 個 UI。

5.1 建立 UI

  1. 在項目上點擊右鍵,並選擇 Add New,選擇 QtQuick UI File 模板;

2. 輸入 UI 的名稱,並完成建立,會直接進入設計窗口;

3. 根據前面的設計,經過拖拽控件以及調整位置等操做,完成 UI;

5.2 UI 業務邏輯

完成 UI 後,對應的按鈕所觸發的業務邏輯須要對應添加。在建立 QtQuick UI File 的時候,例如說建立 Splash UI 時,默認會建立兩個 qml 文件:

  • SplashForm.ui.qml:UI 的聲明描述;
  • Splash.qml:UI 對應事件的響應和部分 UI 業務邏輯;

因此,例如說 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 交互響應後,再將該部分邏輯填充進去。

5.3 QML 與 C++ 交互

基本 UI 業務邏輯完成後,通常須要 QML 與 C++ 之間的邏輯交互。例如按下進入頻道的 Join 按鈕後,咱們須要在 C++ 中調用 Agora 的音視頻相關邏輯,來進入頻道進行通話。

在 QML 中使用 C++ 的類和對象,通常有兩種方式:

  1. 在 C++ 中定義一個 QObject 的子類,註冊到 QML 中,在 QML 中建立該類的對象;
  2. 在 C++ 中建立對象,並將該對象設置爲 QML 的上下文屬性,在 QML 中使用該屬性;

這裏使用第二種方式,定義 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);
複製代碼

6 視頻渲染

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 上了。

7 核心業務邏輯

咱們須要簡單封裝 Agora SDK 的相關邏輯,以提供音視頻通話的功能。

7.1 回調事件

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 中進行響應,不作複雜的處理邏輯。

7.2 資源管理

定義 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;
複製代碼

7.3 登陸頻道

大部分的邏輯基本上處理好了,接下來就是最重要的一步了。

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 中,調用 AgoraRtcEnginejoinChannel 函數,若是成功則切換到 InRoom 界面:

function joinChannel(channel) {
    if (channel.length > 0 && agoraRtcEngine.joinChannel("", channel, 0) === 0) {
        channelName = channel
        loader.setSource(Qt.resolvedUrl("InRoom.qml"))
    }
}
複製代碼

7.4 本地流

進入 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);
}
複製代碼

7.5 遠端流

當收到 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);
}
複製代碼

至此,基本的核心業務邏輯完成,通話效果以下:

8 總結

Qt 做爲一個很成熟的圖形界面庫,使用起來很是簡單,而且具有大量的文檔和解決方案,我的認爲是桌面下開發圖形界面庫首選的方案之一。這個 Demo 的開發,但願能夠幫到那些,想要爲本身的應用增長了音視頻通話功能的場景的同窗。

相關文章
相關標籤/搜索