C#应用 - winform跨DPI的缩放一致性

前言

我之前做的winform应用,都是在一台分辨率1920x1080,缩放100%的电脑上开发的。且正好客户的电脑分辨率都小于等于1920x1080,缩放也是100%。直到我获得到一台分辨率1920x1080,缩放125%的新电脑。

好在最终还是找到了一种通用的方法,解决绝大部分情况下由缩放变化导致的问题。

1,概念

简单介绍几个概念

  • DPI:每英寸像素,即显示器用多少个像素来显示1英寸。windows系统中缩放100%相当于DPI=96,用96个像素表示1英寸。缩放125%相当于DPI=120,用120个像素表示1英寸。

  • Point:磅,指 1/72 英寸。缩放100%时,1point = 96 / 72 ≈ 1.3333pixel。

  • DPI Aware:DPI感知,桌面应用软件的一种功能,此功能会抑制系统对软件的缩放,使软件控件尺寸按开发者给定的像素显示。比如软件窗体大小尺寸为400x300,那么就会使用400x300的像素来显示窗体(或许叫DPI抑制更容易理解)。

2,问题

开发配置,IDE是VS2022,框架是.Net Framework 4.7.2。

从缩放100%转到缩放125%,会导致两个问题。

  • 一是运行时,在缩放125%的电脑运行项目,控件和字体都会放大1.25倍,并且字体会变得模糊,同分辨率下还会导致软件页面显示不全。
  • 二是设计时,在缩放125%的电脑用VS2022打开项目,布局显示不一样,更诡异的是有些控件变了,有些控件又没变,设计器控件尺寸与缩放100%时相同,但是字体尺寸变大了1.25倍(或者说字体尺寸相同,但是控件尺寸变小了),问题出在字体上。

反过来从缩放125%转到缩放100%,也会带来这些问题,只不过表现是反过来的。

注意这两个问题是相对独立的,需要逐个解决。

3,解决

我的目的也是两个。

  • 一是运行时,在任意缩放的电脑上软件都是按开发者给定的像素显示。
  • 二是设计时,在任意缩放的电脑上都具备相同的设计器开发体验。

3.1 运行时

运行时就只需要给开发的桌面软件加上DPI感知功能就行了。办法很简单,网上有已经大量文章描述,简单说一下步骤。

  1. 右键项目添加app.manifest。
    image

  2. 在app.manifest中添加如下文字。

<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>

image

  1. 重新编译项目运行。<dpiAware>true</dpiAware>的作用是开启DPI感知。<dpiAwareness>PerMonitorV2</dpiAwareness>的作用是动态DPI感知,即修改了缩放后不用重启电脑。
  2. 从.NET Core 3.0开始,winform就自带了DPI感知,不需要上述操作了。

3.2 设计时

3.2.1 猜测

探索过程中,我们发现控件的缩放和字体的缩放总是不同步的。那么控件尺寸和字体尺寸的区别在哪?这是否就是问题的本质呢?

事实证明猜想是对的,本质就在于控件的单位是pixel,而字体的默认单位是point。
imageimage

3.2.2 分析

  • 普通控件的尺寸为什么没事?

    打开任何一个Form的Designer.cs,你可能会看到这样的代码,this.button1.Size = new Size(100, 30)。
    button1.Size 存的都是像素,是绝对值。96 DPI 下写的 100px,到 120 DPI 下还是 100px。所以控件是稳定的。
    而point做单位时,其占用像素数依赖DPI,像素数 = 磅数 × DPI ÷ 72。同一个9pt,在缩放 125% 的机器上渲染出来放大了 25%。于是所有尺寸由字体决定的控件都膨胀了。

  • 哪些控件的尺寸会出问题?

    比如Label,AutoSize=true时,其宽高随文字计算。
    比如Button/CheckBox的文字区域。
    比如DataGridView的行高(默认跟随字体尺寸)。

  • AutoScaleMode 从中作梗

    Form有AutoScaleMode属性,其VS模板的默认值是AutoScaleMode.Font。当前 DPI 与设计时的基准不一致时,自动缩放窗体的控件布局。

    1. AutoScaleMode.Font:根据字体大小的比例缩放所有控件,它会与上文point做单位时依赖DPI的膨胀叠加,让问题更加扑朔迷离

    2. AutoScaleMode.DPI:根据屏幕显示分辨率缩放所有控件,让问题更加扑朔迷离

    3. AutoScaleMode.None:什么都不缩放,控件按像素值表现

    所以必须把AutoScaleMode设置成AutoScaleMode.None。

3.2.3 办法

清楚了原因,那么就可以转换成3个工程目标了

  • 把项目里所有控件的Font的Unit改成pixel,并且Size按标准的DPI=96进行换算,1point = 96 / 72 ≈ 1.3333pixel。Font 的其他属性保持不变。就能使控件和字体的尺寸具有相同的表现。
  • 把项目里所有容器(Form/UserControl/Panel/GroupBox等)的 AutoScaleMode 统一为 None。
  • 设计时(设计器视图)和运行时都生效,且设计器里所见即所得。

这样我们至少能得到如下好处:

  • 任意缩放下,在设计器视图都具有在缩放100%时的开发体验。
  • 只需要修改代码,不需要修改任何开发环境或系统的设置。
  • 在设计器中你给Control.Size或Font.Size设置成多少,那么它就会占用显示器多少个物理像素,与缩放无关。

在我看来,这是一个很棒的方法,下文重点讲解如何实现。

3.3 工程实现

3.3.1 方法一

使用正则表达式,修改所有 *.Designer.cs 中有关 Font 的语句。

举个栗子,正则表达式如下:

(.*).Font = new System.Drawing.Font\((.*), (.*)\);
替换为
$1.Font = new System.Drawing.Font($2, $3 * 96F / 72F, GraphicsUnit.Pixel);

替换效果如下:

this.label1.Font = new System.Drawing.Font("宋体", 9F);
替换为
this.label1.Font = new System.Drawing.Font("宋体", 9F * 96F / 72F, GraphicsUnit.Pixel);

具体的替换表达式要根据现有代码来定,所以非常繁琐。而且此方法有个重大的缺陷,控件从工具箱拖到窗体上时,*.Designer.cs中根本就没有生成Font相关的代码,替换也无从谈起。还须继续探索。

3.3.2 方法二

给 Form 做一个基类 FormFontUnit ,把转换逻辑放在基类,项目里所有 Form 只要继承它,就自动获得转换能力。

public partial class FormFontUnit : Form
{
    public FormFontUnit()
    {
        InitializeComponent();
    }

    private bool _designHooked;   // 防止 Site 被多次赋值时重复订阅

    // ISite是控件在设计器里的登记对象
    // 设计时,设计器给控件登记时会调用 set
    // 运行时,Site 恒为 null
    public override ISite Site
    {
        get 
        { 
            return base.Site; 
        }
        set
        {
            base.Site = value;
            if (_designHooked || value == null)
            {
                return;
            }
            _designHooked = true;
            FontUnitHelper.HookDesigner(this, value);  // 向设计器订阅 LoadComplete 事件
        }
    }

    // OnLoad在设计时和运行时都会触发
    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);
        // 设计时交给 LoadComplete
        // 运行时交给 OnLoad,控件树已建立,整个控件树 Font 转换
        if (LicenseManager.UsageMode == LicenseUsageMode.Runtime)
        {
            FontUnitHelper.ConvertAll(this);
        }
    }
}

// Font转换工具(pt → px),其他 Font 属性保留
public static class FontUnitHelper
{
    // 换算系数,1 point = 96/72 pixel = 1.33333
    private const float PointToPixel = 96f / 72f;

    // 设计时用,向设计器订阅"加载完成"事件
    public static void HookDesigner(Control root, ISite designSite)
    {
        IDesignerHost host = designSite.GetService(typeof(IDesignerHost)) as IDesignerHost;
        if (host == null)  // 设计器宿主,非null表示处于设计时,null表示处于运行时
        {
            return;
        }
        host.LoadComplete += (s, e) => ConvertAll(root);  // 设计器加载完成时 Font 转换
    }

    // 把 root 及其所有后代(包括 UserControl 内部)的 Font 单位全部换成 pixel
    public static void ConvertAll(Control root)
    {
        // 容器(Form/UserControl/Panel/GroupBox都是ContainerControl)的AutoScaleMode统一为None
        // 必须在改字体之前统一为None,防止字体变化触发布局自动缩放导致控件错位
        if (root is ContainerControl containerControl && containerControl.AutoScaleMode != AutoScaleMode.None)
        {
            containerControl.AutoScaleMode = AutoScaleMode.None;
        }

        // 转换自身字体
        ConvertFont(root);

        // DataGridView 的单元格字体是独立对象,不在 Control.Font 体系里,单独处理
        // 此处只列举了 DataGridView,其他类似控件也需要在此处理
        if (root is DataGridView dataGridView)
        {
            dataGridView.DefaultCellStyle.Font = ConvertFont(dataGridView.DefaultCellStyle.Font);
        }

        // 递归所有子控件
        foreach (Control c in root.Controls)
        {
            ConvertAll(c);
        }
    }

    // Font转换(pt → px)
    public static Font ConvertFont(Font old)
    {
        // 仅当单位是 Point 时换算
        if (old == null || old.Unit != GraphicsUnit.Point)
        {
            return old;
        }
        return new Font(old.FontFamily, old.Size * PointToPixel, old.Style, GraphicsUnit.Pixel, old.GdiCharSet, old.GdiVerticalFont);
    }

    // Font转换(pt → px)
    private static void ConvertFont(Control c)
    {
        Font newFont = ConvertFont(c.Font);
        if (!ReferenceEquals(newFont, c.Font))
        {
            c.Font = newFont;
        }
    }
}

ConvertAll是递归找出所有控件的Font重新赋值(注意某些特殊控件比如DataGridView需要单独处理)。
在设计时通过LoadComplete事件调用ConvertAll,在运行时通过OnLoad方法触发调用ConvertAll。

使用上很简单,让所有Form继承FormFontUnit就完事了。

public partial class Form1 : FormFontUnit  //Form
{
    public Form1()
    {
        InitializeComponent();
    }
}

编译项目跑起来,没错这就是我要的效果!

然后对于UserControl,依葫芦画瓢,定义了UserControlFontUnit

public partial class UserControlFontUnit : UserControl
{
    public UserControlFontUnit()
    {
    	InitializeComponent();
    }
    // 其余代码与FormFontUnit一模一样
}

然后让所有Form继承FormFontUnit,所有UserControl继承UserControlFontUnit,勉强算大功告成了。

3.3.3 FormFontUnit详解

先补充3个基础概念

  • Font是自动继承的:控件没有设置过 Font 时,Designer.cs 里没有给控件 Font 赋值。控件就会当场向父容器要,labe控件 → 没有Font → 问窗体 → 窗体也没有 → 继续问基类。并且窗体 Font 一变,所有没设置过 Font 的子控件立刻跟着变。

  • 设计器的奇葩行为:设计器只实例化基类,然后对基类执行 Designer.cs 中的的语句。比如打开 Form1 的设计器时,VS 不创建 Form1 这个类的实例,而是真实地 new 一个基类 FormFontUnit,然后调用基类的 OnLoad 方法,此时控件树还没有建立。最后把 Form1.Designer.cs 里的语句逐行执行到这个基类实例上,比如 new Label、设置属性、添加控件。而 Form1 自己的任何代码(构造函数、OnLoad)一行都不会运行。

  • VS保存规则:键盘 Ctrl+S 保存时,VS 按内存里对象的当前值整体重新生成 Designer.cs 文件。但有一条省略规则,当前值与属性默认值或继承值相同时不会写到Designer.cs,反之则显式写出。

让Form1继承FormFontUnit,打开Form1的设计器时,设计时完整时间线如下

  • T1:反序列化开始,设计器读取Form1.Designer.cs
  • T2:创建根实例,即new FormFontUnit(),调用构造函数,执行InitializeComponent方法
  • T3:设计器显示FormFontUnit实例,OnLoad触发,此处选择跳过ConvertAll。理由如下,此刻Controls是空的,执行ConvertAll只能改变自己,而且后面有LoadComplete兜底,所以跳过就好
  • T4:执行Form1.Designer.cs的InitializeComponent方法,应用到根实例上,比如AutoScaleMode = AutoScaleMode.Font,控件的Font设置成"宋体 9pt"等
  • T5:根实例构建完成,触发LoadComplete事件,调用ConvertAll(this),重新把AutoScaleMode.Font变成AutoScaleMode.None,Font.Unit=Point 变成Font.Unit=Pixel,设计时达到最终正确状态
  • T6 :按 Ctrl+S 保存文件,重新生成Form1.Designer.cs文件,达到AutoScaleMode.None + Font.Unit=Pixel的目标状态。此处有个小技巧,若 Ctrl+S 是灰的,随便拖动一个控件再拖回原位(把文档标脏)再保存,注意观察保存后Designer.cs文件的具体变化。

然后把exe跑起来,运行时完整时间线如下

  • T1:创建Form1,即 new Form1,执行InitializeComponent
  • T2:窗体显示之前,触发OnLoad,执行ConvertAll(this),对整颗控件树进行AutoScaleMode和Font.Unit的转换

3.3.4 探查ConvertAll的调用

在FormFontUnit的OnLoad方法增加一行MessageBox.Show

protected override void OnLoad(EventArgs e)
{
    base.OnLoad(e);
    MessageBox.Show($"OnLoad {LicenseManager.UsageMode} {(DesignMode ? "DesignMode":"RunMode")}\r\n");  // 新增
    // 设计时交给 LoadComplete
    // 运行时交给 OnLoad,控件树已建立,整个控件树 Font 转换
    if (LicenseManager.UsageMode == LicenseUsageMode.Runtime)
    {
        FontUnitHelper.ConvertAll(this);
    }
}

在FontUnitHelper的ConvertAll方法增加一行MessageBox.Show

public static void ConvertAll(Control root)
{
    MessageBox.Show($"{ LicenseManager.UsageMode} {(root.Site.DesignMode ? "DesignMode":"RunMode")} {root.Name}\r\n");  // 新增
    ...原有代码...
}

然后新建Form1继承FormFontUnit,双击Form1.cs,打开设计器之前就会有弹窗了。先弹出OnLoad,然后弹出FormFontUnit。
imageimage

  • 说明在设计时,确实先触发的OnLoad,然后触发LoadComplete。
  • 说明LicenseManager.UsageMode只能在LoadComplete触发前正确反应当前模式。
  • 说明DesignMode属性整个设计时都能正确反应当前模式。
posted @ 2025-06-10 11:27  tossorrow  阅读(1158)  评论(0)    收藏  举报