在线教育直播工具 Windows(PC)源码导读

前言

在线教育解决方案方便开发者快速接入开发,大型直播模块、互动连麦模块、小班互动/在线会议模块采用C++开发,能满足市面上绝大部分需求,同时支持开发者自定义需求,讨论区部分支持开发者通过前端开发实现不一样的交互体验,支持完全的自定义需求。以下为客户端框架:

目前在线教育直播工具采用了在线教育解决方案,实现了大型直播和互动连麦的功能,以下为线教育解决方案整体架构设计:

UI引擎库开发资料

在线教育Windows桌面端的界面开发都依赖云信DuiLib库,关于云信DuiLib库的使用方法和注意事项,请参考:云信Duilib

控件属性

控件属性

界面布局介绍

网易云信 DuiLib 布局功能指南

CEF开发指南

CEF开发指南

工程结构

源码导读

启动客户端

当前客户端支持常规的启动模式以及网页唤起模式。这里主要讲解下网页唤起的设计和代码:

首先,安装程序会在注册表中写入所需的数据,以下参数的值只是用于举例,实际由开发者自行确定:

    #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。

主窗口

主窗口主要分为两部分,一部分是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);
            }
        }
    }

其他窗口

画布合成和渲染

大型直播

互动连麦直播

    这个文件包含了互动直播的所有步骤。用户点“开始直播”时,首先会调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心跳,也会自动将该成员移出房间。

客户端与Web的通讯和协议

在线教育解决方案提供给开发者高可扩展性的开发方案,可以让开发者在对桌面开发不熟悉的情况下通过前端开发高度自定义讨论区,成员区等场景。学生进出教室、在讨论区发言、请麦、上麦、下麦,以及与应用服务器之间的通信,都是前端负责。业务逻辑上,Native通过开放接口只做一些消息和命令的透传,以及执行前端希望执行的工作。这样的设计可以让前端页面和交互体验自定义起来更加容易方便。如果客户想做一些业务上的修改,只需要修改前端页面或应用服务器即可,Native代码需要的改动较小。

hybird_im_kit这个工程封装了客户端Native和前端Web之间的通信接口。其代码实现主要是在im_middleware_service.cpp这个文件中。下面说明一下客户端Native和Web之间的相互调用具体是怎样实现的。