首页
/ PowerToys FancyZones 架构设计解读:从 WorkArea/ZoneSet 模型到多显示器多虚拟桌面的布局管理重构

PowerToys FancyZones 架构设计解读:从 WorkArea/ZoneSet 模型到多显示器多虚拟桌面的布局管理重构

2026-09-06 18:13:20作者:翟萌耘Ralph

导读

本文基于微软 PowerToys 仓库中的 FancyZones-DCR 设计文档(Design Change Request),系统梳理 FancyZones 窗口分区管理器的核心对象模型:ZoneZoneSetWorkArea 与顶层 FancyZones 类各自承担的职责,并结合当前仓库源码分析其真实落点。在此基础上,文章深入剖析该 DCR 的核心议题——当显示环境变化(切换虚拟桌面、增删显示器等)时反复触发 EnumDisplayMonitors 所导致的 WorkArea 对象频繁销毁重建问题,以及文档给出的"按虚拟桌面+显示器去重缓存、按需增量创建"改进方案。读完本文,你将能够基于这份设计文档理解 FancyZones 在桌面/窗口事件驱动下的整体工作流,掌握其从"全量重建"到"增量同步"的设计演进思路,并能在源码中定位每一层对象与对应实现文件。

一、文档背景:一份 FancyZones 的设计变更请求(DCR)

本文所依据的 FancyZones-DCR.md 位于仓库 doc/specs/ 目录下,该目录与 SCOOBE.mdsettings-search.mdpower-display-adaptive-brightness.md 等并列,存放 PowerToys 各类功能的设计文档。FancyZones 本体代码位于 src/modules/fancyzones,其核心算法库是 src/modules/fancyzones/FancyZonesLib

文档结构十分清晰:前半部分是 FancyZones library 的四层对象架构简介(§1–§4 即 Zone/ZoneSet/WorkArea/FancyZones 四个类),后半部分则围绕第 4 类"环境变化处理"提出修改提案(Proposal)。因此,本文也按"现状架构 → 痛点分析 → 改进方案 → 源码印证"的脉络展开。

二、FancyZones 核心对象模型:四个类各司其职

1. Zone:矩形结构的轻量封装

文档原文将 Zone 定义为:

Class which is basically wrapper around rectangle structure, representing one zone inside applied zone layout.

即在已应用的布局中,一个 Zone 就代表一个"可吸附"的窗口区域,本质上是矩形(RECT)结构的封装,ZoneSet 以数组形式持有这些 Zone,共同构成一个完整布局。

源码佐证见 FancyZonesLib/Zone.hclass Zone 的私有成员正是 const RECT m_rectconst ZoneIndex m_index,并通过 GetZoneRect()Id()IsValid()GetZoneArea() 等接口对外暴露矩形、序号、有效性与面积。ZoneIndexZoneIndexSet 也被定义在同一文件中(using ZoneIndex = int64_t; using ZoneIndexSet = std::vector<ZoneIndex>;),其中 ZoneIndexSet 表示一次窗口吸附所跨过的全部 zone 序号集合。此外 Zone.h 还定义了间距下限常量 ZoneConstants::MAX_NEGATIVE_SPACING = -20,说明布局的 zone 间距允许为负(区域交叠)但不超过 -20 像素。

2. ZoneSet(现已演化为 Layout):布局的几何计算器

文档原文将 ZoneSet 定义为:

Class implementing actual zone layout applied... responsible for actual calculation of rectangle coordinates (whether is grid or canvas layout) and moving window through them.

ZoneSet 负责两种布局模式下各 zone 矩形坐标的实际计算:

  • Grid(网格)布局:由行列/单元格模板自动推导各 zone 的矩形;
  • Canvas(画布)布局:每个 zone 由用户自由摆放的矩形定义。

以及在这些 zone 之间移动窗口。当前 WorkArea 持有的"当前生效布局"即为 ZoneSet

需要说明的是,这份 DCR 写作年代较早,命名在后续版本中发生了演进:从当前源码看,负责持有 zone 集合与几何计算的核心类已更名为 Layout,见 FancyZonesLib/Layout.hLayout 持有 ZonesMap m_zones,其接口 ZonesFromPoint()(命中测试)、GetCombinedZoneRange()(计算跨越起始/终止两个 ZoneIndexSet 的最小包围盒范围内的全部 zone)、GetCombinedZonesRect() 正对应文档描述的"矩形坐标计算与跨 zone 移动窗口"职责。因此,可以认为 当前源码中的 Layout 类继承了原 DCR 中 ZoneSet 的职责与定位。布局类型的枚举 ZoneSetLayoutType(包含 focus/grid/rows/columns/priority-grid/custom 等值)仍保留在 FancyZonesDataTypes.h 中,印证了类型层面与旧命名的延续关系。

3. WorkArea:由"显示器 × 虚拟桌面"确定的唯一工作区

文档给出了一个非常关键的定义:

Class representing work area, which is defined by monitor and current virtual desktop... if You have two monitors connected and two virtual desktops, You have 4 work areas available, and each of them can have separate zone layout.

WorkArea = 某一台显示器 × 某一个虚拟桌面 的组合。例如 2 台显示器 + 2 个虚拟桌面 = 4 个 WorkArea,每个 WorkArea 可以拥有彼此独立的分区布局,且持有当前生效的 ZoneSet(现在为 Layout)。

源码印证见 FancyZonesLib/WorkArea.hWorkAreaFancyZonesDataTypes::WorkAreaId m_uniqueId 唯一标识自身,持有 std::unique_ptr<Layout> m_layout(即文档所说的 active ZoneSet)、LayoutAssignedWindows m_layoutWindows(窗口与布局的吸附绑定关系)、以及一个隐藏工具窗口 HWND m_window("Hidden tool window used to represent current monitor desktop work area",用于承载 overlay 交互)。其创建采用工厂方法 WorkArea::Create(hinstance, uniqueId, parentUniqueId, workAreaRect)——注意这里的 parentUniqueId 参数,它用于支持当前文档未覆盖、但后续版本引入的布局继承能力(新 WorkArea 可从"父 WorkArea"继承布局配置)。对外行为接口包括 Snap()/Unsnap()(吸附/解除)、ShowZones()/HideZones()/FlashZones()(overlay 显隐与闪烁提示)、CycleWindows()(在 zone 内循环切换窗口)等。

4. FancyZones:顶层入口与一切用户动作的调度者

文档将 FancyZones 定位为:

Top level entity and entry point for all user actions (which goes through actual module interface).

并归纳了四项主要职责:

  1. 按用户请求携带适当命令行参数启动 FancyZones Editor(C#):即设置界面中"编辑所选区域布局"时拉起编辑器进程。相关参数构造逻辑可在 FancyZonesLib/EditorParameters.h.cpp 中找到(例如传递 work area id、monitor 信息等),而 Editor 本体为 C#/WPF 项目,位于 src/modules/fancyzones 下的 editor/ 及 FancyZonesEditor 相关工程。
  2. 跟踪每台显示器上当前激活的 WorkArea:即"显示器 → 当前活跃 WorkArea"的映射维护。
  3. 跟踪当前活跃的虚拟桌面:文档特别注明这是在独立线程中通过轮询 VirtualDesktopIDs 注册表键并解析其内容完成的。对应源码见 FancyZonesLib/VirtualDesktop.cpp.h,其中实现了虚拟桌面变化监听/查询能力。
  4. 检测工作环境的一切变化,包括:创建 / 销毁 / 切换虚拟桌面、关闭 FancyZones Editor、显示设置变更(分辨率、显示器热插拔等),并处理这些变化。

从模块接口层面看,FancyZones 通过 COM 接口暴露自身,顶层接口定义见 FancyZonesLib/FancyZones.hIFancyZones::Run()/Destroy() 负责启停;IFancyZonesCallback 则定义了运行期回调,其中 VirtualDesktopChanged()(虚拟桌面切换通知)与 HandleWinHookEvent()(窗口移动/缩放等 WinEventHook 事件)正是文档第 4 点所述"环境变化"驱动 FancyZones 内部逻辑的两大入口。该文件同时把事件回调到核心逻辑的桥接方式写得很清楚:FancyZones.cppVirtualDesktopChanged() 的实现(FancyZones.cpp)注明"该回调来自可重入的 WinHookProc 函数,因此必须把实际逻辑推迟处理"——这与本文下一章讨论的"在事件风暴下避免反复重建对象"是同一设计语境。

三、痛点分析:为什么反复调用 EnumDisplayMonitors 会成为问题

DCR 文档第 14 行起("Proposal for modifications of handling described in 4.4")剖析了既有实现的问题:

现状:面对上节第 4 点所述的任何环境变化(虚拟桌面增删切换、Editor 关闭、显示设置变更),FancyZones 都会调用 Win32 API EnumDisplayMonitors 并传入回调函数。EnumDisplayMonitors 是异步工作的,会为每个可用 WorkArea 触发一次回调——沿用前文的例子,2 台显示器 × 2 个虚拟桌面时回调被触发 4 次。

问题:每次回调触发时,代码都不加区分地为该 WorkArea 销毁旧的 WorkArea 对象并创建新的对象,即使实际上什么都没变(典型场景:用户在两个虚拟桌面之间来回切换)。文档原文的表述是:

This constant creation and deletion of WorkArea has caused some problems in the past and it's not ideal for some other fixes we would like to make in the multi-monitor/multi-desktop scenario.

即这种持续的创建/删除行为:

  • 在过去已经引发过一些实际 bug;
  • 且对后续想在"多显示器 + 多虚拟桌面"场景推进的其他修复/特性而言并非理想基础。

从工程角度不难推断其代价:每次全量重建都意味着重新走一遍 WorkArea 初始化(包括WorkArea.hInitWindow() 创建隐藏工具窗口、InitLayout() 加载布局、恢复 LayoutAssignedWindows 吸附绑定等一系列重活),同时伴随窗口消息、overlay 状态与 JSON 持久化层的连带抖动,属于典型的"无差别全量重建"资源浪费。

四、改进方案:基于虚拟桌面跟踪器的增量同步

4.1 设计思想:把"每次全量重建"换成"按需增量创建"

文档提出的方案并不复杂,但切中要害:

  1. 复用既有虚拟桌面跟踪器(见文档 §4.3,即上文 FancyZones 第 3 项职责中基于轮询 VirtualDesktopIDs 注册表键的实现),并将其能力稍作扩展,使系统能够判断某个 WorkArea 到底是"新出现的"还是"已经处理过的";
  2. 对于已经处理过的 WorkArea,跳过"删除旧对象 + 创建新对象"的流程,即使它的环境通知再次到达(例如虚拟桌面来回切换);
  3. 维护一张映射表:以虚拟桌面 id 为 key,值为该虚拟桌面上已经处理过的显示器集合。文档特别注明虚拟桌面是跨所有显示器存在的("virtual desktop exists across all monitors"),因此一张虚拟桌面下需要登记全部显示器;
  4. 当收到 EnumDisplayMonitors 回调、携带着"虚拟桌面 id + 显示器"所定义的工作区时,先去查这张表判断其是否为新工作区:
    • 是新的 → 创建对应的 WorkArea 对象;
    • 已存在 → 直接跳过(不重建)。
  5. 删除虚拟桌面这一动作(同样由虚拟桌面跟踪器登记)会触发该映射表的对应更新,同时更新 FancyZones 的 JSON 存储(即持久化层,如各显示器/桌面的布局分配记录)。

这套方案的本质是从"面向事件的盲目重建"转向"面向状态的增量同步"——把 EnumDisplayMonitors 的多次异步回调收敛为"与已有状态做差集"的去重操作。

4.2 源码中的呼应:状态表与 JSON 持久化

虽然 DCR 描绘的是目标态设计,当前仓库代码已经从多个侧面印证了该方向的实际落地:

  • 虚拟桌面跟踪器:文档 4.3 所述基于 VirtualDesktopIDs 注册表轮询的实现,对应 FancyZonesLib/VirtualDesktop.cpp。它向 FancyZones 提供虚拟桌面的创建/切换/删除等事件信号,正是"判断 WorkArea 新旧"所需的前提信息源。
  • 工作区与布局绑定关系WorkAreaConfiguration(见 FancyZonesLib/WorkAreaConfiguration.h)负责按 WorkAreaId 关联/更新每个工作区的布局与窗口分配,是"已处理过的显示器集合 + 布局"这一类状态映射在代码层面的承载;WorkAreaId 的类型定义(monitor + virtual desktop 的组合键)见 FancyZonesDataTypes.h
  • JSON 存储层:DCR 中"删除虚拟桌面时同步更新 JSON 存储"所指向的持久化,对应 FancyZonesLib/FancyZonesData 目录下的数据组件,例如 AppliedLayoutsAppliedLayouts.h / AppliedLayouts.cpp,记录"work area → 已应用布局"映射)与 AppZoneHistoryAppZoneHistory.h / AppZoneHistory.cpp,记录"应用窗口 → 上次所在 zone"历史,供窗口恢复吸附位置使用)。这类数据以 JSON 落在用户的 PowerToys 配置目录下,从而保证布局与窗口分配在虚拟桌面/显示器增删、甚至应用重启后依然可恢复。
  • 增量创建的另一佐证:前文提到 WorkArea.hCreate() 接收 parentUniqueId,当某工作区首次出现时可从"父工作区"继承布局。这一机制的成立前提,正是系统能够区分"新 WorkArea"与"既有 WorkArea"——与 DCR 提议的查表判断逻辑一脉相承。

整体上可以推断,DCR 提出的"虚拟桌面 id → 已处理显示器集合"的去重映射,与当前源码中"WorkAreaId(显示器 + 虚拟桌面)作为键、WorkAreaConfiguration/AppliedLayouts 作为状态与持久化容器"的架构在职责划分上是一致的:前者解决运行期对象生命周期的抖动,后者解决布局状态的归属与恢复

五、设计要点的工程启示

从这份 DCR 中,可以提炼出几条对理解 FancyZones(乃至同类多桌面窗口管理工具)有价值的设计原则:

  1. 枚举 API 的语义与去重需求EnumDisplayMonitors 这类"枚举 + 回调"API 天然会为当前所有组合产生通知,回调次数等于"显示器数 × 虚拟桌面数",且可能与真实变化无关。消费方必须自带"幂等/去重"逻辑,不能把"收到回调"等价于"状态真变了"。
  2. 对象生命周期与状态缓存分离:把昂贵的对象(WorkArea 及其隐藏窗口、overlay、布局绑定)生命周期控制在"真正变化时",而把"哪些组合已存在"这样的元状态单独用映射表缓存,能显著减少多显示器/多桌面场景下的重建风暴。
  3. 事件源与持久化的联动:虚拟桌面删除这类事件不仅要更新内存态(映射表、WorkArea 集合),还要同步更新 JSON 持久化(AppliedLayouts 等),否则会出现"内存已清理、配置残留"的不一致。
  4. 为未来功能预留增量基础:文档反复强调此改动"有利于后续多显示器/多虚拟桌面场景的其他修复",说明架构级的对象生命周期治理通常是后续功能(如跨桌面布局继承、窗口状态迁移)的前置条件——WorkArea.hparentUniqueId 所体现的布局继承即是这种前置价值的例证。

六、延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐