发布于2026-08-05 阅读(0)
扫一扫,手机访问
需求看起很简单,但每一条背后都藏着技术决策的取舍:

| 需求 | 决策点 |
|---|---|
| 选择多个文件 | wx.FileDialog(FD_MULTIPLE) + 拖拽 wx.FileDropTarget |
| 输入 ZIP 名称 / 密码 | 输入合法性校验(Windows 非法字符、两次密码一致) |
| 生成加密 ZIP | 标准库做不到,必须引第三方库 |
| 操作保存到数据库 | SQLite 双表 + 外键级联 |
| 配置保存并下次回填 | JSON + 字段白名单 |
| 打包 exe 后仍能定位同目录的配置和数据库 | 这条最容易写错,见第二节 |
| 操作简单、界面美观 | 自绘按钮、分组框、进度条、可取消 |
最终技术栈:
Python 3.x
wxPython 4.2.4 (wxWidgets 3.2.8 msw, phoenix) —— GUI
pyzipper 0.4.0 —— AES 加密 ZIP 读写
Pillow 11.3.0 —— 可选,图片解码兜底
sqlite3 / json / ctypes / threading —— 标准库
安装:
pip install wxPython pyzipper pillow
为什么选 wxPython 而不是 PyQt/Tkinter?
ListCtrl 这类多列表格要自己拼;代价是:原生控件意味着你要迁就系统主题的脾气。这个代价在第七节会具体咬人一口。
这是需求里最硬的一条约束,也是新手最容易翻车的地方。
先看正确写法:
def app_dir():
"""程序所在目录:.py 直接运行取脚本目录,PyInstaller 打包后取 exe 目录。"""
if getattr(sys, "frozen", False):
# onefile 模式下 sys.executable 是 exe 本身,不是解包的临时目录
return os.path.dirname(os.path.abspath(sys.executable))
return os.path.dirname(os.path.abspath(__file__))
BASE_DIR = app_dir()
CONFIG_PATH = os.path.join(BASE_DIR, CONFIG_NAME)
DB_PATH = os.path.join(BASE_DIR, DB_NAME)
错法 1:直接用相对路径
CONFIG_PATH = "config.json" # ❌
相对路径是相对于当前工作目录(CWD),不是程序目录。用户从桌面快捷方式启动,CWD 可能是 C:WindowsSystem32;从命令行 cd D: && python C:toolzip_maker.py 启动,CWD 是 D:。结果就是配置文件“随机”散落在文件系统里,用户会觉得“我的设置怎么时有时无”。
错法 2:只用 __file__
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) # ❌ 打包后就错了
PyInstaller onefile 模式下,exe 运行时会把自己解压到一个临时目录(形如 C:UsersxxxAppDataLocalTemp_MEIxxxxx),__file__ 指向的是那个临时目录里的脚本。于是:
用户的体验是“每次打开都是空的,历史记录一条都没有”——而且几乎无法自查,因为文件确实被写成功过。
错法 3:用 sys._MEIPASS
BASE_DIR = sys._MEIPASS # ❌ 语义反了
很多博客会告诉你用这个。但 sys._MEIPASS 是只读资源目录,语义是“我打包进去的图标、字体、模板在哪”,不是“用户数据该写在哪”。往里面写数据,同样活不过进程生命周期。
| 运行方式 | sys.frozen | sys.executable | __file__ |
|---|---|---|---|
python zip_maker.py | 不存在 | Python 解释器路径 | 脚本路径 ✅ |
| PyInstaller onefile exe | True | exe 自身路径 ✅ | 临时解包目录 ❌ |
| PyInstaller onedir exe | True | exe 自身路径 ✅ | 临时/内部目录 ❌ |
所以两个分支各取所需,正好互补。用 getattr(sys, "frozen", False) 而不是 sys.frozen 是因为未打包时这个属性根本不存在,直接访问会 AttributeError。
最后一个细节——把路径显示在状态栏上,让用户(和未来的你)随时能自查:
self.CreateStatusBar()
self.SetStatusText(f"配置: {CONFIG_PATH} 数据库: {DB_PATH}")
这一行的排障价值远超它的代码量。任何“我的数据丢了”的报告,第一句话就能问清楚。
先破除一个普遍误解。Python 标准库 zipfile 对加密的支持是极度残缺的:
import zipfile
zf = zipfile.ZipFile("a.zip")
zf.setpassword(b"123456") # ✅ 能用:解密读取传统 ZipCrypto
zf = zipfile.ZipFile("a.zip", "w")
zf.setpassword(b"123456") # ⚠️ 有这个方法,但对写入完全无效
zf.write("f.txt") # 结果:一个毫无加密的普通 zip
setpassword() 的文档写得很清楚:只用于解密。标准库从来没有实现过加密写入。所以那些“用 zipfile 加密压缩”的教程,产出的其实是明文 zip——而且不报错,静默失败,这才是最危险的。
而且即便标准库支持,传统 ZipCrypto 也是一个 1990 年代的算法,已被已知明文攻击彻底攻破,有现成工具能在几分钟内破解。用它等于没加密。
pyzipper 是 zipfile 的一个 fork,API 几乎完全兼容,但补上了 WinZip AES 加密:
with pyzipper.AESZipFile(
self.zip_path, "w",
compression=pyzipper.ZIP_DEFLATED,
encryption=pyzipper.WZ_AES,
) as zf:
zf.setpassword(self.password.encode("utf-8"))
zf.setencryption(pyzipper.WZ_AES, nbits=self.nbits)
...
zf.write(path, arcname)
三个必须注意的点:
setpassword 和 setencryption 都要调。只设密码不设加密方式,某些路径下不会真正启用 AES;setencryption 才是决定密钥长度的那个开关。utf-8,含中文的密码才不会在跨机器解压时对不上。AES_BITS = {"AES-256 (最强)": 256, "AES-192": 192, "AES-128 (兼容性好)": 128}
self.cho_enc = wx.Choice(box_opt, choices=list(AES_BITS.keys()))
界面拿 key、逻辑拿 value,无需维护第二份映射表,也不会出现“下拉框加了一项但逻辑忘了改”的错位。Python 3.7+ 的 dict 有序,list(keys()) 的顺序就是你写的顺序,可以放心依赖。
兼容性提示:WinZip AES 是事实标准,7-Zip、WinRAR、WinZip 都能正常解开。但 Windows 资源管理器内置的“解压缩全部”不认,双击进去会提示密码错误或直接失败。这不是 bug,是系统资源管理器只支持 ZipCrypto。
同名文件从不同目录加进来是很常见的(报告/data.csv 和 备份/data.csv)。ZIP 内部只存 arcname,不去重就会产生两个同名条目——解压时后者覆盖前者,静默丢数据。
used = set()
...
arcname = os.path.basename(path)
stem, ext = os.path.splitext(arcname)
n = 1
while arcname.lower() in used:
arcname = f"{stem}({n}){ext}"
n += 1
used.add(arcname.lower())
用 .lower() 比较是因为 Windows 文件系统大小写不敏感,Data.csv 和 data.csv 解压到同一目录还是会撞。重命名风格 data(1).csv 沿用 Windows 的习惯,用户一看就懂。
压缩 1GB 文件要几十秒。如果在主线程里干,窗口会白屏、标题栏变“(无响应)”,Windows 甚至会建议用户强制结束进程。
GUI 编程的铁律:主线程只做界面,耗时操作一律扔出去;反过来,非主线程绝对不能碰任何控件。
wxPython 里跨线程通知 UI 的标准做法是自定义事件:
ProgressEvent, EVT_PROGRESS = wx.lib.newevent.NewEvent() DoneEvent, EVT_DONE = wx.lib.newevent.NewEvent()
NewEvent() 一次返回两个东西:事件类(用来构造)和事件绑定器(用来 Bind)。工作线程只负责 wx.PostEvent:
wx.PostEvent(self.sink, ProgressEvent(current=i, total=total, name=arcname))
wx.PostEvent 是线程安全的——它把事件塞进主线程的事件队列,由主线程在下一轮事件循环里取出处理。传什么关键字参数,事件对象上就有什么属性,主线程直接 evt.current 取用。
主线程侧的绑定和处理干净得像同步代码:
self.Bind(EVT_PROGRESS, self.on_progress)
self.Bind(EVT_DONE, self.on_done)
def on_progress(self, evt):
self.gauge.SetValue(evt.current)
self.set_status(f"正在压缩 ({evt.current}/{evt.total}): {evt.name}")
顺带说一句 wx.CallAfter:它也线程安全,适合“随手调一下某个函数”。而自定义事件的优势是语义清晰、可被多处监听、参数结构化。任务型的通知(进度/完成)用事件,零散调用用 CallAfter。
class ZipWorker(threading.Thread):
def __init__(self, sink, files, zip_path, password, nbits):
super().__init__(daemon=True)
...
self._cancel = threading.Event()
def cancel(self):
self._cancel.set()
三个设计点:
1. daemon=True:用户点右上角关闭时,主线程退出后进程不会被这个后台线程吊住。
2. 用 threading.Event 而不是布尔标志:Event 的读写有内存屏障保证,不依赖 GIL 的实现细节。虽然 CPython 下布尔标志实际也能跑,但 Event 是表达“我知道自己在写并发代码”的正确姿势。
3. 取消检查点放在循环开头,且要清理残骸:
for i, path in enumerate(self.files, 1):
if self._cancel.is_set():
raise InterruptedError("用户取消操作")
except InterruptedError as e:
self._cleanup()
wx.PostEvent(self.sink, DoneEvent(ok=False, message=str(e), ...))
def _cleanup(self):
try:
if os.path.exists(self.zip_path):
os.remove(self.zip_path)
except OSError:
pass
用异常而非 return 来实现取消,是为了复用 with 语句的退出逻辑。 with pyzipper.AESZipFile(...) 在异常穿出时会正常关闭文件句柄,然后 _cleanup() 才能删得掉这个文件——Windows 上句柄没释放的文件是删不掉的(WinError 32)。顺序错了就会留下一个半成品 zip。
半成品 zip 必须删。留在磁盘上的话,用户会以为压缩成功了,去解压才发现文件不全——而那时原文件可能已经被“压缩后删除”功能清掉了。
except Exception:
self._cleanup()
wx.PostEvent(self.sink, DoneEvent(
ok=False,
message=traceback.format_exc(limit=2).strip().splitlines()[-1],
elapsed=time.time() - started))
format_exc(limit=2) 限制栈深度,取最后一行——也就是 PermissionError: [Errno 13] Permission denied: 'D:/x.zip' 这种真正有信息量的一行。既不会把三十行栈糊到用户脸上,也不会丢掉根因。
一次压缩操作是“一条记录 + N 个文件”,典型的一对多。硬塞进一张表的两种做法都不好:把文件列表 JSON 序列化成一个字段(没法查询、没法统计),或者一个文件一行、记录字段全部冗余(改一处要改 N 行)。
正经拆两张表:
CREATE TABLE IF NOT EXISTS zip_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
zip_name TEXT NOT NULL,
target_dir TEXT NOT NULL,
zip_path TEXT NOT NULL,
file_count INTEGER NOT NULL DEFAULT 0,
source_size INTEGER NOT NULL DEFAULT 0,
zip_size INTEGER NOT NULL DEFAULT 0,
encryption TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT,
elapsed REAL,
password TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS zip_record_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_id INTEGER NOT NULL REFERENCES zip_records(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
file_size INTEGER
);
CREATE INDEX IF NOT EXISTS idx_record_files ON zip_record_files(record_id);
几个容易被忽略的点:
file_count 是刻意的冗余。列表页要显示文件数,如果每行都去 COUNT(*) 子表,一百条记录就是一百次查询。写入时算一次存下来,读取零成本。ON DELETE CASCADE 必须配合 PRAGMA。SQLite 的外键约束默认关闭,而且是“每个连接”级别的设置,不是数据库级别的:def connect(self):
conn = sqlite3.connect(self.path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
漏了这一行,你的 CASCADE 就是一句注释。这也是为什么每次连接都要重新执行——放在建表时执行一次是无效的。
row_factory = sqlite3.Row 让结果支持 row["zip_name"] 按名取值。用下标 row[2] 的代码,在你往 SELECT 里加一个字段的那天就会集体错位,而且不报错。created_at 存 TEXT 而非 INTEGER 时间戳。SQLite 无原生日期类型,存 "2026-08-03 14:30:00" 格式的字符串,字典序即时间序,ORDER BY 和 LIKE '2026-08%' 都直接可用,导出 CSV 给人看也不用转换。“操作记录里加入密码”这个需求来得比数据库晚。用户手上已经有一个装着真实记录的 zip_records.db,CREATE TABLE IF NOT EXISTS 对已存在的表什么都不做,新字段永远加不上去。
cols = {r["name"] for r in conn.execute("PRAGMA table_info(zip_records)")}
if "password" not in cols: # 兼容旧版本已存在的数据库
conn.execute("ALTER TABLE zip_records ADD COLUMN password TEXT")
PRAGMA table_info(表名) 返回每一列的信息,取 name 组成集合,缺谁补谁。这个模式的价值:
NULL;对于单文件小工具,这是性价比最高的迁移方案。局限也要知道:SQLite 的 ALTER TABLE 只能加列,不能改列类型、不能删列、不能加约束。真要改结构,得走“建新表 → 拷数据 → 删旧表 → 改名”的四步流程。
新列一定被追加到列顺序的末尾——这就是为什么第三节强调不能用下标访问结果集。
def connect(self):
conn = sqlite3.connect(self.path)
...
return conn
def list_records(self, keyword=""):
...
with self.connect() as conn:
return conn.execute(sql, args).fetchall()
with sqlite3.connect(...) 管的是事务,不是连接。 它在退出时 commit()(或异常时 rollback()),但不会 close()。所以这里每调用一次就泄漏一个连接,靠垃圾回收兜底。
这个坑在开发时真实咬过自己一次:测试脚本跑完想删掉临时 db 文件,PermissionError: [WinError 32] 另一个程序正在使用此文件。Windows 上没关的文件句柄会锁住文件。
正确写法是嵌套 contextlib.closing,或者干脆持有一个长连接:
from contextlib import closing
with closing(self.connect()) as conn:
with conn: # 事务
return conn.execute(sql, args).fetchall()
代码里保留了原样,因为桌面工具的调用频次低、进程生命周期短,实际不会耗尽句柄。但如果你把这段代码搬到服务端或者高频循环里,它一定会炸。写出来,比藏起来有价值。
class Config:
DEFAULTS = {
"files": [],
"target_dir": "",
"zip_name": "",
"encryption": "AES-256 (最强)",
"remember_password": False,
"password": "",
"window_size": [960, 700],
}
def load(self):
try:
with open(self.path, "r", encoding="utf-8") as f:
saved = json.load(f)
if isinstance(saved, dict):
for k, v in saved.items():
if k in self.DEFAULTS: # ← 关键在这一行
self.data[k] = v
except (FileNotFoundError, ValueError, OSError):
pass
if k in self.DEFAULTS 这个白名单过滤是整个类的核心。 没有它,一个手工编辑过(或版本降级留下)的 config.json 就能往 self.data 里塞任意键;界面代码取到意外的键值,报的错会离现场很远,极难排查。有了它:
DEFAULTS;isinstance(saved, dict) 那一层也不能省。config.json 里如果只有一个 [] 或 "abc",json.load 会成功返回,然后 .items() 抛 AttributeError ——而这个异常不在 except 列表里,程序直接崩在启动阶段,连界面都出不来。
异常一律吞掉是刻意的:配置读失败的正确降级是“用默认值启动”,而不是弹窗拦住用户。第一次运行没有 config.json 更是完全正常的情况。
界面上有 6 个可交互设置项,但 DEFAULTS 里只有 5 个对应键——“压缩成功后删除原文件”故意没有配置键。
这是一个刻意的产品决策:删原文件是不可逆操作(虽然进回收站,但用户不一定会去捡)。如果它被记住,用户上次为某个特殊场景勾了一次,下次打开程序时它还亮着,而用户完全不记得——批量文件就这么没了。
破坏性操作的默认状态必须是关闭,而且不能被持久化。 每次都要用户重新做一次决定,这点“不方便”是设计的一部分,不是遗漏。同理它在界面上是红色的:
self.chk_delete = wx.CheckBox(box_opt, label="压缩成功后删除原文件(移入回收站)") self.chk_delete.SetForegroundColour(wx.Colour(0xC0, 0x39, 0x2B))
而且真正执行前还有一次二次确认:
if self.chk_delete.GetValue() and wx.MessageBox(
f"压缩成功后将把这 {len(self.files)} 个原文件移入回收站。n"
f"确定继续吗?", APP_NAME,
wx.YES_NO | wx.ICON_EXCLAMATION) != wx.YES:
return
def apply_config(self):
self.add_files([p for p in self.cfg["files"] if os.path.isfile(p)], silent=True)
上次记住的文件,这次可能已经被移动、重命名或删除了。启动时用 os.path.isfile 过一遍,避免列表里躺着一堆点了就报错的幽灵条目。silent=True 是不让恢复配置的过程去刷“已添加 N 个文件”的状态栏——那句话应该只在用户真的做了添加动作时出现。
想做一个蓝色强调按钮,直觉写法:
btn = wx.Button(panel, label="开始生成") btn.SetBackgroundColour(wx.Colour(0x2D, 0x6C, 0xDF)) # ❌ Windows 上毫无效果
代码不报错,方法调用成功,按钮依然是系统灰。
原因:Windows Vista 之后,按钮由主题引擎(UxTheme)绘制,整个背景是一张主题位图。SetBackgroundColour 设置的是控件背景色属性,而主题绘制过程根本不读这个属性。这不是 wxPython 的 bug,是原生控件的既定行为——在 GTK 上同一行代码是生效的,所以这个坑只在 Windows 上出现。
解法是换成自绘按钮。wx.lib.buttons.GenButton 是纯 Python 用 DC 画出来的按钮,它自己负责所有像素,所以颜色说了算:
class FlatButton(wx.lib.buttons.GenButton):
"""扁平强调色按钮。Windows 主题下原生 wx.Button 会忽略 SetBackgroundColour,故改用自绘按钮。"""
DISABLED = wx.Colour(0xC4, 0xC9, 0xD2)
def __init__(self, parent, label, color, hover, size=wx.DefaultSize):
super().__init__(parent, label=label, size=size, style=wx.BORDER_NONE)
self._base, self._hover = color, hover
self.SetBezelWidth(0) # 去掉 3D 立体边,才是"扁平"
self.SetUseFocusIndicator(False) # 去掉焦点虚线框
self.SetBackgroundColour(color)
self.SetForegroundColour(wx.WHITE)
f = self.GetFont()
f.SetWeight(wx.FONTWEIGHT_BOLD)
self.SetFont(f)
self.SetCursor(wx.Cursor(wx.CURSOR_HAND))
self.Bind(wx.EVT_ENTER_WINDOW, lambda e: self._tint(self._hover))
self.Bind(wx.EVT_LEAVE_WINDOW, lambda e: self._tint(self._base))
def _tint(self, colour):
if self.IsEnabled():
self.SetBackgroundColour(colour)
self.Refresh()
def Enable(self, enable=True):
ret = super().Enable(enable)
self.SetBackgroundColour(self._base if enable else self.DISABLED)
self.Refresh()
return ret
自绘的代价是所有视觉状态都得自己管。GenButton 不会因为你 Enable(False) 就自动变灰,所以要重写 Enable,在里面手动改色并 Refresh()。悬停高亮同理,靠 EVT_ENTER_WINDOW / EVT_LEAVE_WINDOW 自己切。忘了 Refresh() 的话颜色属性改了但屏幕不重绘,要等到窗口被遮挡再露出才更新——一个非常迷惑的“偶发 bug”。
只有需要强调色的两个按钮(生成、取消)用 FlatButton,其余保持原生 wx.Button。混用是有意的:次要按钮跟随系统主题,视觉层级自然分明,也少写代码。
用户报的现象很具体:最大化窗口后勾选“显示密码”,输入框会移动位置。
有问题的常规思路是:切换时销毁原控件,用新的样式重建。
# ❌ 这会导致布局跳动 self.txt_pwd.Destroy() self.txt_pwd = wx.TextCtrl(parent, style=0 if show else wx.TE_PASSWORD)
wx.TE_PASSWORD 这个样式位只能在创建时指定,运行时改不了,所以“销毁重建”看似是唯一出路。但新建的控件是被 append 到 sizer 末尾的,在 FlexGridSizer 里就是掉到了另一个格子;而且新控件的 best size 与原来未必一致,一整行的高度都可能变。窗口越宽(最大化时),这个错位越明显。
解法:两个控件都建好,叠在一起,只切换谁可见。
class PasswordCtrl(wx.Panel):
"""密码输入框。掩码/明文两个控件叠放并切换显示,避免切换时重建控件导致布局跳动。"""
def __init__(self, parent):
super().__init__(parent)
self.SetBackgroundColour(parent.GetBackgroundColour())
self.masked = wx.TextCtrl(self, style=wx.TE_PASSWORD)
self.plain = wx.TextCtrl(self)
self.plain.Hide()
s = wx.BoxSizer(wx.VERTICAL)
s.Add(self.masked, 0, wx.EXPAND)
s.Add(self.plain, 0, wx.EXPAND)
self.SetSizer(s)
def GetValue(self):
return (self.plain if self.plain.IsShown() else self.masked).GetValue()
def SetValue(self, value):
self.masked.SetValue(value)
self.plain.SetValue(value)
def show_text(self, show):
value = self.GetValue()
self.plain.Show(show)
self.masked.Show(not show)
self.SetValue(value)
self.Layout()
关键在于 Hide() 的控件不占 sizer 空间,所以垂直 BoxSizer 里两个控件的实际效果是“同一位置二选一”。外层 PasswordCtrl 作为一个 wx.Panel,从父布局的角度看尺寸和位置永远不变——父级 sizer 根本不知道里面发生了切换,自然不会重排。
show_text 里先取值、切换、再写回,是因为两个 TextCtrl 各有独立的内容缓冲。用户在掩码框里输入的字符不会自动出现在明文框里,不同步就会“一勾选密码就没了”。
顺带一提,这个封装还带来一个额外好处:GetValue / SetValue 的签名和 wx.TextCtrl 一致,调用方(validate()、save_config())完全不需要知道内部有两个控件。
需求是:在操作记录里选一条,预览其中的图片。设计上有三个决策。
原文件可能已经被“压缩后删除”清掉了,也可能被用户移走。ZIP 才是那条记录的权威内容。所以主路径是拿记录里存的密码去解密 zip,只在 zip 不可用时回退到磁盘:
def image_items(self, row):
"""收集可预览的图片,返回 (zip 句柄, [(名称, 读字节函数)], 来源说明)。
优先用记录里的密码从 zip 内解密读取;zip 缺失或密码不可用时回退到磁盘上的原文件。
"""
pwd = deobfuscate(row["password"])
zip_path = row["zip_path"] or ""
if os.path.isfile(zip_path):
zf = None
try:
zf = pyzipper.AESZipFile(zip_path)
if pwd:
zf.setpassword(pwd.encode("utf-8"))
names = sorted(n for n in zf.namelist() if is_image(n))
if names:
zf.open(names[0]).close() # 先探一次,密码不对就走回退分支
return zf, [(n, lambda n=n: zf.read(n)) for n in names], "ZIP 内解密"
except Exception:
pass
if zf:
zf.close()
items = [(os.path.basename(f["file_path"]), f["file_path"])
for f in self.db.record_files(row["id"])
if is_image(f["file_path"]) and os.path.isfile(f["file_path"])]
return None, [(n, lambda p=p: read_file(p)) for n, p in items], "磁盘原文件"
zf.open(names[0]).close() 这一行是探测:密码错误时 open 会立刻抛异常,此时还来得及走回退分支。如果不探测,等到用户点开预览窗口才发现每张图都读不出来,体验就差了一截。
来源 字符串会显示在预览窗标题栏上(“来源: ZIP 内解密” / “来源: 磁盘原文件”)。告诉用户他正在看的是哪份数据,这在两份数据可能不一致时很重要。
用户的压缩包可能有几十张 1024×1536 的图。一次性全解密、全解码,内存和等待时间都不可接受。
所以列表里存的不是数据,而是取数据的函数:
[(n, lambda n=n: zf.read(n)) for n in names]
PreviewDialog 只在切到某一张时才调用它:
def load(self):
name, read = self.items[self.index]
self.image, self.cache, err = None, (None, None), ""
try:
self.image, err = decode_image(read()) # ← 此刻才真正读取
except Exception as e:
err = str(e) or e.__class__.__name__
这里的 lambda n=n: 不是多余的,是躲一个经典的 Python 陷阱。
# ❌ 错误示范 fns = [lambda: zf.read(n) for n in names] fns[0]() # 读的是 names[-1],不是 names[0]
Python 闭包捕获的是变量本身(late binding),不是变量当时的值。循环结束后 n 停在最后一个元素上,所有 lambda 都会读同一个文件。用 n=n 把当前值绑成默认参数,就在函数定义时“快照”下来了。同样的技巧在下一行的 lambda p=p: read_file(p) 里再用了一次。
这个 bug 极其阴险:图片列表长度、文件名显示全都正常,只是每一张显示的都是最后一张的内容。如果你的测试数据恰好是两张相似的图,很可能看不出来。
功能写完自测通过,交付。用户回了三个字:“预览不成功”。
没有错误信息、没有操作步骤。这时候最快的路径不是追问,而是直接看真实数据:读 zip_records.db 里的实际记录,打开用户真实的那个 zip。
结果一目了然——压缩包里 8 个文件全是 .webp(AI 生成的图片批次,文件名形如 assets_task_01jxy..._img_1.webp)。而自己写测试时造的 fixture 全是 PNG。
验证根因:
>>> img = wx.Image(io.BytesIO(webp_bytes)) >>> img.IsOk() False >>> wx.BITMAP_TYPE_WEBP AttributeError: module 'wx' has no attribute 'BITMAP_TYPE_WEBP'
wxWidgets 3.2.8 里没有 WebP 解码器。 WebP 支持是 wxWidgets 3.3 才加进去的,当前 wxPython 4.2.4 绑的还是 3.2.x。所以每一张图都显示“无法识别的图片格式”,功能等于不存在。
解法:让 Pillow 兜底。
try: # wxWidgets 3.2 不带 webp 解码器,用 Pillow 兜底
from PIL import Image as PILImage, ImageOps
except ImportError:
PILImage = None
def decode_image(data):
"""把图片字节解成 wx.Image,失败时返回 (None, 原因)。
wx 自带解码器不认的格式(webp 最常见)交给 Pillow,顺带按 EXIF 摆正方向。
"""
with wx.LogNull(): # 解析失败时不弹 wx 自带的错误框
img = wx.Image(io.BytesIO(data))
if img.IsOk():
return img, ""
if PILImage is None:
return None, "格式不支持,安装 Pillow 后可预览(pip install pillow)"
try:
with PILImage.open(io.BytesIO(data)) as pil:
rgba = ImageOps.exif_transpose(pil).convert("RGBA")
img = wx.Image(rgba.width, rgba.height)
raw = rgba.tobytes()
img.SetData(rgba.convert("RGB").tobytes())
img.SetAlpha(raw[3::4])
return img, ""
except PILImage.UnidentifiedImageError:
return None, "无法识别的图片格式"
except Exception as e:
return None, f"无法解码: {e or e.__class__.__name__}"
拆开看每个细节:
with wx.LogNull():wx.Image 解码失败会自己弹一个错误对话框。LogNull 在作用域内屏蔽 wx 的日志目标,让我们能安静地“试一下”再决定怎么处理。没有它,用户会先吃一个丑陋的系统弹窗。ImageOps.exif_transpose:手机拍的照片方向信息在 EXIF 里,不处理会横躺。wx 原生解码不管这个,用 Pillow 时顺手做掉。Pillow → wx.Image 的通道拆分:这是最容易写错的地方。wx.Image 把 RGB 和 Alpha 分开存(SetData 收 3 字节/像素,SetAlpha 收 1 字节/像素),而 Pillow 的 RGBA tobytes() 是 4 字节交错的。所以 raw[3::4] 用切片步长 4 从偏移 3 开始取,正好抽出所有 alpha 字节。RGB 部分则用 convert("RGB").tobytes() 让 Pillow 自己去掉 alpha 通道——比手写切片拼接更不容易错。UnidentifiedImageError 单独捕获:不加这一层的话,用户会在界面上看到 无法解码: cannot identify image file <_io.BytesIO object at 0x000001F2...>。Python 的异常 repr 泄漏到 UI 上是很不专业的,单独捕获这个最常见的异常,换成一句人话。e or e.__class__.__name__:某些异常的 str() 是空字符串,那样会显示成 无法解码: 。退化到类名至少还有信息。Pillow 是可选依赖(try/except ImportError),没装时程序照常运行,只是遇到 webp 会提示“安装 Pillow 后可预览”。但打包 exe 时不能漏——用户 100% 会走到这条路径。
修复后用用户真实的那个压缩包验证:8/8 全部解码成功,逐像素与 Pillow 参考结果一致,HasAlpha() 为 True,ConvertToBitmap() 正常。
def on_paint(self, _):
dc = wx.AutoBufferedPaintDC(self.canvas)
dc.SetBackground(wx.Brush(self.BG))
dc.Clear()
cw, ch = self.canvas.GetClientSize()
if not self.image or cw < 4 or ch < 4:
return
iw, ih = self.image.GetWidth(), self.image.GetHeight()
scale = min(cw / iw, ch / ih, 1.0) # 只缩不放,避免拉伸模糊
w, h = max(1, int(iw * scale)), max(1, int(ih * scale))
if self.cache[0] != (w, h):
img = self.image if (w, h) == (iw, ih) else self.image.Scale(
w, h, wx.IMAGE_QUALITY_HIGH)
self.cache = ((w, h), img.ConvertToBitmap())
dc.DrawBitmap(self.cache[1], (cw - w) // 2, (ch - h) // 2, True)
wx.AutoBufferedPaintDC + SetBackgroundStyle(wx.BG_STYLE_PAINT) 是消除闪烁的标准组合。先在内存位图上画完再一次性贴到屏幕,避免用户看到“清背景→画图”的中间态。BG_STYLE_PAINT 告诉 wx“背景我自己画,你别擦”。min(..., 1.0) 里的 1.0 意思是小图保持原始尺寸,不放大。放大只会得到一张模糊的图,不如让用户看清 真实像素。max(1, ...) 防御极端窄窗口下算出 0 宽度——Scale(0, h) 会抛异常。(w, h)。窗口拖动时 EVT_SIZE 会高频触发重绘,如果每次都 Scale(...IMAGE_QUALITY_HIGH),1024×1536 的图会明显卡顿。尺寸没变就复用上次的 Bitmap。DrawBitmap(..., True) 最后那个参数是 useMask,让 alpha 通道生效,透明 PNG 才不会显示成黑块。(cw - w) // 2 让图片在画布中居中。键盘导航用 EVT_CHAR_HOOK 而不是 EVT_KEY_DOWN:
self.Bind(wx.EVT_CHAR_HOOK, self.on_key)
def on_key(self, evt):
key = evt.GetKeyCode()
if key in (wx.WXK_LEFT, wx.WXK_UP, wx.WXK_PAGEUP):
self.step(-1)
elif key in (wx.WXK_RIGHT, wx.WXK_DOWN, wx.WXK_PAGEDOWN, wx.WXK_SPACE):
self.step(1)
else:
evt.Skip()
EVT_CHAR_HOOK 绑在 Dialog 上,在按键被分派给具体子控件之前就能拿到。用 EVT_KEY_DOWN 的话,焦点在“下一张”按钮上时,方向键会被按钮自己吃掉去做焦点导航。else: evt.Skip() 必须留着——不然 Tab、Esc 等键全部失灵。
step 用取模实现循环翻页,最后一张的下一张回到第一张:
def step(self, delta):
if len(self.items) > 1:
self.index = (self.index + delta) % len(self.items)
self.load()
“压缩成功后删除原文件”这个需求,os.remove 是不能用的——它是彻底删除,误操作无法挽回。正确做法是走系统回收站,用户还有一次后悔的机会。
Python 标准库没有回收站 API(send2trash 是第三方库)。为了不增加依赖,用 ctypes 直接调 Win32 的 SHFileOperationW:
def move_to_trash(paths):
"""把文件移入回收站,返回 (成功数, [(路径, 失败原因)])。
Windows 走 SHFileOperationW + FOF_ALLOWUNDO,误删可从回收站还原;
其他平台没有统一的回收站 API,退化为直接删除。
"""
paths = [os.path.abspath(p) for p in paths if os.path.isfile(p)]
if not paths:
return 0, []
if sys.platform != "win32":
ok, failed = 0, []
for p in paths:
try:
os.remove(p)
ok += 1
except OSError as e:
failed.append((p, str(e)))
return ok, failed
from ctypes import wintypes
class SHFILEOPSTRUCTW(ctypes.Structure):
_fields_ = [
("hwnd", wintypes.HWND),
("wFunc", wintypes.UINT),
("pFrom", wintypes.LPCWSTR),
("pTo", wintypes.LPCWSTR),
("fFlags", ctypes.c_uint),
("fAnyOperationsAborted", wintypes.BOOL),
("hNameMappings", ctypes.c_void_p),
("lpszProgressTitle", wintypes.LPCWSTR),
]
FO_DELETE = 3
FOF_SILENT, FOF_NOCONFIRMATION, FOF_ALLOWUNDO, FOF_NOERRORUI = 0x04, 0x10, 0x40, 0x400
op = SHFILEOPSTRUCTW()
op.wFunc = FO_DELETE
# pFrom 是双 结尾的路径串
op.pFrom = " ".join(paths) + " "
op.fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI
ret = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(op))
if ret != 0:
return 0, [(p, f"SHFileOperation 错误码 {ret}") for p in paths]
if op.fAnyOperationsAborted:
return 0, [(p, "操作被中止") for p in paths]
remaining = [p for p in paths if os.path.isfile(p)]
return len(paths) - len(remaining), [(p, "仍然存在") for p in remaining]
几个必须踩准的细节:
FOF_ALLOWUNDO 是“进回收站”的开关。漏了这个标志,FO_DELETE 就是永久删除。整个函数的价值全押在这一个位上。pFrom 必须是双