众所周知,Qt 是一个跨平台的 C++ 图形用户界面应用程序开发框架,它具有跨平台、丰富的 API、支持 2D/3D 图形渲染、支持 OpenGL、开源等优秀的特性。很多市面上常见的应用或者游戏,例如说 VLC、WPS Office、极品飞车等,都是基于 Qt 开发。
本文将介绍如何使用 Qt 开发一个音视频通话应用。
一、使用 Qt Quick
Qt 目前有两种创建用户界面的方式:
目前 Qt Widgets 已经基本处于维护阶段,已经非常稳定且成熟。而 Qt Quick 是未来发展的主要方向,其开发更加简捷方便,用户体验更加好。
所以本文选择 Qt Quick 作为创建用户界面的方式,开发环境如下:
Qt:5.12.0
Qt Creator:4.8.2
Agora Video SDK:2.4.0
二、设计交互流程
首先,我们设计一个简单的视频通话 UI 交互流程。
有 2 个主要 UI 界面:
三、创建 Qt 项目
打开 Qt Creator,选择创建新的项目。
选择 Qt Quick Application - Empty;
输入项目名称 AgoraVideoCall,并选择项目路径;
3. 选择 qmake 编译;
4. 选择最小支持的 Qt 版本,这里默认为 Qt 5.9;
5. 选择本地 Qt 版本,这里使用 5.12.0;
6. 选择版本控制系统;
四、导入资源
导入 images 资源
我们先将准备好的图标等资源,导入到项目中。
将 images 文件夹拷贝到工程目录中;
在 Qt Creator 的项目视图中,右键点击 Resources/qml.qrc 文件;
选择添加现有路径;
选择 images 文件夹;
images 文件夹下的所有资源,会自动添加到 qml.qrc 文件中;
导入 controls 资源
在 Qt Quick 中使用按钮等控件时,有两种方式:
使用 Qt Quick 定义的控件;优点是不用自己开发,可以快速集成使用。
使用用户自定义控件;优点是样式可以自己定义,且可以定义更多官方不提供的控件。
我们这里使用事先准备的一些控件,所以先按照步骤导入到项目中。
将 controls 文件夹拷贝到工程目录中;
在 Qt Creator 的项目视图中,右键点击 Resources/qml.qrc 文件;
选择添加现有路径;
选择 controls 文件夹;
controls 文件夹下的所有控件,会自动添加到 qml.qrc 文件中;
需要注意的是,默认情况下控件是没有导入的,需要开发者在要使用的 UI 中导入,例如:
导入 Agora.io 音视频通话 SDK
使用音视频通话功能,需要导入 Agora.io 对应的 SDK,可以注册 Agora.io 的开发者账号,并从 SDK 下载地址中获取对应平台的 SDK。
下载后将对应的头文件拷贝到项目的 include 文件夹中,静态库拷贝到项目中的 lib 文件夹中,动态库则拷贝到项目中的 dll 文件夹中。
之后则修改 Qt 的工程文件,指定链接的动态库,打开 AgoraVideoCall.pro 文件,并添加以下内容:
INCLUDEPATH += $$PWD/lib
win32: LIBS += -L$$PWD/lib/ -lagorartcsdk
复制代码
五、UI 及 UI 业务逻辑
完成项目创建和资源导入后,我们首先需要实现前面设计的 5 个 UI。
创建 UI
在项目上点击右键,并选择 Add New,选择 QtQuick UI File 模板;
输入 UI 的名称,并完成创建,会直接进入设计窗口;
根据前面的设计,通过拖拽控件以及调整位置等操作,完成 UI;
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 交互响应后,再将该部分逻辑填充进去。
QML 与 C++ 交互
基本 UI 业务逻辑完成后,一般需要 QML 与 C++ 之间的逻辑交互。例如按下进入频道的 Join 按钮后,我们需要在 C++ 中调用 Agora 的音视频相关逻辑,来进入频道进行通话。
在 QML 中使用 C++ 的类和对象,一般有两种方式:
在 C++ 中定义一个 QObject 的子类,注册到 QML 中,在 QML 中创建该类的对象;
在 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);
复制代码
六、视频渲染
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 的开发,希望可以帮到那些,想要为自己的应用增加了音视频通话功能的场景的同学。
本文转载自 声网 Agora 公众号。
原文链接:https://mp.weixin.qq.com/s/Y-wAl67kiu9Z4solhCfrjQ
评论