键锋 KEYFORGE Qt 版:把 CRT 终端装进桌面应用

• 9 分钟阅读 • code

上个月我做了一个网页版的编程打字练习工具「键锋 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. 状态机:照搬逻辑,而不是照搬代码

打字练习的核心是一个状态机:

统计公式和网页版一字不差:

一个细节值得说: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 手绘的磷光绿

视觉上要还原网页版的全部特效:

这里有个网页版踩过的坑被完整继承:不能在窗口根节点上加 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 的模块,不会复制应用自己的模块。解法是打包脚本里手工补两件事:

  1. 把 build/Keyforge/ 下的 qmldir、Main.qml、Keyforge.qmltypes 复制到 dist/Keyforge/Keyforge/;
  2. 写一个 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 的编码与退出码

纯工程化的小坑,但每个都坑掉过一次流水线:

7. 测试与交付:一条命令验证一切

重构这种"逻辑 1:1 迁移"的项目,最怕迁移过程中悄悄改坏了行为。所以主程序内置了三种模式:

tools/verify.ps1 -Package 一条命令串起全部:配置 → 构建 → 自测 → 冒烟 → windeployqt 打包 → 在清空 Qt 环境变量的"干净环境"里对打包产物再跑一遍自测,验证 DLL 自包含。最后一道是人工验证:打包产物真实启动 5 秒存活、窗口标题正确、stderr 零输出。

最终产物 dist/Keyforge/ 共 1388 个文件:Qt 运行库、平台插件 qwindows.dll、全部 QML 模块、编译器运行库,外加手工补的应用模块目录和 qt.conf。整个目录拷走就能跑,不依赖开发机上的任何东西。

8. 结语

这次重构的体会,浓缩成三条:

  1. 把"逻辑"和"外壳"分清楚。状态机一行不差地搬过来,界面完全重写,两者之间只靠属性和信号通信。这种分层让自测可以脱离界面单独跑 79 项断言。
  2. 桌面应用的交付从打包那一刻才开始。构建通过只完成了七成,windeployqt 的误判、应用模块的部署、qt.conf 的可移植性——每一个都能让"构建成功"变成"双击秒退"。
  3. 静态检查工具说了不算。qmllint 零警告的代码在运行时成片报错,反过来 107 处机械替换就能修好。工具是辅助,验证以实际跑起来为准。

项目源码在 GitHub 上开源:Urtaome0211/KeyForge 的 keyforge-qt/ 目录。如果你也在做 Qt 6 + QML 的桌面应用,希望这几条踩坑记录能帮你省下几个晚上。

阅读进度 0%