针对 Visual Studio 扩展器的按监视器感知支持

Visual Studio 2019 之前的版本将其 DPI 感知上下文设置为系统感知,而不是按监视器 DPI 感知(PMA)。 每当 Visual Studio 必须在不同的缩放系数或远程呈现到具有不同显示配置(例如不同 Windows 缩放)的监视器之间时,系统感知中运行会导致视觉体验下降(例如字体或图标模糊)。

Visual Studio 2019 的 DPI 感知上下文设置为 PMA,当环境支持它时,允许 Visual Studio 根据托管位置的显示(而不是单个系统定义配置)进行呈现。 最终,对于支持 PMA 模式的外围应用,最终转换为一个始终清晰的 UI。

有关 本文档中介绍的术语和整体方案的详细信息,请参阅 Windows 上的高 DPI 桌面应用程序开发文档。

快速入门

  • 确保 Visual Studio 在 PMA 模式下运行(请参阅 启用 PMA

  • 验证扩展在一组常见方案中正常工作(请参阅 测试适用于 PMA 问题的扩展)

  • 如果发现问题,可以使用本文档中讨论的策略/建议来诊断和修复这些问题。 还需要将新的 Microsoft.VisualStudio.DpiAwareness NuGet 包添加到项目以访问所需的 API。

启用 PMA

若要在 Visual Studio 中启用 PMA,需要满足以下要求:

  • Windows 10 2018 年 4 月更新(v1803、RS4)或更高版本
  • .NET Framework 4.8 RTM 或更高版本
  • 启用了“优化不同像素密度屏幕的呈现”选项的 Visual Studio 2019

满足这些要求后,Visual Studio 会自动在整个过程中启用 PMA 模式。

注意

仅当 Visual Studio 2019 版本 16.1 或更高版本时,Visual Studio(例如属性浏览器)中的Windows 窗体内容才支持 PMA。

测试 PMA 问题的扩展

Visual Studio 正式支持 WPF、Windows 窗体、Win32 和 HTML/JS UI 框架。 当 Visual Studio 处于 PMA 模式时,每个 UI 堆栈的行为方式不同。 因此,无论 UI 框架如何,都建议执行测试通行证,以确保所有 UI 都符合 PMA 模式。

建议验证以下常见方案:

  • 在应用程序运行时更改单个监视环境的规模因子。

    此方案有助于测试 UI 是否响应动态 Windows DPI 更改。

  • 将附加监视器设置为主显示器的笔记本电脑停靠/取消停靠,并且附加的监视器在应用程序运行时具有与笔记本电脑不同的缩放系数。

    此方案有助于测试 UI 是否响应显示 DPI 更改,以及处理正在动态添加或删除的显示。

  • 具有多个具有不同比例系数的监视器,并在它们之间移动应用程序。

    此方案有助于测试 UI 是否响应显示 DPI 更改

  • 当本地计算机和远程计算机对主监视器有不同的缩放系数时,远程处理到计算机。

    此方案有助于测试 UI 是否响应动态 Windows DPI 更改。

UI 是否有问题的初步测试是代码是否使用 Microsoft.VisualStudio.Utilities.Dpi.DpiHelperMicrosoft.VisualStudio.PlatformUI.DpiHelperVsUI::CDpiHelper 类。 这些旧的 DpiHelper 类仅支持系统 DPI 感知,并且当进程为 PMA 时,不会始终正常运行。

这些 DpiHelpers 的典型用法如下所示:

Point screenTopRight = logicalBounds.TopRight.LogicalToDeviceUnits();

POINT screenIntTopRight = new POINT
{
    x = (int)screenTopRIght.X,
    y = (int)screenTopRIght.Y
}

// Declared via P/Invoke
IntPtr monitor = MonitorFromPoint(screenIntTopRight, MONITOR_DEFAULTTONEARST);

在上一示例中,表示窗口逻辑边界的矩形将转换为设备单元,以便可以传递给需要设备坐标的本机方法 MonitorFromPoint,以便返回准确的监视器指针。

问题的类

为 Visual Studio 启用 PMA 模式时,UI 可以通过多种常见方式副本 (replica)问题。 大多数(如果不是全部)这些问题都可能发生在任何 Visual Studio 支持的 UI 框架中。 此外,在混合模式 DPI 缩放方案中托管一段 UI(请参阅 Windows 文档 了解详细信息)时,也可能发生这些问题。

Win32 窗口创建

使用 CreateWindow() 或 CreateWindowEx()创建窗口时,常见的模式是创建位于坐标(0,0)(主显示器左上角)的窗口,然后将其移动到其最终位置。 但是,这样做可能会导致窗口触发 DPI 更改的消息或事件,这可能会重新尝试其他 UI 消息或事件,并最终导致意外的行为或呈现。

WPF 元素放置

使用旧版 Microsoft.VisualStudio.Utilities.Dpi.DpiHelper 移动 WPF 元素时,每当元素位于非主 DPI 上时,都可能无法正确计算左上角坐标。

UI 元素大小或位置的序列化

当 UI 大小或位置(如果另存为设备单位)在与存储位置不同的 DPI 上下文中还原时,它的位置和大小不正确。 发生这种情况是因为设备单元具有固有的 DPI 关系。

缩放不正确

在主 DPI 上创建的 UI 元素将正确缩放,但是,当移动到具有不同 DPI 的显示器时,它们不会重新缩放,并且其内容太大或太小。

边界不正确

与缩放问题类似,UI 元素在主要 DPI 上下文上正确计算边界,但是移动到非主 DPI 时,它们不会正确计算新边界。 因此,与宿主 UI 相比,内容窗口太小或太大,这会导致空白或剪辑。

拖放

每当在混合模式 DPI 方案中(例如,以不同 DPI 感知模式呈现的不同 UI 元素)时,拖放坐标可能会被错误计算,导致最终放置位置最终不正确。

进程外 UI

某些 UI 是进程外创建的,如果创建外部进程处于与 Visual Studio 不同的 DPI 感知模式,则这可能会导致以前的任何呈现问题。

Windows 窗体控件、图像或布局呈现不正确

并非所有Windows 窗体内容都支持 PMA 模式。 因此,你可能会看到呈现问题与不正确的布局或缩放。 在这种情况下,可能的解决方法是在“系统感知”DpiAwarenessContext 中显式呈现Windows 窗体内容(请参阅强制控件进入特定的 DpiAwarenessContext)。

Windows 窗体控件或窗口不显示

此问题的主要原因之一是开发人员尝试将具有一个 DpiAwarenessContext 的控件或窗口重新分析为具有不同 DpiAwarenessContext 的窗口。

下图显示了父窗口中当前的 默认 Windows 操作系统限制:

A screenshot of the correct parenting behavior

注意

可以通过设置线程托管行为(请参阅 Dpi_Hosting_Behavior枚举)来更改此行为。

因此,如果在不支持的模式之间设置父子关系,它将失败,并且控件或窗口可能无法按预期呈现。

诊断问题

确定 PMA 相关问题时需要考虑许多因素:

  • UI 或 API 是否要求逻辑或设备值?

    • WPF UI 和 API 通常使用逻辑值(但并非总是)
    • Win32 UI 和 API 通常使用设备值
  • 值来自何处?

    • 如果从其他 UI 或 API 接收值,则会传递设备或逻辑值。
    • 如果从多个源接收值,它们是否都使用/期望相同类型的值,或者转换是否需要混合和匹配?
  • UI 常量是否正在使用,以及它们采用何种形式?

  • 线程是否在正确的 DPI 上下文中接收其值?

    启用混合 DPI 承载的更改通常应该将代码路径置于正确的上下文中,但是,在主消息循环或事件流之外完成的工作可能会在错误的 DPI 上下文中执行。

  • 值是否跨越 DPI 上下文边界?

    拖放是一种常见情况,其中坐标可以跨 DPI 上下文。 窗口尝试执行正确的操作,但在某些情况下,主机 UI 可能需要执行转换工作,以确保匹配上下文边界。

PMA NuGet 包

可以在 Microsoft.VisualStudio.DpiAwareness NuGet 包中找到新的 DpiAwarness 库。

以下工具可帮助在 Visual Studio 支持的一些不同 UI 堆栈中调试与 PMA 相关的问题。

探听

Snoop 是一种 XAML 调试工具,它具有一些内置 Visual Studio XAML 工具没有的额外功能。 此外,Snoop 不需要主动调试 Visual Studio 才能查看和调整其 WPF UI。 Snoop 可用于诊断 PMA 问题的两种主要方法是验证逻辑放置坐标或大小边界,以及验证 UI 具有正确的 DPI。

Visual Studio XAML 工具

与 Snoop 一样,Visual Studio 中的 XAML 工具可以帮助诊断 PMA 问题。 找到可能罪魁祸首后,可以设置断点,并使用实时可视化树窗口以及调试窗口来检查 UI 边界和当前 DPI。

修复 PMA 问题的策略

替换 DpiHelper 调用

在大多数情况下,修复 PMA 模式下的 UI 问题归结为将托管代码中的调用替换为旧 Microsoft.VisualStudio.Utilities.Dpi.DpiHelper 和 Microsoft.VisualStudio.PlatformUI.DpiHelper 类的调用,并调用新的 Microsoft.VisualStudio.Utilities.DpiAwareness 帮助程序类。

// Remove this kind of use:
Point deviceTopLeft = new Point(window.Left, window.Top).LogicalToDeviceUnits();

// Replace with this use:
Point deviceTopLeft = window.LogicalToDevicePoint(new Point(window.Left, window.Top));

对于本机代码,它将需要将对旧 VsUI::CDpiHelper 类的调用替换为对新的 VsUI::CDpiAwareness 类的调用。

// Remove this kind of use:
int cx = VsUI::DpiHelper::LogicalToDeviceUnitsX(m_cxS);
int cy = VsUI::DpiHelper::LogicalToDeviceUnitsY(m_cyS);

// Replace with this use:
int cx = m_cxS;
int cy = m_cyS;
VsUI::CDpiAwareness::LogicalToDeviceUnitsX(m_hwnd, &cx);
VsUI::CDpiAwareness::LogicalToDeviceUnitsY(m_hwnd, &cy);

新的 DpiAwareness 和 CDpiAwareness 类提供与 DpiHelper 类相同的单元转换帮助程序,但需要额外的输入参数:UI 元素用作转换操作的引用。 请务必注意,新的 DpiAwareness/CDpiAwareness 帮助程序中不存在图像缩放帮助程序,如果需要, 应改用 ImageService

托管 DpiAwareness 类为 WPF 视觉对象、Windows 窗体控件和 Win32 HWND 和 HMONITOR(以 IntPtrs 的形式提供),而本机 CDpiAwareness 类提供 HWND 和 HMONITOR 帮助程序。

Windows 窗体错误 DpiAwarenessContext 中显示的对话框、窗口或控件

即使在具有不同 DpiAwarenessContexts(由于 Windows 默认行为)的窗口的成功父级之后,用户仍可能会看到缩放问题,因为具有不同 DpiAwarenessContext 的窗口会以不同的方式缩放。 因此,用户可能会在 UI 上看到对齐/模糊的文本或图像问题。

解决方案是为应用程序中的所有窗口和控件设置正确的 DpiAwarenessContext 范围。

顶级混合模式 (TLMM) 对话框

创建顶级窗口(如模式对话)时,请务必确保在创建窗口(及其句柄)之前线程处于正确的状态。 线程可以通过在本机或托管的 DpiAwareness.EnterDpiScope 帮助程序中使用 CDpiScope 帮助程序进入系统感知。 (通常应在非 WPF 对话/windows 上使用 TLMM。

子级混合模式 (CLMM)

默认情况下,子窗口在不使用父级的情况下创建时接收当前线程 DPI 感知上下文,或者在使用父级创建时接收父级的 DPI 感知上下文。 若要创建具有与其父级不同的 DPI 感知上下文的子级,可将线程放入所需的 DPI 感知上下文中。 然后,可以在没有父级的情况下创建子级,并手动重新父窗口。

CLMM 问题

作为主消息循环或事件链的一部分发生的大多数 UI 计算工作应该已在正确的 DPI 感知上下文中运行。 但是,如果在这些主要工作流之外(例如空闲时间任务期间或 UI 线程外)执行坐标或大小调整计算,则当前 DPI 感知上下文可能不正确,导致 UI 错放或大小调整问题。 将线程置于 UI 工作的正确状态通常会修复此问题。

选择退出 CLMM

如果将非 WPF 工具窗口迁移到完全支持 PMA,则需要选择退出 CLMM。 为此,需要实现一个新的接口:IVsDpiAware。

[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IVsDpiAware
{
    [ComAliasName("Microsoft.VisualStudio.Shell.Interop.VSDPIMode")]
    uint Mode {get;}
}
IVsDpiAware : public IUnknown
{
    public:
        HRRESULT STDMETHODCALLTYPE get_Mode(__RCP__out VSDPIMODE *dwMode);
};

对于托管语言,实现此接口的最佳位置与派生自 Microsoft.VisualStudio.Shell.ToolWindowPane 的类相同。 对于 C++,实现此接口的最佳位置与从 vsshell.h 实现 IVsWindowPane 的类相同。

接口上的 Mode 属性返回的值是__VSDPIMODE(并强制转换为托管中的 uint):

enum __VSDPIMODE
{
    VSDM_Unaware    = 0x01,
    VSDM_System     = 0x02,
    VSDM_PerMonitor = 0x03,
}
  • 不知道意味着工具窗口需要处理 96 DPI,Windows 将处理所有其他 DPIs 的缩放。 导致内容稍微模糊。
  • 系统意味着工具窗口需要处理主显示 DPI 的 DPI。 任何具有匹配 DPI 的显示器看起来都清晰,但如果 DPI 在会话期间不同或更改,Windows 将处理缩放,并且会稍微模糊一些。
  • PerMonitor 意味着工具窗口需要处理所有显示器上的所有 DPIs,每当 DPI 发生更改时。

注意

Visual Studio 仅支持 PerMonitorV2 感知,因此 PerMonitor 枚举值转换为DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2的 Windows 值。

强制控件进入特定的 DpiAwarenessContext

未更新为支持 PMA 模式的旧 UI,在 Visual Studio 在 PMA 模式下运行时,仍可能需要进行细微调整。 一种此类修复涉及确保 UI 在正确的 DpiAwarenessContext 中创建。 若要强制 UI 进入特定的 DpiAwarenessContext,可以使用以下代码输入 DPI 范围:

using (DpiAwareness.EnterDpiScope(DpiAwarenessContext.SystemAware))
{
    Form form = new MyForm();
    form.ShowDialog();
}
void MyClass::ShowDialog()
{
    VsUI::CDpiScope dpiScope(DPI_AWARENESS_CONTEXT_SYSTEM_AWARE);
    HWND hwnd = ::CreateWindow(...);
}

注意

强制 DpiAwarenessContext 仅适用于非 WPF UI 和顶级 WPF 对话框。 创建要托管在工具窗口或设计器内的 WPF UI 时,一旦内容插入 WPF UI 树,它就会转换为当前进程 DpiAwarenessContext。

已知问题

Windows 窗体

若要针对新的混合模式方案进行优化,Windows 窗体更改了在未显式设置控件和窗口时创建控件和窗口的方式。 以前,没有显式父级的控件使用内部“停车窗口”作为正在创建的控件或窗口的临时父级。

在 .NET 4.8 之前,有一个“停车窗口”可从窗口创建时间的当前线程 DPI 感知上下文中获取其 DpiAwarenessContext。 当创建控件的句柄时,任何未父控件都继承与停车窗口相同的 DpiAwarenessContext,并且将由应用程序开发人员重新父级。 如果“停车窗口”的 DpiAwarenessContext 高于最终父窗口,则会导致基于计时的故障。

从 .NET 4.8 开始,现在每个遇到 DpiAwarenessContext 都有一个“停车窗口”。 另一个主要区别是,创建控件时缓存用于控件的 DpiAwarenessContext,而不是在创建句柄时缓存。 这意味着整体结束行为是相同的,但可以将过去用作基于计时的问题变成一致的问题。 它还为应用程序开发人员提供了更确定性的行为,用于编写 UI 代码并正确确定其范围。