键锋 KEYFORGE Qt 版:把 CRT 终端装进桌面应用
上个月我做了一个网页版的编程打字练习工具「键锋 KEYFORGE」——一台画在浏览器里的 CRT 荧光终端:输入真实代码片段,实时统计准确率和速度,键盘按指法分色提示,完成评级 S~D。这一篇讲的是它的桌面版:用 Qt 6 完整重写,保留全部功能和视觉,最后用 windeployqt 打包成一个可以直接双击运行、拷贝到任何 Windows 机器都能跑的独立目录。
1. 为什么重写:从 <script> 到桌面
网页版零依赖、双击就能开,但它终究住在浏览器里:全局快捷键会被浏览器截胡,窗口外观受制于页面容器,本地存储只能躲在 localStorage 里。桌面版可以拥有真正的窗口、真正的系统托盘按键处理,数据存在 QSettings 里,还能完整打包成"绿色软件"分发给别人。
重写之前,先做了减法:核心逻辑照搬。打字状态机、统计公式、评级阈值、键位数据、32 段练习代码——这些在网页版里已经打磨过了,重构的原则是"逻辑 1:1 迁移,只换外壳"。真正重写的是三层:状态机从 JavaScript 到 C++,界面从 DOM 到 QML,音效从 WebAudio 到 QAudioSink。
2. 架构:C++ 引擎 × QML 皮肤
Qt 桌面应用最常见的问题是 UI 和逻辑搅在一起。这次刻意做成了严格的"引擎—皮肤"两层:
┌─────────────────────────────┐
│ qml/Main.qml(约 1230 行) │ ← 皮肤:纯声明式,只管画
│ 主题色、CRT 特效、控件样式 │
└──────────────┬──────────────┘
│ Q_PROPERTY / NOTIFY 信号(单向数据流)
┌──────────────┴──────────────┐
│ TypingEngine(C++20) │ ← 引擎:只管算,不知道界面长什么样
│ 状态机、统计、评级、HTML 渲染 │
│ SoundEngine QAudioSink 合成│
│ SettingsStore QSettings 持久化│
└─────────────────────────────┘
引擎以单例形式注册进 QML:
qmlRegisterSingletonInstance("Keyforge", 1, 0, "Engine", &engine);
QML 里写 Engine.wpm、Engine.currentChar 就能拿到实时状态——引擎改一个 Q_PROPERTY,界面上所有绑定自动刷新。界面把按键交给引擎的方式则相反:用一个应用级事件过滤器,把 QKeyEvent 喂给引擎的 sendKey(),引擎算出结果,再通过属性变化通知界面。两条通道都是单向的,没有互相调用,这也是整个项目里最省心的部分。
数据文件也走"生成器"路线:tools/gen-keyboard.js 从网页版 app.js 里解析键位表生成 keyboarddata.cpp,tools/gen-snippets.js 把 32 段练习代码转成 C++ 的 raw string 字面量。改练习内容只改网页版,跑一遍生成器,两边永远同步。
3. 状态机:照搬逻辑,而不是照搬代码
打字练习的核心是一个状态机:
typed:已经正确输入的前缀;wrong:打错待修正的字符串;- 只要
wrong非空,任何输入都计一次错误,退格键先删wrong,清空之后才开始删typed; Enter对应代码里的换行\n、Tab对应\t,按中时不计错误。
统计公式和网页版一字不差:
- 准确率 =
(keystrokes − errors) / keystrokes × 100; - WPM =
typedLength / 5 / 分钟,CPM =typedLength / 分钟; - 评级:S 要求准确率 ≥98 且 WPM ≥45,A ≥95 且 ≥35,B ≥90 且 ≥25,C ≥85,其余 D。
一个细节值得说:restart() 在网页版里依赖"当前分类"来判断重开什么,重构时发现这个隐式依赖在自测里会咬人(自定义练习后按 Enter 无法重开)。C++ 版把它改成按 currentSnippetId 判空——id 在就重载该片段,id 空但有自定义文本就重载自定义。状态机的依赖越显式,越容易测试。
4. 键盘与音效:数据驱动与程序内合成
键盘是 58 个键、5 行,每个键定义成一条数据:
struct KeyDef {
QString cap; // 显示字符
QString shift; // Shift 上档字符
QString label; // 特殊键标签(Tab/Shift/退格…)
QString title; // 悬停提示
int finger, hand; // 手指(0-4)与左右手
QString code; // QKeyEvent 的 code 名
double w; // 键宽权重(每行总和 15)
};
QML 里两层 Repeater 把数据铺成键盘,键宽 = w × 行宽 / 15。五指五色(小指粉、无名指蓝、中指橙、食指绿、拇指白),下一键辉光呼吸高亮,按中下沉 2px,Shift 按住时整排联动显示上档字符。整个键盘没有一行手写坐标,全是数据。
音效也没有任何音频文件。SoundEngine 在内存里合成 WAV 数据,交给 QAudioSink 播放:按键是方波,音高随输入进度逐级爬升(每 11 次提升一档);打错是 120Hz 锯齿波;完成是一段琶音。AudioContext 在网页版里可以惰性创建,这里同理——QAudioSink 在第一次实际发声前不初始化,音频设备失败就静默降级,不影响打字。
5. CRT 特效:Canvas 手绘的磷光绿
视觉上要还原网页版的全部特效:
- 扫描线:一个 Canvas 每两行画一条半透明暗线,隔几秒随机抖一下模拟老显像管闪烁;
- 暗角:另一个 Canvas 用
createRadialGradient从中心到四角渐变压暗; - 开机爆闪:启动时一个覆盖层 0.7 秒内从白闪到绿再淡出,模拟显像管通电;
- 辉光:关键元素用多层半透明同色叠出光晕。
这里有个网页版踩过的坑被完整继承:不能在窗口根节点上加 CSS filter 那样的全局特效(QML 里对应 opacity/effect 动画),否则它会创建包含块,把弹出层的定位坐标系整个劫持掉。所以特效层都是独立 Item,按 z 序叠放:内容 < 暗角(60) < 报告(90) < 帮助(100) < 扫描线(120) < 开机(200),装饰层一律 enabled: false 不挡鼠标。
6. 踩坑实录:这几个坑值得单独记录
6.1 编译期 QML 的"隐形"作用域问题
最大的坑,也是最有意思的一个。smoke 测试一跑,stderr 里涌出成片的 ReferenceError: cGreen is not defined——但 qmllint 检查零警告,报错行都是主题常量引用,常量明明定义在顶层 Item 上。
原因是:qmllint 静态解析能顺着作用域链找到外层 Item 的属性,但 Qt 6.12 的编译期 QML(qmlcachegen 产物)在内联组件里只解析 id 限定引用,解析不到外层对象的裸引用。也就是说:color: cPanel 报错,color: root.cPanel 正常——root 是外层 Item 的 id。
验证方法很土但有效:把一处改成 root.cPanel 重建,该行报错消失,旁边留一处裸引用作对照,它照旧报错。确认后写了个脚本批量处理:跳过声明行和注释行,把 107 处主题引用全部加上 root. 前缀。教训:qmllint 干净不等于运行时干净,编译期 QML 有自己的作用域规则。
6.2 windeployqt 把 release 库误判成 debug
打包时报 Unable to find the platform plugin.,一个文件都没部署。开 verbose 才发现:kit 里所有 DLL 都被判成 “debug” 版本——llvm-mingw 工具链的产物让 windeployqt 的 PE 解析器误判了。加 --release 参数反而把所有含平台插件在内的 DLL 全过滤掉。解法是去掉 --release,让默认模式部署。
6.3 应用自己的 QML 模块不会被打包
打包完双击,秒退。stderr 里是 Module "Keyforge" contains no type named "Main"。排查发现:开发机上的 exe 一直靠构建目录里的 build/Keyforge/qmldir 文件系统模块兜底(Qt 的模块导入路径包含 exe 所在目录),而不是靠内嵌资源;windeployqt 只部署 Qt 的模块,不会复制应用自己的模块。解法是打包脚本里手工补两件事:
- 把
build/Keyforge/下的qmldir、Main.qml、Keyforge.qmltypes复制到dist/Keyforge/Keyforge/; - 写一个
qt.conf([Paths] Prefix=.),让插件和 QML 库路径全部相对 dist 目录解析——否则 exe 会用编译期内嵌的D:\Qt\...绝对前缀,换个机器就跑不起来。
6.4 QVariantList 的拼接重载
自测里键盘行数断言失败:keyRows().size() 返回 58 而不是 5。排查半天,真凶是 QVariantList::append(同类列表) 命中了 QList 的拼接重载,把 5 行键盘摊平成了 58 个元素。改成 push_back(QVariant::fromValue(rowList)) 才让每行作为一个整体装进去。这类"看起来没问题、行为偷偷不同"的重载,是 C++ 迁移里最防不胜防的。
6.5 PowerShell 5.1 的编码与退出码
纯工程化的小坑,但每个都坑掉过一次流水线:
- 本机 PowerShell 5.1 读无 BOM 的 UTF-8 脚本按 GBK 解码,脚本里的中文注释全变乱码,直接 ParserError。以后每次编辑
.ps1都必须确认 BOM 还在。 Start-Process -PassThru拿到的进程对象,ExitCode在 PS 5.1 下恒为空(Refresh()也没用)。流水线判断成败改用输出标记行(SELFTEST OK/SMOKE OK)+ 超时,不再依赖退出码。
7. 测试与交付:一条命令验证一切
重构这种"逻辑 1:1 迁移"的项目,最怕迁移过程中悄悄改坏了行为。所以主程序内置了三种模式:
Keyforge.exe --selftest:跑 79 项核心逻辑自测(状态机、退格顺序、统计公式、评级阈值、纪录覆盖规则、自定义文本归一化、设置恢复),全过输出SELFTEST OK;Keyforge.exe --smoke:无头模式(offscreen 平台)加载 QML,2.5 秒自动退出,输出SMOKE OK——专门用来抓 QML 运行期错误;- 不带参数:正常启动。
tools/verify.ps1 -Package 一条命令串起全部:配置 → 构建 → 自测 → 冒烟 → windeployqt 打包 → 在清空 Qt 环境变量的"干净环境"里对打包产物再跑一遍自测,验证 DLL 自包含。最后一道是人工验证:打包产物真实启动 5 秒存活、窗口标题正确、stderr 零输出。
最终产物 dist/Keyforge/ 共 1388 个文件:Qt 运行库、平台插件 qwindows.dll、全部 QML 模块、编译器运行库,外加手工补的应用模块目录和 qt.conf。整个目录拷走就能跑,不依赖开发机上的任何东西。
8. 结语
这次重构的体会,浓缩成三条:
- 把"逻辑"和"外壳"分清楚。状态机一行不差地搬过来,界面完全重写,两者之间只靠属性和信号通信。这种分层让自测可以脱离界面单独跑 79 项断言。
- 桌面应用的交付从打包那一刻才开始。构建通过只完成了七成,windeployqt 的误判、应用模块的部署、qt.conf 的可移植性——每一个都能让"构建成功"变成"双击秒退"。
- 静态检查工具说了不算。qmllint 零警告的代码在运行时成片报错,反过来 107 处机械替换就能修好。工具是辅助,验证以实际跑起来为准。
项目源码在 GitHub 上开源:Urtaome0211/KeyForge 的 keyforge-qt/ 目录。如果你也在做 Qt 6 + QML 的桌面应用,希望这几条踩坑记录能帮你省下几个晚上。