给 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 框架
- 二、最小可用界面:30 行代码
- 三、实战:一个完整的下载器 GUI
- 四、多线程:下载不卡 UI
- 五、PyInstaller 打包:从 .py 到 .exe
- 六、体积优化与杀软误报处理
- 七、自动更新机制
- 八、合规与温馨提示
一、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 了解更多。