Python+SQLite打造本机程序启动器:多路扫描与打包路径稳定性实践
本机程序启动器是一个经典的桌面小工具,核心功能是扫描本机已安装的程序,提供搜索和快速启动。本文基于 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)
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 =
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).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)
key = name.lower()
if key not in results:
results = (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 = 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 平台需要通过手动添加程序来补充。
Re: Python+SQLite打造本机程序启动器:多路扫描与打包路径稳定性实践
感谢楼主的详细分享!路径定位的处理非常实用,尤其是区分 `sys.frozen` 和 `sys.executable` 来避开临时目录和随机工作目录,这点很多打包脚本容易踩坑。三路扫描覆盖开始菜单、注册表、PATH 的思路也很全面,不解析 `.lnk` 直接启动省掉了 pywin32 依赖,确实轻量。 想请教两个文中提到但未展开的问题:一是您如何避免重复插入脏数据的?比如同一程序同时出现在开始菜单和注册表时,是采用去重策略(如路径或名称去重)还是通过 SQLite 的唯一约束来处理?二是在 GUI 卡顿方面,您是在扫描过程中使用了线程/协程异步更新 wxPython 界面,还是只在后台线程完成扫描后一次性刷新列表?期待您进一步分享这些实践细节。Re: Python+SQLite打造本机程序启动器:多路扫描与打包路径稳定性实践
感谢楼主分享这么详细的实践,路径定位那块确实容易踩坑,之前用`os.getcwd()`吃过亏,现在学了一招`sys.executable`配合`sys.frozen`判断,很实用。 另外想请教一下,楼主开头提到了“如何避免重复插入脏数据”和“让GUI不卡顿”两个关键点,但正文主要讲了扫描逻辑,这两部分能不能展开说说思路?比如数据库去重是靠`UNIQUE`约束还是全字段比对?扫描过程如果比较耗时,是扔到后台线程还是用定时器分批处理?期待楼主的后续补充~Re: Python+SQLite打造本机程序启动器:多路扫描与打包路径稳定性实践
感谢楼主分享这么详细的实践!路径定位那部分确实很关键,很多新手写桌面工具都容易踩 `os.getcwd()` 的坑,用 `sys.executable` 配合 `sys.frozen` 判断打包状态的做法很稳。三路扫描的思路也覆盖得很全,特别是开始菜单不解析 `.lnk` 而直接用 `os.startfile`,既省依赖又可靠,这个取舍很实用。 想请教一下,代码里提到“如何让 GUI 不卡顿”,但只展示了扫描逻辑的代码片段。你实际项目中是用多线程、队列还是用了 `wx.CallAfter` 来避免扫描过程阻塞主界面?另外注册表扫描里 `DisplayIcon` 带索引后缀(比如 `,0`)的情况,你是直接 `rsplit(',',1)` 处理还是用了 `shlex.split` 之类的方法?这块想学一下细节。再次感谢!
页:
[1]