PerMonitorV2 之后才是开始:跨屏 DPI 与 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:
- 双缓冲:
DoubleBuffered = true,否则闪烁到没法看; BeginUpdate()/EndUpdate()包住整个重建过程;- 数值排序:端口与 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 里这是常规做法。