原文项目:钟毓英语衡水体字帖生成器
重构前后:PyQt6 v1.1.1 → Electron v2.0.x(跨平台 Windows / macOS / Linux)
关键词:electron-vite、Canvas 2D、tesseract.js OCR、electron-builder、pickle 兼容、Bottles 交叉打包
前言
笔者手上有一款用 PyQt6 开发的英语衡水体字帖生成器,功能挺全乎:四种生成模式(描红/抄写/描红+抄写/字帖)、两种线格、多标签页、PDF 导出,还有个自定义的 .zyecb 工程文件格式。原版在 Windows 上跑得好好的,可架不住用户问"Linux 有吗"、“mac 能装吗”——Python 桌面应用的分发短板一下就暴露了:PyInstaller 体积大、跨平台得各开一台机器、系统库依赖分分钟给你整出兼容玄学。
得,那就用 Electron 彻底重构吧。本文不整"为什么选 Electron"这种正确的废话,直接上干货:构建思路怎么定的、关键决策怎么做的、以及踩过的那些真实坑和最终怎么爬出来的。正在折腾桌面应用重构的朋友,希望能帮你少走点弯路。
一、整体架构与技术选型
1.1 技术栈
|
层面 |
选型 |
说明 |
|---|---|---|
|
构建工具 |
electron-vite 2 + Vite 5 |
主/预加载/渲染进程统一构建,HMR 香得很 |
|
运行时 |
Electron 33.4.11 |
直接上 33,别问,问就是被 Node 20.14 坑过(见第六节坑 4) |
|
渲染 |
原生 JavaScript + Canvas 2D |
无框架,直接复刻 QPainter 自绘逻辑 |
|
打包 |
electron-builder 24.13.3 |
NSIS / AppImage / deb / rpm / dmg / zip 一网打尽 |
|
OCR |
tesseract.js v7 |
截图识别,语言数据内置,离线可用 |
1.2 目录与多入口设计
原版就是个单窗口 QMainWindow 套 QTabWidget。到了 Electron 这边,我把"全屏自绘"的场景拆成了独立 HTML 入口,各司其职:
src/
├── main/ 主进程:IPC、窗口、菜单、打印、OCR
├── preload/ contextBridge 桥
└── renderer/
├── index.html 主界面(标签页 + 控件 + 预览 Canvas)
├── print.html 隐藏窗口:渲染 A4 页面供 PDF 导出 / 打印
├── sel.html 截图选区窗口(每显示器一个,全屏无边框)
└── preview.html 打印预览窗口
四个入口在 electron.vite.config.mjs 的 rollupOptions.input 里注册。这种"按全屏场景拆入口"的设计,比在单个 BrowserWindow 里切视图清爽多了——打印和预览窗口本来就不需要主界面那一堆控件。
1.3 逐像素对齐原版:别瞎优化
重构桌面应用,最忌讳的就是"顺手优化一下",结果用户一打开感觉"这味儿不对啊"。我的策略很简单:页面坐标系、字号、颜色、行高全部一比一复刻。
-
页面尺寸 800×1131px,边距 (20, 20, 760, 1091),头部高 100;
-
四线三格行高 40、组距 40(线位 y+0/13/26/39),单横线行高 30、组距 0;
-
QFont 的 pt 字号按 96 DPI 换算:px = pt × 4/3;
-
描红色
#ff6464、网格线#c0c0c0、页码#a0a0a0。
排版引擎集中在 src/renderer/src/engine/copybook.js 的 buildPages,一次排版分页。顺带还修了原版一个潜伏 bug:单横线模式下原版按四线三格行高估算页容量,跨页时单词会重复出现,重构版按实际行高算就好了——算是重构附赠的彩蛋。
二、工程文件双向兼容:pickle 这个老顽童
原版的 .zyecb 工程文件是 Python pickle(Py3 默认协议 4)。老用户迁移是刚需,新版必须能读;要让用户在新旧版本间自由切换,新版存的文件原版最好也能打开。
2.1 读取:手写协议 0~4 子集解析器
pickle 本质是个基于栈的虚拟机字节码。我吭哧吭哧手写了一个解析器,把常用 opcode 都覆盖了:PROTO、STOP、MARK、EMPTY_LIST/DICT/TUPLE、APPEND、SETITEM、BINUNICODE、SHORT_BINUNICODE、GLOBAL、REDUCE 等等,协议 4 的 FRAME、MEMOIZE 也没落下。这活儿不复杂但碎,建议边对照 pickletools.dis() 的输出边写,事半功倍。
2.2 写入:用协议 0,但小心 \uXXXX
输出我选了 协议 0(纯 ASCII),任何 Python 版本都能读,出问题了肉眼也能 debug。
这里有个能把人逼疯的坑:协议 0 里字符串 opcode V 后面跟一行以 \n 结尾的 raw-unicode-escape 字符串。Python 的 raw-unicode-escape 只认 \uXXXX 形式的转义,不认 \n、\\ 这种简写。所以字符串里的换行、反斜杠、控制符必须全部编码成 \u000a、\u005c 等形式,否则原版 pickle.load 轻则 UnicodeDecodeError,重则读到一堆乱码。
2.3 回归测试
双向跑一遍:
-
原版保存一批典型工程(特殊字符、空内容、长文本都来点)→ 新版打开;
-
新版保存 → 原版打开;
-
断言渲染结果一字不差。
最终实现了真正的双向兼容,用户双击 .zyecb 就能用新版打开(通过 electron-builder 的 fileAssociations 注册文件关联)。
三、打印与打印预览:一条管线走天下
原版"导出 PDF"和"打印"走的是 QPrinter。到了 Electron,我把这两条路合并成一条渲染管线,再额外加了个"打印预览"窗口——毕竟都 2026 年了,没预览的打印是不完整的。
3.1 三路复用的渲染窗口
主进程 ipc.js 抽出 createRenderWindow(data):
-
建一个隐藏的 BrowserWindow,加载
print.html; -
IPC 把排版数据塞过去,渲染进程用 Canvas 2D 按 A4 尺寸逐页画;
-
画完了 IPC 回报,主进程按 sender id 过滤(多窗口串消息这种事,防一手),设个 30s 超时兜底;
-
根据调用场景分流:
-
pdf:export→webContents.printToPDF; -
print:direct→webContents.print弹系统打印对话框; -
print:preview→ 复用单例预览窗口显示。
-
打印参数统一为 { printBackground: true, pageSize: 'A4', margins: { marginType: 'none' } }。划重点:新版 Electron 用 margins 对象,旧的 marginsType 已经被打入冷宫。
3.2 高清渲染:2 倍 DPR
A4 页面按 2 倍 DPR(192 DPI) 绘制(794×1123 @96dpi 的 2 倍),打出来的字边缘那叫一个锐利。打印和 PDF 共用同一份 Canvas 数据,效果完全一致,用户再也不会说"PDF 看着挺好,打出来糊了"。
3.3 打印预览窗口的那些小细节
-
单例复用:重复打开就 focus + 重渲染,别傻乎乎每次新建窗口;
-
渲染完再 show():不然用户看到白屏闪烁,体验分骤降;
-
setMenu(null):非 macOS 下附加窗口默认带应用菜单栏,必须手动清掉,不然预览窗口顶个文件编辑帮助菜单怪尴尬的; -
CSS
zoom缩放:别用transform: scale,zoom 是参与 Chromium 布局计算的,滚动条和页码定位才对得上; -
"适应页宽"算法:
zoom = (clientWidth - 两侧留白) / 794,794 是 A4 宽 @96dpi 的像素值; -
页码跟随滚动:用
getBoundingClientRect找"最后一个 top ≤ 视口 35% 的页"当当前页。别问我为什么知道——初版用current++写了个差一 bug,翻第一页显示"第 2/2 页",当场社死。
3.4 Linux 下验证的小坑
在 deepin 上做自动化验证时发现一个有意思的现象:GTK 打印对话框打开期间,父窗口渲染进程的 JS 被模态阻塞了,CDP Runtime.evaluate 直接超时,对话框一关立马恢复。所以测试时系统对话框那块(选打印机、点确认)得交给 xdotool,应用内交互(点预览按钮、拖缩放)才能用 CDP。
另外 xdotool 在 GTK 保存对话框里 type 路径会被输入法劫持,斜杠还会被吃掉,解决方案是测试导出时先接受默认文件名,事后再改回去。
四、截图识别:desktopCapturer + tesseract.js
光有手动输入哪够,必须上个截图识别——看到屏幕上的英文直接框一下就进字帖,多香。
4.1 完整流程走一遍
-
主窗口先藏起来;
-
desktopCapturer.getSources({ types: ['screen'] })截屏,thumbnailSize设成显示器物理尺寸 × scaleFactor,HiDPI 下才不会糊; -
每个显示器弹一个全屏无边框
alwaysOnTop选区窗口(sel.html),复用主 preload; -
用户拖框选(宽高 < 8px 当误触处理),Enter 或双击确认、Esc 取消;
-
裁出的 dataURL 经 IPC 扔给主进程的 tesseract.js;
-
识别结果用
document.execCommand('insertText', false, text)回填输入框——这样能保留原生撤销栈,还能自动触发 input 事件联动预览。
4.2 双击确认的交互坑
写的时候踩了个挺隐蔽的 bug:拖出选区后双击居然确认不了。一通 debug 发现,双击的第一次 mousedown 命中了已有选区,代码把选区重置成了一个点,到 dblclick 时选区宽高已经是 0 了,自然啥也确认不了。
修复很简单:mousedown 时如果点落在已有选区内,不重置选区,只记个起始点,把确认的机会留给 dblclick。改完 Enter 确认、双击确认、Esc 取消就各司其职了。
4.3 HiDPI 坐标换算
screen API 返回的是 DIP 尺寸(125% 缩放下 1536×864),但截图是物理像素(1920×1080)。选区坐标到图像坐标的换算就一句:sx = image.naturalWidth / window.innerWidth(也就是 scaleFactor),裁剪时 x * sx、y * sy 就行。
4.4 tesseract.js 在主进程跑
-
createWorker(lang, 1, { langPath, cachePath, logger: () => {} }),worker 按语言 Map 缓存,别每次识别都新建; -
输入用 Buffer(dataURL 先转 Buffer);
-
语言数据用 tessdata_fast 的
.gz,丢resources/ocr-data里,通过extraResources内置,打包后路径是process.resourcesPath/ocr-data; -
必须在
package.json的dependencies里(externalizeDepsPlugin 会把主进程依赖外部化,运行时从 node_modules require); -
重依赖懒加载:别在主进程入口顶层
import,首次调用时再await import('tesseract.js')——这是第六节"四个致命坑"之一,白屏闪退的元凶。
官方 tessdata.projectnaptha.com 直连会重置连接,换 cdn.jsdelivr.net/gh/tesseract-ocr/tessdata_fast 下 plain 文件再本地 gzip 即可。
五、多平台打包:electron-builder 全攻略
5.1 基础配置
{"win": { "target": "nsis", "icon": "resources/app_icon.ico" },"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true},"mac": {
"target": ["dmg", "zip"],
"icon": "resources/app_icon.icns",
"artifactName": "${name}-${version}-${arch}.${ext}"},"linux": {
"target": ["AppImage", "deb", "rpm"],
"icon": "resources/icons",
"maintainer": "Your Name <email@example.com>",
"artifactName": "${name}-${version}-${arch}.${ext}"}}
几个要点:
-
maintainer必须带 email,不然 deb 构建给你报个莫名其妙的错; -
artifactName用${name}保持 ASCII 文件名(Windows 除外,默认按 productName 中文命名); -
linux.icon 指向多尺寸目录而不是单张 PNG(原因见坑 3)。
5.2 架构支持矩阵
|
平台 |
x64 |
arm64 |
riscv64 |
|---|---|---|---|
|
Windows NSIS |
✅ |
✅(合并包) |
❌ |
|
Linux AppImage/deb/rpm |
✅ |
✅(交叉) |
❌ |
|
macOS dmg/zip |
✅ |
✅ |
❌ |
-
Linux 交叉打 arm64:
npx electron-builder --linux AppImage deb rpm --arm64; -
Windows NSIS 不指定
--x64时默认打 x64+arm64 双架构合并安装包(安装时让用户自选架构); -
riscv64 没官方 Electron 二进制,死心吧。
5.3 Linux 上交叉打 Windows NSIS(无系统 wine,用 flatpak Bottles)
本机装不了系统级 wine,退而求其次用 flatpak 的 Bottles:
-
flatpak install flathub com.usebottles.bottles -
建 bottle:
bottles-cli new --bottle-name builder --environment application --arch win64 -
沙箱网络是个大坑:bottles 组件从 github 下,沙箱默认不走 host socks5 代理;Python requests 还缺 SOCKS 支持 →
pip download PySocks(纯 py wheel)解到 bottles 能访问的目录,建 bottle 时加--env=PYTHONPATH=... --env=https_proxy=socks5h://x.x.x.x:port -
新 wine(soda runner 11.x)wow64 合并了,只有 wine 没有 wine64,而且 standalone 是个 bash 脚本不是二进制
-
写个 wine 包装器(wine 和 wine64 都软链到它):
export LD_LIBRARY_PATH=<runner>/lib:<runner>/lib/wine/x86_64-unix:<runner>/lib/wine/i386-unix export WINEPREFIX=<bottle路径>export PATH=<runner>/bin:$PATHexec <runner>/bin/wine "$@" -
PATH=/tmp/eb-wine:$PATH npx electron-builder --win nsis --publish never
实测验证:rcedit(exe 版本信息能塞中文产品名)、makensis 都正常,还能在 bottle 里 Setup.exe /S 静默安装走一遍流程。
5.4 Linux 上出 macOS zip
dmg-license 是 macOS 专属可选依赖,Linux 上装不上(原生库 invalid ELF header)。绕法:npm i --no-save dmg-license --force 然后把它的 index.js 替换成 module.exports = {} 桩模块(zip 目标只 require 不调用)。dmg 必须在 macOS 上构建(要 hdiutil),Linux 上没办法。完事 npm prune 清掉。
5.5 rpm 4.20 环境的特殊处理
本机 rpm 4.20 和 electron-builder 内置的 fpm 1.9.3 八字不合:fpm 传的 --define buildroot X 被 rpm 4.20 当空气,结果就是 “File not found”。解法:写个 rpmbuild 包装器,把 --define buildroot X 翻译成 4.20 还认的 --buildroot X,构建时 PATH 前置。
另外非 root 跑需要用户级 rpmdb:rpm --initdb 初始化 ~/.cache/rpmdb,在 ~/.rpmmacros 写 %_dbpath /home/<user>/.cache/rpmdb,不然会报 /var/lib/rpm/rpmdb.sqlite 打不开。
六、四个致命的打包坑(真实用户机器上栽过的跟头)
这节是全文最有含金量的部分——这四个问题本地开发压根测不出来,都是打包后扔到真实用户环境才现原形的。
坑 1:Windows 安装后白屏闪退
现象:安装一切正常,双击快捷方式,窗口一闪而过,连个错误日志都不给你留。
根因:src/main/ocr.js 顶层写了 import { createWorker } from 'tesseract.js',构建产物在主进程入口变成 require("tesseract.js"),某些打包/系统环境下初始化失败,直接闪退白屏。
修复:改成函数内 await import('tesseract.js') 懒加载,首次用到 OCR 时才加载。
教训:主进程顶层永远别 import 重依赖,这条记住能救命。
坑 2:deepin/UOS 装 deb 报 EXDEV 硬链接错误
现象:dpkg: 错误:新建硬链接 ... 无效的跨设备链接 (EXDEV)。
根因:app_icon.png 既当 extraResources(装到 /opt/…/resources/)又当 linux.icon(装到 /usr/share/icons/hicolor/),electron-builder staging 阶段用 hardlink 复制,fpm 1.9.3 把硬链接写进 tar(条目类型 h)。deepin/UOS 这类不可变系统 /opt 和 /usr 是不同挂载点,dpkg 解包时 link() 跨设备直接失败。
冷知识:fpm 1.9.3 没有 --deb-no-hardlinks 选项(1.10+ 才有),配 deb.fpm 会直接构建失败,别在这上面浪费时间。
根治:图标别放 extraResources,打进 app.asar(files 里加 "resources/app_icon.png"),打包后 join(__dirname, '../../resources/app_icon.png'),nativeImage 支持 asar 路径,三平台通吃。这样 hicolor 成了唯一物理文件,硬链接条目数直接归零。
验证方法:
-
deb:
ar x pkg.deb && tar tvf data.tar.* | grep -c '^h' -
rpm:
rpm2archive pkg.rpm | tar tv | grep -c '^h'
坑 3:Linux 图标显示"未知类型"占位图
现象:deb 装上了,启动器里应用图标是个灰色的"未知类型"占位图,丑得离谱。
根因:linux.icon 给单张 PNG 时,electron-builder 只把它扔到 /usr/share/icons/hicolor/0x0/apps/。0x0 不符合 hicolor-icon-theme 规范,deepin/UOS 的启动器直接不索引。
修复:弄个多尺寸图标目录 resources/icons/,塞进去 16/24/32/48/64/128/256/512 的 NxN.png(用 PIL LANCZOS 从 256px 源图生成,512 是放大的),linux.icon 指向这个目录,构建后就装到各 hicolor/<size>/apps/ 了。
验证:模拟 XDG 数据目录后用 GTK Gtk.IconTheme.get_default().lookup_icon(name, 256, 0) 能解析出来。注意 offscreen 下 PyQt 的 QIcon.fromTheme 连系统图标都查不到,别拿它验证,纯属浪费时间。
坑 4:Windows 12 代+ Intel 大小核 CPU 上 Node 初始化崩溃
现象:Windows 11 真机装完打不开(退出码 0),但同一个安装包扔 VirtualBox Win10 里跑得欢,VS Code 等其他 Electron 应用在这台机器上也正常。
根因:Electron 31 内置的 Node 20.14 在 Intel 大小核混合 CPU(12 代及以后)上调 GetLogicalProcessorInformationEx 枚举处理器组时缓冲区越界,直接 fatal。
修复:升级到 Electron 33.4.11(Node 20.18,修了这 bug)。少数处理器组信息异常的机器,还得检查 BIOS 或 Windows 启动参数(bcdedit 里的 groupsize/maxgroup/numproc/usegroup)。
教训:新项目直接 Electron ≥33,别给自己找麻烦。
七、菜单助记符的跨平台玄学
这是个小坑但烦人的很。Electron 的菜单在 Linux GTK 和 Windows 上,对 (&X) 的处理还不一样:
-
顶层菜单:
(&F)会被整体从显示文本剥离,只注册 Alt 助记符——所以顶层得双写`文件(F)(&F)`,才能既显示(F)又有 Alt+F; -
子菜单项:只剥
&符号本身,新建(&N)显示带下划线的 N,原生写法就行; -
&&→ 字面&(不注册助记符);_在 GTK 不当下划线语法,原样显示。
封装两个 helper 一劳永逸:
const topMenuLabel = (text, key) => `${text}(${key})(&${key})`const itemLabel = (text, key, suffix = '') => `${text}(&${key})${suffix}`
八、没有商业 UI 测试工具?这套组合拳顶用
deepin 上做验证,没有付费测试工具,全靠开源凑:
-
CDP:
--remote-debugging-port=9223,临时装个ws包(Node 20 没全局 WebSocket)写脚本,Runtime.evaluate读 DOM/canvas 像素、Input.dispatchKeyEvent驱动应用内按键; -
xdotool:负责 GTK 原生对话框(菜单导航
key --delay 200 alt+f,不加 delay 菜单丢键); -
Pillow:
ImageGrab.grab(xdisplay=':0')截图(物理分辨率,坐标按像素算); -
tkinter:造 OCR 测试素材——
overrideredirect + topmost置顶窗显示已知文本,setsid nohup启动防 shell 退出连带杀进程,文字四周留足 padding,不然贴边字母识别错; -
pkill 技巧:
pkill -f的模式如果匹配到自身命令行会自杀,用[x]括号技巧,比如pkill -f '[e]lectron .'。
九、总结
从 PyQt6 到 Electron 的重构,最大的收获是跨平台分发能力的质变:一套代码产出 Windows NSIS、Linux AppImage/deb/rpm、macOS zip/dmg,OCR、打印、文件关联这些原生能力也都齐活。代价嘛,就是得重新适应浏览器环境下的渲染、IPC、打包模型。
几个扎心的经验:
-
逐像素对齐原版是桌面应用重构的第一原则,别擅自"优化"用户已经习惯的视觉;
-
文件格式双向兼容是迁移的生命线,pickle 协议 0 写入的
\uXXXX坑一定要绕开; -
主进程顶层不 import 重依赖,一律懒加载,白屏闪退能防一大半;
-
打包坑全在真实环境暴露:EXDEV 硬链接、hicolor 0x0 图标、Intel 大小核崩溃——这些本地开发永远测不出来,必须上目标系统验;
-
打印预览要单例、渲染完再 show、记得清菜单,细节决定体验。
重构这趟下来,感觉 Electron 做桌面应用其实没网上传的那么不堪,关键是别把它当网页写,得有"桌面应用"的意识——窗口生命周期、系统对话框、原生菜单、文件关联,一个都不能少。
希望这篇踩坑记录能帮到正在做类似重构的你。有问题欢迎评论区开麦 🎤