前言
最近做了一个小工具:程序启动器。功能听起来很朴素——扫描本机安装过的程序,做成一个可以搜索、双击运行的列表,跟 Windows 自带的开始菜单搜索有点像。但真正把它做"能用、能打包、能长期维护"的过程中,踩了几个很典型的坑:
- 程序打包成 exe 之后,配置文件和数据库到底应该存哪里?
- 扫描"已安装程序"这件事,到底该扫哪些数据源才算全?
- GUI 程序里做耗时操作(扫描),怎么才能不卡界面?
- SQLite 怎么设计才能做到"重复扫描不产生脏数据"?
这篇文章就把这个项目的源码从头到尾拆一遍,重点讲清楚"为什么这么写",而不只是"写了什么"。完整代码大约 770 行,技术栈是 wxPython(GUI)+ SQLite(数据)+ JSON(配置)+ threading(后台任务),最终用 PyInstaller 打包成单文件 exe。
C:\myApp\files (8)

一、需求拆解与整体架构
把需求拆成四块,正好对应代码里的四个模块:
| 需求 | 对应模块 |
|---|---|
| 找到本机装了哪些程序 | scan_start_menu / scan_registry / scan_command_line_tools |
| 存起来,支持搜索 | DBManager(SQLite) |
| 记住用户的操作习惯 | ConfigManager(JSON) |
| 界面 + 交互 | MainFrame(wxPython) |
数据流是一条很直白的单向管道:
系统数据源(开始菜单/注册表/PATH)
│ scan_*()
▼
[ (name, path), ... ] ← 内存中的元组列表
│ db.bulk_insert_scanned()
▼
SQLite (apps.db)
│ db.search(keyword)
▼
wx.ListCtrl 界面渲染
理解了这条链路,后面看代码就不会散。
二、最容易被忽视的坑:exe 打包后到底该往哪写文件
这是我在需求里被特别强调的一点:不管是直接跑 .py,还是打包成 .exe,配置文件和数据库都要落在"程序自身所在的文件夹"。这个需求听起来简单,但背后有个经典陷阱。
很多人的第一反应是用 os.getcwd()(当前工作目录),但这是错的——如果用户从别的目录、或者创建了桌面快捷方式来启动这个 exe,工作目录很可能根本不是 exe 所在的文件夹,配置文件就会莫名其妙地散落到别处。
第二个常见错误是在 PyInstaller 单文件模式(-F)下使用 sys._MEIPASS。_MEIPASS 指向的是程序启动时解压出来的临时目录,每次运行都会变、程序退出后还会被清理,用它来存数据库等于"每次都是一个新数据库"。
正确的做法是区分"是否被冻结(frozen)":
def get_base_path() -> str:
"""返回程序自身所在目录(用于存放配置文件和数据库文件)"""
if getattr(sys, "frozen", False):
# PyInstaller 打包后运行(无论 -F 单文件还是 -D 目录模式)
# sys.executable 始终指向"最终生成的 exe 文件"本身的位置,
# 而不是 -F 模式下的临时解压目录(sys._MEIPASS 才是临时目录,这里不能用它)。
base = os.path.dirname(os.path.abspath(sys.executable))
else:
# 直接以 .py 方式运行
base = os.path.dirname(os.path.abspath(__file__))
return base
BASE_DIR = get_base_path()
DB_PATH = os.path.join(BASE_DIR, DB_FILENAME)
CONFIG_PATH = os.path.join(BASE_DIR, CONFIG_FILENAME)
关键点在 sys.frozen 这个标志:PyInstaller、cx_Freeze 等打包工具在运行时都会给 sys 对象加上这个属性。只要判断出"我是被打包过的",就用 sys.executable(exe 自身的真实路径,而不是临时解压路径)来定位目录;否则退回到 __file__(脚本自身路径)。两个分支殊途同归——最终都拿到"程序所在的文件夹",DB_PATH 和 CONFIG_PATH 在模块加载时就被确定为全局常量,后面所有读写都基于这两个绝对路径,不会再受工作目录影响。
三、数据从哪来:三路扫描的设计
这是整个项目里最有意思的部分。一开始我只做了两路扫描:
- 开始菜单快捷方式(
.lnk文件) - 注册表卸载列表(
...\Uninstall键)
跑起来之后发现一个问题:像 Claude Code、Codex 这类通过 npm install -g 装的命令行工具,两路都扫不到。原因也很直接——这类工具的安装方式跟传统 GUI 软件完全不同:npm 只是往全局目录里丢一个可执行文件(Windows 下是 .cmd shim),再把这个目录塞进 PATH 环境变量,既不建快捷方式,也不写注册表。这倒逼出了第三路扫描。三路扫描各自的实现思路如下。
3.1 开始菜单扫描:最简单也最可靠
def scan_start_menu():
results = []
candidates = []
program_data = os.environ.get("ProgramData")
app_data = os.environ.get("APPDATA")
if program_data:
candidates.append(os.path.join(program_data, "Microsoft", "Windows", "Start Menu", "Programs"))
if app_data:
candidates.append(os.path.join(app_data, "Microsoft", "Windows", "Start Menu", "Programs"))
for base in candidates:
if not os.path.isdir(base):
continue
for root, _dirs, files in os.walk(base):
for f in files:
if f.lower().endswith(".lnk"):
full_path = os.path.join(root, f)
name = os.path.splitext(f)[0]
results.append((name, full_path))
return results
这里有个讨巧的地方:不需要解析 .lnk 文件指向的真实目标(那需要额外依赖 pywin32 去调用 COM 接口 WScript.Shell)。因为 Windows Shell 本身就认识 .lnk 文件,直接用 os.startfile(lnk_path) 就能像双击图标一样正确启动,所以扫描阶段只需要把 .lnk 的路径原样存下来。这个决定省掉了一个第三方依赖,也是"少即是多"的一个例子。
ProgramData 目录下是所有用户共享的开始菜单项,APPDATA 下是当前用户私有的,两个都要扫,用 os.walk 递归是因为开始菜单里经常有分类子文件夹(比如"Microsoft Office")。
3.2 注册表扫描:拿到"官方认证"的安装列表
def scan_registry():
if not IS_WINDOWS:
return []
results = []
roots = [
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"),
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
]
for hive, path in roots:
try:
key = winreg.OpenKey(hive, path)
except FileNotFoundError:
continue
...
这里有几个细节值得展开:
- 为什么要查三个根键,而不是一个?
SOFTWARE\...\Uninstall是 64 位程序注册的地方;32 位程序在 64 位系统上会被重定向到WOW6432Node子键下;还有些程序(尤其是"仅当前用户安装"的)写在HKEY_CURRENT_USER而不是HKEY_LOCAL_MACHINE。漏掉任何一个都会让扫描结果不完整。 SystemComponent过滤:注册表里除了真正的应用程序,还有大量.NET 运行库、驱动补丁之类的"系统组件",它们也会出现在同一个 Uninstall 列表里。微软的约定是给这类条目打上SystemComponent=1,代码里直接把它们过滤掉,避免列表被无意义的补丁项淹没。- 可执行文件路径的两级兜底:优先从
DisplayIcon字段拿(它通常直接指向 exe,但可能带,0这种图标索引后缀,需要split(",")[0]切掉);如果这个字段没有或者指向的文件不存在,再退而求其次,去InstallLocation目录下找第一个.exe文件。这种"多级兜底"的写法在处理系统 API/注册表这种非结构化数据源时很常见——你没法假设每个字段都一定存在,只能层层降级。
3.3 PATH 扫描:为了 CLI 工具专门加的一路
def scan_command_line_tools():
if not IS_WINDOWS:
return []
exclude_keywords = ("system32", "syswow64", "windowspowershell", "\\windows\\", "wbem")
exe_exts = (".exe", ".cmd", ".bat", ".ps1")
path_env = os.environ.get("PATH", "")
dirs = [d.strip('"') for d in path_env.split(os.pathsep) if d.strip()]
seen_dirs = set()
results = {}
for d in dirs:
norm = os.path.normcase(os.path.normpath(d))
if norm in seen_dirs:
continue
seen_dirs.add(norm)
if not os.path.isdir(d):
continue
if any(kw in norm for kw in exclude_keywords):
continue
try:
entries = os.listdir(d)
except OSError:
continue
for f in entries:
ext = os.path.splitext(f)[1].lower()
if ext not in exe_exts:
continue
full_path = os.path.join(d, f)
if not os.path.isfile(full_path):
continue
name = os.path.splitext(f)[0]
key = name.lower()
if key not in results:
results[key] = (name, full_path)
return list(results.values())
这段代码最核心的设计取舍是排除关键词表 exclude_keywords。如果不做排除,直接遍历 PATH 里的每一条目录(System32 通常也在 PATH 里),会把几百个 Windows 系统自带命令(notepad.exe、ping.exe……)全部收进来,列表可用性直接归零。所以这里用一个很简单但有效的策略:只要目录路径里包含 system32、syswow64、windowspowershell、windows\ 等关键词就直接跳过,只保留用户自己装的那些工具目录(比如 npm 全局目录、Python Scripts 目录等)。
另一个细节是去重两次:第一次是 seen_dirs 去重目录本身(PATH 里经常有重复路径),第二次是 results 字典按文件名(小写)去重——如果同一个命令名在多个目录里都有可执行文件(比如同时装了 .cmd 和 .exe),只保留 PATH 中排在前面的那个,这跟命令行实际调用时"谁在前面生效"的行为是一致的,语义上更准确。
3.4 三路数据怎么合并
前两路(开始菜单 + 注册表)在 scan_all_programs() 里合并去重,第三路单独跑:
def scan_all_programs():
merged = {}
for name, path in scan_registry(): # 注册表结果优先,能拿到真实 exe 路径
key = name.lower()
if key not in merged:
merged[key] = (name, path)
for name, path in scan_start_menu(): # 开始菜单作为补充
key = name.lower()
if key not in merged:
merged[key] = (name, path)
return list(merged.values())
之所以没有把 CLI 工具也塞进这同一个字典,是因为它们在数据库里需要保留不同的"来源标签"(app vs cli),方便在界面上分类展示,这个设计留到下一节数据库部分细说。
四、数据层:SQLite 的表结构与"幂等扫描"设计
表结构很简单:
CREATE TABLE IF NOT EXISTS programs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
path TEXT NOT NULL,
source TEXT DEFAULT 'scan',
use_count INTEGER DEFAULT 0,
last_used TEXT,
added_time TEXT,
UNIQUE(path)
)
有两个设计点值得说一下。
第一,UNIQUE(path) + INSERT OR IGNORE 组合,实现"幂等扫描"。 每次点"重新扫描",理论上会重复插入之前已经存在的程序。如果没有唯一约束,每扫一次数据库就多一倍脏数据。这里用路径做唯一键(同一个路径只能存一条记录),插入语句用 INSERT OR IGNORE——冲突了就静默跳过,不报错、不覆盖:
def bulk_insert_scanned(self, items, source="app"):
conn = self._connect()
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.executemany(
"INSERT OR IGNORE INTO programs "
"(name, path, source, use_count, last_used, added_time) "
"VALUES (?, ?, ?, 0, NULL, ?)",
[(name, path, source, now) for name, path in items],
)
conn.commit()
conn.close()
配合"重新扫描"前先 clear_scanned()(删除所有 source != 'custom' 的记录)再重新插入,就实现了一个干净的刷新逻辑:自动扫描出来的数据每次都是全新快照,用户手动添加的自定义程序永远不受影响。
第二,source 字段承担了"数据血缘"的角色。 一条记录是从开始菜单/注册表扫来的(app)、从 PATH 扫来的(cli),还是用户手动添加的(custom),全靠这一个字段区分。界面上这三种来源会显示成"已安装 / 命令行工具 / 自定义"三种不同标签,用户一眼就能看出这条记录是自动发现的还是自己加的,也方便后续做分类过滤(虽然当前版本还没做分类筛选,但字段已经预留好了)。
搜索本身就是一条 LIKE 模糊查询,按使用次数降序、名称升序排列,让用得越多的程序排得越靠前,是一个很轻量但很实用的"权重排序":
cur.execute(
"SELECT id, name, path, source, use_count, last_used FROM programs "
"WHERE name LIKE ? ORDER BY use_count DESC, name COLLATE NOCASE",
(f"%{keyword}%",),
)
COLLATE NOCASE 保证排序对大小写不敏感,中英文混排的场景下会更符合直觉。
五、配置层:一个容错的 JSON 深合并
配置管理的需求是"记住用户的窗口大小/位置、上次搜索内容,下次启动自动恢复"。实现上没有用什么框架,就是一个手写的深合并:
DEFAULT_CONFIG = {
"window": {"width": 920, "height": 600, "x": -1, "y": -1, "maximized": False},
"last_search": "",
"last_scan_time": "",
}
class ConfigManager:
def _load(self) -> dict:
merged = json.loads(json.dumps(DEFAULT_CONFIG)) # 深拷贝默认值
if os.path.isfile(self.path):
try:
with open(self.path, "r", encoding="utf-8") as f:
saved = json.load(f)
for key, value in saved.items():
if key == "window" and isinstance(value, dict):
merged["window"].update(value)
else:
merged[key] = value
except Exception:
pass # 配置文件损坏时使用默认值,不影响程序启动
return merged
两个小细节:
json.loads(json.dumps(DEFAULT_CONFIG))是一种"穷人版深拷贝",比copy.deepcopy更直白(反正配置本身就是可以 JSON 序列化的简单结构),避免多个ConfigManager实例共享同一份默认字典对象导致互相污染。- 读取配置文件用了一个大大的
except Exception: pass。这是有意为之:配置文件本质上是"锦上添花"的数据,哪怕它被用户手动改坏了、或者磁盘写入过程中意外损坏成半截 JSON,也绝不能因为这个让整个程序崩溃启动不了——静默回退到默认配置,用户顶多是"窗口位置没记住",而不是"程序打不开"。这种"优雅降级"的思路在处理任何用户可写的配置文件时都值得借鉴。
六、界面与交互:不卡 UI 的关键是"扫描放到后台线程"
wxPython 跟大多数 GUI 框架一样,是单线程事件循环模型——所有界面刷新都必须发生在主线程。如果直接在按钮点击回调里跑 scan_all_programs(),注册表 + 开始菜单 + PATH 三路扫描少说也要几百毫秒到几秒,界面会明显卡死、无响应。
解决方式是标准的"子线程干活,wx.CallAfter 回主线程更新界面":
def start_scan(self, auto: bool = False):
self.btn_refresh.Disable()
self.status_bar.SetStatusText("正在扫描已安装的程序,请稍候…")
thread = threading.Thread(target=self._scan_worker, daemon=True)
thread.start()
def _scan_worker(self):
app_items, cli_items = [], []
try:
app_items = scan_all_programs()
except Exception as e:
wx.CallAfter(wx.MessageBox, f"扫描已安装程序时发生错误:\n{e}", "扫描失败", wx.OK | wx.ICON_ERROR)
try:
cli_items = scan_command_line_tools()
except Exception as e:
wx.CallAfter(wx.MessageBox, f"扫描命令行工具时发生错误:\n{e}", "扫描失败", wx.OK | wx.ICON_ERROR)
def finish():
self.db.clear_scanned()
self.db.bulk_insert_scanned(app_items, source="app")
self.db.bulk_insert_scanned(cli_items, source="cli")
self.refresh_list(self.search_ctrl.GetValue())
self.btn_refresh.Enable()
wx.CallAfter(finish)
threading.Thread(..., daemon=True) 里跑的是纯 I/O 操作(读注册表、读文件系统),不涉及任何 wx 控件的直接操作——子线程里绝对不能直接调用 self.list_ctrl.xxx() 这类 UI API,这是 wxPython(以及几乎所有 GUI 框架)的铁律。所有真正触碰界面的代码,都包在 finish() 函数里,通过 wx.CallAfter(finish) 扔回主线程的事件队列去执行,由框架保证它在合适的时机、在主线程里被调用。这个模式简单但很容易被新手忽略,忽略了轻则界面绘制错乱,重则直接崩溃。
其余的交互设计相对常规,简单提一下用到的几个 wx 组件:
wx.SearchCtrl:自带搜索图标和清除按钮的输入框,比自己拼一个TextCtrl加按钮要省事得多,也更符合原生系统的视觉习惯,EVT_TEXT绑定实时过滤、EVT_SEARCHCTRL_CANCEL_BTN处理一键清空。wx.ListCtrl+ListCtrlAutoWidthMixin:报表模式的列表控件,混入AutoWidthMixin之后最后一列会自动撑满剩余宽度,不用手动处理窗口缩放时的列宽重新计算。EVT_LIST_ITEM_ACTIVATED:这是"双击运行"需求对应的事件,wx 里双击列表项、或者选中后按回车都会触发这个事件,天然贴合"双击运行"的交互预期。- 右键菜单
PopupMenu:动态创建wx.Menu,把"运行 / 打开所在文件夹 / 删除"三个高频操作挂上去,用完即销毁(menu.Destroy()),避免每次右键都往内存里堆对象。
七、启动流程小结
把前面几节串起来,MainFrame.__init__ 里的这几行其实是整个程序的"主线":
self._build_ui()
self._restore_window_state(win_cfg)
self._bind_events()
self.Show()
if self.db.count() == 0:
self.start_scan(auto=True) # 数据库为空 → 首次运行,自动全量扫描
else:
last_search = self.config.get("last_search", "")
self.search_ctrl.SetValue(last_search)
self.refresh_list(last_search) # 数据库有数据 → 直接展示,并恢复上次搜索
db.count() == 0 这个判断很关键:它把"首次安装自动初始化数据"和"日常启动秒开"两种场景优雅地统一到了一套代码里,用户完全不需要感知"我要不要点一下扫描按钮"这种细节。
八、打包分发:验证路径方案是否真的生效
pip install pyinstaller
pyinstaller -F -w -n 程序启动器 app_launcher.py
-F 打单文件、-w 不带控制台窗口(纯 GUI 程序)。打包完成后,把生成的 程序启动器.exe 复制到任意目录下双击运行,正确的表现应该是:exe 所在目录下自动生成 config.json 和 apps.db 这两个文件,而不是出现在系统临时目录或者 %TEMP% 下的某个随机文件夹里。这也是验证第二节 get_base_path() 方案是否真正生效的最直接办法——如果打包后配置文件跑丢了,基本可以断定是用错了 sys._MEIPASS 或者依赖了 os.getcwd()。
九、几个可以继续优化的方向
代码目前是一个能跑、逻辑清晰的版本,但还有一些可以打磨的空间,留在这里做个记录:
- 图标提取:目前列表是纯文本,如果用
SHGetFileInfo或wx.Icon.FromFile把每个程序的图标读出来显示在列表前面,视觉上会更接近真正的开始菜单体验。 - PATH 扫描的误报:
scan_command_line_tools目前只按扩展名 + 目录关键词过滤,如果用户的PATH里有一些不常用的第三方目录,仍可能扫进一些"技术上是可执行文件,但用户其实不关心"的条目,未来可以考虑加一个"从列表隐藏"的操作,而不是只有物理删除。 - 数据库连接方式:现在每次操作都是"开连接 → 执行 → 关连接",对这种单机小工具够用,但如果以后要支持更高频的操作(比如输入时就实时查库),可以考虑维护一个常驻连接 + 加锁,减少反复建连的开销。
- 跨平台完整度:
launch_program已经对 macOS(open)、Linux(xdg-open)做了兼容,但扫描逻辑(开始菜单 / 注册表 / PATH 排除规则)目前只针对 Windows,非 Windows 平台只能靠手动"添加程序",这是当前版本一个明确的能力边界。
十、总结
这个项目体量不大,但完整走了一遍"桌面小工具"从设计到能分发的典型链路:路径定位、多数据源采集与合并、SQLite 幂等写入、JSON 容错配置、GUI 线程模型、打包验证——每一个环节单独拎出来都不复杂,但组合在一起、并且要经得起"打包成 exe 到处运行"的检验,才是这类工具真正的门槛所在。如果你也在做类似的本机小工具,希望这篇拆解能帮你少踩几个坑。
255

被折叠的 条评论
为什么被折叠?



