Python+wxPython从零编写一个AES加 ZIP工具(附源码)
作者:winfredzhang
一、需求与技术选型
原始需求很朴素,但每一条都藏着一个技术决策点:
| 需求 | 决策点 |
|---|---|
| 选择多个文件 | 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?
- Tkinter 自带但界面观感太"上世纪",
ListCtrl这类多列表格要自己拼; - PyQt/PySide 好看,但 LGPL/GPL 授权问题在商用场景上要额外考虑,打包体积也更大;
- wxPython 用的是操作系统原生控件,Windows 上天然就是 Win 的观感,打包后体积可控。
代价是:原生控件意味着你要迁就系统主题的脾气。这个代价在第七节会具体咬人一口。
二、第一个坑:打包成 exe 后配置文件去哪了
这是需求里最硬的一条约束,也是新手最容易翻车的地方。
先看正确写法:
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:\Windows\System32;从命令行 cd D:\ && python C:\tool\zip_maker.py 启动,CWD 是 D:\。结果就是配置文件"随机"散落在文件系统里,用户会觉得"我的设置怎么时有时无"。
错法 2:只用 __file__
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) # ❌ 打包后就错了
PyInstaller onefile 模式下,exe 运行时会把自己解压到一个临时目录(形如 C:\Users\xxx\AppData\Local\Temp\_MEIxxxxx),__file__ 指向的是那个临时目录里的脚本。于是:
- 配置写进了临时目录;
- 程序一退出,临时目录连同你的配置和数据库一起被删除。
用户的体验是"每次打开都是空的,历史记录一条都没有"——而且几乎无法自查,因为文件确实被写成功过。
错法 3:用 sys._MEIPASS
BASE_DIR = sys._MEIPASS # ❌ 语义反了
很多博客会告诉你用这个。但 sys._MEIPASS 是只读资源目录,语义是"我打包进去的图标、字体、模板在哪",不是"用户数据该写在哪"。往里面写数据,同样活不过进程生命周期。
为什么sys.executable是对的
| 运行方式 | 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}")
这一行的排障价值远超它的代码量。任何"我的数据丢了"的报告,第一句话就能问清楚。
三、加密 ZIP:为什么标准库 zipfile 根本写不了
先破除一个普遍误解。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 的正确用法
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才是决定密钥长度的那个开关。- 密码必须是 bytes,且编码要固定。这里统一用
utf-8,含中文的密码才不会在跨机器解压时对不上。 - AES 位数暴露给用户选。用一个字典把界面文案和数值绑在一起:
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 的习惯,用户一看就懂。
四、不卡界面:工作线程 + 自定义 wx 事件
压缩 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' 这种真正有信息量的一行。既不会把三十行栈糊到用户脸上,也不会丢掉根因。
五、SQLite 双表设计与"加列即迁移"
一次压缩操作是"一条记录 + 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; - 零配置:不需要 Alembic 那种迁移框架,也不需要维护版本号表。
对于单文件小工具,这是性价比最高的迁移方案。局限也要知道: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 个文件"的状态栏——那句话应该只在用户真的做了添加动作时出现。
七、UI 层的两个反直觉修复
Windows 上原生按钮会无视你的背景色
想做一个蓝色强调按钮,直觉写法:
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,只在 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 极其阴险:图片列表长度、文件名显示全都正常,只是每一张显示的都是最后一张的内容。如果你的测试数据恰好是两张相似的图,很可能看不出来。
决策三(也是用户实际报的 bug):wxWidgets 不认 WebP
功能写完自测通过,交付。用户回了三个字:“预览不成功”。
没有错误信息、没有操作步骤。这时候最快的路径不是追问,而是直接看真实数据:读 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 的日志目标,让我们能安静地"试一下"再决定怎么处理。没有它,用户会先吃一个丑陋的系统弹窗。- 先试 wx 再试 Pillow:wx 走的是 C++ 原生解码,PNG/JPG 这些常见格式更快,也不引入额外依赖。Pillow 只是兜底。
ImageOps.exif_transpose:手机拍的照片方向信息在 EXIF 里,不处理会横躺。wx 原生解码不管这个,用 Pillow 时顺手做掉。Pillow → wx.Image的通道拆分:这是最容易写错的地方。wx.Image把 RGB 和 Alpha 分开存(SetData收 3 字节/像素,SetAlpha收 1 字节/像素),而 Pillow 的 RGBAtobytes()是 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()
九、删原文件:用 ctypes 调 Windows 回收站
"压缩成功后删除原文件"这个需求,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 是双 \0 结尾的路径串
op.pFrom = "\0".join(paths) + "\0\0"
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必须是双\0结尾的字符串。这是 Win32 表达"字符串数组"的老式约定:路径之间用\0分隔,整个列表再以一个额外的\0收尾。只写一个\0会导致最后一个路径被截断或读到越界内存。这也是为什么能一次删多个文件而只调一次 API。- 结构体字段顺序和类型必须与头文件严格一致。
ctypes.Structure是按_fields_顺序做内存布局的,错一个字段类型就是内存错位,轻则参数乱掉,重则崩溃。 SHFileOperationW的返回值不是布尔,0 才是成功,非 0 是错误码。而且成功返回 0 不代表文件真的删了——所以后面还要fAnyOperationsAborted和os.path.isfile两道复查。API 说成功、文件还在的情况真实存在(比如被其他进程占用)。from ctypes import wintypes放在函数内部。wintypes在非 Windows 平台上导入会失败,放在模块顶层会让整个程序在 Linux/macOS 上直接 import 不进来。放在 win32 分支之后,非 Windows 平台永远不会执行到。- 返回
(成功数, 失败清单)而不是布尔。批量操作的结果天然是部分成功的,调用方需要知道具体哪些失败、为什么失败,才能给用户有用的提示。
调用侧还要同步界面状态——删掉的文件不能继续留在"待压缩文件"列表里:
def trash_sources(self, info):
"""把已成功压缩的原文件移入回收站,并从待压缩列表中同步移除。"""
ok, failed = move_to_trash(info["files"])
gone = {p for p in info["files"] if not os.path.isfile(p)}
for i in range(len(self.files) - 1, -1, -1):
if self.files[i] in gone:
self.list.DeleteItem(i)
del self.files[i]
self.refresh_count()
if failed:
wx.MessageBox("以下原文件未能删除:\n" +
"\n".join(f"{p}\n {why}" for p, why in failed[:5]),
APP_NAME, wx.ICON_WARNING)
return f" · 已将 {ok} 个原文件移入回收站" if ok else ""
range(len - 1, -1, -1) 倒序遍历删除是必须的。正序删除时,DeleteItem(3) 之后原来的第 4 项变成了第 3 项,索引全部错位,会漏删或删错。倒序遍历时被删项之后的索引变化不影响还没遍历到的前面部分。
判断"删掉了哪些"用 os.path.isfile 复查,而不是信 move_to_trash 的成功数。成功数只是个计数,不告诉你具体是哪几个成功。以磁盘实际状态为准最可靠。
failed[:5] 限制弹窗只列 5 条——一次删 200 个文件全失败的话,弹窗会长到超出屏幕,用户连"确定"按钮都点不到。
十、踩坑合集
把前面散落的坑和几个额外的集中列一遍,方便速查。
| # | 坑 | 现象 | 根因 / 解法 |
|---|---|---|---|
| 1 | __file__ 定位配置 | 打包后配置每次都丢 | onefile 的 __file__ 在临时解包目录,进程退出即删。改用 sys.frozen + sys.executable |
| 2 | zipfile.setpassword 写入 | 生成的 zip 毫无加密,且不报错 | 标准库 setpassword 只用于解密。改用 pyzipper.AESZipFile |
| 3 | Windows 按钮改色 | SetBackgroundColour 静默无效 | UxTheme 主题绘制不读背景色属性。改用自绘 GenButton,并自己管 disabled/hover |
| 4 | 切换密码可见性 | 最大化后输入框跳位 | TE_PASSWORD 只能创建时指定,销毁重建打乱 sizer。改为两控件叠放 Show/Hide |
| 5 | WebP 预览 | 全部显示"无法识别的图片格式" | wxWidgets 3.2.8 无 WebP 解码器,无 wx.BITMAP_TYPE_WEBP。Pillow 兜底 |
| 6 | 循环里建 lambda | 每张图都显示最后一张的内容 | Python 闭包 late binding。用 lambda n=n: 绑定默认参数快照 |
| 7 | with sqlite3.connect() | 删 db 文件报 WinError 32 | 该上下文管理器只管事务不 close 连接。需嵌套 contextlib.closing |
另外三个在写测试时才暴露的:
8. Pillow 存 WebP 默认是有损的。 造测试 fixture 时用 Image.new(...).save("x.webp"),然后断言解码出来的像素是 (30, 90, 200)——实际拿到 (31, 90, 202)。差了 1~2 个色阶,看起来像解码器串色 bug,其实是 WebP 有损压缩的正常损失。做像素级断言的 fixture 必须 save(path, lossless=True)。
9. wx.ScreenDC 截图不可靠。 想用截图验证界面渲染,Blit 出来的图大面积空白带斜向撕裂,大概率跟 DPI 缩放和桌面合成(DWM)有关。GUI 的自动化验证不要依赖截图,改成程序化断言更稳:用 GetRed/GetGreen/GetBlue 抽查像素、用 bitmap.IsOk() 确认位图已生成、用矩形相交判断控件有没有重叠。
10. py_compile.compile(..., cfile=os.devnull) 在 Windows 上会炸。 报 FileExistsError: nul is a non-regular file。Windows 的 nul 设备不是常规文件,写不进去。老实编译到临时文件再删掉。
十一、必须说清楚的安全性问题
这一节比前面所有功能都重要。
代码里有一对函数:
def obfuscate(text):
"""base64 混淆存储的密码。仅防止肉眼直读,不是加密。"""
return base64.b64encode(text.encode("utf-8")).decode("ascii") if text else ""
def deobfuscate(text):
if not text:
return ""
try:
return base64.b64decode(text.encode("ascii")).decode("utf-8")
except (ValueError, UnicodeDecodeError):
return ""
base64 不是加密,是编码。 它没有密钥,任何人拿到密文就能还原明文——一行 Python、甚至一个在线工具就够了。
>>> base64.b64decode("MTIzNDU2").decode()
'123456'
它在这里的作用只有一个:防止密码在文本编辑器里被瞄到。用户打开 config.json 或用 DB 工具浏览 zip_records.db 时,看到的是 MTIzNDU2 而不是 123456,不会被路过的同事一眼记住。
它挡不住的:
- 任何人拿到
zip_records.db文件 → 所有历史密码明文可得; - 任何人拿到
config.json(且用户勾了"记住密码")→ 当前密码明文可得; - 恶意软件扫描用户目录 → 同上。
所以界面上的复选框文案是 “记住密码(明文风险,仅本机)” ——直接把风险写在标签上,而不是藏在文档里。而且第六节提到的:不勾"记住密码"时,Config.save() 会主动把这个字段写空,不留残留:
def save(self):
out = dict(self.data)
if out["remember_password"] and out["password"]:
out["password"] = obfuscate(out["password"])
else:
out["password"] = "" # 不记住就清空,不留残留
为什么不做真加密
因为在这个场景下做不到。要加密就要有密钥,密钥要么:
- 也存在本地 → 攻击者拿到程序就拿到密钥,等于没加密(这是所谓 “security through obscurity”);
- 让用户每次输主密码 → 那用户不如直接记住 zip 密码,功能失去意义。
这是密码管理器要解决的问题,不是一个压缩工具该解决的问题。
如果你要把这段代码用在多人共用的机器、或有合规要求的环境,正确做法是:
- 根本不存密码。历史记录里只记"用过密码"这个事实。代价是预览功能要让用户手动输密码。
- 用 Windows DPAPI(
CryptProtectData)。它用当前用户的登录凭据派生密钥,密文只有同一 Windows 账户能解开,拷到别的机器上是废数据。这是 Windows 上唯一"不用管密钥"的正经方案。 - 接系统凭据管理器(
keyring库)。密码存进 Windows 凭据保管库 / macOS Keychain,程序只存一个引用。
顺便说清楚:ZIP 文件本身的 AES-256 加密是真加密,是可靠的。 本节讨论的风险只涉及"程序为了方便帮你记下的那份密码副本"。压缩包本身拿到任何机器上,没有密码都是打不开的。
十二、已知短板与改进方向
一份诚实的自我检查,也是这份代码最有参考价值的部分。
1. 数据库连接不 close(第五节详述)。 桌面工具低频调用不致命,搬到高频场景必炸。修复:contextlib.closing。
2. 删原文件前没有回读校验。 trash_sources 只信 evt.ok(压缩线程没抛异常)就动手删。更稳的做法是先把 zip 完整读一遍再删:
with pyzipper.AESZipFile(zip_path) as zf:
zf.setpassword(pwd.encode("utf-8"))
bad = zf.testzip() # 校验所有条目的 CRC
assert bad is None
在执行不可逆操作之前,验证可逆的那一半确实成功了——这是删除类功能的基本原则。当前实现只做到了"进回收站"这一层兜底。
3. zf.read() 把整张图一次性读进内存。 单张 1024×1536 的 webp 不到 1MB 无所谓,但如果压缩包里是 100MB 的 TIFF,切一张就吃 100MB。改进方向是 zf.open() 流式读,配合 Pillow 的 draft() 做降采样解码。
4. PreviewDialog 的闭包捕获了 zf,而 on_preview 的 finally 会关掉它。
zf, items, note = self.image_items(r)
try:
...
with PreviewDialog(self, r["zip_name"], items, note) as dlg:
dlg.ShowModal()
finally:
if zf:
zf.close()
这段**只因为对话框是模态的(ShowModal 阻塞)**才安全——finally 执行时对话框已经销毁,没人再用那些闭包了。哪天有人把它改成 Show() 非模态,闭包就会去读一个已关闭的 ZipFile,抛 ValueError: seek of closed file。这是一个隐式的耦合,应该用注释标注,或者干脆让 PreviewDialog 自己持有并负责关闭 zf。
5. 进度粒度是"文件级"的。 压 1000 个小文件进度条很流畅,压 1 个 5GB 的文件则会从 0% 直接跳到 100%,中间毫无反馈。要做字节级进度,得绕过 zf.write() 自己分块喂 zf.open(name, "w")。
6. 大量文件时 ListCtrl 会慢。 每 InsertItem 一次就触发一次重绘。加 Freeze() / Thaw() 包住批量插入可以显著改善;上万条则应该换 LC_VIRTUAL 虚拟列表模式。
7. 没有"只压缩不带路径"之外的选项。 现在一律用 os.path.basename 平铺,无法保留目录结构。加个"保留相对路径"的选项并不难,但要处理跨盘符时公共前缀不存在的情况。
十三、打包与运行
# 安装依赖 pip install wxPython pyzipper pillow # 直接运行 python zip_maker.py # 打包(-w 不显示控制台,-F 单文件) pyinstaller -w -F zip_maker.py
打包后 dist/zip_maker.exe 可以随意搬动,config.json 和 zip_records.db 会在 exe 同目录生成——这正是第二节那 6 行代码保证的。
最后一个 Windows 小细节,在 __main__ 里:
if __name__ == "__main__":
if sys.platform == "win32":
try:
import ctypes
ctypes.windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
App(False).MainLoop()
不声明 DPI 感知,Windows 会把窗口按系统缩放比例做位图拉伸,在 150%/200% 缩放的高分屏上,整个界面的文字会有一层明显的模糊。SetProcessDpiAwareness(1) 告诉系统"我自己按物理像素画",字体就锐利了。
必须包在 try/except 里:shcore.dll 是 Windows 8.1 才有的,更老的系统上会 AttributeError。而且这个调用必须在创建任何窗口之前执行——放到 App.OnInit 里就晚了。
App(False) 的 False 是 redirect 参数,意思是不要把 stdout/stderr 重定向到一个弹出窗口。默认行为在打包后会让每个 print 都弹一个框,非常烦人。
结语
这个工具最终 1106 行,功能清单不长:多选文件、AES 加密压缩、操作落库、配置回填、历史查询、图片预览、原文件回收。但里面有价值的东西不在功能上,而在那些"看起来该这么写、实际不能这么写"的地方:
- 打包后的路径语义,
__file__/sys.executable/sys._MEIPASS三者的分工; - 标准库
zipfile加密支持的静默失败——最危险的 bug 是不报错的那种; - 原生控件在不同平台的脾气(
SetBackgroundColour在 GTK 生效、在 Windows 不生效); - Python 闭包的 late binding,在循环里造函数时永远要想一次;
- 自造的测试数据和用户真实数据的分布差异——PNG 全绿、WebP 全红,这是整个项目里最贵的一课。
最后那条值得单独强调。功能上线前我跑了 36 项断言全部通过,用户只回了三个字"预览不成功"。测试通过和功能可用之间的距离,就是我的 fixture 和用户真实文件之间的距离。
以上就是Python+wxPython从零编写一个AES加 ZIP工具(附源码)的详细内容,更多关于Python加密zip的资料请关注脚本之家其它相关文章!
