找回密码
 立即注册
搜索
查看: 134|回复: 6

从 PyQt6 到 Electron:一款英语字帖生成器的跨平台重构实战与踩坑全记录

[复制链接]

主题

0

回帖

0

积分

积分
0
发表于 2026-10-3 23:32:02 | 显示全部楼层 |阅读模式
原文项目:钟毓英语衡水体字帖生成器
重构前后: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 入口,各司其职:

  1. src/
  2. ├── main/          主进程:IPC、窗口、菜单、打印、OCR
  3. ├── preload/       contextBridge 桥
  4. └── renderer/
  5.     ├── index.html     主界面(标签页 + 控件 + 预览 Canvas)
  6.     ├── print.html     隐藏窗口:渲染 A4 页面供 PDF 导出 / 打印
  7.     ├── sel.html       截图选区窗口(每显示器一个,全屏无边框)
  8.     └── 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):

1.
建一个隐藏的 BrowserWindow,加载 print.html;

2.
IPC 把排版数据塞过去,渲染进程用 Canvas 2D 按 A4 尺寸逐页画;

3.
画完了 IPC 回报,主进程按 sender id 过滤(多窗口串消息这种事,防一手),设个 30s 超时兜底;

4.
根据调用场景分流:

•
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 完整流程走一遍

1.
主窗口先藏起来;

2.
desktopCapturer.getSources({ types: ['screen'] }) 截屏,thumbnailSize 设成显示器物理尺寸 × scaleFactor,HiDPI 下才不会糊;

3.
每个显示器弹一个全屏无边框 alwaysOnTop 选区窗口(sel.html),复用主 preload;

4.
用户拖框选(宽高 < 8px 当误触处理),Enter 或双击确认、Esc 取消;

5.
裁出的 dataURL 经 IPC 扔给主进程的 tesseract.js;

6.
识别结果用 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 基础配置

  1. {"win": { "target": "nsis", "icon": "resources/app_icon.ico" },"nsis": {
  2.     "oneClick": false,
  3.     "allowToChangeInstallationDirectory": true,
  4.     "createDesktopShortcut": true},"mac": {
  5.     "target": &#91;"dmg", "zip"&#93;,
  6.     "icon": "resources/app_icon.icns",
  7.     "artifactName": "${name}-${version}-${arch}.${ext}"},"linux": {
  8.     "target": &#91;"AppImage", "deb", "rpm"&#93;,
  9.     "icon": "resources/icons",
  10.     "maintainer": "Your Name <email@example.com>",
  11.     "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:

1.
flatpak install flathub com.usebottles.bottles

2.
建 bottle:bottles-cli new --bottle-name builder --environment application --arch win64

3.
沙箱网络是个大坑: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

4.
新 wine(soda runner 11.x)wow64 合并了,只有 wine 没有 wine64,而且 standalone 是个 bash 脚本不是二进制

5.
写个 wine 包装器(wine 和 wine64 都软链到它):

  1. export LD_LIBRARY_PATH=<runner>/lib:<runner>/lib/wine/x86_64-unix:<runner>/lib/wine/i386-unix
  2. export WINEPREFIX=<bottle路径>export PATH=<runner>/bin:$PATHexec <runner>/bin/wine "$@"
复制代码

6.
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 一劳永逸:

  1. 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 的模式如果匹配到自身命令行会自杀,用 &#91;x&#93; 括号技巧,比如 pkill -f '&#91;e&#93;lectron .'。



--------------------------------
九、总结

从 PyQt6 到 Electron 的重构,最大的收获是跨平台分发能力的质变:一套代码产出 Windows NSIS、Linux AppImage/deb/rpm、macOS zip/dmg,OCR、打印、文件关联这些原生能力也都齐活。代价嘛,就是得重新适应浏览器环境下的渲染、IPC、打包模型。

几个扎心的经验:

1.
逐像素对齐原版是桌面应用重构的第一原则,别擅自"优化"用户已经习惯的视觉;

2.
文件格式双向兼容是迁移的生命线,pickle 协议 0 写入的 \uXXXX 坑一定要绕开;

3.
主进程顶层不 import 重依赖,一律懒加载,白屏闪退能防一大半;

4.
打包坑全在真实环境暴露:EXDEV 硬链接、hicolor 0x0 图标、Intel 大小核崩溃——这些本地开发永远测不出来,必须上目标系统验;

5.
打印预览要单例、渲染完再 show、记得清菜单,细节决定体验。



重构这趟下来,感觉 Electron 做桌面应用其实没网上传的那么不堪,关键是别把它当网页写,得有"桌面应用"的意识——窗口生命周期、系统对话框、原生菜单、文件关联,一个都不能少。

希望这篇踩坑记录能帮到正在做类似重构的你。有问题欢迎评论区开麦 🎤

原文链接

打赏作者

当前余额:0 Token,打赏后立即到账

10

主题

35

回帖

0

积分

积分
0
发表于 2026-10-3 23:45:02 | 显示全部楼层
潜水很久了,PyQt6这个话题必须冒个泡。

主题

0

回帖

0

积分

积分
0
发表于 2026-10-4 00:15:01 | 显示全部楼层
有一说一,现在肯把PyQt6讲这么细的人不多了。

主题

0

回帖

0

积分

积分
0
发表于 2026-10-4 00:45:01 | 显示全部楼层
看完受益,已转给同事,让他们也看看Electron这块。

主题

0

回帖

0

积分

积分
0
发表于 2026-10-4 01:15:01 | 显示全部楼层
PyQt6这个方向确实热度不减,不过坑也不少,楼主总结得很及时。

主题

0

回帖

0

积分

积分
0
发表于 2026-10-4 01:45:01 | 显示全部楼层
从头看到尾,收获不少,感谢分享。

主题

0

回帖

0

积分

积分
0
发表于 2026-10-4 02:15:01 | 显示全部楼层
好文,PyQt6的趋势基本被楼主说中了。
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

{ template common/footer}