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 Awareness:DPI感知,桌面应用软件的一种功能,此功能会抑制系统对软件的缩放,使软件控件尺寸按开发者给定的像素显示。比如软件窗体大小尺寸为400x300,那么就会使用400x300的像素来显示窗体(或许叫DPI抑制更容易理解)。
2,问题
开发配置,IDE是VS2022,框架是.Net Framework 4.7.2。
从缩放100%转到缩放125%,会导致两个问题。
-
一是设计时,在缩放125%的电脑用VS2022打开项目,VS本身一般会自动开启DPI感知,结果就是设计器控件尺寸与缩放100%时相同,但是字体尺寸变大了1.25倍(或者说字体尺寸相同,但是控件尺寸变小了)。
-
二是运行时,在缩放125%的电脑运行项目,控件和字体都会放大1.25倍,并且字体会变得模糊,同分辨率下还会导致屏幕显示不全。
反过来从缩放125%转到缩放100%,也会带来这些问题,只不过表现是反过来的。
注意这两个问题是相对独立的,需要逐个解决。
3,解决
我的目的也是两个。
- 一是设计时,在任意缩放的电脑上都具备相同的开发体验。
- 二是运行时,在任意缩放的电脑上软件都是按开发者给定的像素显示。
3.1 设计时
3.1.1 方法一
设计时就把缩放改回100%,开发完了再改回125%。
在分辨率相差不大时这样做确实简单有效。但如果新电脑的分辨率是2560x1600或者3840x2160呢,这样VS整个界面会变得非常小,根本无法看清,为了方法的通用性,还须继续探索。
3.1.2 方法二
直接点击VS提示的词条 使用100%缩放比例重新启动Visual
。
重启之后,控件尺寸与字体尺寸都变大1.25倍。问题本身倒是解决了,但是带来一个新的问题,1.25倍不是整数,导致像素插值致使VS整个界面都会变得模糊(如果是2倍之类的整数可能不会有这个问题)。我难以接受如此模糊的开发界面,还须继续探索。
3.1.3 方法三
微软的官方文档提到的 <ForceDesignerDPIUnaware>
。
微软可能意识到了设计时缩放带来的问题,在VS2022 17.8版本,以及.Net8框架下,提供了ForceDesignerDPIUnaware关键字,单独对设计器的行为做了修改。

但是我在VS2022遵循文档操作的结果是设计器黑屏,所以无法观察是什么效果,到目前为止我没有找到原因。

再者.Net Framework没有这个功能,为了方法的通用性,还须继续探索。
3.1.4 方法四
在上述探索过程中,我们发现控件的缩放和字体的缩放总是不同步的。
当VS本身开启DPI感知时,实际上是设计器控件自动适应了,但字体并没有。控件尺寸和字体尺寸的区别在哪?这是否就是问题的本质呢?
事实证明猜想是对的,本质就在于控件的单位是pixel,而字体的默认单位是point。
DPI感知只对像素起作用,那么我们需要把所有Font的Unit改成pixel,并且按标准的DPI=96进行换算,1point = 96 / 72 = 1.3333pixel。就能使控件和字体的尺寸具有相同的表现。
使用这个方法,我们至少能得到如下好处:
- 任意缩放下,配合VS本身的DPI感知,在设计器视图都具有在缩放100%时的开发体验。
- 只需要修改代码,不需要修改任何开发环境或系统的设置。
- 在设计器中你给Control.Size或Font.Size设置成多少,那么它就会占用显示器多少个物理像素,与缩放无关。
在我看来,这是一个很棒的方法,至于如何把现有项目中所有Font的Unit改成pixel,下文会提到。
3.2 运行时
在上一章,我们用修改Font.Unit的方法使得控件和字体具有相同的表现,那么运行时就只需要给开发的桌面软件加上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开始,winform就自带了DPI感知,不需要上述操作了。
3.3 全局修改Font.Unit
你需要同时影响设计时和运行时,所以必须让设计器知道你修改了Font才能正确加载设计器视图。
项目很小就手动在设计器里改吧,对于大点的项目,必须要有批量修改方法。
3.3.1 方法一
使用正则表达式,修改所有*.Designer.cs中有关Font的语句。
举个栗子,正则表达式如下:
(.*).Font = new System.Drawing.Font\((.*), (.*)\);
替换为
$1.Font = new System.Drawing.Font($2, $3 * 1.3333F, GraphicsUnit.Pixel);
替换效果如下:
this.label1.Font = new System.Drawing.Font("宋体", 9F);
替换为
this.label1.Font = new System.Drawing.Font("宋体", 9F * 1.3333F, GraphicsUnit.Pixel);
具体的替换表达式要根据现有代码来定,所以非常繁琐。而且此方法有个重大的缺陷,控件从工具箱拖到窗体上时,*.Designer.cs中根本就没有生成Font相关的代码,替换也无从谈起。还须继续探索。
3.3.2 方法二
重载过OnPaint方法的朋友都知道,OnPaint想要在设计器中生效,必须定义一个自定义控件,然后在自定义控件中重载OnPaint方法。而一个winform项目的根控件一般是一个Form,因此定义如下FormFontUnit。
public partial class FormFontUnit : Form
{
public FormFontUnit()
{
InitializeComponent();
}
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
ChangeUnit(this);
}
private void ChangeUnit(Control control)
{
if (control.Font.Unit != GraphicsUnit.Pixel)
{
control.Font = new Font(control.Font.FontFamily, control.Font.Size * 1.333f, unit: GraphicsUnit.Pixel);
if (control is DataGridView)
{
DataGridView dataGridView = control as DataGridView;
dataGridView.DefaultCellStyle.Font =
new Font(dataGridView.DefaultCellStyle.Font.FontFamily, dataGridView.DefaultCellStyle.Font.Size * 1.333f, unit: GraphicsUnit.Pixel);
}
}
foreach (Control c in control.Controls)
{
ChangeUnit(c);
}
}
}
代码很简单,递归找出所有控件的Font重新赋值(注意某些特殊控件比如DataGridView需要进一步处理)。然后让项目的根Form继承FormFontUnit。
public partial class Form1 : FormFontUnit //Form
{
public Form1()
{
InitializeComponent();
}
}
重新编译项目跑行来,没错这就是我要的效果!
至此我们已经很接近大功告成了,但还有个问题。只有继承了FormFontUnit的Form才能在设计器中正确显示字体,因为在设计器视图中,当前控件都是由设计器独立实例化的。那岂不是每个Form都要继承FormFontUnit?原本继承自UserControl的自定义控件也要继承UserControlFontUnit?
emmm,如果不想探索太深入的话,暂时只能这样了,所以我依葫芦画瓢,定义了UserControlFontUnit
public partial class UserControlFontUnit : UserControl
{
public UserControlFontUnit()
{
InitializeComponent();
}
// 其余代码与FormFontUnit一模一样
}
然后让所有Form继承FormFontUnit,所有用户控件继承UserControlFontUnit,虽有瑕疵,也算大功告成了。
嗯,差不多到此为止了。但如果你追求完美且尚有余力的话,也可以继续探索。
3.3.3 更多方法
提几条思路。
-
从设计器本身入手:设计器视图是通过设计器来实现的,大致流程是 VS 内部创建
DesignSurface
,再通过DesignerLoader
加载解析*.Designer.cs
文件,然后通过Activator.CreateInstance
对其中的控件进行实例化,最终调用控件的OnPaint
方法渲染视图。那么能否通过影响实例化的过程达到目的? -
从代码编译入手:OnPaint方法的基类是Control,使用工具在编译时修改Control的IL代码达到目的。可选的工具有动态IL注入工具(如Lib.Harmony),代码织入工具(如Fody,PostSharp),也许后面有机会再继续尝试。