先简单回顾一下网页端的实现。详细的技术细节写过两篇文章• 做了一个网页天气可视化• 做了一个网页天气可视化 2第一篇主要解决怎么让天气看起来像那么回事。Canvas 2D 画粒子——雨滴是上窄下宽的梯形风力通过 windOffset 让它斜着落雪花飘落带正弦波摇摆落到导航栏上会堆积温度高了会融化雾天是 Canvas 雾团 CSS 烟雾纹理 backdrop-filter 三层叠出来的晴天有镜头光斑位置跟太阳成镜像关系闪电用递归算法做分叉。数据结构用 SoA Float32Array 做连续内存访问绘制按透明度分档批量 fill 减少 draw call雾气纹理预渲染到离屏 Canvas。接了 Open-Meteo 的 API浏览器定位拿经纬度WMO 天气代码映射到视觉效果每十分钟刷新一次。第二篇是后续更新。声音系统全面重做——雷声用 Web Audio API 合成了七层crack、sub-bass boom、re-strike、rolling rumble、延迟重击、远方余震每层都是独立的音频节点链路预生成多个变体缓存在内存里每次打雷听起来都不完全一样。雨声、风声、雪声也不是 MP3 循环而是多层滤波噪声合成音量和滤波器频率跟天气参数实时联动。风向还会影响左右声道——风从右边吹雨声就偏右耳。新增了冰雹和沙尘暴两种极端天气。冰雹有加速下落、旋转、落地弹跳碎片、地面碎冰堆积和融化。沙尘暴分三层——350 颗不规则多边形沙粒、5 种手绘碎屑树枝、树叶、石子、塑料片、泥块、60 个贴地滚动的沙团——再盖一层全屏沙色蒙版。世界地图让你点地球上任意一个点就能看那里的天气带 24 小时和 7 天预报时间线。沉浸模式把所有 UI 藏起来全屏只剩天气本身。这些东西加在一起网页端已经是一个可以打开、选地方、听天气、挂一整天的天气体验器了。但它始终跑在浏览器里。为什么要做桌面应用原因只有一个我想让它真的变成壁纸。不是看起来像壁纸是嵌到桌面图标后面那一层和 Windows 原生壁纸占同一个位置。桌面图标在上面网页天气在下面任务栏正常显示AltTab 看不到它。开机自动启动托盘常驻不占任务栏位置。这种需求在动态壁纸领域并不新鲜。Wallpaper Engine 早就做了Lively Wallpaper 也是开源方案。但我不需要一个通用的动态壁纸引擎——我只需要把一个特定的网页嵌到桌面上。所以我参考了 Lively 的核心思路用 .NET 9 WPF WinForms WebView2 写了一个极简版本。核心原理WorkerW 窗口层Windows 的桌面壁纸机制其实很有意思。桌面本身是一个叫 ProgmanProgram Manager的窗口桌面图标放在它的子窗口 SHELLDLL_DefView 里的 SysListView32 中。要把自己的窗口塞到壁纸层关键是让 Windows 创建一个 WorkerW 窗口。方法是向 Progman 发送一个未公开的消息0x052CNativeMethods.SendMessageTimeout( _progman, 0x052C, new IntPtr(0xD), new IntPtr(0x1), NativeMethods.SendMessageTimeoutFlags.SMTO_NORMAL, 1000, out _);发完之后Windows 会在 Progman 下面创建一个 WorkerW 窗口。这时候窗口层级变成了Progman ├── SHELLDLL_DefView (桌面图标) └── WorkerW (壁纸层)把你的窗口 SetParent 到 WorkerW 里它就在图标后面了。这就是 Lively 和大多数动态壁纸软件用的方案。但 Windows 11 和较新的 Windows 10 有一个区别——Progman 带了WS_EX_NOREDIRECTIONBITMAP标志这意味着桌面使用了raised desktop模式。这种模式下 WorkerW 是 Progman 的子窗口需要用不同的策略来 attachif (_isRaisedDesktop) { // 设置 WS_CHILD 样式 style | NativeMethods.WS_CHILD; // 设置 WS_EX_LAYERED 并设满不透明度 NativeMethods.SetLayeredWindowAttributes(hwnd, 0, 255, NativeMethods.LWA_ALPHA); // 父窗口设为 Progman NativeMethods.SetParent(hwnd, _progman); // Z 序壁纸在 SHELLDLL_DefView 下面 NativeMethods.SetWindowPos(hwnd, _shellDLL_DefView, 0, 0, 0, 0, flags); } else { // 经典模式直接挂到 WorkerW NativeMethods.SetParent(hwnd, _workerW); }多显示器的情况也要处理。壁纸窗口需要定位到目标显示器的坐标上reparent 之前用MapWindowPoints把屏幕坐标转成父窗口内的相对坐标不然多屏环境下位置会算错。WebView2把网页塞进桌面有了壁纸层下一步是把网页塞进去。这里用的是 Microsoft 的 WebView2——基于 Chromium 的嵌入式浏览器控件。架构上是这样的一个隐藏的 WinForms 窗口WallpaperHostForm里放一个 WebView2 控件WebView2 加载网页然后把整个 WinForms 窗口 SetParent 到桌面的 WorkerW 层。_hostForm new WallpaperHostForm(); _hostForm.Size new System.Drawing.Size((int)monitor.Bounds.Width, (int)monitor.Bounds.Height); _hostForm.Show(); // 初始化 WebView2 await InitializeWebView2(audioEnabled); // 把宿主窗口嵌到桌面 _desktopWorker.SetWallpaper(_hostForm.Handle, monitor.Bounds);WallpaperHostForm 做了几个特殊处理•FormBorderStyle.None无边框•ShowInTaskbar false不在任务栏显示•WS_EX_TOOLWINDOWAltTab 里隐藏•WS_EX_NOACTIVATE不抢焦点•ShowWithoutActivation true显示时不激活• 初始位置(-32000, -32000)先放到屏幕外避免闪烁WebView2 初始化时有一个关键参数——--autoplay-policyno-user-gesture-required。网页天气的声音系统依赖 Web Audio API而浏览器默认的 autoplay policy 要求用户先交互才能播放声音。桌面壁纸没有用户点一下这个动作所以必须绕过这个限制var options new CoreWebView2EnvironmentOptions(--autoplay-policyno-user-gesture-required); var env await CoreWebView2Environment.CreateAsync(null, userDataPath, options); await _webView.EnsureCoreWebView2Async(env);同时还关掉了右键菜单、状态栏、缩放控制、新窗口弹出、下载——这些在壁纸场景里都是不需要的。鼠标事件转发让壁纸可以交互壁纸嵌到桌面之后有一个问题你点桌面鼠标消息会被 SHELLDLL_DefView桌面图标层吃掉WebView2 收不到。但网页天气是有交互的——世界地图可以点控制面板可以拖沉浸模式可以切换。如果壁纸完全不能交互就少了很多有意思的玩法。所以我用了一个全局低级鼠标钩子WH_MOUSE_LL在鼠标消息到达任何窗口之前拦截它判断当前前台窗口是不是桌面Progman、WorkerW 或 SHELLDLL_DefView如果是就把鼠标消息转发给 WebView2_hookId NativeMethods.SetWindowsHookEx( NativeMethods.WH_MOUSE_LL, _hookProc, NativeMethods.GetModuleHandle(null), 0);转发的时候不能简单地 PostMessage 给 WebView2 的顶层窗口——WebView2 内部有多层子窗口Chrome 的渲染进程窗口鼠标消息需要送到最深层的子窗口才能被正确处理。所以用了一个递归查找最深层可见子窗口的方法private static IntPtr GetDeepestChild(IntPtr parent, NativeMethods.POINT screenPt) { var clientPt screenPt; NativeMethods.ScreenToClient(parent, ref clientPt); var child NativeMethods.ChildWindowFromPointEx(parent, clientPt, CWP_SKIPINVISIBLE); if (child IntPtr.Zero || child parent) return parent; return GetDeepestChild(child, screenPt); }目前支持鼠标移动、左键、右键和滚轮。键盘输入没做转发——壁纸场景下基本用不到。应用层面的完整度技术原理之外作为一个真的会拿来用的应用还需要处理很多日常细节系统托盘应用没有主窗口常驻系统托盘。双击打开设置右键菜单支持设置、停止、重启和退出。关闭设置窗口只是隐藏不会退出程序。设置持久化网页地址、目标显示器、声音开关、开机启动这些配置保存在%LOCALAPPDATA%\WeatherWallpaper\settings.json启动时自动读取并恢复上次的壁纸。多显示器支持通过EnumDisplayMonitors枚举所有显示器设置界面可以选择把壁纸显示在哪块屏幕上。开机启动写入注册表HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run标准的 Windows 启动项方案。单实例控制用 Mutex 防止重复启动多开会弹提示。默认网页地址就是https://weather.anhejin.cn——也就是之前做的那个网页天气可视化。打开应用点应用壁纸桌面就变成了实时天气。它会自动获取你的地理位置显示你所在城市的当前天气每十分钟刷新一次。雨天桌面上飘雨雪天看见雪花堆积雾天整个桌面朦胧一片打雷的时候闪电劈下来还有声音。当然因为本质上是嵌入一个网页所以理论上任何网页都能当壁纸用——只是这个项目本身就是为天气可视化设计的。技术栈•.NET 9net9.0-windows10.0.18362.0•WPF主程序入口和设置窗口•WinFormsWebView2 的宿主窗体WebView2 的 WinForms 版本更适合做底层窗口嵌入•Microsoft.Web.WebView2嵌入式 Chromium 浏览器•Win32 APIWorkerW/Progman 操作、显示器枚举、鼠标钩子、输入转发•Newtonsoft.Json设置文件读写整个项目代码量不大核心逻辑集中在三个文件•DesktopWorker.cs桌面层管理处理 WorkerW/Progman 窗口层级和 raised desktop 兼容•InputForwarder.cs全局鼠标钩子和消息转发•WallpaperEngine.csWebView2 初始化、壁纸启停、总调度