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

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 静默吞掉,不留任何痕迹。

完整的问题链:

  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 之前

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 和配置文件)进行测试,而不是复用开发机的数据。


六、避坑清单

  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. 字体检测不要只看"标题能不能显示":标题用硬编码默认值,配置数据缺失时标题仍能显示,容易误判为字体问题

七、相关文章

相关推荐
姑苏老陈2 年前
【Python基础】代码如何打包成exe可执行文件
开发语言·python·打包为exe可执行文件·pyinstaller打包