【技术分享】Windows桌面端录屏采集实现教程

作为实时屏幕共享的第一步,录屏采集在不同终端和系统上的实现方式有所不同。本次将分享Windows桌面端屏幕共享的录屏采集实现的方法。

【技术分享】Windows桌面端录屏采集实现教程

本系列为即构科技与「极客时间-每日一课」栏目共同打造的深度技术分享内容,视频发布于「极客时间app-每日一课」栏目。

11-3

点击观看视频 如何实现macOS桌面端的录屏采集?

实时屏幕共享功能,在视频会议、游戏直播、在线教育等场景中已广泛被应用。近日,主打屏幕分享的社交应用「Squad」被Twitter收购,让我们看到了实时屏幕共享融于更多行业,开启丰富玩法的趋势。

作为实时屏幕共享的第一步,录屏采集在不同终端和系统上的实现方式有所不同。之前我们已经分享了Android端、iOS端、macOS桌面端实现录屏采集的方式,可以戳下面查看:

【技术分享】Android端录屏采集实现教程

【技术分享】iOS端录屏采集实现教程

【技术分享】macOS桌面端录屏采集实现教程

下面将分享本系列的最后一篇,如何实现Windows桌面端屏幕共享的录屏采集。
在进入具体的方式讨论前,我们先看看 Windows 桌面图形界面的简化架构,如下图:

22-1

在 Windows Vista 之前,Windows 界面的复合画面经由 Graphics Device Interface(以下简称 GDI)技术直接渲染到桌面上。

在 Windows Vista 以及之后的版本,Desktop Composition 的工作就交由一个新的模块 Desktop Window Manager(以下简称 DWM) 来完成了。

如上图所示,应用程序画完自己的界面后,提交给 DWM 把它合成到桌面上,而 DWM 经过一系列演进后,为了提效,基于微软自己的 Direct3D(以下简称 D3D) 实现了整套技术,在 D3D 这层的下面是 Windows Display Driver Model(以下简称 WDDM,Windows 图形驱动程序模型)。

33-1

所以在 Windows 下实现录屏采集,基本上可以从最基本的 GDI 技术和 D3D 技术两方面考虑。

基于 GDI 技术实现录屏采集

基本采样流程

在 Windows 平台上有过图形开发经验的开发者,应该都知道 BitBlt 这个 API, 它为我们实现了 Windows DC 间的内容拷贝,假如将 Source DC 指定为 Window DC 或是 Destop DC,这就实现了对屏幕指定源的画面截取。下图说明了基于 GDI 技术录屏采集的大致调用过程:

44

以整个桌面作为采集源举例,通用做法是调用 GetDC(GetDesktokWindow()) 获取桌面 DC,通过 CreateDIBSection 创建一个设备无关的位图对象以及内存 DC,最后调用 BitBlt 把桌面 DC 的原始数据翻转到内存 DC 上,这样从内存 DC 上就能直接获取到桌面的原始 RGB 数据。

需要注意的是,创建一个设备无关的位图时,CreateDIBSection 的第4个参数 ppvBits 是提前分配好的位图数据缓冲区。而在以实时视频流的方式共享屏幕的场景下,需要以每秒十几次甚至几十次的频率进行采样,从效率的要求考虑,这里自然不可能每次都重新分配缓冲区,所以可以根据源的分辨率,在采集前就分配一个足够大的空间。

CreateDIBSection的函数原型如下图所示:

HBITMAP CreateDIBSection(
  HDC              hdc,
  const BITMAPINFO *pbmi,
  UINT             usage,
  VOID             **ppvBits,
  HANDLE           hSection,
  DWORD            offset
);

绘制鼠标

经过以上基本采样流程,得到的画面内容是不含鼠标的,而在一次研讨会或在线课的材料共享环节,主讲人往往要通过鼠标,指明当前所讲,因此必须将鼠标还原到画面中。

具体实现过程如下:

CURSORINFO ci = { 0 };
ci.cbSize = sizeof(ci);
ZeroMemory(&ci, sizeof(CURSORINFO));
ci.cbSize = sizeof(CURSORINFO);

if (::GetCursorInfo(&ci))
{
  POINT ptCursorPos = { 0, 0 };
  ptCursorPos = ci.ptScreenPos;

  ::DrawIconEx(m_hCompDC,
        ptCursorPos.x , ptCursorPos.y,
      ci.hCursor,
      0, 0, 0, 0,
      DI_NORMAL | DI_DEFAULTSIZE | DI_COMPAT);
}

优劣势分析

以上 GDI 录屏技术,其优势是通用性,即可以在所有 Windows 系统版本实现。

但同时,它也存在一些劣势。由于整体的运算、拷贝过程都在 CPU 中完成,导致采样效率偏低,尤其在高频采样(> 20fps)时,对 CPU资源的消耗过高,而且后续还要处理为实时视频流,经过同样高频的编码、网络包发送,多方面因素叠加,自然对机器性能有更高的要求。而现实场景中,主持人、老师的机器往往吃不消,在可行的情况下,我们尽量争取应用更进阶、高效的技术实现, GDI 则可以作为一种保底方案。

在具体实现中,还有两点需要特别提醒:

1)在 Windows XP 下,可以通过 BitBlt 函数最后的参数,来控制是否拷贝 Layered Window。只有 SRCCPY 标识,表示拷贝内容不包含 Layered Window;如果是SRCCPY | CAPTUREBLT,则表示拷贝包括 Layered Window 在内的所有窗口。而这个标识,在 Windows Vista 之后的系统版本开启 DWM 的情况下,已经无效,因为这种情况下所有的窗口都是 Layered Window;

2)在 Windows Vista 之后的系统版本开启 DWM 的情况下,单次抓取速度变得非常慢(作者机器实测 30ms +);

基于 DXGI 技术实现录屏采集

基本采样流程

除了用 GDI 技术实现录屏,实际上在 Windows 平台上,微软提供了多种录屏方案,相对 GDI 技术来说,其大多数接口的处理性能并不理想,或存在诸多限制,通用性不足。

从 Windows 8 开始,微软引入了一套新技术叫 Desktop Duplication API,应用程序可以通过这套 API 请求桌面的图形数据。由于 Desktop Duplication API 是通过 DirectX Graphics Infrastructure(以下简称 DXGI)来提供桌面图像的,竞争的是 GPU 流水线资源,所以 CPU 占用率很低,采集性能非常高。

由于这套能力整合在 DirextX 中提供,所以与大部分 DirectX 接口的使用方式基本一致,其流程概括如下图。

55-1

如图所示,使用 DXGI 需要一些简单的 DirectX 基础知识,通过各种 DirectX COM 接口的查询,最终获取 IDXGIOutputDuplication 接口指针,截屏时使用其中核心的AcquireNextFrame API 获取当前桌面图像,此外,它还提供 GetFrameDirtyRects 等 API,可以获取经过 GPU 计算后发生了变化的脏矩形区域。

绘制鼠标

和 GDI 面临相同的问题,直接通过 AcquireNextFrame API 获取到的画面中,也是不含鼠标图像的。想要将鼠标绘制到画面中,我们需要和GDI相关的API配合使用。

在采集之前创建数据缓冲区时,将缓冲区关联到设备无关的位图上,并将位图选入临时的内存DC(一般由桌面DC生成的临时内存DC),再将通过 AcquireNextFrame获取的画面拷贝到缓冲区后,这时可以使用GDI绘制鼠标的方法,将鼠标绘制到位图上,这样数据缓冲区中的图像数据就包含了鼠标了。

优劣势分析

在 Windows 平台上,从现有的录屏采集方式(包括GDI采集和放大镜采集)来看,DXGI 是性能最好的。

其劣势是只在 Windows 8 系统版本及以上才支持,所以在整体方案中,一般要与 GDI 共同组合提供。此外,它无法指定某个程序窗口进行采集。

基于放大镜技术实现录屏采集

基本采样流程

前面所述的两种方式都可以实现录屏采集,也是最常用的两种方式。

但有时,我们要指定源来采集,并且希望采集到的画面不被其他内容干扰。比如,在线课堂上,老师指定采集了一个 PPT 窗口,共享授课的过程中时不时需要操作一下其他 App,此时,其他 App的窗口有可能遮挡了 PPT 窗口,而老师并不想将这些无关或隐私信息对外暴露。因此我们需要实现一个采集源过滤器(filter),排除掉无关信息。

在 Windows XP 时代,用 GDI 的 BitBlt API 进行采集时,指定一个 Window DC,并且对最后一个参数去掉 CAPTUREBLT 标识,即可排除掉其他 Layered Window 的干扰。但如今 Windows XP 已成过去式,这项措施无法解决现有系统的过滤问题,必须找到另一替代方案。

从 Windows Vista 开始,微软新引入了一个新的 Magnification API(放大镜效果),当我们将放大倍率设置成1(默认倍率就是1)亦可以用它来截取屏幕图像。MSDN上提供了该库的完整文档。根据文档,可以通过以下步骤简单地完成录屏采集过程:

66

在初始化相关模块后,首先创建放大镜控件的主窗口,并且将其设置为全屏不可见,因为我们要使用它来捕获图像,它只是一个工具,所以不能也不需要在用户侧显示它。因此,设置窗口扩展属性 WS_EX_LAYERED,调用 SetLayeredWindowAttributes 设置全透明。

::SetLayeredWindowAttributes(hwnd, 0 ,255, LWA_ALPHA);

接着创建放大镜窗口作为主窗口的子窗口,窗口类名必须为“Magnifier”。如果要捕获鼠标光标,还要设置窗口属性为 MS_SHOWMAGNIFIEDCURSOR。

hwndMag = ::CreateWindow(WC_MAGNIFIER, TEXT("Magnifier"), 
    WS_CHILD /*| MS_SHOWMAGNIFIEDCURSOR */| WS_VISIBLE,
    0, 0, m_ScreenX, m_ScreenY, 
    hostDlg->GetSafeHwnd(), NULL, hInstance, NULL );

最关键的部分,利用 MagSetWindowFilterList 这个神奇的 API,它能够指定一些窗口,在我们截取指定源目标时,从采集到的图像中将 FilterList 中的窗口过滤掉,好像这些窗口根本没有显示一样。这就是我们使用这放大镜方案的主要原因。

那么如何获得录屏图像呢?每当我们调用 MagSetWindowSource 时,都会触发回调

MagSetImageScalingCallback数据回调。原型如下:
BOOL MagImageScaling(HWND hwnd, void *srcdata, MAGIMAGEHEADER srcheader,  void *destdata, MAGIMAGEHEADER destheader, RECT unclipped, RECT clipped, HRGN dirty)

其中第二个参数 srcdata 就是指向录屏结果图像的原始数据,srcheader 则包含数据的长度,以及图像长宽等元信息,至此,我们可以使用这两个参数来构建位图了。

优劣势分析

这个方法最大的优点是采集时能够过滤掉指定的一些窗口。

缺点是只能在Windows 8及以后系统使用,虽然Magnification API是微软从Windows Vista开始引入,但是关键的获取图像数据相关的API只有在Windows 8才能够使用,也就是说在Windows 8以前的版本,我们只能使用放大镜来放大屏幕,却无法使用放大镜来截取图像数据。

并且同样也有效率问题,采集的效率甚至比不上用GDI截屏,整屏采集一次需要50ms左右。

总结

上面我们描述了三种录屏采集方式的实现流程,并分析了各自的优劣势:

GDI是在Windows下截屏的通用方法,没有什么限制,一般在没有特殊需求的场景下使用;

DXGI截取效率最高,但是不能在Windows 7下使用,也不能过滤窗口;

放大镜可以过滤窗口,但是也不能在Windows 7下使用,并且效率也不高。

Windows在不断发展和进步,基本没有一种通用的抓屏技术可以高效的抓取所有的系统。所以我们在实现录屏采集时,要灵活组合三种方式,以适配复杂的应用场景。