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

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

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

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

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

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

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

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

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

在 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 图形驱动程序模型)。

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

基于 GDI 技术实现录屏采集

基本采样流程

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

以整个桌面作为采集源举例,通用做法是调用 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 接口的使用方式基本一致,其流程概括如下图。

如图所示,使用 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上提供了该库的完整文档。根据文档,可以通过以下步骤简单地完成录屏采集过程:

在初始化相关模块后,首先创建放大镜控件的主窗口,并且将其设置为全屏不可见,因为我们要使用它来捕获图像,它只是一个工具,所以不能也不需要在用户侧显示它。因此,设置窗口扩展属性 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在不断发展和进步,基本没有一种通用的抓屏技术可以高效的抓取所有的系统。所以我们在实现录屏采集时,要灵活组合三种方式,以适配复杂的应用场景。