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感知功能就行了。办法很简单,网上有已经大量文章描述,下面简单说一下步骤。

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

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

  1. 重新编译项目运行。<dpiAware>true</dpiAware>的作用是开启DPI感知。<dpiAwareness>PerMonitorV2</dpiAwareness>的作用是动态DPI感知,即修改了缩放后不用重启电脑。
  2. 从某个高版本.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),也许后面有机会再继续尝试。

posted @ 2025-06-10 11:27  tossorrow  阅读(317)  评论(0)    收藏  举报