在线教育直播工具 Windows(PC)源码导读
前言
在线教育解决方案方便开发者快速接入开发,大型直播模块、互动连麦模块、小班互动/在线会议模块采用C++开发,能满足市面上绝大部分需求,同时支持开发者自定义需求,讨论区部分支持开发者通过前端开发实现不一样的交互体验,支持完全的自定义需求。以下为客户端框架:
目前在线教育直播工具采用了在线教育解决方案,实现了大型直播和互动连麦的功能,以下为线教育解决方案整体架构设计:
UI引擎库开发资料
在线教育Windows桌面端的界面开发都依赖云信DuiLib库
,关于云信DuiLib库
的使用方法和注意事项,请参考:云信Duilib
控件属性
界面布局介绍
CEF开发指南
工程结构
- base:基础能力模块
- base:提供加密,文件操作,线程,锁,消息循环,字符串处理等基础能力
- db:提供数据库能力
- shared:对基础能力的封装,包括第三方tinyxml,duilib等能力的封装
- uikit:UI组件
- cef:提供Web容器能力,当前内核版本为49,其中有三个相关工程。cef_module和libcef_dll_wrapper是两个静态库,封装了对安装目录下cef模块的调用。cef_render是个应用程序,在需要加载web页面时,会启动这个程序来渲染页面。
- canvas_merge:主要实现了将各种类型的图层元素(摄像头画面、取全屏、部分取屏、应用程序画面、静态图片等等)融合在一个画布上,然后将画布画面绘制到直播窗口上,直播开启时,还需要将其转化成yuv数据并通过相应的sdk推流到cdn。另外还支持图层的添加、删除、移动、改变大小、设为背景/取消背景、显示/隐藏等操作
- hybird_im_kit:Web容器IM业务的封装层
- nrtc_video:定义了统一的数据帧(音频、视频)结构,canvas_merge将不同类型图层的数据转换并保存到该结构,然后以相同的方法合并到画布上。另外,工程还定义了一个单例类用来在全局管理音视频设备的启动和关闭、音视频数据的接收和发送等等
- edulive:程序的启动项目,包含程序的main函数和图形用户界面的实现。直播窗口、音视频设置、设备检测、背景音乐等窗口都在这里。另外,一些内部自动事件,记住用户操作、检查版本更新、处理程序崩溃等等也在这个模块。
- nim_cpp_sdk:云信PC SDK C++封装层,主要提供了互动音视频能力,IM能力(Web容器加载的是聊天室Web SDK)
- nim_lss_sdk:直播PC SDK封装层,主要提供了推流的能力
源码导读
启动客户端
当前客户端支持常规的启动模式以及网页唤起模式。这里主要讲解下网页唤起的设计和代码:
首先,安装程序会在注册表中写入所需的数据,以下参数的值只是用于举例,实际由开发者自行确定:
#define START_CMD_KEY _T("EduLiveAppWebAgent") //cmd 启动的key用于网页等启动
#define $INSTDIR _T("$INSTDIR/")
#define RUN_NAME _T("EduLiveApp.exe") //启动文件
_WriteRegValue(HKCR, START_CMD_KEY, L"", L"URL Protocol");
_WriteRegValue(HKCR, START_CMD_KEY, L"URL Protocol", $INSTDIR RUN_NAME);
_WriteRegValue(HKCR, START_CMD_KEY L"\\shell\\open\\command", L"", L"\""$INSTDIR RUN_NAME L"\" \"%1\"");
在程序入口,我们通过解析lpCmdLine来判断是否是从网页唤起:
//main.cpp
int APIENTRY _tWinMain(_In_ HINSTANCE hInstance,
_In_opt_ HINSTANCE hPrevInstance,
_In_ LPTSTR lpCmdLine,
_In_ int nCmdShow)
{
...
//判断是不是从网页呼起
{
std::wstring cmd_data = lpCmdLine;
if (cmd_data.find(L"\"") == 0 && cmd_data.rfind(L"\"") == cmd_data.size() - 1)
{
cmd_data = cmd_data.substr(1, cmd_data.size() - 2);
}
std::wstring cmd_key = L"EduLiveWebAgent:";
std::wstring cmp_str = cmd_data.substr(0, cmd_key.length());
if (nbase::MakeLowerString(cmd_key) == nbase::MakeLowerString(cmp_str)) //从网页呼起
{
std::wstring inform = cmd_data.substr(cmd_key.length(), cmd_data.length() - cmd_key.length());
std::string request_data;
DecryptWebAgintCmdInformation(UTF16ToUTF8(inform), request_data);
if (inform.empty())
QLOG_ERR(L"Origin encrypted string is empty.");
else if (request_data.empty())
QLOG_ERR(L"Decrypted string is empty.");
bool parse_success = ParseReceiveCmdInform(request_data, login_information_);
if (!request_data.empty() && !parse_success)
QLOG_ERR(L"Parse decrypted string error.");
}
}
...
}
同时,在前端代码中加入以下代码:
<a href="EduLiveAppWebAgent://xxxxx"></a>
以上就是网页唤起本地客户端的过程。
注意
因为不同浏览器或者本地防火墙的限制,也会出现唤起失败的情况,所以我们在前段设计上应该加一个判断,如果唤起客户端失败,就提示用户从本地自行打开客户端。
SDK初始化和登陆
在线教育解决方案Native部分接入了网易云信通讯与视频的多个SDK,包括云信SDK,直播SDk,在程序初始化的时候我们需要初始化这些SDK。
初始化直播SDK
//main.cpp int APIENTRY _tWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ LPTSTR lpCmdLine, _In_ int nCmdShow) { ... //初始化视频相关接口 nim_nls::LsManange::Init(); //LsManange为直播SDK封装层对象 nim_nls::LsManange::LogDirPath = app_data_path + L"live_stream\\"; { MainThread thread; // 创建主线程 thread.RunOnCurrentThreadWithLoop(nbase::MessageLoop::kUIMessageLoop); // 执行主线程循环 } ... }
反初始化直播SDK
//main.cpp int APIENTRY _tWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ LPTSTR lpCmdLine, _In_ int nCmdShow) { ... { MainThread thread; // 创建主线程 thread.RunOnCurrentThreadWithLoop(nbase::MessageLoop::kUIMessageLoop); // 执行主线程循环 } //清理直播组件 nim_nls::LsManange::Exit(); ... }
初始化云信SDK
云信SDK提供了注册全局广播通知的接口,通常的在初始化后就要注册号这类接口,以防有通知丢失。
//login_form.cpp void LoginForm::InitNim() { nim::SDKConfig config; config.database_encrypt_key_ = ""; //string(db key必填,目前只支持最多32个字符的加密密钥!建议使用32个字符) bool ret = nim::Client::Init(login_information_.im_app_key_, nbase::UTF16ToUTF8(AppConfigs::GetAppDataPath()).c_str(), "", config); // 载入云信sdk,初始化安装目录和用户目录 assert(ret); nim::Client::RegReloginCb(&nim_comp::LoginCallback::OnReLoginCallback); //注册断线重连广播通知 nim::Client::RegDisconnectCb(&nim_comp::LoginCallback::OnDisconnectCallback); //注册断线广播通知 nim::Client::RegKickoutCb(&nim_comp::LoginCallback::OnKickoutCallback); //注册被踢广播通知 ret = nim::VChat::Init(""); // 初始化云信音视频 assert(ret); nim::VChat::SetVideoDataCb(true, nim_comp::VChatCallback::VideoCaptureData); nim::VChat::SetVideoDataCb(false, nim_comp::VChatCallback::VideoRecData); nim::VChat::SetAudioDataCb(true, nim_comp::VChatCallback::AudioCaptureData); nim::VChat::SetAudioDataCbEx(2, nim_comp::VChatCallback::MergedAudioData); nim::VChat::SetCbFunc(nim_comp::VChatCallback::VChatCb); nim_http::Init(); //初始化Http工具模块 //注册返回发送自定义消息的结果的回调,和收到系统消息(包括自定义消息)的回调 nim::SystemMsg::RegSendCustomSysmsgCb(nbase::Bind(&nim_comp::SysmsgCallback::OnSendCustomSysmsgCallback, std::placeholders::_1)); nim::SystemMsg::RegSysmsgCb(nbase::Bind(&nim_comp::SysmsgCallback::OnReceiveSysmsgCallback, std::placeholders::_1)); }
账号登陆云信SDK
//login_callback.cpp void LoginCallback::DoLogin(std::string user, std::string pass, std::string app_key) { ... //注意: //1. app key是应用的标识,不同应用之间的数据(用户、消息、群组等)是完全隔离的。开发自己的应用时,请替换为自己的app key。 //2. 用户登录自己的应用是不需要对密码md5加密的,替换app key之后,请记得去掉加密。 auto cb = std::bind(OnLoginCallback, std::placeholders::_1, nullptr); nim::Client::Login(app_key, LoginManager::GetInstance()->GetAccount(), LoginManager::GetInstance()->GetPassword(), cb); } //登陆回调 void LoginCallback::OnLoginCallback(const nim::LoginRes& login_res, const void* user_data) { QLOG_APP(L"OnLoginCallback: {0} - {1}") << login_res.login_step_ << login_res.res_code_; if (login_res.res_code_ == nim::kNIMResSuccess) { if (login_res.login_step_ == nim::kNIMLoginStepLogin) { //登陆成功 Post2UI(nbase::Bind(&UILoginCallback, login_res.res_code_, false)); if (!login_res.other_clients_.empty()) { Post2UI(nbase::Bind(LoginCallback::OnMultispotChange, true, login_res.other_clients_)); } } } else { Post2UI(nbase::Bind(&UILoginCallback, login_res.res_code_, false)); } }
账号登出云信SDK
//login_callback.cpp //执行sdk退出函数 void NimLogout(nim::NIMLogoutType type) { QLOG_APP(L"-----logout begin {0}-----") << type; nim::Client::Logout(type, &LoginCallback::OnLogoutCallback); } //登出回调 void LoginCallback::OnLogoutCallback(nim::NIMResCode res_code) { ... ::PostQuitMessage(0); }
反初始化云信SDK
反初始化云信SDK必须在账号登出云信SDK回调之后做,一般而言和直播SDK一样我们在程序退出前反初始化云信SDK即可。
//main.cpp int APIENTRY _tWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ LPTSTR lpCmdLine, _In_ int nCmdShow) { ... { MainThread thread; // 创建主线程 thread.RunOnCurrentThreadWithLoop(nbase::MessageLoop::kUIMessageLoop); // 执行主线程循环 } //反初始化云信SDK nim_http::Uninit(); nim::VChat::Cleanup(); nim::Client::Cleanup(); ... }
主窗口
主窗口主要分为两部分,一部分是Web容器(下图红框处),一部分是画布和画布控制器区域。
在主窗体类LiveForm
的初始化函数InitWindow
中初始化了画布控件、Web容器以及其他基础控件。示例如下:
//live_form.cpp
void LiveForm::InitWindow()
{
...
canvas_ctrl_ = static_cast<ui::CBitmapControl*>(FindControl(L"canvas")); //画布控件
...
{
im_container_ = static_cast<VBox*>(FindControl(L"im_container"));
//Web容器
auto middleware = nim_uikit_service::IMMiddlewareService::GetInstance()->CreateIMMiddlewareContainer(nim_uikit_service::IMMiddlewareService::kWeb);
if (im_container_ != nullptr && middleware != nullptr)
{
im_container_->Add(middleware);
nim_uikit_service::IMMiddlewareService::LoadInfo load_info;
load_info.url_ = login_information_.load_im_url_;
load_info.on_dom_ready_callback_js_func_name_ = "onDomReady";
load_info.on_dom_ready_js_func_name_ = "loginChatroom";
load_info.on_dom_ready_js_func_params_ = login_information_.src_;
nim_uikit_service::IMMiddlewareService::GetInstance()->RegSendCustomMsgFunc(nbase::Bind(&nim_comp::SysmsgCallback::InvokeSendCustomMsg, std::placeholders::_1));
nim_uikit_service::IMMiddlewareService::GetInstance()->RegNotifyLiveStatusResponse(nbase::Bind(&LiveForm::OnNotifyLiveStatusResponse, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3));
nim_uikit_service::IMMiddlewareService::GetInstance()->RegCheckUpdateCallback(UpdateManager::OnCheckUpdateCallback);
nim_uikit_service::IMMiddlewareService::GetInstance()->RegDomReadyCallback(nbase::Bind(&LiveForm::OnWebDomReadyCb, this));
nim_uikit_service::IMMiddlewareService::GetInstance()->RegLoginChatroomCb(nbase::Bind(&LiveForm::OnLoginChatroomCb, this, std::placeholders::_1));
nim_uikit_service::IMMiddlewareService::GetInstance()->RegShowMsgBoxFunc(nbase::Bind(&LiveForm::OnShowMsgBoxCalled, this, std::placeholders::_1, std::placeholders::_2, \
std::placeholders::_3, std::placeholders::_4, std::placeholders::_5, std::placeholders::_6, std::placeholders::_7));
nim_uikit_service::IMMiddlewareService::GetInstance()->LoadURL(load_info);
}
}
}
其他窗口
- 登陆窗口 login_form.h
- 音视频设置窗口 video_setting.h
- 设备检测窗口 device_check.h
- 背景音乐选择窗口 bg_music_select.h
- 连麦选择窗口 mic_conn_style.h
- 选择应用共享窗口 window_select.h
- 选择共享窗口 screen_capture_type.h
画布合成和渲染
video_frame_mng.cpp
video_frame_mng.cpp中定义了PicRegion类和另一个非常重要的类VideoFrameMng。PicRegion类的一个对象是画布以及所有图层的画面帧单位。VideoFrameMng用来即时缓存从摄像头取得的画面以及音视频通话时收到的视频帧,并提供成员方法让其他类可以取到这些视频帧。VideoFrameMng::capture_video_pic_用于缓存摄像头画面,当开启美颜功能时,VideoFrameMng::beauty_video_pic_list_缓存了美颜后的摄像头画面。VideoFrameMng::recv_video_pic_list_则缓存了音视频通话中不同用户的画面。
layer_manager.cpp、layer_manager_move_resize.cpp
LayerManager有一个VideoFrameMng类型的成员作为画布。用户在直播窗口上添加图层,删除图层,选中图层,隐藏/显示,设为背景/取消背景等操作,会直接调用LayerManager的相应成员方法(AddxxxLayer、RemoveLayer、SelectLayer、ShowOrHideLayer、SetLayerAsBackground等),修改图层的相应属性。
用户用鼠标在画布上点击时,收到Windows消息(鼠标按下、移动、释放)会导致LayerManager调用移动图层或改变图层大小的方法ResizeLayerRect。
UI层想要得到画布内容的时候,调用LayerManager::MakeFrame就可以把当前所有图层合到画布上,得到画布最终的yuv数据。
video_manager.cpp
VideoManager是一个单例类,它可以看做是云信音视频能力的入口。它通过device_session_type_这个成员管理着各种窗口(如直播窗口、音视频设置窗口、设备检测窗口)对本地各种设备(如音频输入设备、音频输出设备、视频输入设备、视频输出设备、背景音乐程序)的开启和关闭。当有设备开启、关闭、移除、切换时,通过device_status_cb_list_、default_device_cb_list_、start_device_cb_list_、audio_data_cb_list_的方式通知观察者对象。
VideoManage提供了多人音视频互动的相关函数,如创建音视频房间(CreateRoom),进入房间(JoinRoom)、离开房间(EndChat)、设为观众模式(SetViewerMode)等等。VideoManage还在OnVChatEvent中监听云信音视频sdk上报的事件,如有人上麦、下麦,通话音量,连接断开等。
大型直播
live_form_streaming.cpp
这个文件里面实现了推流直播的所有步骤。用户点击界面上的“开始直播”时,首先是调用nim_lss_sdk工程中的LsSession::InitSession()函数来做一些初始化工作,包括创建直播推流的对象,设置参数,设置回调函数等等。直播sdk本身是支持上层指定图层类型,由sdk来合成画布的,但我们希望自己来定制画布,所以ST_NLSS_VIDEOIN_PARAM::enInType设置为raw data(原始数据),表示由上层提供推流数据,直接推流。LsSession::InitSession()的主要代码如下:
NLSS_RET ret = NLSS_OK; //返回值 ls_client_ = new _HNLSSERVICE; std::wstring work_dir = SDKFunction::GetCurrentWorkDirectory(); //指定sdk工作目录 std::wstring log_dir = LsManange::LogDirPath; //指定日志目录 if (!nbase::FilePathIsExist(log_dir, true)) nbase::CreateDirectory(log_dir); ret = NLS_SDK_GET_FUNC(Nlss_Create)(nbase::UTF16ToUTF8(work_dir).c_str(), nbase::UTF16ToUTF8(log_dir).c_str(), pLsClient); //创建一个提供直播服务的对象 if (ret != NLSS_OK) { delete ls_client_; ls_client_ = nullptr; assert(0); return false; } //设置推给服务器的音频数据参数 NLS_SDK_GET_FUNC(Nlss_GetDefaultParam)(LsClient, &ls_param_); ls_param_.stAudioParam.stIn.iInSamplerate = 44100; //码率 ls_param_.stAudioParam.stIn.paaudioDeviceName = ""; ls_param_.stAudioParam.stIn.enInType = EN_NLSS_AUDIOIN_RAWDATA; //数据类型是自定义音频数据 //设置拉流的视频数据参数 ls_param_.stVideoParam.enOutCodec = EN_NLSS_VIDEOOUT_CODEC_X264; //输出格式 ls_param_.stVideoParam.bHardEncode = false; ls_param_.stVideoParam.iOutFps = 18; ls_param_.stVideoParam.iOutBitrate = 800000; ls_param_.stVideoParam.iOutHeight = 720; ls_param_.stVideoParam.iOutWidth = 1280; ls_param_.enOutContent = EN_NLSS_OUTCONTENT_AV; ls_param_.paOutUrl = (char*)url.c_str(); //使参数生效 if (NLS_SDK_GET_FUNC(Nlss_InitParam)(LsClient, &ls_param_) != NLSS_OK) { init_session_ = false; return false; } init_session_ = true; //设置推给服务器的视频数据参数 ST_NLSS_VIDEOIN_PARAM ls_child_video_in_param_; ls_child_video_in_param_.enInType = EN_NLSS_VIDEOIN_RAWDATA; //数据类型是自定义视频数据 ls_child_video_in_param_.iCaptureFps = 18; ls_child_video_in_param_.u.stInCustomVideo.enVideoInFmt = EN_NLSS_VIDEOIN_FMT_I420; //输入格式 ls_child_video_in_param_.u.stInCustomVideo.iInHeight = 720; //输入画面高度 ls_child_video_in_param_.u.stInCustomVideo.iInWidth = 1280; //输入画面宽度 ls_child_client = new _HNLSSCHILDSERVICE; LsChildClient = NLS_SDK_GET_FUNC(Nlss_ChildVideoOpen)(LsClient, &ls_child_video_in_param_); //创建一个子视频对象来处理 NLS_SDK_GET_FUNC(Nlss_ChildVideoSetBackLayer)(LsChildClient); NLS_SDK_GET_FUNC(Nlss_ChildVideoStartCapture)(LsChildClient); //开始接收视频数据 ls_error_cb = ls_error_cb_; NLS_SDK_GET_FUNC(Nlss_SetStatusCB)(LsClient, ErrorCallback); //注册状态回调函数 NLS_SDK_GET_FUNC(Nlss_Start)(LsClient);
初始化成功之后,一直到直播窗口关闭都不需要再初始化了,即时中间停止过推流。现在执行LsSession::OnStartLiveStream()将会在一个专门用来推流的线程中去开启推流,开启成功就开始发送视频数据和音频数据。主要代码如下:
if (NLS_SDK_GET_FUNC(Nlss_StartLiveStream)(LsClient) == NLSS_OK) //开启直播成功 { StdClosure task = nbase::Bind(&LsSession::SendVideoFrame, this); nbase::ThreadManager::PostRepeatedTask(threading::kThreadLiveStreaming, ls_data_timer_.ToWeakCallback(task), nbase::TimeDelta::FromMilliseconds(60)); //定时推视频流 StdClosure task2 = nbase::Bind(&LsSession::SendAudioFrame, this); nbase::ThreadManager::PostRepeatedTask(threading::kThreadLiveStreaming, ls_data_timer_.ToWeakCallback(task2), nbase::TimeDelta::FromMilliseconds(40)); //定时推音频流 } else //开启直播错误 QLOG_ERR(L"Nlss_StartLiveStream error.");
推视频流的代码如下:
int width = 0; int height = 0; std::string data; int64_t time = 0; bool ret = video_frame_mng_->GetVideoFrame("", time, data, width, height); //从画布取yuv格式的视频数据 if (ret && (!data.empty())) NLS_SDK_GET_FUNC(Nlss_VideoChildSendCustomData)(LsChildClient, (char*)data.c_str(), data.size()); //用子视频对象推视频流
推音频流的代码如下:
std::string data; int rate = 0; audio_frame_mng_->GetAudioFrame(true, data, rate); //从VideoManager的音频帧缓存中取音频数据及其码率 if (!data.empty()) NLS_SDK_GET_FUNC(Nlss_SendCustomAudioData)(LsClient, (char*)data.c_str(), data.size(), rate); //推音频流
用户点击“停止直播”就会调LsSession::OnStopLiveStream来让sdk停止推流。
互动连麦直播
互动直播
- live_form_interact.cpp
这个文件包含了互动直播的所有步骤。用户点“开始直播”时,首先会调VideoManager::CreateRoom来创建音视频房间,在回调中又调VideoManager::JoinRoom来进入房间。对于主播来说,进入房间时需要传入一些必要的参数和配置,如推流地址、是否开启旁路推流、帧率、视频画面质量、自定义画面布局等等。具体代码在LiveForm::CreateVChatRoomCallback()函数中:
nim::NIMVideoChatMode mode = nim::kNIMVideoChatModeVideo; //主播始终是视频推流
std::string vchat_room_name = nbase::Int64ToString(live_id_); //音视频房间名
vchat_session_id_ = shared::tools::GetUUID(); //音视频会话ID
std::string layout;
if (mic_conn_style_ == kAudioVideo) //填充主播和连麦画面布局参数
{
std::vector<RECT> rects;
CustomMultiVideoRect(rects);
layout = nim_comp::VideoManager::GetInstance()->GenerateLayoutParam(FIXED_CANVAS_WIDTH, FIXED_CANVAS_HEIGHT, rects, 0);
}
nim_comp::VideoManager::GetInstance()->JoinRoom(mode, vchat_room_name, vchat_session_id_, push_rtmp_url_, true, layout, cb);//第5个参数表示开启服务器旁路推流;帧率、画面质量等参数在VideoManager::JoinRoom()函数内部写定。
JoinRoom的返回码如果是200,就认为主播成功进入了房间,可以开始发送音视频数据了。这里的视频数据和音频数据都是上层自给的,因此调用nim::VChat::CustomVideoData和nim::VChat::CustomAudioData来发送数据。发送视频数据的代码如下:
int width = FIXED_CANVAS_WIDTH;
int height = FIXED_CANVAS_HEIGHT;
int size = width * height * 3 / 2;
std::string data;
data.resize(size);
int64_t time = 0;
bool ret = layer_manager_.GetCanvas()->GetVideoFrame("", time, (char*)data.c_str(), width, height, false, false, false); //获取原始尺寸的画布内容(yuv格式)
if (ret)
nim::VChat::CustomVideoData(time, data.c_str(), size, width, height, ""); //推视频流
主播注册了几个回调给VideoManager,最重要的一个是成员上下麦的通知对应的回调LiveForm::RoomPeopleChangeCallback()。一个成员(不论是主播还是连麦者)进入房间时,会立刻收到已经在房间中其他人的上麦通知。然后成员在会话期间,有成员上下麦,都会收到通知。这就方便我们定制视频画面的布局,以及在UI上展现当前在互动的成员列表。下面是LiveForm::RoomPeopleChangeCallback()的主要代码:
nim_uikit_service::IMMiddlewareService::GetInstance()->InvokeNotifyQueueChanged(uid, join ? 1 : 2); //通知前端修改互动成员列表
if (join) //有人上麦
{
if (uid != nim_comp::LoginManager::GetInstance()->GetAccount()) //不是自己上麦
{
... //判断mic_uid_list_是否已有此人
mic_uid_list_[available_pos] = uid; //mic_uid_list_中添加此人,画布上将会展示他的画面
}
}
else
{
if (uid != nim_comp::LoginManager::GetInstance()->GetAccount()) //不是自己下麦
{
for (auto &member : mic_uid_list_)
{
if (member == uid)
member.clear(); //该用户ID置空,不再展示其画面
}
}
}
主播点击“停止直播”就会调VideoManager::EndChat来退出房间。注意:这时候如果房间中还有人的话,该房间是会继续保留的,房间外的观众无法拉流了,但是连麦者之间还是可以继续通话。直到所有人都退出房间,房间就会被服务器清理掉。如果房间中的任何成员意外掉出房间(如程序崩溃、网络连接断开),服务器在一段时间内没有收到该成员发送的UDP心跳,也会自动将该成员移出房间。
连麦方案
目前音视频服务器暂不提供实时查询一个互动房间里面的成员列表(麦上成员列表),因此在线教育解决方案通过自身的应用服务器开发能力配合云信SDK提供的自定义消息(自定义系统通知)能力实现了一套连麦方案,应用服务器上的麦序由讲师端维护;当讲师端收到学生申请/取消上麦以及上下麦通知后会将列表同步给应用服务区,如果出现异常情况掉线后重新进入互动房间后,为防止异常数据产生,讲师端会重新同步一次麦序列表给应用服务器。整体方案设计如下:
橙色部分为在线教育解决方案应用服务器能力。
绿色部分[2]是通过云信聊天室Web SDK提供的自定义消息将麦序状态广播通知给所有人,绿色部分[1]是通过云信PC SDK提供的自定义消息将麦序变动发送给制定的学生端。
后续在线教育解决方案针对连麦场景还会做进一步完善,提高更加方便快捷的方案。
客户端与Web的通讯和协议
在线教育解决方案提供给开发者高可扩展性的开发方案,可以让开发者在对桌面开发不熟悉的情况下通过前端开发高度自定义讨论区,成员区等场景。学生进出教室、在讨论区发言、请麦、上麦、下麦,以及与应用服务器之间的通信,都是前端负责。业务逻辑上,Native通过开放接口只做一些消息和命令的透传,以及执行前端希望执行的工作。这样的设计可以让前端页面和交互体验自定义起来更加容易方便。如果客户想做一些业务上的修改,只需要修改前端页面或应用服务器即可,Native代码需要的改动较小。
hybird_im_kit这个工程封装了客户端Native和前端Web之间的通信接口。其代码实现主要是在im_middleware_service.cpp这个文件中。下面说明一下客户端Native和Web之间的相互调用具体是怎样实现的。
Web调用Native暴露的接口
webkit初始化时,Native会向js注入NimCefWebFunction这个方法(参见源码中ClientApp::OnWebKitInitialized()函数),前端只需调用NimCefWebFunction(data),Native就能收到回调。其中data是前端填充的一个json字符串,其结构定义如下面的示例:
{ "functionName": " func_name ", "param": "…" }
func_name是Web要求Native执行的函数名,param是该函数对应的参数,是一个Json字符串。Native的Cef控件会执行下面一句代码来注册一个回调函数给cef内核,供Js界面来调用。
cef_control_->AttachJsCallback(nbase::Bind(&IMMiddlewareContainer::OnJsCallback, this, std::placeholders::_1, std::placeholders::_2));
Js调用NimCefWebFunction方法时,Native会进入IMMiddlewareContainer::OnJsCallback,然后根据和前端协议好的解析方式,把functionName和param解析出来并执行。以发送自定系统通知为例:
void IMMiddlewareService::CallbackOnJsFunction(const std::string& function_name, const std::string& params) { if (container_ == nullptr || load_info_ == nullptr) { assert(0); return; } if (function_name == "sendCustomMsg") { if (on_send_custommsg_func_.get() != nullptr) { CustomMsg message(params); //解析params中的参数,并创建Native消息结构体 (*on_send_custommsg_func_)(message); //执行真正的Native函数,发送自定系统通知 } } }
目前Native开放了以下接口:
onBridgeReady
聊天室登录完成后调用,接口参数说明:
{ "functionName" : "onBridgeReady", "param" : "{"status" : bool}" //登陆聊天室是否成功 }
sendCustomMsg
发送自定义消息时调用,接口参数说明:
{ "functionName" : "sendCustomMsg", "param" : "{ "uid" : "", /**< 用户ID,收到消息时为发送者云信ID,发送消息时为接收者云信ID */ "msgType" : "", /**< 消息类型 "p2p" or "team"*/ "time" : int64, /**< 消息时间*/ "msgid" : "", /**< 消息ID*/ "apnsText" : "", /**< 支持消息推送时的推送文本*/ "content" : "", /**< 消息内容*/ "attach" : "", /**< 消息附件内容*/ "offline" : int, /**< 是否支持离线消息 支持1 不支持0 没有该项走服务器默认*/ "push" : int, /**< 是否支持消息推送 支持1 不支持0 没有该项走服务器默认*/ "count" : int, /**< 是否支持未读计数 支持1 不支持0 没有该项走服务器默认*/ "nick" : int, /**< 是否支持消息推送带昵称 支持1 不支持0 没有该项走服务器默认*/ }" }
notifyCheckUpdateResult
检测更新将结果通知Native时调用,接口参数说明:
{ "functionName" : "notifyCheckUpdateResult", "param" : "{ "code" : int, /**< 错误码,目前只支持:1-可选更新 0-无更新*/ "url" : "", /**< 下载地址*/ }" }
notifyLiveStatusResponse
变更直播状态时调用,接口参数说明:
{ "functionName" : "notifyLiveStatusResponse", "param" : "{ "informType" : int, /**< 通知类型:0-开启课程 1-关闭课程 */ "code" : int, /**< 错误码: 0-成功*/ "reason" : "", /**< 不能开启时的原因,客户端会弹窗展示*/ }" }
showMsgBox
显示提示窗口时调用,接口参数说明:
{ "functionName" : "showMsgBox", "param" : "{ "title" : "", /**< 提示窗口Title */ "icon" : int, /**< 提示窗口Icon类型:0-问号 1-提示 2-警告 3-错误 4-完成 5-默认*/ "content" : "", /**< 提示窗口Content*/ "buttonYes" : "", /**< 提示窗口确认按钮文案*/ "buttonNo" : "", /**< 提示窗口取消按钮文案*/ "yesCallback" : "", /**< 暂不需要填*/ "noCallback" : "", /**< 暂不需要填*/ }" }
Native调用Web的JS方法
Native调Web是通过Cef的Js Bridge执行同名Js方法。函数参数是native填好的json字符串。Cef内部会把方法名和参数拼接成下面形式的Js语句,让前端页面执行。
JSFunc(param);
以上麦下麦通知为例:
void IMMiddlewareService::InvokeNotifyQueueChanged(const UTF8String &uid, int command) { ... UTF8String params; if (QueryOnNotifyQueueChangedParamString(uid, command, params)) //填充参数并转化为json字符串 container_->ExcuteJSFunction("notifyQueueChange", params); //让Js执行bridge.notifyQueueChange函数 ... } ... bool IMMiddlewareContainer::ExcuteJSFunction(const UTF8String& js_function_name, const UTF8String& js_function_param, bool log) { if (cef_control_ == nullptr) { assert(0); return false; } //组装JS UTF8String js = QueryJsCommand(js_function_name, js_function_param); //执行JS cef_control_->ExecJavaScript(js); return true; } ... static UTF8String QueryJsCommand(const UTF8String &js_function, const UTF8String &js_param) { UTF8String js = nbase::StringPrintf("bridge.%s('%s');", js_function.c_str(), js_param.c_str()); return js; }
目前客户端在以下情景时会调用前端JS方法:
收到自定义通知
void IMMiddlewareService::InvokeReceiveCustomMsg(const CustomMsg &msg) { ... std::string params = "{"uid" : "", "content" : JsonObject}"; //uid为发送者云信ID,content为系统通知Attach container_->ExcuteJSFunction("notifyCustomMsg", params); ... }
收到音视频房间互动列表更新通知
void IMMiddlewareService::InvokeNotifyQueueChanged(const UTF8String &uid, int command) { ... std::string params = "{"uid" : "", "command" : }"; //uid为云信ID,command 1:上麦 2:下麦 container_->ExcuteJSFunction("notifyQueueChange", params); ... }
收到音视频房间互动者音量变化通知
void IMMiddlewareService::InvokeNotifyVolumeChanged(const std::map<UTF8String, int> &volumes) { ... std::string params = "{["uid" : "", "volume" : ], ...}"; //uid为云信ID,volume 音量0-255 container_->ExcuteJSFunction("notifyVolume", params); ... }
直播状态变更
void IMMiddlewareService::InvokeNotifyLiveStatus(int live_status) { ... std::string params = "{"informType" : }"; //informType 通知类型:0-开启课程 1-关闭课程 container_->ExcuteJSFunction("notifyLiveStatus", params); ... }
执行更新检测
void IMMiddlewareService::InvokeNotifyCheckUpdate(const std::string& local_version) { ... std::string params = "{"version" : ""}"; //本地版本号,版本号通过获取exe文件信息中的版本号中前两位的major_version和minor_version拼接而成,major_version.minor_version container_->ExcuteJSFunction("checkUpdate", params); ... }
客户端崩溃重启后
void IMMiddlewareService::InvokeNotifyCrashReport(const std::string dump_info) { ... container_->ExcuteJSFunction("notifyCrashReport", dump_info); ... }