从零到能打包分发:用 wxPython + SQLite 写一个本机程序启动器

前言

最近做了一个小工具:程序启动器。功能听起来很朴素——扫描本机安装过的程序,做成一个可以搜索、双击运行的列表,跟 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.executableexe 自身的真实路径,而不是临时解压路径)来定位目录;否则退回到 __file__(脚本自身路径)。两个分支殊途同归——最终都拿到"程序所在的文件夹",DB_PATHCONFIG_PATH 在模块加载时就被确定为全局常量,后面所有读写都基于这两个绝对路径,不会再受工作目录影响。

三、数据从哪来:三路扫描的设计

这是整个项目里最有意思的部分。一开始我只做了两路扫描:

  1. 开始菜单快捷方式.lnk 文件)
  2. 注册表卸载列表...\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.exeping.exe……)全部收进来,列表可用性直接归零。所以这里用一个很简单但有效的策略:只要目录路径里包含 system32syswow64windowspowershellwindows\ 等关键词就直接跳过,只保留用户自己装的那些工具目录(比如 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.jsonapps.db 这两个文件,而不是出现在系统临时目录或者 %TEMP% 下的某个随机文件夹里。这也是验证第二节 get_base_path() 方案是否真正生效的最直接办法——如果打包后配置文件跑丢了,基本可以断定是用错了 sys._MEIPASS 或者依赖了 os.getcwd()

九、几个可以继续优化的方向

代码目前是一个能跑、逻辑清晰的版本,但还有一些可以打磨的空间,留在这里做个记录:

  1. 图标提取:目前列表是纯文本,如果用 SHGetFileInfowx.Icon.FromFile 把每个程序的图标读出来显示在列表前面,视觉上会更接近真正的开始菜单体验。
  2. PATH 扫描的误报scan_command_line_tools 目前只按扩展名 + 目录关键词过滤,如果用户的 PATH 里有一些不常用的第三方目录,仍可能扫进一些"技术上是可执行文件,但用户其实不关心"的条目,未来可以考虑加一个"从列表隐藏"的操作,而不是只有物理删除。
  3. 数据库连接方式:现在每次操作都是"开连接 → 执行 → 关连接",对这种单机小工具够用,但如果以后要支持更高频的操作(比如输入时就实时查库),可以考虑维护一个常驻连接 + 加锁,减少反复建连的开销。
  4. 跨平台完整度launch_program 已经对 macOS(open)、Linux(xdg-open)做了兼容,但扫描逻辑(开始菜单 / 注册表 / PATH 排除规则)目前只针对 Windows,非 Windows 平台只能靠手动"添加程序",这是当前版本一个明确的能力边界。

十、总结

这个项目体量不大,但完整走了一遍"桌面小工具"从设计到能分发的典型链路:路径定位、多数据源采集与合并、SQLite 幂等写入、JSON 容错配置、GUI 线程模型、打包验证——每一个环节单独拎出来都不复杂,但组合在一起、并且要经得起"打包成 exe 到处运行"的检验,才是这类工具真正的门槛所在。如果你也在做类似的本机小工具,希望这篇拆解能帮你少踩几个坑。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值