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感知功能就行了。办法很简单,网上有已经大量文章描述,简单说一下步骤。
-
右键项目添加app.manifest。

-
在app.manifest中添加如下文字。
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>

- 重新编译项目运行。
<dpiAware>true</dpiAware>的作用是开启DPI感知。<dpiAwareness>PerMonitorV2</dpiAwareness>的作用是动态DPI感知,即修改了缩放后不用重启电脑。 - 从.NET Core 3.0开始,winform就自带了DPI感知,不需要上述操作了。
3.2 设计时
3.2.1 猜测
探索过程中,我们发现控件的缩放和字体的缩放总是不同步的。那么控件尺寸和字体尺寸的区别在哪?这是否就是问题的本质呢?
事实证明猜想是对的,本质就在于控件的单位是pixel,而字体的默认单位是point。


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 与设计时的基准不一致时,自动缩放窗体的控件布局。
-
AutoScaleMode.Font:根据字体大小的比例缩放所有控件,它会与上文point做单位时依赖DPI的膨胀叠加,让问题更加扑朔迷离
-
AutoScaleMode.DPI:根据屏幕显示分辨率缩放所有控件,让问题更加扑朔迷离
-
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。


- 说明在设计时,确实先触发的OnLoad,然后触发LoadComplete。
- 说明LicenseManager.UsageMode只能在LoadComplete触发前正确反应当前模式。
- 说明DesignMode属性整个设计时都能正确反应当前模式。

浙公网安备 33010602011771号