不抢焦点的常驻悬浮窗(C# WinForms)
为什么需要专门做
普通的 TopMost 窗口一 Show() 就会把焦点抢过来。如果你的程序是"在用户正在打字的窗口旁边显示点东西"(实时字幕、上屏预览、状态提示),一旦抢焦点,后续的模拟输入 / 用户输入就会落到悬浮窗自己身上,直接打错地方。
WS_EX_NOACTIVATE 才是正解:窗口可见、置顶,但永远不会成为前台窗口。
模板
skeleton/Program.cs 是一个可直接编译的完整骨架(底部居中的半透明圆角语言条)。核心就这几行:
protected override bool ShowWithoutActivation => true;
protected override CreateParams CreateParams {
get {
CreateParams cp = base.CreateParams;
cp.ExStyle |= 0x08000000; // WS_EX_NOACTIVATE 永不激活
cp.ExStyle |= 0x00000080; // WS_EX_TOOLWINDOW 不出现在任务栏/Alt-Tab
return cp;
}
}
显示的时候不要用 Show() / Activate(),用:
SetWindowPos(Handle, HWND_TOPMOST, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW);
配套:FormBorderStyle=None、TopMost=true、ShowInTaskbar=false、StartPosition=Manual。
常用细节
- 半透明:
Opacity = 0.94。 - 圆角:
Region = new Region(GraphicsPath),四段AddArc(180/270/0/90)+CloseFigure。 - 自动量高:
TextRenderer.MeasureText(text, font, new Size(innerW, 10000), TextFormatFlags.WordBreak),再据此设ClientSize和子控件Bounds;注意在Relayout()里加_laying重入标志,否则ClientSize赋值会触发Resize递归。 - 鼠标交互:
WS_EX_NOACTIVATE下点击仍会送到窗口,所以"按住拖动""点 ✕ 退出"都能用。真要鼠标穿透再加WS_EX_TRANSPARENT(0x20),但那样用户就没法关它了。 - 底部居中定位:
Screen.PrimaryScreen.WorkingArea,Y = wa.Bottom - h - 22;用户拖过之后用_userMoved标志别再自动归位。 - 打包:
dotnet publish -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -p:DebugType=none -o app,单文件约 170KB,目标机要有 .NET 桌面运行时。
验证方法(重要)
沙箱/无桌面环境里截图往往不可用,改用窗口内容自证:
- 让程序把当前显示内容写进
Form.Text(截断到几十字符即可)。 - 用 Python
ctypes调EnumWindows+GetWindowTextW读回来,同时取IsWindowVisible和GetWindowRect—— 一次性验证"显示了什么 / 有没有显示 / 在屏幕哪块"。 - 同时打印推送前后的
GetForegroundWindow标题,证明焦点没被抢(这是这个组件的核心指标,必须单独验)。 - 如果进程是被脚本/Sandbox 拉起的 GUI,"启动 + 验证"要放在同一次命令调用里,很多沙箱会在调用结束时回收子进程。
坑
- csproj 建议加
<Nullable>disable</Nullable>:dotnet new winforms默认开 nullable,骨架里Control c = sender as Control;之类会刷一排 CS8600 警告;不加也能跑,但拿不到 0 警告。 dotnet new winforms默认开 ImplicitUsings,Timer会和System.Threading.Timer撞名(CS0104),写全名System.Windows.Forms.Timer。- WinExe 没有控制台,出问题看不见异常。加一个写文件的
Log(),并挂上
Application.ThreadException/AppDomain.CurrentDomain.UnhandledException/TaskScheduler.UnobservedTaskException三个处理器,不然崩溃是静默的。 async void事件处理器里抛异常会直接终结进程,异步轮询必须整个包在 try/catch 里,连 catch 分支里的 UI 操作也要再包一层。