實戰(zhàn)與踩坑全記錄)
原文項目鐘毓英語衡水體字帖生成器重構(gòu)前后PyQt6 v1.1.1 → Electron v2.0.x跨平臺 Windows / macOS / Linux關(guān)鍵詞electron-vite、Canvas 2D、tesseract.js OCR、electron-builder、pickle 兼容、Bottles 交叉打包前言筆者手上有一款用 PyQt6 開發(fā)的英語衡水體字帖生成器功能挺全乎四種生成模式描紅/抄寫/描紅抄寫/字帖、兩種線格、多標(biāo)簽頁、PDF 導(dǎo)出還有個自定義的.zyecb工程文件格式。原版在 Windows 上跑得好好的可架不住用戶問Linux 有嗎、“mac 能裝嗎”——Python 桌面應(yīng)用的分發(fā)短板一下就暴露了PyInstaller 體積大、跨平臺得各開一臺機(jī)器、系統(tǒng)庫依賴分分鐘給你整出兼容玄學(xué)。得那就用Electron徹底重構(gòu)吧。本文不整為什么選 Electron這種正確的廢話直接上干貨構(gòu)建思路怎么定的、關(guān)鍵決策怎么做的、以及踩過的那些真實坑和最終怎么爬出來的。正在折騰桌面應(yīng)用重構(gòu)的朋友希望能幫你少走點彎路。一、整體架構(gòu)與技術(shù)選型1.1 技術(shù)棧層面選型說明構(gòu)建工具electron-vite 2 Vite 5主/預(yù)加載/渲染進(jìn)程統(tǒng)一構(gòu)建HMR 香得很運行時Electron 33.4.11直接上 33別問問就是被 Node 20.14 坑過見第六節(jié)坑 4渲染原生 JavaScript Canvas 2D無框架直接復(fù)刻 QPainter 自繪邏輯打包electron-builder 24.13.3NSIS / AppImage / deb / rpm / dmg / zip 一網(wǎng)打盡OCRtesseract.js v7截圖識別語言數(shù)據(jù)內(nèi)置離線可用1.2 目錄與多入口設(shè)計原版就是個單窗口 QMainWindow 套 QTabWidget。到了 Electron 這邊我把全屏自繪的場景拆成了獨立 HTML 入口各司其職src/ ├── main/ 主進(jìn)程IPC、窗口、菜單、打印、OCR ├── preload/ contextBridge 橋 └── renderer/ ├── index.html 主界面標(biāo)簽頁 控件 預(yù)覽 Canvas ├── print.html 隱藏窗口渲染 A4 頁面供 PDF 導(dǎo)出 / 打印 ├── sel.html 截圖選區(qū)窗口每顯示器一個全屏無邊框 └── preview.html 打印預(yù)覽窗口四個入口在electron.vite.config.mjs的rollupOptions.input里注冊。這種按全屏場景拆入口的設(shè)計比在單個 BrowserWindow 里切視圖清爽多了——打印和預(yù)覽窗口本來就不需要主界面那一堆控件。1.3 逐像素對齊原版別瞎優(yōu)化重構(gòu)桌面應(yīng)用最忌諱的就是順手優(yōu)化一下結(jié)果用戶一打開感覺這味兒不對啊。我的策略很簡單頁面坐標(biāo)系、字號、顏色、行高全部一比一復(fù)刻。頁面尺寸 800×1131px邊距 (20, 20, 760, 1091)頭部高 100四線三格行高 40、組距 40線位 y0/13/26/39單橫線行高 30、組距 0QFont 的 pt 字號按 96 DPI 換算px pt × 4/3描紅色#ff6464、網(wǎng)格線#c0c0c0、頁碼#a0a0a0。排版引擎集中在src/renderer/src/engine/copybook.js的buildPages一次排版分頁。順帶還修了原版一個潛伏 bug單橫線模式下原版按四線三格行高估算頁容量跨頁時單詞會重復(fù)出現(xiàn)重構(gòu)版按實際行高算就好了——算是重構(gòu)附贈的彩蛋。二、工程文件雙向兼容pickle 這個老頑童原版的.zyecb工程文件是Python picklePy3 默認(rèn)協(xié)議 4。老用戶遷移是剛需新版必須能讀要讓用戶在新舊版本間自由切換新版存的文件原版最好也能打開。2.1 讀取手寫協(xié)議 0~4 子集解析器pickle 本質(zhì)是個基于棧的虛擬機(jī)字節(jié)碼。我吭哧吭哧手寫了一個解析器把常用 opcode 都覆蓋了PROTO、STOP、MARK、EMPTY_LIST/DICT/TUPLE、APPEND、SETITEM、BINUNICODE、SHORT_BINUNICODE、GLOBAL、REDUCE等等協(xié)議 4 的FRAME、MEMOIZE也沒落下。這活兒不復(fù)雜但碎建議邊對照pickletools.dis()的輸出邊寫事半功倍。2.2 寫入用協(xié)議 0但小心\uXXXX輸出我選了協(xié)議 0純 ASCII任何 Python 版本都能讀出問題了肉眼也能 debug。這里有個能把人逼瘋的坑協(xié)議 0 里字符串 opcodeV后面跟一行以\n結(jié)尾的 raw-unicode-escape 字符串。Python 的 raw-unicode-escape只認(rèn)\uXXXX形式的轉(zhuǎn)義不認(rèn)\n、\\這種簡寫。所以字符串里的換行、反斜杠、控制符必須全部編碼成\u000a、\u005c等形式否則原版pickle.load輕則UnicodeDecodeError重則讀到一堆亂碼。2.3 回歸測試雙向跑一遍原版保存一批典型工程特殊字符、空內(nèi)容、長文本都來點→ 新版打開新版保存 → 原版打開斷言渲染結(jié)果一字不差。最終實現(xiàn)了真正的雙向兼容用戶雙擊.zyecb就能用新版打開通過 electron-builder 的fileAssociations注冊文件關(guān)聯(lián)。三、打印與打印預(yù)覽一條管線走天下原版導(dǎo)出 PDF和打印走的是 QPrinter。到了 Electron我把這兩條路合并成一條渲染管線再額外加了個打印預(yù)覽窗口——畢竟都 2026 年了沒預(yù)覽的打印是不完整的。3.1 三路復(fù)用的渲染窗口主進(jìn)程ipc.js抽出createRenderWindow(data)建一個隱藏的 BrowserWindow加載print.htmlIPC 把排版數(shù)據(jù)塞過去渲染進(jìn)程用 Canvas 2D 按 A4 尺寸逐頁畫畫完了 IPC 回報主進(jìn)程按 sender id 過濾多窗口串消息這種事防一手設(shè)個 30s 超時兜底根據(jù)調(diào)用場景分流pdf:export→webContents.printToPDFprint:direct→webContents.print彈系統(tǒng)打印對話框print:preview→ 復(fù)用單例預(yù)覽窗口顯示。打印參數(shù)統(tǒng)一為{ printBackground: true, pageSize: A4, margins: { marginType: none } }。劃重點新版 Electron 用 margins 對象舊的 marginsType 已經(jīng)被打入冷宮。3.2 高清渲染2 倍 DPRA4 頁面按2 倍 DPR192 DPI繪制794×1123 96dpi 的 2 倍打出來的字邊緣那叫一個銳利。打印和 PDF 共用同一份 Canvas 數(shù)據(jù)效果完全一致用戶再也不會說PDF 看著挺好打出來糊了。3.3 打印預(yù)覽窗口的那些小細(xì)節(jié)單例復(fù)用重復(fù)打開就 focus 重渲染別傻乎乎每次新建窗口渲染完再 show()不然用戶看到白屏閃爍體驗分驟降setMenu(null)非 macOS 下附加窗口默認(rèn)帶應(yīng)用菜單欄必須手動清掉不然預(yù)覽窗口頂個文件編輯幫助菜單怪尷尬的CSSzoom縮放別用transform: scalezoom 是參與 Chromium 布局計算的滾動條和頁碼定位才對得上適應(yīng)頁寬算法zoom (clientWidth - 兩側(cè)留白) / 794794 是 A4 寬 96dpi 的像素值頁碼跟隨滾動用getBoundingClientRect找最后一個 top ≤ 視口 35% 的頁當(dāng)當(dāng)前頁。別問我為什么知道——初版用current寫了個差一 bug翻第一頁顯示第 2/2 頁當(dāng)場社死。3.4 Linux 下驗證的小坑在 deepin 上做自動化驗證時發(fā)現(xiàn)一個有意思的現(xiàn)象GTK 打印對話框打開期間父窗口渲染進(jìn)程的 JS 被模態(tài)阻塞了CDPRuntime.evaluate直接超時對話框一關(guān)立馬恢復(fù)。所以測試時系統(tǒng)對話框那塊選打印機(jī)、點確認(rèn)得交給 xdotool應(yīng)用內(nèi)交互點預(yù)覽按鈕、拖縮放才能用 CDP。另外 xdotool 在 GTK 保存對話框里type路徑會被輸入法劫持斜杠還會被吃掉解決方案是測試導(dǎo)出時先接受默認(rèn)文件名事后再改回去。四、截圖識別desktopCapturer tesseract.js光有手動輸入哪夠必須上個截圖識別——看到屏幕上的英文直接框一下就進(jìn)字帖多香。4.1 完整流程走一遍主窗口先藏起來desktopCapturer.getSources({ types: [screen] })截屏thumbnailSize設(shè)成顯示器物理尺寸 × scaleFactorHiDPI 下才不會糊每個顯示器彈一個全屏無邊框alwaysOnTop選區(qū)窗口sel.html復(fù)用主 preload用戶拖框選寬高 8px 當(dāng)誤觸處理Enter 或雙擊確認(rèn)、Esc 取消裁出的 dataURL 經(jīng) IPC 扔給主進(jìn)程的 tesseract.js識別結(jié)果用document.execCommand(insertText, false, text)回填輸入框——這樣能保留原生撤銷棧還能自動觸發(fā) input 事件聯(lián)動預(yù)覽。4.2 雙擊確認(rèn)的交互坑寫的時候踩了個挺隱蔽的 bug拖出選區(qū)后雙擊居然確認(rèn)不了。一通 debug 發(fā)現(xiàn)雙擊的第一次mousedown命中了已有選區(qū)代碼把選區(qū)重置成了一個點到dblclick時選區(qū)寬高已經(jīng)是 0 了自然啥也確認(rèn)不了。修復(fù)很簡單mousedown時如果點落在已有選區(qū)內(nèi)不重置選區(qū)只記個起始點把確認(rèn)的機(jī)會留給dblclick。改完 Enter 確認(rèn)、雙擊確認(rèn)、Esc 取消就各司其職了。4.3 HiDPI 坐標(biāo)換算screenAPI 返回的是 DIP 尺寸125% 縮放下 1536×864但截圖是物理像素1920×1080。選區(qū)坐標(biāo)到圖像坐標(biāo)的換算就一句sx image.naturalWidth / window.innerWidth也就是 scaleFactor裁剪時x * sx、y * sy就行。4.4 tesseract.js 在主進(jìn)程跑createWorker(lang, 1, { langPath, cachePath, logger: () {} })worker 按語言 Map 緩存別每次識別都新建輸入用 BufferdataURL 先轉(zhuǎn) Buffer語言數(shù)據(jù)用tessdata_fast的.gz丟resources/ocr-data里通過extraResources內(nèi)置打包后路徑是process.resourcesPath/ocr-data必須在package.json的dependencies里externalizeDepsPlugin 會把主進(jìn)程依賴外部化運行時從 node_modules require重依賴懶加載別在主進(jìn)程入口頂層import首次調(diào)用時再await import(tesseract.js)——這是第六節(jié)四個致命坑之一白屏閃退的元兇。官方tessdata.projectnaptha.com直連會重置連接換cdn.jsdelivr.net/gh/tesseract-ocr/tessdata_fast下 plain 文件再本地 gzip 即可。五、多平臺打包electron-builder 全攻略5.1 基礎(chǔ)配置{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 emailexample.com,artifactName:${name}-${version}-${arch}.${ext}}}幾個要點maintainer必須帶 email不然 deb 構(gòu)建給你報個莫名其妙的錯artifactName用${name}保持 ASCII 文件名Windows 除外默認(rèn)按 productName 中文命名linux.icon 指向多尺寸目錄而不是單張 PNG原因見坑 3。5.2 架構(gòu)支持矩陣平臺x64arm64riscv64Windows NSIS??合并包?Linux AppImage/deb/rpm??交叉?macOS dmg/zip???Linux 交叉打 arm64npx electron-builder --linux AppImage deb rpm --arm64Windows NSIS 不指定--x64時默認(rèn)打x64arm64 雙架構(gòu)合并安裝包安裝時讓用戶自選架構(gòu)riscv64 沒官方 Electron 二進(jìn)制死心吧。5.3 Linux 上交叉打 Windows NSIS無系統(tǒng) wine用 flatpak Bottles本機(jī)裝不了系統(tǒng)級 wine退而求其次用 flatpak 的 Bottlesflatpak install flathub com.usebottles.bottles建 bottlebottles-cli new --bottle-name builder --environment application --arch win64沙箱網(wǎng)絡(luò)是個大坑bottles 組件從 github 下沙箱默認(rèn)不走 host socks5 代理Python requests 還缺 SOCKS 支持 →pip download PySocks純 py wheel解到 bottles 能訪問的目錄建 bottle 時加--envPYTHONPATH... --envhttps_proxysocks5h://x.x.x.x:port新 winesoda runner 11.xwow64 合并了只有 wine 沒有 wine64而且 standalone 是個 bash 腳本不是二進(jìn)制寫個 wine 包裝器wine 和 wine64 都軟鏈到它exportLD_LIBRARY_PATHrunner/lib:runner/lib/wine/x86_64-unix:runner/lib/wine/i386-unixexportWINEPREFIXbottle路徑exportPATHrunner/bin:$PATHexecrunner/bin/wine$PATH/tmp/eb-wine:$PATH npx electron-builder --win nsis --publish never實測驗證rceditexe 版本信息能塞中文產(chǎn)品名、makensis 都正常還能在 bottle 里Setup.exe /S靜默安裝走一遍流程。5.4 Linux 上出 macOS zipdmg-license 是 macOS 專屬可選依賴Linux 上裝不上原生庫 invalid ELF header。繞法npm i --no-save dmg-license --force然后把它的index.js替換成module.exports {}樁模塊zip 目標(biāo)只 require 不調(diào)用。dmg 必須在 macOS 上構(gòu)建要 hdiutilLinux 上沒辦法。完事npm prune清掉。5.5 rpm 4.20 環(huán)境的特殊處理本機(jī) rpm 4.20 和 electron-builder 內(nèi)置的 fpm 1.9.3 八字不合fpm 傳的--define buildroot X被 rpm 4.20 當(dāng)空氣結(jié)果就是 “File not found”。解法寫個 rpmbuild 包裝器把--define buildroot X翻譯成 4.20 還認(rèn)的--buildroot X構(gòu)建時 PATH 前置。另外非 root 跑需要用戶級 rpmdbrpm --initdb初始化~/.cache/rpmdb在~/.rpmmacros寫%_dbpath /home/user/.cache/rpmdb不然會報/var/lib/rpm/rpmdb.sqlite打不開。六、四個致命的打包坑真實用戶機(jī)器上栽過的跟頭這節(jié)是全文最有含金量的部分——這四個問題本地開發(fā)壓根測不出來都是打包后扔到真實用戶環(huán)境才現(xiàn)原形的。坑 1Windows 安裝后白屏閃退現(xiàn)象安裝一切正常雙擊快捷方式窗口一閃而過連個錯誤日志都不給你留。根因src/main/ocr.js頂層寫了import { createWorker } from tesseract.js構(gòu)建產(chǎn)物在主進(jìn)程入口變成require(tesseract.js)某些打包/系統(tǒng)環(huán)境下初始化失敗直接閃退白屏。修復(fù)改成函數(shù)內(nèi)await import(tesseract.js)懶加載首次用到 OCR 時才加載。教訓(xùn)主進(jìn)程頂層永遠(yuǎn)別 import 重依賴這條記住能救命???2deepin/UOS 裝 deb 報 EXDEV 硬鏈接錯誤現(xiàn)象dpkg: 錯誤新建硬鏈接 ... 無效的跨設(shè)備鏈接 (EXDEV)。根因app_icon.png既當(dāng)extraResources裝到 /opt/…/resources/又當(dāng)linux.icon裝到 /usr/share/icons/hicolor/electron-builder staging 階段用 hardlink 復(fù)制fpm 1.9.3 把硬鏈接寫進(jìn) tar條目類型 h。deepin/UOS 這類不可變系統(tǒng) /opt 和 /usr 是不同掛載點dpkg 解包時link()跨設(shè)備直接失敗。冷知識fpm 1.9.3沒有--deb-no-hardlinks選項1.10 才有配deb.fpm會直接構(gòu)建失敗別在這上面浪費時間。根治圖標(biāo)別放 extraResources打進(jìn) app.asarfiles 里加resources/app_icon.png打包后join(__dirname, ../../resources/app_icon.png)nativeImage 支持 asar 路徑三平臺通吃。這樣 hicolor 成了唯一物理文件硬鏈接條目數(shù)直接歸零。驗證方法debar x pkg.deb tar tvf data.tar.* | grep -c ^hrpmrpm2archive pkg.rpm | tar tv | grep -c ^h坑 3Linux 圖標(biāo)顯示未知類型占位圖現(xiàn)象deb 裝上了啟動器里應(yīng)用圖標(biāo)是個灰色的未知類型占位圖丑得離譜。根因linux.icon給單張 PNG 時electron-builder 只把它扔到/usr/share/icons/hicolor/0x0/apps/。0x0 不符合 hicolor-icon-theme 規(guī)范deepin/UOS 的啟動器直接不索引。修復(fù)弄個多尺寸圖標(biāo)目錄resources/icons/塞進(jìn)去 16/24/32/48/64/128/256/512 的NxN.png用 PIL LANCZOS 從 256px 源圖生成512 是放大的linux.icon指向這個目錄構(gòu)建后就裝到各hicolor/size/apps/了。驗證模擬 XDG 數(shù)據(jù)目錄后用 GTKGtk.IconTheme.get_default().lookup_icon(name, 256, 0)能解析出來。注意 offscreen 下 PyQt 的QIcon.fromTheme連系統(tǒng)圖標(biāo)都查不到別拿它驗證純屬浪費時間。坑 4Windows 12 代 Intel 大小核 CPU 上 Node 初始化崩潰現(xiàn)象Windows 11 真機(jī)裝完打不開退出碼 0但同一個安裝包扔 VirtualBox Win10 里跑得歡VS Code 等其他 Electron 應(yīng)用在這臺機(jī)器上也正常。根因Electron 31 內(nèi)置的 Node 20.14 在 Intel 大小核混合 CPU12 代及以后上調(diào)GetLogicalProcessorInformationEx枚舉處理器組時緩沖區(qū)越界直接 fatal。修復(fù)升級到 Electron 33.4.11Node 20.18修了這 bug。少數(shù)處理器組信息異常的機(jī)器還得檢查 BIOS 或 Windows 啟動參數(shù)bcdedit里的groupsize/maxgroup/numproc/usegroup。教訓(xùn)新項目直接 Electron ≥33別給自己找麻煩。七、菜單助記符的跨平臺玄學(xué)這是個小坑但煩人的很。Electron 的菜單在 Linux GTK 和 Windows 上對(X)的處理還不一樣頂層菜單(F)會被整體從顯示文本剝離只注冊 Alt 助記符——所以頂層得雙寫文件(F)(F)才能既顯示(F)又有 AltF子菜單項只剝符號本身新建(N)顯示帶下劃線的 N原生寫法就行→ 字面不注冊助記符_在 GTK 不當(dāng)下劃線語法原樣顯示。封裝兩個 helper 一勞永逸consttopMenuLabel(text,key)${text}(${key})(${key})constitemLabel(text,key,suffix)${text}(${key})${suffix}八、沒有商業(yè) UI 測試工具這套組合拳頂用deepin 上做驗證沒有付費測試工具全靠開源湊CDP--remote-debugging-port9223臨時裝個ws包Node 20 沒全局 WebSocket寫腳本Runtime.evaluate讀 DOM/canvas 像素、Input.dispatchKeyEvent驅(qū)動應(yīng)用內(nèi)按鍵xdotool負(fù)責(zé) GTK 原生對話框菜單導(dǎo)航key --delay 200 altf不加 delay 菜單丟鍵PillowImageGrab.grab(xdisplay:0)截圖物理分辨率坐標(biāo)按像素算tkinter造 OCR 測試素材——overrideredirect topmost置頂窗顯示已知文本setsid nohup啟動防 shell 退出連帶殺進(jìn)程文字四周留足 padding不然貼邊字母識別錯pkill 技巧pkill -f的模式如果匹配到自身命令行會自殺用[x]括號技巧比如pkill -f [e]lectron .。九、總結(jié)從 PyQt6 到 Electron 的重構(gòu)最大的收獲是跨平臺分發(fā)能力的質(zhì)變一套代碼產(chǎn)出 Windows NSIS、Linux AppImage/deb/rpm、macOS zip/dmgOCR、打印、文件關(guān)聯(lián)這些原生能力也都齊活。代價嘛就是得重新適應(yīng)瀏覽器環(huán)境下的渲染、IPC、打包模型。幾個扎心的經(jīng)驗逐像素對齊原版是桌面應(yīng)用重構(gòu)的第一原則別擅自優(yōu)化用戶已經(jīng)習(xí)慣的視覺文件格式雙向兼容是遷移的生命線pickle 協(xié)議 0 寫入的\uXXXX坑一定要繞開主進(jìn)程頂層不 import 重依賴一律懶加載白屏閃退能防一大半打包坑全在真實環(huán)境暴露EXDEV 硬鏈接、hicolor 0x0 圖標(biāo)、Intel 大小核崩潰——這些本地開發(fā)永遠(yuǎn)測不出來必須上目標(biāo)系統(tǒng)驗打印預(yù)覽要單例、渲染完再 show、記得清菜單細(xì)節(jié)決定體驗。重構(gòu)這趟下來感覺 Electron 做桌面應(yīng)用其實沒網(wǎng)上傳的那么不堪關(guān)鍵是別把它當(dāng)網(wǎng)頁寫得有桌面應(yīng)用的意識——窗口生命周期、系統(tǒng)對話框、原生菜單、文件關(guān)聯(lián)一個都不能少。希望這篇踩坑記錄能幫到正在做類似重構(gòu)的你。有問題歡迎評論區(qū)開麥 相關(guān)資源項目倉庫GitHubv2.0.2 Release