DspFindSeed:从 WPF XAML 到 WebView2 的 GUI 迁移记录
DspFindSeed:从 WPF XAML 到 WebView2 的 GUI 迁移记录
本文记录了将戴森球计划种子搜索工具的界面从传统 WPF XAML 迁移到 WPF + WebView2(HTML/CSS/JS)的完整过程、每一步的设计考量,以及踩过的坑。
背景:为什么要迁移?
DspFindSeed 是一个为《戴森球计划》制作的种子搜索工具,它复刻了游戏的程序化生成算法,允许玩家在游戏外快速搜索符合条件的宇宙种子。
原有界面使用 WPF 的 XAML 布局,一个 18 列 × 23 行的 Grid,所有控件通过 Grid.Row 和 Grid.Column 硬编码定位。这种方式虽然能用,但维护困难、界面自由度低、难以实现现代化视觉效果。我们希望界面更现代、更灵活,同时保持以下约束:
- 保持 .NET Framework 4.8:Windows 10/11 自带运行时,用户双击 exe 直接运行,无需安装任何东西
- 体积尽可能小:不引入 Electron/CefSharp 等重量级框架
- 后端逻辑不动:种子搜索算法(UniverseGen、StarGen、PlanetGen)依赖游戏 DLL,不做重写
技术选型与考量
为什么不升级到 .NET 6+?
| 方案 | 用户体验 | 体积影响 |
|---|---|---|
| .NET 4.8(当前) | Windows 10/11 自带运行时,双击直接运行 | 无额外体积 |
| .NET 6+ 框架依赖 | 用户需要先安装 .NET Runtime | 弹出安装提示,体验差 |
| .NET 6+ 独立部署 | 双击直接运行 | 单个 exe 膨胀到 60-150MB+ |
结论:为了保持"双击即用 + 体积最小"的用户体验,继续使用 .NET Framework 4.8。
为什么选 WebView2?
| 方案 | 额外体积 | 兼容 .NET 4.8 | 界面自由度 |
|---|---|---|---|
| WPF + HandyControl UI 库 | ~几百 KB | ✅ | 中等(仍是 XAML) |
| WPF + WebView2 | ~几百 KB | ✅ | 非常高(HTML/CSS) |
| CefSharp | +150MB+(内嵌 Chromium) | ✅ | 高 |
| Tauri | ~2MB | ❌ 需用 Rust 重写后端 | 高 |
| Electron | +200MB+(Node.js + Chromium) | ❌ 需完全重写 | 高 |
WebView2 的核心优势:
- 零额外体积 — Windows 10/11 已预装 Edge WebView2 Runtime,NuGet 包本身只有几百 KB
- HTML/CSS 自由度 — 可以实现任意界面效果(动画、渐变、圆角、Grid、Flexbox……)
- 后端不动 — 所有 C# 搜索算法逻辑、游戏 DLL 依赖完全保留
- 调试方便 — 修改 HTML 不需要重新编译 C#
整体架构
重构前
1 | ┌──────────────────────────────────────────┐ |
重构后
1 | ┌─────────────────────────────────────────────────┐ |
核心思路:XAML 只保留一个 WebView2 控件作为容器,所有 UI 逻辑交给 HTML/CSS/JS,C# 只负责后端计算和系统交互(文件对话框、线程管理等)。只替换了"View 层",Model 和 Controller 几乎不变。
逐步改动详解
第一步:安装 WebView2 NuGet 包
文件变更:packages.config、DspFindSeed.csproj
在 NuGet 包管理器中安装 Microsoft.Web.WebView2,会自动:
- 在
packages.config中添加:
1 | <package id="Microsoft.Web.WebView2" version="1.0.2792.45" targetFramework="net48" /> |
- 在
.csproj中添加三个 DLL 引用:
1 | <!-- 核心 API --> |
- 在
.csproj末尾导入构建 targets:
1 | <Import Project="..\packages\Microsoft.Web.WebView2.1.0.2792.45\build\Microsoft.Web.WebView2.targets" /> |
背后的考量:
lib/net462/目录的 DLL 兼容 .NET 4.6.2+,当然也兼容 4.8Microsoft.Web.WebView2.targets会在编译后自动复制WebView2Loader.dll(原生加载器)到输出目录,程序运行时通过它连接系统中已安装的 Edge WebView2 Runtime- 虽然我们用的是 WPF,但 WPF 控件内部依赖 WinForms 组件,所以必须三个 DLL 都引用,否则运行时报错
- 项目已有
Newtonsoft.Json依赖,直接复用它来做 JS ↔ C# 的 JSON 序列化
第二步:将 XAML 界面替换为 WebView2 控件
文件变更:DspSearchSeed.xaml
改造前 — 395 行 XAML:
1 | <Window ...> |
改造后 — 12 行 XAML:
1 | <Window x:Class="DspFindSeed.MainWindow" |
背后的考量:
xmlns:wv2声明引入 WebView2 的 WPF 命名空间- 只放一个
WebView2控件,让它自动填满整个窗口 Background="#1a1a2e"和 HTML 的背景色保持一致,避免 WebView2 加载时出现白色闪烁- 窗口尺寸 800×850 适配 HTML 内容的纵向布局
- XAML 从 395 行降到 12 行,所有布局能力转移到 HTML/CSS
第三步:重写 Code-Behind,建立 JS ↔ C# 通信桥梁
文件变更:DspSearchSeed.xaml.cs
这是整个迁移的核心设计。原来 C# 直接通过 x:Name 操作 XAML 控件(如 seedID.Text、resource0.Text),现在改为通过 JSON 消息 双向通信。
通信流程
1 | 用户操作 → JS 收集表单数据 → postMessage({action, data}) → C# |
WebView2 初始化
1 | private async void InitWebView() |
踩坑:EnsureCoreWebView2Async 是异步的,必须 await 后才能访问 CoreWebView2,否则它是 null。
消息分发器(统一入口)
1 | private void OnWebMessageReceived(object sender, CoreWebView2WebMessageReceivedEventArgs e) |
为什么用 Dictionary<string, object> 而不是强类型?
前端发来的消息结构各不相同——有的带 condition,有的带 data,有的只有 action。用 Dictionary 做第一层解析,然后在各个 Handler 里再精确反序列化,更灵活。
为什么不用 AddHostObjectToScript?
AddHostObjectToScript 需要给 C# 对象标注 [ClassInterface] 和 [ComVisible] 属性,调用方式也比较复杂。JSON 消息更简单直观,类似前端 Redux 的 dispatch 模式,而且方便调试——所有通信都是可序列化的 JSON。
线程安全:Dispatcher
搜索在后台线程运行,但 WebView2 的 PostWebMessageAsJson 必须在 UI 线程调用:
1 | private void PostMessage(object data) |
为什么用 BeginInvoke 而不是 Invoke?
Invoke 是同步的——搜索线程会等待 UI 线程处理完才继续。如果 UI 线程正好在等搜索线程的某个锁,就会死锁。BeginInvoke 是异步的,投递消息后立即返回,不会阻塞搜索线程。
消息协议总结
JS → C# 的消息:
| action | 用途 | 携带数据 |
|---|---|---|
init |
页面加载完成,请求初始化数据 | 无 |
startSearch |
开始搜索 | data: { searchType, seedId, onceCount, times, fileName, magCount, bluePlanetCount, oPlanetCount, minStarCount, maxStarCount } |
stopSearch |
终止搜索 | 无 |
addNecessary |
添加必须条件 | condition: { planetCount1, starType, resourceCount, ... } |
addLog |
添加仅记录条件 | 同上 |
deleteCondition |
删除条件 | index, isLog |
selectCondition |
选中条件查看详情 | index, isLog |
importCondition |
导入条件文件 | 无(C# 弹系统文件对话框) |
exportCondition |
导出条件文件 | fileName |
importSeedFile |
导入种子ID CSV | 无(C# 弹系统文件对话框) |
saveConfig |
保存配置到 JSON | data: { onceCount, times, ... } |
C# → JS 的消息:
| action | 用途 | 携带数据 |
|---|---|---|
initData |
返回初始化数据 | config, starCounts, necessaryCount, logCount |
conditionUpdated |
条件列表变化 | necessaryCount, logCount |
conditionData |
返回条件详情 | condition: { ... } |
searchProgress |
搜索进度更新 | curId, time, seeds, lastSeed, progress |
searchComplete |
搜索完成 | message, time, seeds, lastSeed, progress |
searchStopped |
搜索被终止 | curId, curSeeds, lastSeedId |
error |
错误消息 | message |
info |
信息提示 | message |
第四步:创建 HTML/CSS 前端界面
新增文件:web/index.html
整个前端是一个单文件 HTML(~730行),包含内联 CSS 和 JS。
设计语言
- 深色主题:背景
#0f0f23,卡片#1a1a2e,与戴森球计划的太空氛围一致 - CSS 变量:统一管理颜色,方便后续主题切换
1 | :root { |
界面分区(6 个卡片)
- 搜索参数 — 种子ID、单次搜索数、总次数、恒星数范围
- 全局条件 — 磁石总量、蓝巨星数、O恒星数(整个宇宙的独立条件)
- 星系筛选条件 — 卫星数、潮汐锁定、行星总数、光度、初始距离、戴森球包含、重氢速率、星系类型、星球类型(6个下拉框)
- 矿物资源条件 — 13 种矿物数量输入 + 原油/水/硫酸 布尔条件
- 条件管理 — 添加必须条件/仅记录条件、删除、保存配置、条件选择器
- 操作区 — 搜索类型选择、开始/终止、导入/导出、文件名、进度条、日志
布局技巧
矿物网格(CSS Grid 自适应):
1 | .resource-grid { |
auto-fill + minmax 让矿物输入框根据窗口宽度自动排列,比 XAML 的硬编码 Grid 灵活得多。
星球类型网格(固定3列):
1 | .planet-grid { |
Tooltip 纯 CSS 实现(无需 JS 库):
1 | .has-tooltip:hover::after { |
JS 端关键函数
init()— 页面加载时填充星球类型下拉框,请求 C# 发送配置collectCondition()— 从所有表单元素收集一个完整的条件对象applyCondition(c)— 将条件对象回填到表单(用于查看已保存条件)startSearch()— 收集搜索参数,发送给 C#updateProgress(msg)— 更新进度条和日志appendLog(text)— 带时间戳的日志追加
第五步:配置 HTML 文件自动复制到输出目录
在 .csproj 中添加:
1 | <ItemGroup> |
背后的考量:
PreserveNewest表示只在源文件比目标新时才复制,加快增量编译- 编译后
bin/Debug/web/index.html自动就位,Navigate时能找到 - 选择加载本地文件而非
NavigateToString嵌入字符串,是为了:- 调试时可以直接编辑 HTML,不需要重新编译 C#
- 后续可以拆分为多个文件(CSS、JS 分离)
- 路径可控,容易管理
遇到的坑
1. 游戏 DLL 引用路径
项目依赖戴森球计划游戏目录中的 4 个 DLL(Assembly-CSharp.dll、UnityEngine.dll、UnityEngine.CoreModule.dll、UnityEngine.SharedInternalsModule.dll)。原作者用的是深层相对路径 ..\..\..\..\..\..\Program Files (x86)\Steam\...,换一台机器路径不对就编译失败。
解决:修改 .csproj 中的 HintPath 为实际的游戏安装路径。
2. NuGet 包还原
克隆项目后首次编译,需要先右键解决方案 → “还原 NuGet 程序包”,否则 WebView2 和 Newtonsoft.Json 的 DLL 找不到,会报大量 CS0246 错误。
3. WebView2 初始化是异步的
EnsureCoreWebView2Async(null) 必须 await 后才能操作 CoreWebView2,否则它是 null,任何操作都会抛 NullReferenceException。
4. 后台线程不能直接操作 WebView2
搜索线程直接调用 PostWebMessageAsJson 会抛线程访问异常。必须通过 Dispatcher.BeginInvoke 调度到 UI 线程。
5. 窗口背景色闪烁
WebView2 控件加载 HTML 需要短暂时间,这期间如果 WPF 窗口背景是默认的白色,用户会看到一次白色→深色的闪烁。解决方法是把 XAML 的 Background 设为和 HTML 背景一致的 #1a1a2e。
文件结构
1 | DspFindSeed/ |
编译与运行
环境要求
- Visual Studio 2019 或 2022+
- .NET Framework 4.8 Targeting Pack
- 戴森球计划游戏(需要其 Managed DLL)
编译步骤
- 打开
DspFindSeed.sln - 右键解决方案 → “还原 NuGet 程序包”
- 如果游戏 DLL 路径不对,修改
.csproj中的HintPath Ctrl+B编译
运行
编译产物在 bin/Debug/ 目录:
DspFindSeed.exe— 主程序web/index.html— 前端界面(自动复制)WebView2Loader.dll— WebView2 原生加载器(自动复制)
双击 DspFindSeed.exe 即可运行。要求 Windows 10 1803+ 或 Windows 11(已预装 WebView2 Runtime)。
后续可以做的
- 拆分前端文件 — 目前 index.html 是单文件(~730行),可以拆分 CSS 和 JS 为独立文件
- 引入前端框架 — 如 Vue.js(CDN 引入),处理更复杂的状态管理
- 界面热更新 — 因为 HTML 是外部文件,可以在不重新编译 C# 的情况下修改界面
- 搜索结果可视化 — 用 Canvas/SVG 绘制星图,用 Chart.js 展示数据分布
- 深色/浅色主题切换 — 已有 CSS 变量体系,切换只需覆盖变量值
- 多语言支持 — HTML 层面做 i18n 比 XAML 简单得多
总结
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 界面技术 | WPF XAML (395行) | HTML/CSS/JS (730行) + XAML (12行) |
| 布局方式 | 18×23 硬编码 Grid | CSS Flexbox + Grid 自适应 |
| 数据绑定 | x:Name 直接引用控件 | JSON 消息双向通信 |
| 视觉效果 | WPF 原生控件 | 自定义暗色主题、卡片式布局 |
| 后端逻辑 | 不变 | 不变 |
| 额外依赖 | 无 | WebView2 NuGet (~几百KB) |
| 用户体验 | 双击即用 | 双击即用(不变) |