本机程序启动器是一个经典的桌面小工具,核心功能是扫描本机已安装的程序,提供搜索和快速启动。本文基于 Python + SQLite + wxPython 技术栈,重点解析开发过程中遇到的几个典型问题:打包后文件该存哪里、如何完整扫描所有已安装程序、如何避免重复插入脏数据、以及如何让 GUI 不卡顿。以下代码实践来自一个约 770 行的完整项目,已通过 PyInstaller 打包为单文件 exe 并验证。
一、路径定位:打包后配置文件写到哪?
很多开发者直接使用 `os.getcwd()` 来获取程序目录,但这在 exe 被双击时不可靠——工作目录可能指向桌面或其他路径。另一个常见错误是使用 `sys._MEIPASS`,该路径在 PyInstaller 单文件模式下指向临时解压目录,每次运行不同且程序退出后清除,无法持久化数据。正确做法是检测 `sys.frozen` 属性,区分打包状态和源码运行状态:
- def get_base_path() -> str:
- if getattr(sys, "frozen", False):
- base = os.path.dirname(os.path.abspath(sys.executable))
- else:
- base = os.path.dirname(os.path.abspath(__file__))
- return base
- BASE_DIR = get_base_path()
- DB_PATH = os.path.join(BASE_DIR, "apps.db")
- CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
复制代码
`sys.executable` 指向 exe 文件的真实路径,不受工作目录影响。这样无论用户从哪个位置启动,数据库和配置始终与 exe 在一起。
二、三路扫描:覆盖所有安装来源
Windows 上获取“已安装程序”至少有三种常见来源:开始菜单快捷方式、注册表卸载列表、PATH 环境变量中的可执行文件。前三者覆盖传统 GUI 应用,后者专门针对通过 npm/pip 等包管理工具安装的命令行工具。
1. 开始菜单扫描:不解析 .lnk 目标路径
扫描 ProgramData 和 APPDATA 下的 “Start Menu\Programs” 文件夹,收集所有 .lnk 文件。注意这里不解析 .lnk 指向的真实 exe 路径,因为 Windows 原生支持直接运行 .lnk (os.startfile)。这种做法省掉了 pywin32 依赖。
- 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
复制代码
2. 注册表扫描:三个根键 + 组件过滤
Windows 安装程序会在注册表 `Uninstall` 键下注册信息。需要同时查询 64 位 (HKLM\SOFTWARE)、32 位 (HKLM\SOFTWARE\WOW6432Node) 和当前用户 (HKCU) 三个分支。同时过滤掉 `SystemComponent = 1` 的系统组件,避免列表中混入 .NET 运行时等。获取 exe 路径时先尝试 `DisplayIcon`(注意剔除图标索引后缀),如果不存在或文件不可用,再回退到 `InstallLocation` 下自动查找第一个 exe。
- 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
- # ... 遍历子键,跳过 SystemComponent=1,提取 DisplayIcon / InstallLocation
- return results
复制代码
3. PATH 扫描:排除系统目录,去重优先
遍历 PATH 环境变量中的每个目录,排除包含 system32、syswow64、windows\、wbem 等关键词的路径(这些目录下全是系统工具,用户不需要)。仅收集 .exe、.cmd、.bat、.ps1 扩展名的文件。通过 seen_dirs 去重目录,通过小写名称字典 results 去重可执行文件名(保留 PATH 中排在前面的那个)。
- 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())
复制代码
三、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` 实现幂等扫描:每次重新扫描前先删除非用户自定义的记录(`source != 'custom'`),再批量插入新数据。这样做既不会因重复扫描产生脏数据,也保住了用户手动添加的程序。source 字段标记来源(app/cli/custom),便于界面分类显示。搜索采用 LIKE 模糊查询,并按 use_count 降序、name 升序排列,实现“常用优先”。
- 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()
复制代码
四、JSON 配置:深合并与优雅降级
配置存储窗口位置、上次搜索内容等。采取“默认配置 + 用户配置”深合并:先深拷贝默认配置,再将用户 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
复制代码
五、wxPython 线程模型:耗时操作放入后台
扫描注册表和文件系统属于耗时 I/O 操作,如果在主线程执行会导致界面卡死。解决方法是:启动一个 daemon 线程执行扫描,完成后通过 `wx.CallAfter` 将 UI 更新回调放回主线程。子线程内绝不可直接操作 wx 控件。
- def start_scan(self, auto=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 = scan_all_programs() # 注册表+开始菜单
- cli_items = scan_command_line_tools()
- 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)
复制代码
六、打包验证:确保路径方案生效
使用 PyInstaller 打包为单文件:- pip install pyinstaller
- pyinstaller -F -w -n 程序启动器 app_launcher.py
复制代码 打包完成后,将 exe 复制到任意文件夹双击运行,正确表现应为:exe 所在目录自动生成 config.json 和 apps.db。如果文件出现在 %TEMP% 或其他位置,说明路径方案不正确。
七、小结
本机程序启动器虽小,却涉及打包路径稳定性、多源数据爬取、幂等数据库写入、容错配置管理和 GUI 线程安全等关键问题。上述代码实践已通过打包验证,可作为类似桌面工具开发的参考模板。注意:当前扫描逻辑仅适用于 Windows,非 Windows 平台需要通过手动添加程序来补充。 |