使用Layered分层窗口实现视频会议中的桌面区域共享
Posted IT老张
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了使用Layered分层窗口实现视频会议中的桌面区域共享相关的知识,希望对你有一定的参考价值。
目录
你用带有WS_EX_LAYERED风格的Layered分层窗口实现过异形窗口的效果吗?在很多软件中都能看到异形窗口的身影,异形窗口以其独有的视觉效果,被广泛地采用。以常见的360安全卫士的加速球窗口为例:
异形窗口的边界一般是各种形状的圆滑线条,被做成了各种妙趣横生的各种图案效果,给用户带来了良好的视觉效果和体验。
其实,窗口在创建时是矩形形状的,只不过呈现出来的图案是各种独特形状的,除这些形状区域以外,矩形窗口的其他区域被做成透明效果了,并且鼠标是可以穿透这些透明的区域的。本文要讲一种特殊的异形窗口,下面请看我详细道来。
1、项目背景与需求
最近公司中标了一个比较大的项目,客户对我们的会议软件总体上是很满意的,但客户要求在已有的功能上增加一个功能,要在桌面共享功能中增加桌面区域共享的功能。客户明确提出,这个需求是硬性功能指标,必须实现这个功能后才能完成项目的中标。于是相关部门将开发需求落到了我们研发部,要求我们在最短的时间内尽快地实现这个功能。
这个桌面区域共享,和整个桌面共享及应用窗口共享实现机制时完全一样的,都是抓取某一个区域的图像,只要UI层告诉采集编码层要抓取的区域坐标就可以了。所以主要的工作量在UI层,图像采集编码层不用做大的改动即可实现。所以这个功能点的实现重心就在于UI层的交互实现,即UI层如何选定要分享的区域,然后都需要支持哪些操作。
这个桌面部分区域的共享,很多友商都支持了,在时间紧急没有头绪的情况下,赶紧看一下友商的实现方式。经过对比发现,小鱼易联和ZOOM的会议软件,该功能的UI交互和实现机制竟然是一模一样的,至于谁先做出来、谁模仿谁,就不得而知了。于是大概地看了一下他们的实现机制,创建一个特殊的窗口,窗口是有边界的,然后窗口的中间区域是透明的、且鼠标可穿透的,如下所示:(这种显示背景下的截图)
该特殊窗口的边界框住的区域就是要分享的区域,即框住的图像就是要分享出去的图像。通过这个窗口实现了待分享区域的选择,该选择区域的窗口支持标题栏拖动,支持拖动边框改变大小。
初步猜测该窗口应该是具有WS_EX_LAYERED风格的分层窗口,调用UpdateLayeredWindow系统API将窗口中间区域透明掉。使用SPY++工具查看了一下该窗口的属性:
确实是分层窗口,并且设置了TOPMOST窗口置顶的属性。我们有调用UpdateLayeredWindow实现透明窗口的经验,所以本例中这样的窗口效果我们也能实现,于是基本拟定了当前桌面区域共享的几个需求点:
1)选择区域的窗口使用具有WS_EX_LAYERED窗口样式的分层窗口,调用UpdateLayeredWindow实现窗口中间区域的透明及鼠标穿透;
2)该选择区域的窗口,支持标题栏拖动窗口,支持拖动窗口边框改变窗口大小。
2、带透明区域窗口的实现思路
这种带透明区域且鼠标可穿透的窗口,直接设计出一个带透明区域的图片,然后调用UpdateLayeredWindow系统API,将图片贴到目标窗口上即可。
具体的实现思路是,先创建带有WS_EX_LAYERED窗口样式的分层窗口(目标窗口),将带透明区域的图片绘制到内存DC上,中间透明区域则全部是RGB(0,0,0)纯黑色,然后调用UpdateLayeredWindow将内存DC画板中的内容绘制到目标窗口上,中间黑色的区域会变成透明、鼠标可穿透的区域,图片的非透明区域则是不透明的。
但本例中的窗口大小是可变的,需要支持拖动窗口边框改变大小的,所以不能直接将整个图片贴到窗口上,因为UCD组(美工组)提供的图片是固定大小的,不能做缩放操作的。所以我们要从图片中抠出窗口left、top、right、bottom四个方向上的边框区域,然后分块贴到内存DC上即可,只要保证中间要透明的区域是黑色的色块就可以了。贴图及调用UpdateLayeredWindow的相关代码如下所示:
#define DESKTOP_SHARE_AREA_SEL_WND_EDGESIZE_TOP_HEIGHT 27
#define DESKTOP_SHARE_AREA_SEL_WND_EDGESIZE_LEFT_WIDTH 5
#define DESKTOP_SHARE_AREA_SEL_WND_EDGESIZE_RIGHT_WIDTH 5
#define DESKTOP_SHARE_AREA_SEL_WND_EDGESIZE_BOTTOM_HEIGHT 5
void CDesktopShareAreaSelDlg::Update()
{
if ( m_BkImg.IsNull() )
{
return;
}
RECT rcWnd;
::GetWindowRect(m_hWnd, &rcWnd);
int nWndWidth = rcWnd.right - rcWnd.left;
int nWndHeight = rcWnd.bottom - rcWnd.top;
// Create the alpha blending bitmap
BITMAPINFO bmi; // bitmap header
ZeroMemory(&bmi, sizeof(BITMAPINFO));
bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
bmi.bmiHeader.biWidth = nWndWidth;
bmi.bmiHeader.biHeight = nWndHeight;
bmi.bmiHeader.biPlanes = 1;
bmi.bmiHeader.biBitCount = 32; // four 8-bit components
bmi.bmiHeader.biCompression = BI_RGB;
bmi.bmiHeader.biSizeImage = nWndWidth * nWndHeight * 4;
BYTE *pvBits; // pointer to DIB section
HBITMAP hbitmap = CreateDIBSection(NULL, &bmi, DIB_RGB_COLORS, (void **)&pvBits, NULL, 0);
if (pvBits == NULL) {
return;
}
ZeroMemory(pvBits, bmi.bmiHeader.biSizeImage);
// 创建内存DC
HDC hMemDC = CreateCompatibleDC(NULL);
HBITMAP hOriBmp = (HBITMAP)SelectObject(hMemDC, hbitmap);
int nImgWidth = m_BkImg.GetWidth();
int nImgHeight = m_BkImg.GetHeight();
// 将窗口left、top、right、bottom四个方向上的图片绘制到窗口边框的位置
m_BkImg.Draw( hMemDC, 0, 0, DESKTOP_SHARE_AREA_SEL_WND_EDGESIZE_LEFT_WIDTH, nWndHeight, 0, 0, DESKTOP_SHARE_AREA_SEL_WND_EDGESIZE_LEFT_WIDTH, nImgHeight );
m_BkImg.Draw( hMemDC, nWndWidth - DESKTOP_SHARE_AREA_SEL_WND_EDGESIZE_RIGHT_WIDTH, 0, DESKTOP_SHARE_AREA_SEL_WND_EDGESIZE_RIGHT_WIDTH, nWndHeight, nImgWidth-DESKTOP_SHARE_AREA_SEL_WND_EDGESIZE_RIGHT_WIDTH, 0, DESKTOP_SHARE_AREA_SEL_WND_EDGESIZE_RIGHT_WIDTH, nImgHeight );
m_BkImg.Draw( hMemDC, 0, 0, nWndWidth, DESKTOP_SHARE_AREA_SEL_WND_EDGESIZE_TOP_HEIGHT, 0, 0, nImgWidth, DESKTOP_SHARE_AREA_SEL_WND_EDGESIZE_TOP_HEIGHT );
m_BkImg.Draw( hMemDC, 0, nWndHeight-DESKTOP_SHARE_AREA_SEL_WND_EDGESIZE_BOTTOM_HEIGHT, nWndWidth, DESKTOP_SHARE_AREA_SEL_WND_EDGESIZE_BOTTOM_HEIGHT, 0, nImgHeight-DESKTOP_SHARE_AREA_SEL_WND_EDGESIZE_BOTTOM_HEIGHT, nImgWidth, DESKTOP_SHARE_AREA_SEL_WND_EDGESIZE_BOTTOM_HEIGHT );
POINT ptDst = {rcWnd.left, rcWnd.top};
POINT ptSrc = {0, 0};
SIZE WndSize = {nWndWidth, nWndHeight};
BLENDFUNCTION blendPixelFunction= { AC_SRC_OVER, 0, 255, AC_SRC_ALPHA };
BOOL bRet= UpdateLayeredWindow(m_hWnd, NULL, &ptDst, &WndSize, hMemDC,
&ptSrc, 0, &blendPixelFunction, ULW_ALPHA);
DWORD dwRet = GetLastError();
InvalidateRect( m_hWnd, &rcWnd, TRUE );
//_ASSERT(bRet); // something was wrong....
// Delete used resources
SelectObject(hMemDC, hOriBmp);
DeleteObject(hbitmap);
DeleteDC(hMemDC);
}
上述接口要在WM_SIZE消息中调用,即窗口大小改变时,要重新绘制窗口:
LRESULT CDesktopShareAreaSelDlg::HandleMessage( UINT message, WPARAM wParam, LPARAM lParam )
{
TNotifyUI msg;
if ( WM_SIZE == message )
{
Update();
}
else if ( WM_PAINT == message )
{
Update();
return true;
}
}
3、duilib界面库
本文涉及到的很多代码,都和duilib有关,所以此处需要简单地说明一下,我们程序UI使用的是开源的duilib界面库,是基于微软directui思想的一套界面库。
做UI的朋友应该大部分都知道这个duilib界面库,现在很多公司都在用,比如百度、华为、网易、爱奇艺、ZOOM等,这些公司的Windows开发招聘岗位上有对熟悉duilib库的要求。当然,这些厂商会对duilib进行深入的优化和改进,解决了很多bug。我们这边也不例外,也使用过程中也发现了很多问题,也对代码进行了一些优化和改进。
4、调用UpdateLayeredWindow接口返回失败
在创建CDesktopAreaShareDlg窗口时设置了WS_EX_LAYERED分层窗口的风格,结果运行显示出来的窗口并没有显示图片边框,窗口中间也不是透明、鼠标可穿透的。调试代码发现,UpdateLayeredWindow函数调用失败了,GetLastError返回的错误码是87:
到VS的错误查找工具中搜索了一下:
该错误码的含义是:参数错误。但详细检查了一下传入的参数值,并没有异常非法的值,这个就有点奇怪了。
首先创建的位图是包含Alpha通道的32位位图,如果是非32位位图则可能导致UpdateLayeredWindow函数调用失败。其次传入到UpdateLayeredWindow接口中的参数都没问题的,可为啥还会失败呢?
难道是WS_EX_LAYERED窗口风格设置失败了?于是想用SPY++抓了一下窗口属性,看看窗口到底有没有WS_EX_LAYERED风格。但是该窗口因为调用UpdateLayeredWindow失败了,桌面上根本没显示这个窗口,没关系,我们可以直接到SPY++中搜索该窗口,通过创建窗口时使用的类名搜索(只填充类名,将其他输入框清空):
搜索到该窗口,查看窗口属性中的窗口风格:
果然没有WS_EX_LAYERED风格,这就奇怪了,明明设置了WS_EX_LAYERED窗口风格,为啥没生效呢?
这个窗口是继承duilib框架中的CAppWidnow(我们自行封装的,duilib开源代码是没有的)通用窗口类的,好像该通用窗口类中有个设置透明度的选项,可能是处理窗口透明度的代码将WS_EX_LAYERED风格给取消了。
于是到duilib的代码中,找到设置透明度的代码,确实是这个设置透明度的接口将WS_EX_LAYERED风格取消了:
void CPaintManagerUI::SetTransparent(int nOpacity)
{
if (NULL == m_hWndPaint)
{
return;
}
typedef BOOL(__stdcall *PFUNCSETLAYEREDWINDOWATTR)(HWND, COLORREF, BYTE, DWORD);
PFUNCSETLAYEREDWINDOWATTR fSetLayeredWindowAttributes;
HMODULE hUser32 = ::GetModuleHandle(_T("User32.dll"));
if (hUser32)
{
fSetLayeredWindowAttributes =
(PFUNCSETLAYEREDWINDOWATTR)::GetProcAddress(hUser32, "SetLayeredWindowAttributes");
if (NULL == fSetLayeredWindowAttributes)
{
return;
}
}
DWORD dwStyle = ::GetWindowLong(m_hWndPaint, GWL_EXSTYLE);
DWORD dwNewStyle = dwStyle;
if (nOpacity >= 0 && nOpacity < 255)
{
dwNewStyle |= WS_EX_LAYERED;
}
else
{
dwNewStyle &= ~WS_EX_LAYERED;
}
if (dwStyle != dwNewStyle)
{
::SetWindowLong(m_hWndPaint, GWL_EXSTYLE, dwNewStyle);
}
fSetLayeredWindowAttributes(m_hWndPaint, 0, nOpacity, LWA_ALPHA);
}
我们给该窗口设置的透明度为255,即不透明,所以上述代码中发现传入的透明度参数nOpacity为255,就将WS_EX_LAYERED风格给取消了。此处的代码是有问题的,将窗口的透明度设置为不透明,不应该将WS_EX_LAYERED风格取消掉,因为可能会调用处理分层窗口的其他系统API函数,比如我们本案例用到的UpdateLayeredWindow。所以要将这个取消WS_EX_LAYERED风格的代码注释掉。
但代码注释后运行,还是有问题,UpdateLayeredWindow接口还是执行失败了,lasterror值依旧是87。依稀记得,好像设置窗口透明度的接口SetLayeredWindowAttributes和处理异形窗口的接口UpdateLayeredWindow是不能同时调用的。如上的代码所示,设置透明度的接口CPaintManagerUI::SetTransparent中不管是否设置了有效的透明度(小于255),都调用了SetLayeredWindowAttributes,所以这点也是不合理的,应该设置不透明时,就不应该调用SetLayeredWindowAttributes。修改后的代码
重新运行代码后,发现有效果了,窗口边界图片显示出来了,窗口的中间区域也是可穿透的了。
5、点击标题栏无法拖动窗口
对于窗口支持标题栏拖动、窗口支持拖动边框改变窗口大小,duilib中的窗口框架都支持,只要在xml文件中设置两个属性就可以了。对于标题栏拖动,设置caption属性即可,即caption="0,0,0,27";对于窗口边界可拖动,设置sizebox属性即可,即sizebox="5,5,5,5",窗口对应的xml文件如下:
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<Window size="794,500" mininfo="400,250" sizebox="5,5,5,5" caption="0,0,0,27" shadow="false">
<Font name="微软雅黑" size="12" />
<Default name="VScrollBar" value="width="10" showbutton1="false" showbutton2="false" thumbnormalimage="res='IDB_DEFAULT_SCROLL_BAR' restype='png' source='20,0,30,21' " bknormalimage="res='IDB_DEFAULT_SCROLL_BAR' restype='png' source='30,0,40,10' " bkhotimage="res='IDB_DEFAULT_SCROLL_BAR' restype='png' source='30,0,40,10' " bkpushedimage="res='IDB_DEFAULT_SCROLL_BAR' restype='png' source='30,0,40,10' " bkdisabledimage="res='IDB_DEFAULT_SCROLL_BAR' restype='png' source='30,0,40,10' " " />
<VerticalLayout name="desktopareaselwnd" bkcolor="#FFFF0000">
</VerticalLayout>
</Window>
添加属性后,我们测试了一下效果,窗口边界拖动窗口大小是没问题的,但标题栏是无法拖动窗口的。当鼠标移动到窗口中时,会产生WM_NCHITTEST消息,按讲移到窗口标题栏区域时,应该返回HTCAPTION,以支持窗口的拖动。
于是到duilib框架代码中查看CAppWindow类处理WM_NCHITTEST消息的代码:
LRESULT CAppWindow::OnNcHitTest( UINT uMsg, WPARAM wParam, LPARAM lParam, BOOL& bHandled )
{
POINT pt;
pt.x = GET_X_LPARAM( lParam );
pt.y = GET_Y_LPARAM( lParam );
::ScreenToClient( *this, &pt );
RECT rcClient;
::GetClientRect( *this, &rcClient );
// 改变大小
if( !::IsZoomed(*this) )
{
RECT rcSizeBox = m_pm.GetSizeBox();
if( pt.y < rcClient.top + rcSizeBox.top )
{
if( pt.x < rcClient.left + rcSizeBox.left ) return HTTOPLEFT;
if( pt.x > rcClient.right - rcSizeBox.right ) return HTTOPRIGHT;
return HTTOP;
}
else if( pt.y > rcClient.bottom - rcSizeBox.bottom )
{
if( pt.x < rcClient.left + rcSizeBox.left ) return HTBOTTOMLEFT;
if( pt.x > rcClient.right - rcSizeBox.right ) return HTBOTTOMRIGHT;
return HTBOTTOM;
}
if( pt.x < rcClient.left + rcSizeBox.left ) return HTLEFT;
if( pt.x > rcClient.right - rcSizeBox.right ) return HTRIGHT;
}
// 标题栏响应
RECT rcCaption = m_pm.GetCaptionRect();
if( pt.x >= rcClient.left + rcCaption.left
&& pt.x < rcClient.right - rcCaption.right
&& pt.y >= rcCaption.top
&& pt.y < rcCaption.bottom )
{
// 考虑到标题栏区域会放置控件,比如常见的右上角的最小化、最大化和关闭按钮,
// 所以要将按钮等控件过滤掉
CControlUI* pControl = static_cast<CControlUI*>( m_pm.FindControl( pt ) );
if( pControl
&& _tcscmp(pControl->GetClass(), _T("ButtonUI")) != 0
&& _tcscmp(pControl->GetClass(), _T("OptionUI")) != 0
&& _tcscmp(pControl->GetClass(), _T("TextUI")) != 0 )
{
return HTCAPTION;
}
}
return HTCLIENT;
}
打断点调试发现,在判断鼠标位置落在标题栏区域时,会判断鼠标是否落在某个dui控件中,如果返回的dui控件指针为空(鼠标点击点是否落在窗口的dui控件上),是不会返回HTCAPTION值的。于是在CDesktopAreaShareDlg窗口类的HandleMesssge中拦截WM_HITTEST消息,将代码修改一下,去掉是否落在控件中的判断,我们这个窗口比较简单,也比较特殊:
LRESULT CDesktopShareAreaSelDlg::HandleMessage( UINT message, WPARAM wParam, LPARAM lParam )
{
TNotifyUI msg;
if ( WM_SIZE == message )
{
Update();
}
else if ( WM_PAINT == message )
{
Update();
return true;
}
else if ( WM_NCHITTEST == message)
{
POINT pt;
pt.x = GET_X_LPARAM( lParam );
pt.y = GET_Y_LPARAM( lParam );
::ScreenToClient( m_hWnd, &pt );
RECT rcClient;
::GetClientRect( m_hWnd, &rcClient );
// 改变大小
if( !::IsZoomed(m_hWnd) )
{
RECT rcSizeBox = m_pm.GetSizeBox();
if( pt.y < rcClient.top + rcSizeBox.top )
{
if( pt.x < rcClient.left + rcSizeBox.left ) return HTTOPLEFT;
if( pt.x > rcClient.right - rcSizeBox.right ) return HTTOPRIGHT;
return HTTOP;
}
else if( pt.y > rcClient.bottom - rcSizeBox.bottom )
{
if( pt.x < rcClient.left + rcSizeBox.left ) return HTBOTTOMLEFT;
if( pt.x > rcClient.right - rcSizeBox.right ) return HTBOTTOMRIGHT;
return HTBOTTOM;
}
if( pt.x < rcClient.left + rcSizeBox.left ) return HTLEFT;
if( pt.x > rcClient.right - rcSizeBox.right ) return HTRIGHT;
}
// 标题栏响应
RECT rcCaption = m_pm.GetCaptionRect();
if( pt.x >= rcClient.left + rcCaption.left
&& pt.x < rcClient.right - rcCaption.right
&& pt.y >= rcCaption.top
&& pt.y < rcCaption.bottom )
{
return HTCAPTION;
}
}
return CAppWindow::HandleMessage( message, wParam, lParam );
}
对于我们当前的窗口,只要落在窗口标题栏区域就直接返回HTCAPTION值了,不用再去做其他的判断了。
至于为啥会出现鼠标落在的控件指针返回NULL的情况呢?我们的窗口类CDesktopAreaShareDlg的xml文件中已经配置了一个垂直根布局(只有这个根布局):
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<Window size="794,500" mininfo="400,250" sizebox="5,5,5,5" caption="0,0,0,27" shadow="false">
<Font name="微软雅黑" size="12" />
<Default name="VScrollBar" value="width="10" showbutton1="false" showbutton2="false" thumbnormalimage="res='IDB_DEFAULT_SCROLL_BAR' restype='png' source='20,0,30,21' " bknormalimage="res='IDB_DEFAULT_SCROLL_BAR' restype='png' source='30,0,40,10' " bkhotimage="res='IDB_DEFAULT_SCROLL_BAR' restype='png' source='30,0,40,10' " bkpushedimage="res='IDB_DEFAULT_SCROLL_BAR' restype='png' source='30,0,40,10' " bkdisabledimage="res='IDB_DEFAULT_SCROLL_BAR' restype='png' source='30,0,40,10' " " />
<VerticalLayout name="desktopareaselwnd" bkcolor="#FFFF0000">
</VerticalLayout>
</Window>
按讲根布局会铺满整个窗口的,鼠标肯定是落在这个根布局上。于是在CAppWidnow处理WM_NCHITTEST消息的接口中打断点单步调试发现,根布局的区域位置m_rcItem为(0,0,0,0),即根布局的区域大小为0,所以不会鼠标肯定不会落在根布局上。
奥,原来是这样的,我们xml中控件是在所在窗口收到WM_PAINT消息时去布局控件(给控件设置位置)的,但一旦对窗口调用UpdateLayeredWindow后窗口就不会产生WM_PAINT消息的。我们会在xml中设置窗口的大小,我们在调用CreateWIndow(Ex)将窗口创建起来收WM_CREATE消息时区加载解析xml文件,会优先得到xml中设置的窗口大小,会调用SetWindowPos去设置窗口的大小,窗口大小会发生变化,就会产生WM_SIZE消息,而CDesktopAreaShareDlg窗口在收到这个消息时就会调用UpdateLayeredWindow,这样窗口后面就不会再产生WM_PAINT消息了,所以CDesktopAreaShareDlg窗口就没有排布xml中控件的机会了,所以根布局控件的大小始终是0。
6、选择区域坐标该怎么设置给底层的图像采集编码层呢?
选择区域窗口大小可拖动,选择可以拖动标题栏拖动窗口,我们在窗口移动时或者窗口大小发生变化时,将选择区域的坐标实时的设置给图像采集编码层?如果由UI层调用接口将选择区域的坐标设置给媒控层,则存在两个问题:
1) 何时触发调用设置选择区域坐标给图像采集编码层的接口?在窗口移动时,在窗口大小发生变化时都要触发。这样接口调用会非常地频繁。
2) UI层需要调用组件API层的接口,然后再经过组件层内部的多层后,再设置到采集编码库
中,这些层与层之间的接口都是异步的,很难保证选择区域的大小,实时地通知给采集编码层。
其实,有个最好的办法,UI层给采集编码层设置用来选择区域窗口句柄,并将选择窗口四个边界的宽度或高度设置给采集编码层,如下所示:
这样媒控层可以在每次截图时实时去获取选择窗口的坐标,然后将选择窗口边界宽度与高度减掉,就能实时地得到选择区域的坐标了,这应该是最合理的方式!函数调用需要额外的开销,所以采用这种方式,既可以满足实时性获取要求,也能减少函数调用的开销!
以上是关于使用Layered分层窗口实现视频会议中的桌面区域共享的主要内容,如果未能解决你的问题,请参考以下文章
整洁面向对象分层架构 (Clean Object-Oriented and Layered Architecture)