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:
python
def get_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") # ← 每次连接都执行
return conn
目标机器的文件系统不支持 WAL(可能是 NFS 或特殊挂载),每次打开连接都报错。
修复 :把 WAL 设置移到 init_db() 中只执行一次,加 try/except 回退到默认 DELETE 模式。
python
def get_db_connection():
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn # 不再设置 WAL
def init_db():
# ...
try:
cursor.execute("PRAGMA journal_mode = WAL")
except Exception:
pass # 不支持 WAL 时回退到默认模式
这一轮解决了"加载字典失败"的问题,但模板预览内容缺失依旧。
2.2 第二轮:字体回退(猜测中文字体缺失)
标题能显示但内容缺失,我第二反应是字体问题------目标麒麟系统可能没有安装"宋体",Qt 的 QFont('宋体') 找不到字体会静默回退到默认字体,而默认字体可能不支持中文。
修复 :在预览字体构造函数中加了 QFontDatabase 检测和回退链:
python
@classmethod
def _make_preview_font(cls, family, pt, scale, bold=False):
from PySide6.QtGui import QFontDatabase
family = family or '宋体'
db = QFontDatabase()
if family not in db.families():
for fallback in ('Noto Serif CJK SC', 'Noto Sans CJK SC',
'WenQuanYi Zen Hei', 'WenQuanYi Micro Hei',
'SimSun', 'SimHei', 'sans-serif'):
if fallback in db.families():
family = fallback
break
f = QFont(family)
f.setPixelSize(max(1, int(round(float(pt) * cls._PT_TO_MM * scale))))
f.setBold(bool(bold))
return f
结果:问题依旧。现在回头看,这个判断本身就是错的------如果字体问题,标题也不应该显示。标题能显示恰恰说明字体没问题。
2.3 第三轮:PyInstaller 模块遗漏(collect_submodules)
第三轮我怀疑是 PyInstaller 打包时遗漏了业务模块。代码中大量使用函数内延迟导入:
python
def init_db():
# ...
if cursor.fetchone()[0] == 0:
from src.services.archive_template_defaults import get_default_archive_catalog_template
# ↑ PyInstaller 静态分析扫不到函数内的 import
检查打包产物,确实在 PYZ 归档中找不到 archive_template_defaults 模块。
修复 :在 spec 文件中用 collect_submodules 全量收集业务模块:
python
from PyInstaller.utils.hooks import collect_submodules
_src_hidden = collect_submodules('src')
hiddenimports += _src_hidden
print(f"[spec] collect_submodules('src'): {len(_src_hidden)} 个模块")
打包日志显示 collect_submodules('src'): 26 个模块,PYZ TOC 也确认所有关键模块都在包中。
结果:问题依然存在。用户反馈"还是和之前一样"。
2.4 第四轮:真正的根因------import 作用域 bug
到这里我停下来重新审视问题。打包后的程序在开发机上完全正常,说明模块确实已经打包进去了。问题只出现在目标机器上,说明是运行时环境差异导致的。
关键线索:目标机器上 DB 已经存在(之前几轮运行创建的),而开发机上 DB 是源码运行时创建的完整数据。
重新审视 init_db() 的模板初始化逻辑:
python
cursor.execute("SELECT COUNT(*) FROM sys_template")
if cursor.fetchone()[0] == 0:
# ★ import 在 if 分支内
from src.services.archive_template_defaults import get_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()
if row:
try:
old_content = json.loads(row[0]) if row[0] else {}
if 'page_settings' in old_content or 'rows' not in old_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 静默吞掉,不留任何痕迹。
完整的问题链:
- 目标机器首次运行旧版本时,
archive_template_defaults模块未打包,init_db()中 import 失败,模板未正确插入(或插入了空内容) - 后续运行修复后的版本时,DB 已存在 → 走
else分支 - 迁移代码检测到模板内容残缺(
rows不存在),尝试调用get_default_archive_catalog_template()修复 - 但
import在if分支内,else分支中函数名未绑定 →NameError except Exception: pass静默吞掉错误- 残缺模板永远无法修复
- 预览渲染时
cfg.get('rows', [])返回空列表,cfg.get('columns', [])返回空列表 - 只有硬编码的标题文字能显示,所有依赖配置数据的表格/列/分段全部缺失
为什么开发机正常 :开发机的 DB 是源码运行时创建的,模板内容完整,根本不走 else 迁移路径。
三、修复方案
3.1 核心:import 提到 if/else 之前
python
def init_db():
# ...
# ★ import 移到 if/else 之前,两个分支都能使用
from src.services.archive_template_defaults import get_default_archive_catalog_template
cursor.execute("SELECT COUNT(*) FROM sys_template")
if cursor.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,现在增加对所有关键配置段的检查:
python
_need_replace = False
if 'page_settings' in old_content:
_need_replace = True
# 检查所有关键配置段是否存在且非空
vc = old_content.get('volume_cover', {})
if 'rows' not in vc or not vc.get('rows'):
_need_replace = True
vs = old_content.get('volume_spine', {})
if 'sections' not in vs or not vs.get('sections'):
_need_replace = True
fc = old_content.get('file_catalog', {})
if 'columns' not in fc or not fc.get('columns'):
_need_replace = True
co = old_content.get('catalog_of_files', {})
if 'columns' not in co or not co.get('columns'):
_need_replace = True
if _need_replace:
new_content = get_default_archive_catalog_template()
cursor.execute("UPDATE sys_template SET template_content=? ...", ...)
3.3 消除静默异常
把 except: pass 改为带日志输出:
python
except (json.JSONDecodeError, Exception) as e:
logging.warning(f"archive_catalog 模板迁移失败: {e}")
同时在模板编辑器的加载逻辑中也加了回退:
python
if template.get('template_content'):
try:
content = json.loads(template['template_content'])
self._load_template_to_tabs(content)
except Exception as e:
logging.warning(f"加载模板内容失败,使用默认模板: {e}")
from src.services.archive_export_service import ArchiveExportService
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.3 except: pass 是定时炸弹
except Exception: pass 是 Python 中最危险的代码模式之一。它会让任何错误------包括你完全没预料到的 NameError、ImportError、AttributeError------都静默消失,让排查变得极其困难。
这次问题排查了四轮才解决,根本原因就是 except: pass 吞掉了 NameError,让迁移代码"看起来执行了但什么都没做"。
最佳实践:即使确实需要忽略某些异常,也应该至少记录日志:
python
except Exception as e:
logging.warning(f"操作失败: {e}")
5.4 开发机与目标环境的关键差异
打包后的程序在开发机上"正常",并不等于在目标机器上正常。这次问题的关键差异是:
- 开发机:DB 由源码运行时创建,模板内容完整,不走迁移路径
- 目标机器:DB 由旧版本创建,模板内容残缺,走迁移路径但迁移失败
这种"开发机掩盖问题"的现象在持久化数据相关的 Bug 中非常常见。测试打包版本时,应该用全新的环境(删除 DB 和配置文件)进行测试,而不是复用开发机的数据。
六、避坑清单
collect_submodules('src')收集项目模块 :PyInstaller 静态分析扫不到函数内的from src.xxx import yyy,必须用collect_submodules全量收集- 延迟导入放在函数体顶部:不要放在 if/else/for/try 分支内,避免作用域问题
except: pass改为except Exception as e: logging.warning(...):即使要忽略异常也要留痕迹- 迁移代码要验证关键数据 :不能只检查格式版本,还要检查
rows/columns/sections等关键配置段是否非空 - 打包测试用全新环境:删除 DB 和配置文件后再测试,模拟首次部署场景
- WAL 模式不要在每个连接中设置 :SQLite 的
PRAGMA journal_mode = WAL应在init_db()中只执行一次,且加异常回退 - 字体检测不要只看"标题能不能显示":标题用硬编码默认值,配置数据缺失时标题仍能显示,容易误判为字体问题