Windows 10/11 虚拟桌面管理增强

意图

在 Win10、Win11 下,实现一个具有类似 MacOS 下 TotalSpace 功能的程序。

市面上免费的、收费的程序有不少,但都有不尽如人意的地方,尝试自行实现。

Q:为什么只支持 Win10、Win11?

A:Win10、Win11 系统中的虚拟桌面 (Virtual Desktop),是新加入的机制,强调:轻量级与灵活/便利性。较之传统的通过调用 CreateDesktop 生成的桌面,实现某些功能时更加简单直接。详见后文。

原则与限制

既可以由程序完全控制所有桌面(这是 Dexpot 的实现方式),也可以封装并增强系统自带的虚拟桌面。综合考量程序的资源占用、性能、稳定性、兼容性、系统/代码库依赖、打包大小、扩展性、灵活性、便利性等因素,本实现选择后者。

此程序,依赖若干微软公开或非公开的 API;对于非公开的 API 微软可能会进行不兼容的调整,导致在不同版本的系统中出现未定义的行为,因此并不试图达到兼容性最大化。

程序工作流程

枚举目标窗口

一般情况下,Windows 系统中运行的程序的窗口总数多达数百个或更多,而在要实现的这个程序中,只关注那些期望在桌面概览界面中显示的窗口,因此需要过滤诸如:

  • ShellWindow(即:顶层桌面)
  • 不可见窗口/无标题窗口
  • 任务栏窗口
  • IME 窗口
  • 没有 Windows.UI.Core.CoreWindow 子窗口 的 ApplicationFrameWindow 窗口(有点绕,后文解释)
  • 其他不适合显示的窗口,且可以定制
解释

具有 ApplicationFrameWindow 窗口类的窗口是 UWP 程序的沙盒容器(多见于 Windows Store) ,其包含一些与传统 Windows 窗口不同的特性。比如:当该容器包含一个窗口类为 Windows.UI.Core.CoreWindow 的子窗口时,该窗口可见,否则不可见。
如果,一个拥有 Windows.UI.Core.CoreWindow 窗口类的窗口是顶层窗口时,则需要另行判断。

记录并维护目标窗口的虚拟桌面归属

背景知识

自 Windows 10 起引入的虚拟桌面,与传统的通过 CreateDesktop 生成的桌面有很大不同。

传统的或曰经典的 Desktop,是建立在 Windows 的 Sessions、Windows Stations、Desktops 继承树结构之上的。更强调安全性而忽视便利性,比如——在传统 Desktop 中的众多窗口是与该 Desktop 严格绑定的,无法将属于某一个 Desktop 的窗口移动到另一个 Desktop 中。

而新的虚拟桌面 (Virtual Desktop) 机制,并没有真的在系统中创建任何传统意义上的 Desktop,一如其名称中的 Virtual 所暗示的。

此时,窗口间的关系都是传统的父/子、所有者/被所有者关系。使用 Spy++ 工具可以总观当前系统中所有窗口的结构树,可以看到只有一个顶层的、作为所有其他窗口的祖先窗口的“桌面窗口”。

程序角度观察——虚拟桌面只是作为各个窗口的一种特殊属性而存在(类比关系型数据库中表间的关联,虚拟桌面和窗口没有 1-1,1-n,n-n 关联,虚拟桌面只相当于窗口的一个标签字段),Windows 根据各窗口的虚拟桌面属性进而在界面上展现各种效果:

  • 某些窗口只在某个虚拟桌面显示
  • 某些窗口可以在所有虚拟桌面显示
  • 将一个窗口从一个虚拟桌面移动到另一个虚拟桌面
  • ……

而在传统的 Desktop 机制中要实现上述效果,会受到各种限制。

多显示器相关

对于拥有多台物理显示器的系统,当多显示器的设置为将桌面扩展到此显示器时,系统会管理一个唯一的虚拟显示器,该显示器的宽/高是所有物理显示器的相应像素和。

让窗口显示在哪台物理显示器上,实际上还是和只有一台物理显示器一样:设置该窗口的全局坐标。 比如有三台物理显示器 M1,M2,M3,从左到右排列,则:

描述

M1 的横坐标起点为 0
M2 的横坐标起点为 0 + M1.Width
M3 的横坐标起点为 0 + M1.Width + M2.Width
要将某个窗口放在 M3 中,并不是设置该窗口在 M3 内的相对坐标,而是设置三台显示器组合后的整体多边形内的坐标。(可以参考 Win 默认的屏幕截图功能,是给虚拟显示器的整体截图,并非是对各个显示器截图的拼接。)

另可参考:https://stackoverflow.com/a/61776670

要注意的是,尽管虚拟显示器只有一个,但在默认情况下:虚拟桌面并不对应这个唯一的虚拟显示器!而是与多台物理显示器 1-1 对应,也即:即便扩展桌面跨越多台显示器,但虚拟桌面(列表)在各个物理显示器中却是独立存在的。

要验证这一点:按 Win+Tab 使用 Windows 自带的 TaskView 操作缩略图窗口,无法将某一台物理显示器中的窗口,拖放到另一台物理显示器中,而是只能将该窗口,在同属一台物理显示器的不同虚拟桌面间拖放。

这是有一定道理的,参考前节的描述,给窗口换显示器实际上是修改该窗口在虚拟显示器中的全局坐标,然而(默认情况下)虚拟桌面并不与虚拟显示器对应;虚拟桌面中的缩略图窗口也并不依赖窗口的坐标信息(窗口大小、Z-Order 等信息是必要的,因为缩略图的大小、排列顺序依赖这些信息)。在微软看来,给窗口换显示器和给窗口换虚拟桌面是应该在空间和时间上都分开的两件独立事务。

  • 判断一个窗口,“属于”哪台物理显示器方法不唯一:

    • 可以通过自行判断窗口在虚拟显示器中的坐标,窗口最大化时此方法有缺陷
    • Win32 函数 MonitorFromWindow
    • .net 封装函数 System.Windows.Forms.Screen.FromHandle
    • .net 封装函数 System.Windows.Forms.Screen.FromRectangle
    • .net 封装函数 this.Bounds.IntersectsWith(Screen.PrimaryScreen.Bounds)
    • ……
  • 判断一个窗口,“属于”哪个虚拟桌面办法则单一的多,且仅靠微软公开的 API 还不足以完全实现程序功能,需要访问一些私有的 COM 组件接口。

特效窗口展示所有虚拟桌面概览视图

Windows 自带的 TaskView 中的虚拟桌面预览窗口太小,且只能横排;再有,想要细看非当前桌面中的窗口,还需要鼠标悬停在该桌面的预览窗口之上,这正是导致要自行实现本程序的原因之一。

期望达到的效果是——与“在某个虚拟桌面中按 Alt+Tab 可以总览当前桌面中的窗口列表”一样,唤起虚拟桌面概览视图时,可以总览所有虚拟桌面及其中的所有窗口列表(甚至包含最小化的窗口)。

首先虚拟桌面概览视图需要一个窗口做背景/画布/容器;这个窗口与常规窗口有诸多不同,比如:

  • 仅在主显示器上显示(或可定制在所有显示器中显示)
  • 不需要标题栏、边框,不可移动,不可调整大小(严格说是永远保持与主显示器同尺寸)
  • 不响应最大化、最小化、停靠等操作
  • 不显示该窗口的任务栏按钮
  • 唤起该窗口时,要全屏的覆盖在其他所有窗口之上
  • 有多少个虚拟桌面,就有多少个子容器 根据虚拟桌面个数进行子容器的布局,将各个虚拟桌面(及其中的窗口缩略图)填充到子容器中,保证子容器个数大于等于虚拟桌面数即可。——允许子容器个数大于虚拟桌面个数的目的在于:当虚拟桌面个数不是正整数的 2 次方,且布局方式为 $ 行=列 $ 时,可以用空的子容器填满行列中的空白空间。
  • 子容器布局的多样性
    • 摆放顺序:横向(行满换行)或纵向(列满换列)
    • 采取适当机制,保证无论虚拟桌面有多少个,各个子容器的面积须相等,宽高比要与主显示器相同。简单化的实现,可以限制行与列数相等: $$ N_{rows} = N_{cols} ,其中:N \in [1,2,3,...n] \\ 子容器个数 = N^2 $$ 在以上前提下,已知虚拟桌面个数 $X$,则 $行数=列数= N$ 可以如下计算: $$ N = \lceil \sqrt{X} \rceil \\ 向上取整,保证了 N^2 \ge X $$
  • 每个子容器要以各个虚拟桌面的壁纸为背景,如果是不支持独立分配壁纸的系统版本(如早期的 Win10),则程序应该提供这种支持。
    这点很重要,因为这是一种让使用者快速识别目标虚拟桌面的方式,这比根据虚拟桌面的编号、名称、当前包含的窗口内容来判断要直观的多。毕竟,虚拟桌面的意义,就在于避免不同功能、不同属性的众多窗口都集中在一个桌面中,导致难以辨别和定位;而当虚拟桌面的个数上升时,也应该让其保持尽可能多的快速定位的特征。
  • 子容器支持拖放以调整虚拟桌面顺序,且应独立于操作系统的排序,这种隔离为多配置文件,多布局提供基础。
注意

子容器的摆放相对简单,因为其尺寸都相同且宽高比与物理显示器的宽高比是线性相关的。
但某个子容器中的众多缩略图窗口布置则是个复杂问题。如何在充分利用尺寸固定的单个容器面积的前提下,将大小不同、宽高比例不同的众多窗口缩略图填充到其中?
尽管我们知道缩略图窗口的大小是要在保持宽高比的前提下进行缩放的(不然子容器必然放不下这些窗口),然而这一点并不能使该问题变得简单。
感官上微软的实现,是在运算消耗和面积利用之间做了权衡,而且更注重节约运算消耗。在微软看来,稍微牺牲一点面积利用率固然会导致的一些本可以略大一点的缩略图窗口变小,但为了这大一点点却要进行 NP 运算就太不划算了。

此窗口的实现有很多种基础组件可供选择——原生窗口,WinForms、WPF、UWP 都可实现。

核心功能

抓取窗口缩略图,按虚拟桌面归属展示各窗口

可选方案:

A. DwmRegisterThumbnail 将关联窗口的实时缩略图渲染到前文所述的子容器窗口中,达成效果

DwmRegisterThumbnil 是微软公开的 API,效能和稳定性都不错,而且对于最小化窗口,该函数依然能通过读取快照的方式显示目标窗口的界面(虽然此时只是静态图片)

然而也有一些僵硬的限制,如:该函数只能在顶级窗口上渲染所捕捉的缩略图,所以这种方案要求子容器窗口必须是独立的顶级窗口,不能是画布窗口的子窗口或子控件

B. Windows.Graphics.Capture 方式捕获窗口缩略图,直接渲染在画布窗口的子控件

底层利用的是 D3D11,性能也很好,且该方式不要求必须渲染在顶级窗口上,避免了为每个虚拟桌面建立单独的窗口

但是由于不能利用 DWM 的快照机制,对于诸如最小化等状态的窗口无法捕获

本程序选择方案 A,但或可保留 B 的选项。

关于 Z-Order

在 Windows 默认的 TaskView 视图中,某个虚拟桌面中展示的窗口排序,是按照 Z-Order实时改变的,这又是一个导致编写此程序的原因。思考以下场景——

当前系统中有多个虚拟桌面,每个虚拟桌面中有多个窗口,我们定义当前的整体视图为 $V_0$,在进行任何可能改变该视图的操作前,使用者的记忆中会对该视图有一个短期的记忆快照,重要!

开始我们的假想操作——

Win+Tab 唤出 TaskView,在某个虚拟桌面中定位要激活到前台的窗口,点击该窗口后,Windows 切换到相应桌面并将该窗口前端显示,到此还都比较顺理成章,而后续操作会出现两种不同的体验:

$$对于前述的整体视图 V_0$$

  • 体验一:再次 Win+Tab 唤起 TaskView 时,$V_0$中的虚拟桌面及其中的窗口排列都不发生改变(即便有窗口被关闭,顺序也不会改变),即:$V_0 = V_1 = V_2 ...= V_n$
  • 体验二:再次 Win+Tab 唤起 TaskView 时,$V_0$中某个虚拟桌面内的窗口排列,因为最新一个激活的窗口(的 Z-Order 变化)而发生改变(除非操作的窗口原本就是当前激活状态的顶层窗口),即:$V_0 \neq V_1 \neq V_2 ... \neq V_n$

二者无法简单的按对与错区分,但在体验上却差异很大!本期望微软能够给出让使用者定制的选项,那就完美!可惜微软不给,微软选择的是体验二。那么来说说为什么这个体验二也是导致要自行实现本程序的原因。

如前所述,记得那个使用者对于 $V_0$ 的记忆快照吗,显然体验二把这个快照破坏掉了,当频繁的使用 Win+Tab、Alt+Tab 然后选择新的窗口激活时,所有可见窗口的排列变来变去,因而使用者必须在唤起 TaskView 之后,通过目测窗口的内容(而不是那个短期快照中窗口的固定位置)来定位想要选择的窗口;除非使用者的记忆超好,脑海里时刻都保留着各个虚拟桌面中窗口的动态队列……好羡慕。而如果这个顺序不改变,本可以只根据位置来快速定位的。想象查字典时,最快的定位并不是从目录索引然后翻转到指定的位置,而是直接翻到大概位置后微调页码。

还是那句话,不能说微软的设计是错的,类比一下动态缓存的设计,越是被频繁访问的资源,越应该提升其优先级和缓存占用,将那些不怎么被频繁访问的资源优先级降低甚至移除缓存区。在微软看来,刚刚被激活的窗口就应该排在 TaskView 的最前面,多次激活不同的窗口后,那些没有被激活过的窗口自然就排到后面去了。

但,如果使用者希望:即使不看窗口的内容,仅靠短期记忆快照中的固定位置就可以快速选择要激活的窗口,微软的实现就满足不了了。

强调

使用者按自己的意愿将众多窗口放置到不同的虚拟桌面中是刚性需求,但是随着使用者激活不同的窗口,系统自动改变窗口在虚拟桌面中的排列顺序就不是刚性需求了,甚至是个并不友好的副作用。

如果认为自动的按照 Z-Order 排列窗口的顺序,和虚拟桌面的设计初衷并无冲突,则更是错觉!之所以使用虚拟桌面,就是为了让相关度高的窗口相对集中、位置相对固定,并籍此让使用者在自己的系统上形成某种习惯模式,而这种窗口集中行为本身是涵盖位置因素的,而一旦系统用一种用户不可见的属性 ( Z-Order) 进行排列了呢?则直接破坏原有的位置关系。

——相信大多数使用者并不想每次开机,上一次的窗口布局全丢失了,还得从新布置,窗口开的越多这种情况越突显,也正因如此才有人专门写了一个保存/还原窗口布局的工具软件 Windows Layout Manager。该软件功能众多,小巧实用,只可惜目前对虚拟桌面无任何支持,且作者许久不曾修复 BUG。该软件和本文描述的程序可以互为补充。

一言以蔽之:是否接受自动按 Z-Order 重排窗口顺序,应该提供由使用者定制的选项。

我们可以猜测一下微软为什么不内置提供该选项,而是直接使用 Z-Order:

  • 早在没有虚拟桌面的时代,Z-Order 就是伴随 Windows 系统成长的重要内部机制,成熟、稳定、高效几乎没有什么再更迭的必要,窗口排列顺序使用该机制自然而然,无需引入额外的机制
  • Windows 中的窗口是形式多样,属性繁多,操作复杂的集合,且同一系统中还要兼容不同时代的窗口实现,确非易事。虽然,从使用者的角度看,给窗口按不同属性(标题、大小、创建时间…)排序似乎并不是什么困难的事情,但系统的实现者和程序开发者则要面临诸多细节问题,遂言:如无必要,勿增实体
  • 任何系统配置项,对于 Windows 来说都是“稀缺资源”,绝不轻易添加;参考现在主流版本的 Windows 的配置项,无论微软在用户体验上做多少努力,也鲜有把所有配置项以及配置途径和方法都熟烂于胸的使用者,这也好解释,Windows 的复杂是因为她就是为了解决复杂问题而被创造出来的

题外话
Windows 对于微软的重要度不言自明,然而近年来 Windows 的重大变更恰恰是在 Windows 不再是微软唯一的重点之后做出的,设计师、程序员们早年想做而因为种种原因没能做成的事儿,正逐渐的在新版本系统中“恣意妄为”

配置项

界面

1. 画布——覆盖整个主显示器

画布的意义在于,从视觉上与系统当前桌面分隔开来,避免在展示子容器时,给人以仅仅是在当前桌面打开了若干新窗口的错觉。

  • 背景色
  • 透明度(可选的特殊效果,如:毛玻璃)
  • 画布内元素(虚拟桌面容器)之间的间距
2. 虚拟桌面容器——规律的分布在画布内
  • 边框大小——设置为零等同于无高亮边框效果
  • 默认边框色
  • 当前桌面边框色——提示用户当前所在的桌面
  • 高亮边框色——提示当前拖放的目标
  • 边框阴影——可开关的选项,因为这类特效对性能影响很大
  • 默认透明度
  • 拖放目标透明度
  • 容器内缩略图窗口之间的间距
3. 缩略图窗口
  • 拖放源透明度——此选项的作用在于提示拖拽的窗口来自何处。具体是指:当从某个虚拟桌面中拖拽出某缩略图窗口 A 的克隆窗口 A' 时,原缩略图 A 是否显示以及如何显示,完全透明则等同于不显示
4. 本地化——实时切换语言

行为——程序接受的操作

画布
  • 鼠标左键 Keydown——简单关闭视图(与 Windows 的 TaskView 一致)
虚拟桌面容器
1. 布局排序——

由于配置文件可以有多份,每份的配置都彼此独立,因此程序中的虚拟桌面排序并不依赖于系统的排序。

  • 默认左键拖放
2. 鼠标点击
  • 左键——切换到目标桌面,关闭视图
  • 中键——切换到目标桌面,但不关闭视图
  • 右键——上下文菜单
    • 命名虚拟桌面
    • 创建虚拟桌面
    • 移除虚拟桌面
    • 设置壁纸
窗口缩略图
1. 拖放
  • 默认左键拖放
  • 带有某个辅助键的左键拖放——放置并切换到目标桌面
  • 排序规则
    • 默认按标题排序
    • 按 Windows 的 Z-Order 排序
    • 手动排序
2. 鼠标点击
  • 左键——激活目标窗口、切换到目标窗口所属桌面,关闭视图
  • 中键——激活目标窗口、切换到目标窗口所属桌面,但不关闭视图
  • 右键——上下文菜单
    • PIN 窗口在所有桌面/反操作
    • PIN 进程在所有桌面/反操作
    • 关闭窗口
    • 移动到指定桌面
    • 为此窗口创建规则

事件(规则)——动作触发依据

1. 自动触发
  • 系统中有某进程启动
  • 某进程退出
  • 系统中有新窗口创建于某个虚拟桌面中
  • 某窗口被关闭
注意

有窗口必然有进程,反之则不然
通过窗口句柄获取进程信息,需要处理普通用户进程和管理员用户进程的权限差别问题

2. 手动触发

根据当前虚拟桌面、窗口状态,执行预定义动作

条件

基本
Key Value
进程名 xxx
进程全路径 X:\[path]\xxx.exe
窗口标题 是 X
以 X 开头
以 X 结尾
包含 X
正则表达式
窗口类名 WndClassName
细节

最大化/最小化
遮盖中
在第 N 显示器
在某个矩形内(已知矩形的左上角坐标和右下角坐标就可以唯一定位)

动作

订阅指定的事件,触发预定义动作(组合)——比如特定程序启动/窗口出现时自动放置到某个虚拟桌面,并可配置是否立刻切换到与该程序/窗口关联的桌面,等等……

AND
移动到 X 虚拟桌面
最大化
最小化
还原
不改变尺寸
放置到第 N 显示器
PIN 该窗口
PIN 该窗口的进程

日志

Key Value
日志级别 DEBUG
EVENT
INFO
WARNING
ERROR
日志输出位置 GUI 文本框
文件

可扩展的功能

  • 虚拟桌面切换特效:平移、侧推、渐变、翻转、外立方,内立方
    • Windows 默认的 TaskView 中的虚拟桌面是单行排列的,所以只支持左右方向切换,且为瞬时切换,无任何特效。而根据前述的布局方式,本程序的概览视图中,虚拟桌面排列为行列矩阵,因此应增加上下方向切换方式。切换特效为增强体验提供支持。
    • 当在某个虚拟桌面中直接按快捷键(比如:Ctrl+↑、Ctrl+↓、Ctrl+←、Ctrl+→)切换虚拟桌面时,根据相对位置实施上述特效,此时是从当前到相邻桌面的切换,所以特效的运动方向要么水平,要么垂直。
    • 可选的:当从概览视图中切换虚拟桌面时,是否触发特效?若是如何触发特效?因为此时目标桌面与当前桌面的相对位置未必相邻,所以可能不适用水平、垂直运动的特效。
  • 插件支持(一门专门的艺术^_^)
  • TODO

一些要考虑的问题

  • 是否要完全取代 Windows 自带的 TaskView(屏蔽 Win+Tab、Alt+Tab、拦截 TaskView 按钮事件?)
  • 程序自身性能与资源占用
  • 对系统稳定性的影响
  • 时刻要注意这是一个提升用户体验的效率程序,那么为了这个增强的体验,并强调用户的专注度,其他方面的牺牲应该控制在怎样的程度
  • 兼容性与依赖
    • 目标 Windows 版本适配
    • 代码目标框架
  • 许可协议选择

备选技术

Key Value
语言 C++,C#,Python,Go,Javascript……
运行时 Win32,.Net Framework,.Net Core……
窗体 NativeWindow,WinForms,WPF,UWP……
进程通信 Win32 Window Message,WCF,HTTP,RPC,Windows Socket……
WebView 交互基础 CefSharp,Xilium.CefGlue,Electron,Sciter,Chromely/EdgeSharp,Tauri,Wails,Blazor……
HTML 视觉 HTML5,WebGL
UI 自动化 Windows: UIAutomation
数据持久化 JSON,XML,Sqlite……
图形 D3D,OpenGL,Vulkan……

成果展示

github

End


本文采用 知识共享署名许可协议(CC-BY 4.0)进行许可,转载注明来源即可。如有错误劳烦评论或邮件指出。


comments powered by Disqus