自研工具 SwitchDeck:一键切换分辨率/刷新率/音频输出(附 WinForms 高 DPI 踩坑记录)
一、为什么做这个工具
我的日常使用场景大概是这样的:
- 打游戏:2560×1440 + 165Hz + 耳机
- 看电影:4K + 60Hz + 客厅功放
- 写代码:2K + 60Hz + 扬声器
每次切换都要走一遍:桌面右键 → 显示设置 → 改分辨率 → 高级显示 → 改刷新率 → 右下角喇叭 → 切输出设备。一天来回几次,烦不胜烦。
网上现成的工具要么只管显示不管音频,要么是命令行党专属,要么界面停留在 Win7 时代。于是周末动手写了一个:SwitchDeck------把"分辨率 + 刷新率 + 默认音频输出"打包成"场景",之后一键切换。
仓库:https://github.com/Tenkkk/SwitchDeck
下载:https://github.com/Tenkkk/SwitchDeck/releases (便携版 zip / 安装器)
二、SwitchDeck 能做什么
- 把分辨率、刷新率、默认音频输出保存为场景,三项可任意组合启用
- 四种触发方式:主界面点击、托盘菜单 、全局热键 (如 Ctrl+Alt+1)、桌面快捷方式(双击直接执行并退出,不留后台)
- 可选开机后托盘待命;单实例设计,命令自动转发给已运行的实例
- 简体中文 / 繁體中文 / English 三语,可跟随系统
- v0.2.0 起:暖色系圆角界面,亮 / 暗双主题一键切换,场景切换、热键录制都有动效反馈
- 不需要管理员权限,不联网、无遥测;配置就是一个
%LOCALAPPDATA%\SwitchDeck\settings.json
主界面(亮色主题,左侧场景卡片右端的双色小卡既是选中状态指示,也是一键应用按钮):

暗色主题(右上角 ☀/☾ 一键切换):

新建场景对话框(名称与动作项都有内联校验,创建按钮在校验通过前保持半透明):

技术栈:.NET 8 + WinForms ,发布为自包含单文件 exe。显示切换用 ChangeDisplaySettingsEx(应用前先用 CDS_TEST 试探,驱动拒绝就不动手);音频切换用 Core Audio 的 PolicyConfig COM 接口(未公开但业界广泛使用的那个)。
三、为什么是 WinForms,而不是 WPF / WebView
这个选型经常被问。我的理由很实际:
- 体积与启动速度:自包含单文件压缩后 60MB 左右,冷启动不到一秒。托盘常驻工具,轻量优先。
- 需求本质是"系统 API 包装 + 一层皮" :核心逻辑全在 P/Invoke 和 COM,UI 层薄。WinForms 直接
WndProc、RegisterHotKey、NotifyIcon一把梭,没有任何抽象税。 - 界面要求的"现代感"可以自己画:这是本文后半段的主角------只要肯自绘,WinForms 一样能做出圆角卡片、拨杆开关、主题切换和回弹动画。
v0.2.0 我把整个 UI 按设计稿重写了一遍:自定义无边框窗口、自绘控件库(胶囊按钮、拨杆、下拉、卡片、toast)、亮暗双主题、启动动画。下面是这一路踩过的坑,每一个都是真实调试过程,希望能帮后来者省点时间。
四、开发心得:WinForms 高 DPI 的三个深坑
坑 1:TextRenderer 测量和绘制用的不是同一个 DPI
现象:在 200% 缩放的屏幕上,自绘按钮里的文字全部溢出截断,显示成"刷..."。
排查后发现一个反直觉的事实:在 PerMonitorV2 模式下,TextRenderer.MeasureText 的测量路径会把字体按 96 DPI 参考实现,而 DrawText 的绘制路径按窗口实际 DPI 实现。也就是说测出来的宽度是绘制宽度的一半------按钮按测量值定宽,绘制时自然装不下。
更隐蔽的是,Point 单位的字体在两条路径里的换算方式也不一致。我的解法分两步:
第一步,字体全部改用像素单位并自己按控件 DPI 预缩放,让"设计稿像素"到"设备像素"的映射变成显式的:
csharp
// designPixels 是设计稿里的 px 值
float pixels = designPixels * control.DeviceDpi / 96f;
var font = new Font(family, pixels, style, GraphicsUnit.Pixel);
第二步,给测量结果做校正。为了不硬编码"乘 2",我加了一个自校准探针:拿一个已知字号的字体测一个全角字符,对比 font.GetHeight(96f) 给出的真实像素行高,判断当前运行时的测量基准到底是 96 还是设备 DPI,把系数缓存下来:
csharp
Size probe = TextRenderer.MeasureText("口", font, big, flags);
float trueHeight = font.GetHeight(96f); // 像素字体与 DPI 无关的真实行高
float factor = probe.Height < trueHeight * 0.8f ? dpi / 96f : 1f;
这样无论未来运行时行为怎么变,测量值都能自动对齐绘制值。
坑 2:MeasureText 的 proposedSize 传 Size.Empty + 省略号 flag = 灾难
这个坑和坑 1 叠加,让我一度怀疑人生:无论传多长的字符串,测量结果都是 50px 左右。
原因是我的绘制 flags 里带了 TextFormatFlags.EndEllipsis(超宽时画省略号),测量时复用了同一组 flags,而 proposedSize 传了 Size.Empty------MeasureText 会把它当成"宽度为 0 的盒子",然后忠实地帮你算出省略号版本的尺寸。"刷新设备"和"创建桌面快捷方式"量出来一样宽,因为量的都是"X..."。
教训:测量时把三个省略号 flag 全部剥掉,proposedSize 给 int.MaxValue:
csharp
var measureFlags = flags & ~(TextFormatFlags.EndEllipsis
| TextFormatFlags.PathEllipsis | TextFormatFlags.WordEllipsis);
var size = TextRenderer.MeasureText(text, font,
new Size(int.MaxValue, int.MaxValue), measureFlags);
坑 3:Parking Window------控件还没上树,句柄已经在 96 DPI 的"停车场"里了
WinForms 有个冷知识:没有父容器的控件如果被迫创建句柄(比如你调了 CreateGraphics()),它会被挂到框架内部的 parking window 上,而这个停车窗口的 DPI 上下文可能是 96。
我曾经在 OnHandleCreated 里刷新控件尺寸,想着"句柄创建了 DPI 总该准了吧"------结果恰恰相反:构造阶段测量是对的(继承进程 DPI 192),parking window 一掺和反而把尺寸算成了 96 基准,而且控件后来被挂到真正的窗口上时不会再触发一次 handle 重建,错误尺寸就永远留下了。
教训:不要在布局代码里隐式触发句柄创建;DPI 相关的尺寸刷新交给构造期 + OnDpiChangedAfterParent,别依赖 OnHandleCreated。
五、开发心得:无边框窗口与动画
自定义标题栏,但保留系统的阴影、圆角和拖拽缩放
设计稿要求自绘标题栏(Logo + 主题切换 + 自定义最小化/最大化/关闭)。直接 FormBorderStyle.None 会把系统投影、Win11 圆角、边缘拖拽缩放、窗口贴靠全丢掉。标准做法是"保留边框样式,吃掉非客户区":
csharp
// CreateParams 里保留 WS_THICKFRAME | WS_CAPTION 等样式
case WM_NCCALCSIZE when m.WParam != IntPtr.Zero:
// 返回整个窗口矩形作为客户区(最大化时按系统边框内缩)
m.Result = IntPtr.Zero;
return;
case WM_NCHITTEST:
// base 之后把边缘 8px 改判为 HTLEFT/HTTOP/... 实现拖拽缩放
标题栏拖动则由标题栏面板转发 WM_NCLBUTTONDOWN + HTCAPTION 实现。这样阴影、圆角、Aero Snap 全部白嫖系统的。
顺带一个 z 序冷知识:置顶(TopMost)窗口会盖住自己的非置顶 owned 窗口------模态对话框、通知弹窗都会被亲爹压在底下。如果你的置顶主窗口弹了个"看不见"的对话框,先查这里。
GDI+ 里做"CSS 级"动画:定时器 + 插值 + 变换矩阵
v0.2.0 的场景卡片有个状态图标:两张小卡(结构和 App Logo 一致),选中时绿卡带箭头翻到前面,未选中橙卡在前;切换时两卡互换位置,带轻微过冲的回弹感。设计稿给的缓动是 cubic-bezier(0.45, 1.5, 0.4, 1)。
WinForms 没有动画系统,但"动画"拆开就是三件事:16ms 定时器、属性插值、Graphics 变换。连 cubic-bezier 都可以精确还原------它本质是参数曲线,用牛顿迭代按 x 反解参数 t,再代入 y:
csharp
float t = x;
for (int i = 0; i < 6; i++)
{
float error = Bezier(t, x1, x2) - x; // x 轴分量
float slope = BezierDerivative(t, x1, x2);
t = Math.Clamp(t - error / slope, 0f, 1f);
}
return Bezier(t, y1, y2); // y 轴分量,y1=1.5 产生过冲
绘制端用 TranslateTransform / RotateTransform / ScaleTransform 套变换,两张小卡各自朝目标姿态插值,箭头透明度单独走 0.3s 的时间线。整套下来不到 200 行,60fps 稳定,观感和 CSS transition 没有区别。
启动动画同理:把设计稿里 CSS @keyframes 的关键帧抄成数组,逐段 smoothstep 插值就完事了。

主题切换的架构:一个静态调色板 + 一个事件
亮暗主题没有用任何框架,就是最朴素的方案:
- 一个
ThemePaletterecord 装全部颜色 token(背景、文字、边框、主按钮、悬停色......亮暗各一份) - 静态
ThemeManager.Current+Changed事件 - 所有自绘控件在 Paint 时实时读调色板,主题切换时全树
Invalidate
切换瞬间完成,代码量几乎可以忽略。设计系统给的 OKLCH 色板落到代码里就是一组 ColorTranslator.FromHtml,让"设计 token"和"代码常量"一一对应,改色只动一个文件。
六、发布自动化:推个 tag 就发版
仓库配了两条 GitHub Actions:
- CI (main 分支):
dotnet format校验 +dotnet build --warnaserror,警告零容忍 - Release(v* 标签):自动装 .NET SDK 和 Inno Setup → 打包便携 zip + 安装器 → 对产物跑冒烟测试 → 生成 SHA256 校验和 → 创建 GitHub Release 并自动生成 Release Notes
日常发版就三步:改版本号和 CHANGELOG → 提交推送 → git tag v0.2.0 && git push origin v0.2.0,几分钟后 Release 页面就有成品了。
一个小插曲:v0.2.0 发布当天 Release 工作流绿了、CI 却红了------因为 CI 带 --warnaserror 而打包脚本不带,一条可空性警告(CS8600)只在 CI 里被升级成错误。建议把"质量门槛"和"打包脚本"用同一套编译参数,避免这种一红一绿的精神分裂。
七、写在最后
这个项目从第一行代码到 v0.2.0,给我最大的感受有两点:
- 老技术栈不等于老界面。WinForms + GDI+ 自绘的上限比想象高得多,圆角、主题、动画都不是 WPF/Web 的专利;代价是你要自己理解 DPI、字体度量和 Win32 消息,但这些知识一次搞懂,终身受用。
- "顺手的小工具"值得认真做。加上 CI/CD、校验和、多语言、明暗主题之后,自用小工具和"能拿得出手的开源项目"之间,其实只隔着一个周末的距离。
如果你也有多场景切换的需求,欢迎试用和反馈;觉得有用的话给个 Star 就是最大的鼓励:
已知限制(v0.2.0):一个场景暂时只配置一台目标显示器,多台同时配置在 v0.3 的计划里;音频切换依赖的 PolicyConfig 接口非官方公开 API,Windows 大版本更新后可能需要适配。