做者:单辉,声网 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 的开发,但愿能够帮到那些,想要为本身的应用增长了音视频通话功能的场景的同窗。