← 返回笔记列表

PerMonitorV2 之后才是开始:跨屏 DPI 与 WinForms 暗色标题栏

发布于 来自 Kite

  • 工程笔记
  • Panda Port Manager
  • CSharp
  • WinForms

PerMonitorV2 只是「拿到通知」

在 manifest 里声明 PerMonitorV2 之后,窗口拖到另一块不同 DPI 的显示器上会收到 DpiChanged 事件。 但框架已经先缩放过一轮了,你在事件里再按比例改一次,就变成乘两次 —— 表现是按钮越来越大、文字被裁切,字号一路 10 → 5 → 2.5。

解法只有一条:所有固定尺寸以 96 DPI 为基准,每次都做绝对重算,绝不在当前尺寸上继续乘比例。

// 关键:保持窗口逻辑尺寸不变(旧物理 / 旧DPI × 新DPI)
int wantW = (int)Math.Round(physOld.Width  * dpiNew / (float)dpiOld);
int wantH = (int)Math.Round(physOld.Height * dpiNew / (float)dpiOld);
if (dpiOld > 0 && (Math.Abs(Width - wantW) > 2 || Math.Abs(Height - wantH) > 2))
    Size = new Size(wantW, wantH);

// 注意顺序:必须先更新按钮 MinimumSize,再重设字体 ——
// 字体变化会触发 AutoSize 按钮按「新字体 + 新最小宽度」重新布局
float f = dpiNew / 96f;
ScaleToolbarFor(f);

几个踩过才知道的细节:

  • 字体保持 10pt 不动:pt 本身是物理单位,不该随 DPI 事件被再次修改;
  • DpiChanged 里用 BeginInvoke,等框架自己那一轮处理完再覆盖;
  • 再挂一个 FontChanged 兜底,把被错误缩放的字号钳回基准值;
  • 按钮要留 4 像素额外高度,否则微软雅黑的行框加边框会被切掉;
  • 列宽基准也是 96 DPI 下的一套固定值,每次重算。

暗色标题栏不是改 BackColor

WinForms 的暗色要同时处理三层,少一层就会留下明显的白块:

int on = DarkTitle ? 1 : 0;
DwmSetWindowAttribute(Handle, DWMWA_USE_IMMERSIVE_DARK_MODE, ref on, sizeof(int));
DwmSetWindowAttribute(Handle, DWMWA_USE_IMMERSIVE_DARK_MODE_ALT, ref on, sizeof(int));

if (DarkTitle) {
    int caption = ColorTranslator.ToWin32(Color.FromArgb(0x1E, 0x1E, 0x1E));
    int text    = ColorTranslator.ToWin32(Color.FromArgb(0xE0, 0xE0, 0xE0));
    DwmSetWindowAttribute(Handle, DWMWA_CAPTION_COLOR, ref caption, sizeof(int));
    DwmSetWindowAttribute(Handle, DWMWA_TEXT_COLOR,    ref text,    sizeof(int));
}
属性 值 说明
DWMWA_USE_IMMERSIVE_DARK_MODE 20 Windows 10 后期 / 11
早期 Windows 10 的兼容值 19 老版本只认这个
DWMWA_CAPTION_COLOR 35 Windows 11 才有效
DWMWA_TEXT_COLOR 36 Windows 11 才有效

两个坑:句柄创建时应用一次之后,窗口显示后还要再应用一次(DWM 初始化会覆盖第一次设置); ListView 表头与右键菜单是自绘的,ToolStripProfessionalRenderer 默认会忽略菜单项的 ForeColor, 必须重写 OnRenderItemText() 才能把文字颜色改掉。

高频刷新列表的三个细节

这个界面每 3 秒重建一次 ListView:

  1. 双缓冲:DoubleBuffered = true,否则闪烁到没法看;
  2. BeginUpdate() / EndUpdate() 包住整个重建过程;
  3. 数值排序:端口与 PID 按字符串排会出现 10 在 9 前面,看起来就像程序坏了。
/// <summary>列排序比较器:端口、远程端口、PID 三列按数值比较,其余按忽略大小写的字符串比较。</summary>
private sealed class ListViewItemComparer(int column, bool asc) : System.Collections.IComparer {
    public int Compare(object? x, object? y) {
        if (x is not ListViewItem a || y is not ListViewItem b) return 0;
        if (column is 2 or 4 or 6) {
            int.TryParse(a.SubItems[column].Text, out int na);
            int.TryParse(b.SubItems[column].Text, out int nb);
            return asc ? na.CompareTo(nb) : nb.CompareTo(na);
        }
        // ...
    }
}

顺带一提:ListViewItem 不能直接隐藏,所以「筛选」的实现是把全量条目留在 _allItems, 每次筛选清空可见列表再重新加入匹配项 —— 看起来笨,但在 WinForms 里这是常规做法。