自研开源小工具 SwitchDeck:一键切换分辨率/刷新率/音频输出(附 WinForms 高 DPI 踩坑实录)

自研工具 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

这个选型经常被问。我的理由很实际:

  1. 体积与启动速度:自包含单文件压缩后 60MB 左右,冷启动不到一秒。托盘常驻工具,轻量优先。
  2. 需求本质是"系统 API 包装 + 一层皮" :核心逻辑全在 P/Invoke 和 COM,UI 层薄。WinForms 直接 WndProcRegisterHotKeyNotifyIcon 一把梭,没有任何抽象税。
  3. 界面要求的"现代感"可以自己画:这是本文后半段的主角------只要肯自绘,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 插值就完事了。

主题切换的架构:一个静态调色板 + 一个事件

亮暗主题没有用任何框架,就是最朴素的方案:

  • 一个 ThemePalette record 装全部颜色 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,给我最大的感受有两点:

  1. 老技术栈不等于老界面。WinForms + GDI+ 自绘的上限比想象高得多,圆角、主题、动画都不是 WPF/Web 的专利;代价是你要自己理解 DPI、字体度量和 Win32 消息,但这些知识一次搞懂,终身受用。
  2. "顺手的小工具"值得认真做。加上 CI/CD、校验和、多语言、明暗主题之后,自用小工具和"能拿得出手的开源项目"之间,其实只隔着一个周末的距离。

如果你也有多场景切换的需求,欢迎试用和反馈;觉得有用的话给个 Star 就是最大的鼓励:

https://github.com/Tenkkk/SwitchDeck

已知限制(v0.2.0):一个场景暂时只配置一台目标显示器,多台同时配置在 v0.3 的计划里;音频切换依赖的 PolicyConfig 接口非官方公开 API,Windows 大版本更新后可能需要适配。

相关推荐
天糊土1 天前
CentOS 7.6 YUM源更换完整教程
游戏
德迅云安全-上官1 天前
德迅云安全游戏盾与DDoS高防IP对比:差异、优势及选型指南
tcp/ip·游戏·ddos
丁小未1 天前
Unity 几种常见合批手段的要求
游戏·unity·合批·srpbatcher·动态合批·静态合批
REDcker2 天前
显示分辨率标准对照详解
前端·网络·分辨率·显示·屏幕
河南花仙子科技2 天前
花仙子科技详解:小游戏合规开发完整要点
科技·游戏
旧物有情2 天前
游戏开发常用架构 #MVP,MVC
游戏·unity·架构·mvc
笨鸟先飞的橘猫2 天前
游戏后端分布式学习——一致性协议Raft
分布式·学习·游戏
user-猴子2 天前
从零构建 2048 游戏,解析“Python-Use”范式的完整闭环
开发语言·python·游戏
爱自由的代码工2 天前
游戏买量预算怎么分配:核心逻辑、执行步骤与关键指标
游戏·游戏发行·游戏运营·游戏分发