提示

返回博客列表

给 Python 下载器加个界面——PyQt 桌面应用开发与 PyInstaller 打包全流程

给 Python 下载器加个界面——PyQt 桌面应用开发与 PyInstaller 打包全流程

你的 Python 下载器功能很完善了,但每次都要打开命令行敲 python download.py URL。能不能像普通软件一样——双击打开、粘贴链接、点击下载、进度条实时更新?这就是桌面 GUI 的范畴了。Python 生态里最成熟的 GUI 框架是 PyQt/PySide,而把 Python 脚本打包成 .exe 的是 PyInstaller。这篇文章从零开始,教你给视频下载器加上图形界面,然后打包成 Windows 单文件可执行程序,发给别人双击就能用。

TL;DR:PyQt 做 GUI 三步走:设计界面(Qt Designer 拖拽 or 纯代码)→ 绑定信号槽(按钮点击 → 调用下载函数)→ 多线程处理(下载跑在子线程,主线程更新 UI)。PyInstaller 打包:pyinstaller --onefile --windowed main.py。常见坑:打包后文件巨大(200MB+)→ 用 --exclude-module 精简;杀软误报 → 代码签名 + 提交误报申诉;GUI 卡死 → 下载必须放在 QThread 里。

目录

一、PyQt vs 其他 Python GUI 框架

框架 许可证 成熟度 界面美观度 打包体积 学习曲线 推荐度
PyQt6 GPL/商业 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ 大(~200MB) ⭐⭐⭐⭐
PySide6 LGPL ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
Tkinter 内置 ⭐⭐⭐ ⭐⭐ 极小 ⭐⭐
wxPython LGPL ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐
Kivy MIT ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐
Flet (Flutter) Apache 2.0 ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐
NiceGUI MIT ⭐⭐ ⭐⭐⭐ 极低 ⭐⭐⭐

推荐 PySide6:功能和 PyQt6 几乎一样,但 LGPL 许可证对商业使用更友好,不需要付费授权。

二、最小可用界面:30 行代码

import sys
from PySide6.QtWidgets import (
    QApplication, QWidget, QVBoxLayout,
    QLineEdit, QPushButton, QProgressBar, QLabel
)

class DownloaderWindow(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("视频下载器")
        self.setFixedSize(500, 200)

        layout = QVBoxLayout()

        # URL 输入框
        self.url_input = QLineEdit()
        self.url_input.setPlaceholderText("粘贴视频链接...")
        layout.addWidget(self.url_input)

        # 下载按钮
        self.download_btn = QPushButton("下载")
        self.download_btn.clicked.connect(self.on_download)
        layout.addWidget(self.download_btn)

        # 进度条
        self.progress = QProgressBar()
        self.progress.setValue(0)
        layout.addWidget(self.progress)

        # 状态标签
        self.status_label = QLabel("就绪")
        layout.addWidget(self.status_label)

        self.setLayout(layout)

    def on_download(self):
        url = self.url_input.text()
        if not url:
            self.status_label.setText("请输入链接")
            return
        self.status_label.setText(f"正在下载: {url}")
        # 实际下载逻辑在后面会讲

if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = DownloaderWindow()
    window.show()
    sys.exit(app.exec())

运行效果:一个窗口,包含输入框、按钮、进度条、状态文字。粘贴链接点击下载即可。

三、实战:一个完整的下载器 GUI

3.1 界面设计

import sys
import os
import json
from datetime import datetime
from pathlib import Path

from PySide6.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
    QLineEdit, QPushButton, QProgressBar, QLabel, QComboBox,
    QTextEdit, QFileDialog, QGroupBox, QMessageBox, QListWidget,
    QListWidgetItem, QSplitter
)
from PySide6.QtCore import Qt, QThread, Signal, QTimer
from PySide6.QtGui import QFont, QIcon


class DownloadWorker(QThread):
    """下载工作线程——在后台跑,不阻塞 UI"""
    progress = Signal(int, str)   # 进度百分比, 状态文字
    finished = Signal(str)         # 下载完成,传文件路径
    error = Signal(str)            # 下载失败,传错误信息

    def __init__(self, url, output_dir, quality='best'):
        super().__init__()
        self.url = url
        self.output_dir = output_dir
        self.quality = quality

    def run(self):
        try:
            import yt_dlp

            def progress_hook(d):
                if d['status'] == 'downloading':
                    total = d.get('total_bytes') or d.get('total_bytes_estimate', 0)
                    downloaded = d.get('downloaded_bytes', 0)
                    if total:
                        pct = int(downloaded / total * 100)
                        speed = d.get('speed', 0)
                        speed_str = f'{speed/1024/1024:.1f} MB/s' if speed else ''
                        self.progress.emit(pct, f'下载中... {speed_str}')
                elif d['status'] == 'finished':
                    self.progress.emit(100, '合并中...')

            ydl_opts = {
                'outtmpl': os.path.join(self.output_dir, '%(title)s.%(ext)s'),
                'format': self.quality,
                'merge_output_format': 'mp4',
                'progress_hooks': [progress_hook],
                'quiet': True,
                'no_warnings': True,
            }

            with yt_dlp.YoutubeDL(ydl_opts) as ydl:
                info = ydl.extract_info(self.url, download=True)
                filename = ydl.prepare_filename(info)
                # yt-dlp 可能改变扩展名,需要找到实际文件
                base = os.path.splitext(filename)[0]
                for ext in ['.mp4', '.mkv', '.webm']:
                    if os.path.exists(base + ext):
                        self.finished.emit(base + ext)
                        return
                self.finished.emit(filename)

        except Exception as e:
            self.error.emit(str(e))


class DownloaderGUI(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("VidDown - 视频下载器")
        self.setMinimumSize(700, 500)

        # 默认下载目录
        self.output_dir = os.path.expanduser('~/Downloads')
        self.history_file = os.path.expanduser('~/.vidown_history.json')
        self.download_history = self._load_history()

        self._init_ui()
        self._update_history_list()

    def _init_ui(self):
        central = QWidget()
        self.setCentralWidget(central)
        main_layout = QVBoxLayout(central)

        # === 输入区域 ===
        input_group = QGroupBox("下载任务")
        input_layout = QVBoxLayout(input_group)

        # URL 输入
        url_layout = QHBoxLayout()
        self.url_input = QLineEdit()
        self.url_input.setPlaceholderText("粘贴视频链接(支持 YouTube/B站/抖音等 30+ 平台)")
        self.url_input.returnPressed.connect(self.start_download)
        url_layout.addWidget(self.url_input)
        input_layout.addLayout(url_layout)

        # 选项行
        options_layout = QHBoxLayout()

        # 画质选择
        options_layout.addWidget(QLabel("画质:"))
        self.quality_combo = QComboBox()
        self.quality_combo.addItems([
            "最佳画质",
            "1080P",
            "720P",
            "480P",
            "仅音频",
        ])
        options_layout.addWidget(self.quality_combo)

        # 输出目录
        options_layout.addWidget(QLabel("保存到:"))
        self.dir_label = QLabel(self.output_dir)
        self.dir_label.setStyleSheet("color: #666;")
        options_layout.addWidget(self.dir_label)

        self.browse_btn = QPushButton("浏览")
        self.browse_btn.clicked.connect(self._browse_dir)
        options_layout.addWidget(self.browse_btn)

        options_layout.addStretch()
        input_layout.addLayout(options_layout)

        # 下载按钮
        self.download_btn = QPushButton("开始下载")
        self.download_btn.setMinimumHeight(36)
        self.download_btn.clicked.connect(self.start_download)
        self.download_btn.setStyleSheet(
            "QPushButton { background: #2563eb; color: white; "
            "border-radius: 4px; font-size: 14px; }"
            "QPushButton:hover { background: #1d4ed8; }"
            "QPushButton:disabled { background: #93c5fd; }"
        )
        input_layout.addWidget(self.download_btn)

        main_layout.addWidget(input_group)

        # === 进度区域 ===
        self.progress_bar = QProgressBar()
        self.progress_bar.setValue(0)
        self.progress_bar.setTextVisible(True)
        main_layout.addWidget(self.progress_bar)

        self.status_label = QLabel("就绪")
        self.status_label.setStyleSheet("color: #666;")
        main_layout.addWidget(self.status_label)

        # === 下载历史 ===
        history_group = QGroupBox("下载历史")
        history_layout = QVBoxLayout(history_group)

        self.history_list = QListWidget()
        self.history_list.itemDoubleClicked.connect(self._open_file)
        history_layout.addWidget(self.history_list)

        # 历史操作按钮
        hist_btn_layout = QHBoxLayout()
        clear_btn = QPushButton("清空历史")
        clear_btn.clicked.connect(self._clear_history)
        hist_btn_layout.addWidget(clear_btn)

        open_dir_btn = QPushButton("打开下载目录")
        open_dir_btn.clicked.connect(lambda: os.startfile(self.output_dir))
        hist_btn_layout.addWidget(open_dir_btn)

        hist_btn_layout.addStretch()
        history_layout.addLayout(hist_btn_layout)

        main_layout.addWidget(history_group)

    def start_download(self):
        url = self.url_input.text().strip()
        if not url:
            QMessageBox.warning(self, "提示", "请输入视频链接")
            return

        # 获取画质参数
        quality_map = {
            "最佳画质": "bestvideo+bestaudio/best",
            "1080P": "bestvideo[height<=1080]+bestaudio/best[height<=1080]",
            "720P": "bestvideo[height<=720]+bestaudio/best[height<=720]",
            "480P": "bestvideo[height<=480]+bestaudio/best[height<=480]",
            "仅音频": "bestaudio/best",
        }
        quality = quality_map[self.quality_combo.currentText()]

        # 禁用按钮
        self.download_btn.setEnabled(False)
        self.url_input.setEnabled(False)
        self.status_label.setText("正在解析...")

        # 启动下载线程
        self.worker = DownloadWorker(url, self.output_dir, quality)
        self.worker.progress.connect(self._on_progress)
        self.worker.finished.connect(self._on_finished)
        self.worker.error.connect(self._on_error)
        self.worker.start()

    def _on_progress(self, pct, msg):
        self.progress_bar.setValue(pct)
        self.status_label.setText(msg)

    def _on_finished(self, filepath):
        self.progress_bar.setValue(100)
        self.status_label.setText(f"下载完成: {os.path.basename(filepath)}")

        # 添加到历史
        self.download_history.append({
            'url': self.url_input.text(),
            'file': filepath,
            'time': datetime.now().isoformat(),
            'size': os.path.getsize(filepath),
        })
        self._save_history()
        self._update_history_list()

        # 恢复 UI
        self.download_btn.setEnabled(True)
        self.url_input.setEnabled(True)
        self.url_input.clear()

        # 询问是否打开
        reply = QMessageBox.question(
            self, "下载完成",
            f"视频已保存到:\n{filepath}\n\n是否打开文件位置?",
            QMessageBox.Yes | QMessageBox.No
        )
        if reply == QMessageBox.Yes:
            os.startfile(os.path.dirname(filepath))

    def _on_error(self, msg):
        self.progress_bar.setValue(0)
        self.status_label.setText(f"下载失败: {msg}")
        self.download_btn.setEnabled(True)
        self.url_input.setEnabled(True)
        QMessageBox.critical(self, "下载失败", msg)

    def _browse_dir(self):
        dir_path = QFileDialog.getExistingDirectory(
            self, "选择下载目录", self.output_dir
        )
        if dir_path:
            self.output_dir = dir_path
            self.dir_label.setText(dir_path)

    def _load_history(self):
        if os.path.exists(self.history_file):
            with open(self.history_file, 'r', encoding='utf-8') as f:
                return json.load(f)
        return []

    def _save_history(self):
        os.makedirs(os.path.dirname(self.history_file), exist_ok=True)
        with open(self.history_file, 'w', encoding='utf-8') as f:
            json.dump(self.download_history[-50:], f, ensure_ascii=False)

    def _update_history_list(self):
        self.history_list.clear()
        for item in reversed(self.download_history[-20:]):
            name = os.path.basename(item['file'])
            size = item.get('size', 0) / 1024 / 1024
            time_str = item['time'][:19]
            text = f"{name}  ({size:.1f} MB) - {time_str}"
            list_item = QListWidgetItem(text)
            list_item.setToolTip(item['file'])
            self.history_list.addItem(list_item)

    def _open_file(self, item):
        for h in self.download_history:
            if h['file'].endswith(item.text().split(' (')[0]):
                os.startfile(os.path.dirname(h['file']))
                break

    def _clear_history(self):
        reply = QMessageBox.question(
            self, "确认", "确定要清空下载历史吗?",
            QMessageBox.Yes | QMessageBox.No
        )
        if reply == QMessageBox.Yes:
            self.download_history = []
            self._save_history()
            self._update_history_list()


if __name__ == '__main__':
    app = QApplication(sys.argv)
    app.setStyle('Fusion')
    window = DownloaderGUI()
    window.show()
    sys.exit(app.exec())

四、多线程:下载不卡 UI

GUI 程序的铁律:所有耗时操作必须在子线程中执行。 如果在主线程(UI 线程)里跑下载,窗口会卡死——点击按钮没反应、进度条不更新、操作系统可能提示"程序未响应"。

PyQt/PySide 的多线程模式:

class DownloadWorker(QThread):
    progress = Signal(int, str)   # 自定义信号,传进度数据
    finished = Signal(str)
    error = Signal(str)

    def run(self):
        # 耗时操作放在这里
        # 通过 self.progress.emit() 发送信号给主线程
        pass

# 主线程中:
self.worker = DownloadWorker(url, output_dir)
self.worker.progress.connect(self._on_progress)  # 绑定信号到 UI 更新函数
self.worker.start()  # 启动子线程

关键规则: - QThread.run()不能直接操作 UI 组件(会崩溃) - 通过 Signal 发送数据,主线程的槽函数里更新 UI - 不要在 run() 里用 QMessageBox——用 Signal 传回主线程处理

五、PyInstaller 打包:从 .py 到 .exe

5.1 基础打包

# 安装
pip install pyinstaller

# 打包为单文件
pyinstaller --onefile --windowed --name "VidDown" main.py

# 参数说明:
# --onefile  :打包成单个 .exe 文件
# --windowed :不显示命令行窗口(GUI 程序)
# --name     :输出文件名
# --icon     :指定图标文件

5.2 完整打包命令

pyinstaller \
  --onefile \
  --windowed \
  --name "VidDown" \
  --icon "icon.ico" \
  --add-data "ffmpeg.exe;." \
  --add-data "yt-dlp.exe;." \
  --hidden-import yt_dlp \
  --hidden-import yt_dlp.extractor \
  --hidden-import PySide6.QtCore \
  --clean \
  main.py

5.3 打包后目录结构

打包完成后,dist/ 目录下会有一个 VidDown.exe。但单文件打包有个问题:启动时会把所有文件解压到临时目录,所以第一次启动很慢(3-10 秒)。

如果不介意多个文件,用 --onedir 代替 --onefile——启动快,但发布时是一个文件夹。

六、体积优化与杀软误报处理

6.1 为什么打包后这么大

一个最简单的 PyQt 程序:
  .py 文件:5 KB
  打包后 .exe:180 MB  ← 为什么?

原因:
  PyQt 本身 ~80MB
  Python 解释器 ~30MB
  yt-dlp + 依赖 ~40MB
  其他依赖 ~30MB

6.2 优化策略

# 1. 排除不需要的模块
pyinstaller --exclude-module matplotlib \
            --exclude-module numpy \
            --exclude-module pandas \
            --exclude-module scipy \
            --exclude-module tkinter \
            ...

# 2. 使用 UPX 压缩(能减 20-30%)
# 下载 upx.exe 放到 PATH 中,PyInstaller 会自动使用

# 3. 创建虚拟环境,只装必需的包
python -m venv build_env
build_env\Scripts\activate
pip install PySide6 yt-dlp pyinstaller
# 在这个纯净环境里打包,体积最小

6.3 杀软误报

PyInstaller 打包的 .exe 经常被 Windows Defender 或 360 报毒。原因:

  • PyInstaller 的 bootloader 被大量恶意软件滥用,导致杀软"宁可错杀"
  • 没有数字签名的 .exe 天然信任度低

解决方案:

短期方案:
  1. 提交到 VirusTotal 确认是误报
  2. 向杀软厂商提交误报申诉
  3. 让用户添加白名单

长期方案:
  1. 购买代码签名证书(EV Code Signing,约 ¥2000-4000/年)
  2. 用证书签名 .exe → 杀软信任度大幅提升
  3. 安装包用 Inno Setup / NSIS 制作,比裸 .exe 更专业

七、自动更新机制

import requests
import subprocess
import sys
import os

VERSION = '1.2.0'
UPDATE_URL = 'https://your-server.com/version.json'

def check_update():
    """检查更新"""
    try:
        resp = requests.get(UPDATE_URL, timeout=5)
        data = resp.json()
        latest = data['version']

        if latest > VERSION:
            return {
                'has_update': True,
                'version': latest,
                'url': data['download_url'],
                'notes': data.get('release_notes', ''),
            }
    except Exception:
        pass

    return {'has_update': False}


def download_and_install(url):
    """下载新版本并替换当前程序"""
    # 下载到临时文件
    import tempfile
    tmp = os.path.join(tempfile.gettempdir(), 'VidDown_update.exe')

    resp = requests.get(url, stream=True)
    with open(tmp, 'wb') as f:
        for chunk in resp.iter_content(8192):
            f.write(chunk)

    # 启动新版本,退出当前版本
    subprocess.Popen([tmp, '--updated'])
    sys.exit(0)

八、合规与温馨提示

  • PySide6 是 LGPL 许可,商业使用不需要付费授权(和 PyQt 的 GPL 不同)
  • PyInstaller 打包的 .exe 可以自由分发,但包含的第三方库(如 yt-dlp)需遵守各自的许可证
  • 桌面端下载工具同样需要遵守平台的服务条款,仅用于下载合法授权的内容
  • 更多讨论见 下载视频算侵权吗?聊聊个人备份与版权的那条线

做 GUI 这件事,投入的时间比你想象的多——界面布局、多线程、打包、杀软误报、自动更新,每一个都是坑。但做完之后,你的下载器就从"自己用的脚本"变成了"能分享给别人用的软件"。那种感觉还是挺好的。

本文由 VidDown 技术博客原创发布。VidDown 桌面客户端就是基于 PySide6 + yt-dlp 开发的,支持 30+ 平台视频下载,界面简洁,一键安装。访问 VidDown 了解更多。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部