☰
PyInstaller 打包后模板预览内容大面积缺失:一个 import 作用域引发的问题
2026/9/30 19:38:17 网站建设 项目流程

PyInstaller 打包后模板预览内容大面积缺失:一个 import 作用域引发的问题

前言

最近项目上线前遇到了一个问题:基于 PySide6 开发的档案管理系统,源码运行一切正常,打包后的程序在开发机上也没问题,但拷贝到单位办公电脑(银河麒麟 V10)上运行后,目录模板预览页面内容大面积缺失——案卷目录只有标题没有表格、案卷封面只有档号和题名没有底部字段、案卷脊背只有一个空框、卷内目录同样只有标题。

这个问题折腾了整整一天,经历了四轮修复才最终解决。本文完整记录排查过程和真正的根因,希望能帮到踩同样坑的开发者。技术栈:Python 3.10 + PySide6 6.6.3 + PyInstaller 6.22.0 + SQLite。


一、问题现象

打包后的程序在目标机器上的表现:

模板页面预期显示实际显示
案卷目录标题 + 7列表格只有标题"案卷目录"
案卷封面档号 + 题名框 + 4个底部字段只有档号和题名框
案卷脊背6段分区(保管期限/档号/题名等)只有一个空矩形框
卷内目录标题 + 7列表格只有标题"卷内目录"

关键特征:标题能显示,但表格列、行数据、分段配置全部缺失。

同时,登录时还弹出一个错误框:“加载字典失败:locking protocol”。


二、排查过程

2.1 第一轮:SQLite WAL 模式(locking protocol)

“locking protocol” 是 SQLite 的经典错误。查看代码发现,之前的综合审查修复中在get_db_connection()里给每个连接都加了PRAGMA journal_mode = WAL:

defget_db_connection():conn=sqlite3.connect(DB_PATH,timeout=30)conn.row_factory=sqlite3.Row conn.execute("PRAGMA foreign_keys = ON")conn.execute("PRAGMA journal_mode = WAL")# ← 每次连接都执行returnconn

目标机器的文件系统不支持 WAL(可能是 NFS 或特殊挂载),每次打开连接都报错。

修复:把 WAL 设置移到init_db()中只执行一次,加try/except回退到默认 DELETE 模式。

defget_db_connection():conn=sqlite3.connect(DB_PATH,timeout=30)conn.row_factory=sqlite3.Row conn.execute("PRAGMA foreign_keys = ON")returnconn# 不再设置 WALdefinit_db():# ...try:cursor.execute("PRAGMA journal_mode = WAL")exceptException:pass# 不支持 WAL 时回退到默认模式

这一轮解决了"加载字典失败"的问题,但模板预览内容缺失依旧。

2.2 第二轮:字体回退(猜测中文字体缺失)

标题能显示但内容缺失,我第二反应是字体问题——目标麒麟系统可能没有安装"宋体",Qt 的QFont('宋体')找不到字体会静默回退到默认字体,而默认字体可能不支持中文。

修复:在预览字体构造函数中加了QFontDatabase检测和回退链:

@classmethoddef_make_preview_font(cls,family,pt,scale,bold=False):fromPySide6.QtGuiimportQFontDatabase family=familyor'宋体'db=QFontDatabase()iffamilynotindb.families():forfallbackin('Noto Serif CJK SC','Noto Sans CJK SC','WenQuanYi Zen Hei','WenQuanYi Micro Hei','SimSun','SimHei','sans-serif'):iffallbackindb.families():family=fallbackbreakf=QFont(family)f.setPixelSize(max(1,int(round(float(pt)*cls._PT_TO_MM*scale))))f.setBold(bool(bold))returnf

结果:问题依旧。现在回头看,这个判断本身就是错的——如果字体问题,标题也不应该显示。标题能显示恰恰说明字体没问题。

2.3 第三轮:PyInstaller 模块遗漏(collect_submodules)

第三轮我怀疑是 PyInstaller 打包时遗漏了业务模块。代码中大量使用函数内延迟导入:

definit_db():# ...ifcursor.fetchone()[0]==0:fromsrc.services.archive_template_defaultsimportget_default_archive_catalog_template# ↑ PyInstaller 静态分析扫不到函数内的 import

检查打包产物,确实在 PYZ 归档中找不到archive_template_defaults模块。

修复:在 spec 文件中用collect_submodules全量收集业务模块:

fromPyInstaller.utils.hooksimportcollect_submodules _src_hidden=collect_submodules('src')hiddenimports+=_src_hiddenprint(f"[spec] collect_submodules('src'):{len(_src_hidden)}个模块")

打包日志显示collect_submodules('src'): 26 个模块,PYZ TOC 也确认所有关键模块都在包中。

结果:问题依然存在。用户反馈"还是和之前一样"。

2.4 第四轮:真正的根因——import 作用域 bug

到这里我停下来重新审视问题。打包后的程序在开发机上完全正常,说明模块确实已经打包进去了。问题只出现在目标机器上,说明是运行时环境差异导致的。

关键线索:目标机器上 DB 已经存在(之前几轮运行创建的),而开发机上 DB 是源码运行时创建的完整数据。

重新审视init_db()的模板初始化逻辑:

cursor.execute("SELECT COUNT(*) FROM sys_template")ifcursor.fetchone()[0]==0:# ★ import 在 if 分支内fromsrc.services.archive_template_defaultsimportget_default_archive_catalog_template default_template_content=get_default_archive_catalog_template()cursor.execute("INSERT INTO sys_template ...",(...))else:# DB 已存在,走迁移路径cursor.execute("SELECT template_content FROM sys_template WHERE template_code='archive_catalog'")row=cursor.fetchone()ifrow:try:old_content=json.loads(row[0])ifrow[0]else{}if'page_settings'inold_contentor'rows'notinold_content.get('volume_cover',{}):# ★ 这里调用 get_default_archive_catalog_template()# 但 import 在 if 分支内,else 分支中函数名未绑定!new_content=get_default_archive_catalog_template()# ← NameError!cursor.execute("UPDATE sys_template SET template_content=? ...",...)except(json.JSONDecodeError,Exception):pass# ← NameError 被静默吞掉!

这就是根因。

Python 的from X import Y是运行时语句,不是编译期声明。当import在if分支内时,Y只在if分支被执行时才绑定到本地作用域。如果走else分支,Y从未被绑定,调用时抛出NameError。

而NameError被外层的except (json.JSONDecodeError, Exception): pass静默吞掉,不留任何痕迹。

完整的问题链:

  1. 目标机器首次运行旧版本时,archive_template_defaults模块未打包,init_db()中 import 失败,模板未正确插入(或插入了空内容)
  2. 后续运行修复后的版本时,DB 已存在 → 走else分支
  3. 迁移代码检测到模板内容残缺(rows不存在),尝试调用get_default_archive_catalog_template()修复
  4. 但import在if分支内,else分支中函数名未绑定 →NameError
  5. except Exception: pass静默吞掉错误
  6. 残缺模板永远无法修复
  7. 预览渲染时cfg.get('rows', [])返回空列表,cfg.get('columns', [])返回空列表
  8. 只有硬编码的标题文字能显示,所有依赖配置数据的表格/列/分段全部缺失

为什么开发机正常:开发机的 DB 是源码运行时创建的,模板内容完整,根本不走else迁移路径。


三、修复方案

3.1 核心:import 提到 if/else 之前

definit_db():# ...# ★ import 移到 if/else 之前,两个分支都能使用fromsrc.services.archive_template_defaultsimportget_default_archive_catalog_template cursor.execute("SELECT COUNT(*) FROM sys_template")ifcursor.fetchone()[0]==0:default_template_content=get_default_archive_catalog_template()cursor.execute("INSERT INTO sys_template ...",(...))else:# 迁移代码中调用 get_default_archive_catalog_template() 不再报 NameError...

3.2 加强迁移验证

原来只检查page_settings和volume_cover.rows,现在增加对所有关键配置段的检查:

_need_replace=Falseif'page_settings'inold_content:_need_replace=True# 检查所有关键配置段是否存在且非空vc=old_content.get('volume_cover',{})if'rows'notinvcornotvc.get('rows'):_need_replace=Truevs=old_content.get('volume_spine',{})if'sections'notinvsornotvs.get('sections'):_need_replace=Truefc=old_content.get('file_catalog',{})if'columns'notinfcornotfc.get('columns'):_need_replace=Trueco=old_content.get('catalog_of_files',{})if'columns'notincoornotco.get('columns'):_need_replace=Trueif_need_replace:new_content=get_default_archive_catalog_template()cursor.execute("UPDATE sys_template SET template_content=? ...",...)

3.3 消除静默异常

把except: pass改为带日志输出:

except(json.JSONDecodeError,Exception)ase:logging.warning(f"archive_catalog 模板迁移失败:{e}")

同时在模板编辑器的加载逻辑中也加了回退:

iftemplate.get('template_content'):try:content=json.loads(template['template_content'])self._load_template_to_tabs(content)exceptExceptionase:logging.warning(f"加载模板内容失败,使用默认模板:{e}")fromsrc.services.archive_export_serviceimportArchiveExportService default=ArchiveExportService._get_default_template()self._load_template_to_tabs(default)

四、验证

修复后打包,拷贝到目标机器运行。程序启动时迁移代码检测到旧模板残缺,自动替换为完整默认模板,日志输出:

INFO: archive_catalog 模板内容残缺,已替换为默认模板

打开模板编辑页面,所有预览页面完整渲染:

模板页面修复前修复后
案卷目录只有标题标题 + 7列完整表格
案卷封面只有档号和题名全部9行内容完整显示
案卷脊背只有空框6段分区全部显示
卷内目录只有标题标题 + 7列完整表格

五、经验总结

5.1 PyInstaller 打包的三层防御

这次踩坑说明 PyInstaller 的模块收集需要三层防御:

层级手段作用
第一层PyInstaller 静态分析自动检测模块级 import
第二层hiddenimports显式声明补充第三方库延迟导入
第三层collect_submodules('src')全量收集项目内所有子模块

只有三层都到位,才能确保延迟导入的业务模块不遗漏。

5.2 Python import 作用域陷阱

from X import Y是运行时语句,不是编译期声明。它在哪个分支被执行,就只在哪个分支绑定名字。这个特性在纯 Python 环境下很少出问题(因为通常会被再次执行到),但在 PyInstaller 打包 + DB 持久化的场景下,if分支只在首次运行时执行,后续都走else,导致else中的调用永远NameError。

最佳实践:函数内延迟导入应放在函数体顶部,不要放在 if/else 分支内。

5.3except: pass是定时炸弹

except Exception: pass是 Python 中最危险的代码模式之一。它会让任何错误——包括你完全没预料到的NameError、ImportError、AttributeError——都静默消失,让排查变得极其困难。

这次问题排查了四轮才解决,根本原因就是except: pass吞掉了NameError,让迁移代码"看起来执行了但什么都没做"。

最佳实践:即使确实需要忽略某些异常,也应该至少记录日志:

exceptExceptionase:logging.warning(f"操作失败:{e}")

5.4 开发机与目标环境的关键差异

打包后的程序在开发机上"正常",并不等于在目标机器上正常。这次问题的关键差异是:

  • 开发机:DB 由源码运行时创建,模板内容完整,不走迁移路径
  • 目标机器:DB 由旧版本创建,模板内容残缺,走迁移路径但迁移失败

这种"开发机掩盖问题"的现象在持久化数据相关的 Bug 中非常常见。测试打包版本时,应该用全新的环境(删除 DB 和配置文件)进行测试,而不是复用开发机的数据。


六、避坑清单

  1. collect_submodules('src')收集项目模块:PyInstaller 静态分析扫不到函数内的from src.xxx import yyy,必须用collect_submodules全量收集
  2. 延迟导入放在函数体顶部:不要放在 if/else/for/try 分支内,避免作用域问题
  3. except: pass改为except Exception as e: logging.warning(...):即使要忽略异常也要留痕迹
  4. 迁移代码要验证关键数据:不能只检查格式版本,还要检查rows/columns/sections等关键配置段是否非空
  5. 打包测试用全新环境:删除 DB 和配置文件后再测试,模拟首次部署场景
  6. WAL 模式不要在每个连接中设置:SQLite 的PRAGMA journal_mode = WAL应在init_db()中只执行一次,且加异常回退
  7. 字体检测不要只看"标题能不能显示":标题用硬编码默认值,配置数据缺失时标题仍能显示,容易误判为字体问题

七、相关文章

  • 01 架构设计与模板配置
  • 02 QPainter 预览渲染
  • 03 python-docx 导出实现
  • 04 项目周期三位一体实践
  • 05 PDF 页号叠加与图形状态继承
  • 06 页码编号系统全流程实践

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询