从零开发一个桌面工具:我用一天写了个B站视频下载器,踩了10个坑全告诉你
一、先看成果
最终产物是一个48MB 的单文件 exe,双击即用:
输入 BV 号或视频链接,自动解析标题、UP主、分P
支持扫码登录,下载 1080P+ 高清晰度
多线程下载 + ffmpeg 合并音视频
粉蓝渐变 B站原生风格 UI
开发语言:Python 3.13 GUI 框架:tkinter(标准库,打包体积小) 打包工具:PyInstaller 外部依赖:ffmpeg(内嵌到 exe 中) 最终体积:48 MB
整个开发过程踩了10 个坑,本文按时间顺序逐个讲解。
二、开发路线图
在动手之前,先想清楚开发顺序。桌面工具的开发有一个黄金法则:先 CLI 后 GUI,先能跑再好看。
阶段1: 核心逻辑 (CLI) → 确保业务逻辑正确 ↓ 阶段2: 打包 exe → 确认能独立运行 ↓ 阶段3: 加 GUI → 让用户能双击使用 ↓ 阶段4: 稳定性修复 → 处理打包后的坑 ↓ 阶段5: 功能增强 → 登录、清晰度选择 ↓ 阶段6: UI 美化 → 品牌化设计 ↓ 阶段7: 细节打磨 → 图标、黑框、进度 ↓ 阶段8: 体积优化 → 精简打包
核心原则:每改完一步,立即打包 exe 实测。开发环境正常 ≠ 打包后正常,PyInstaller 有大量坑只在打包后才暴露。
三、环境准备
# Python 3.10+ 即可 python -m venv venv venv\Scripts\activate # Windows # 安装依赖 pip install requests # HTTP 请求 pip install qrcode # 二维码生成(登录用) pip install pyinstaller # 打包工具
另外需要下载ffmpeg.exe(用于合并音视频),放在项目目录下。建议下载 essentials 精简版(97MB),包含所有常用编解码器。
四、阶段1:核心逻辑(CLI 先行)
4.1 设计思路
核心逻辑写成独立的类,不依赖sys.stdout,用返回值或回调输出结果。这为后面套 GUI 留好接口。
class BiliDownloader: """B站下载器核心逻辑,不依赖任何 UI。""" def __init__(self, sessdata: str = ""): self.session = requests.Session() self.session.headers.update({ "User-Agent": "Mozilla/5.0 ...", "Referer": "https://www.bilibili.com/" }) if sessdata: self.session.cookies.set("SESSDATA", sessdata) def get_video_info(self, bvid: str) -> dict: """获取视频信息(标题、UP主、分P列表)。""" ... def get_playurl(self, bvid: str, cid: int, qn: int) -> dict: """获取播放地址(DASH 流)。""" ... def download_file(self, urls, dst: str, task_name: str = "下载"): """多线程分块下载。""" ... def merge_av(self, video_path, audio_path, output_path): """用 ffmpeg 合并音视频。""" ...4.2 B站下载的关键技术点
B站视频下载涉及三个关键技术,这里简要说明,完整代码在文末 GitHub 链接。
① WBI 签名(反爬机制)
B站部分接口需要 WBI 签名,流程是:
nav 接口获取 img_key + sub_key ↓ 混淆表重排得到 mixin_key(32位取前32) ↓ 参数排序 + 拼接 wts 时间戳 ↓ MD5 计算 w_rid 签名 ↓ 请求带上 wts 和 w_rid 参数
② DASH 流(音视频分离)
请求playurl接口时传fnval=4048,返回的 DASH 流是音视频分离的:
{ "data": { "dash": { "video": [ {"id": 80, "baseUrl": "https://...", "codecs": "avc1.640032"} ], "audio": [ {"id": 30280, "baseUrl": "https://...", "codecs": "mp4a.40.2"} ] } } }需要分别下载视频流和音频流,再用 ffmpeg 合并。
③ 多线程分块下载
这是踩坑最多的地方。关键设计:每个线程下载到独立的分片文件,全部完成后按序拼接。
def download_file(self, urls, dst, task_name="下载"): # 1. 用 GET + Range: bytes=0-0 探测总大小 total = self._probe_size(urls) # 2. 分成 4 片,每片独立文件 threads_num = 4 chunk = total // threads_num part_paths = [f"{dst}.part{i}" for i in range(threads_num)] # 3. 每个线程下载一片 def fetch_range(start, end, idx): h = {"Range": f"bytes={start}-{end}"} r = self.session.get(url, headers=h, stream=True) with open(part_paths[idx], "wb") as f: for data in r.iter_content(256 * 1024): f.write(data) # 4. 按序拼接 with open(dst, "wb") as out: for pp in part_paths: with open(pp, "rb") as pf: out.write(pf.read())⚠️ 坑1:多线程并发写同一文件会数据损坏
早期方案是"预分配文件大小 + 多线程 seek 写入同一文件",结果下载的视频花屏。原因是多线程并发 seek+write 有竞态条件。改为分片文件 + 顺序拼接后彻底解决。
4.3 ffmpeg 合并
def merge_av(self, video_path, audio_path, output_path): cmd = [ find_ffmpeg(), "-y", "-i", video_path, "-i", audio_path, "-c", "copy", # 直接复制流,不重新编码(极快) output_path ] subprocess.run(cmd, check=True, capture_output=True)
find_ffmpeg()需要兼容打包后的环境(后面会讲):
def find_ffmpeg(): """查找 ffmpeg,兼容 PyInstaller 打包环境。""" if getattr(sys, "frozen", False): # 打包后:从 _MEIPASS 临时目录找 candidate = os.path.join(sys._MEIPASS, "ffmpeg.exe") if os.path.isfile(candidate): return candidate # 开发环境:从 PATH 找 ...
阶段1成果:一个可运行的bili_downloader.py,命令行执行:
python bili_downloader.py BV1xx411c7mD -q 1080P
成功下载视频,核心逻辑验证通过。
五、阶段2:打包成 exe
用 PyInstaller 打包成单文件 exe,让用户无需装 Python。
5.1 打包命令
pyinstaller --onefile --name bili-downloader \ --console --clean --noconfirm \ --collect-data certifi \ --add-binary "ffmpeg.exe;." \ bili_downloader.py
| 参数 | 作用 |
|---|---|
--onefile | 打包成单个 exe |
--console | 保留控制台(CLI 程序用) |
--collect-data certifi | 打包 SSL 证书(网络请求必须) |
--add-binary "ffmpeg.exe;." | 内嵌 ffmpeg(分号是 Windows 分隔符) |
5.2 ffmpeg 路径适配
打包后 ffmpeg 在sys._MEIPASS临时目录里,find_ffmpeg()必须能找到它:
def find_ffmpeg(): if getattr(sys, "frozen", False): candidate = os.path.join(sys._MEIPASS, "ffmpeg.exe") if os.path.isfile(candidate): return candidate # 回退到 PATH import shutil return shutil.which("ffmpeg") or "ffmpeg"阶段2成果:94MB 的 exe,双击弹出命令行窗口,输入参数即可下载。
六、阶段3:加 GUI(tkinter)
用户反馈:"双击打不开啊,没有 UI 界面的吗?"——命令行程序双击只会一闪而过。
6.1 GUI 框架选择
| 框架 | 优点 | 缺点 | 体积增量 |
|---|---|---|---|
| tkinter | 标准库自带 | 原生丑 | ~0 MB |
| PyQt5 | 功能强大 | 体积大 | +30 MB |
| customtkinter | 现代美观 | 第三方库 | +5 MB |
选了 tkinter,因为标准库自带、打包体积最小。丑的问题靠自定义样式解决。
6.2 关键模式:队列通信
GUI 程序最大的坑是线程安全:tkinter 只能在主线程操作 UI,但下载必须在后台线程。解决方案是用 queue 通信:
class BiliGUI: def __init__(self, root): self.log_queue = queue.Queue() # 日志队列 self.progress_queue = queue.Queue() # 进度队列 self._poll_queues() # 启动轮询 def _poll_queues(self): """主线程每 100ms 轮询队列,刷新 UI。""" try: while True: msg = self.log_queue.get_nowait() self.log_text.insert("end", msg + "\n") except queue.Empty: pass try: while True: frac, text = self.progress_queue.get_nowait() self.progress["value"] = frac * 100 except queue.Empty: pass self.root.after(100, self._poll_queues) # 100ms 后再轮询 def on_download(self): """点下载按钮,启动后台线程。""" threading.Thread(target=self._download_worker, daemon=True).start() def _download_worker(self): """后台线程:下载并把进度投递到队列。""" # 下载过程中调用: self.progress_queue.put((0.5, "下载中 50%")) self.log_queue.put("视频流下载完成")┌─────────────┐ queue ┌─────────────┐ │ 下载线程 │ ─────────────→ │ 主线程 │ │ (后台daemon) │ log/progress │ (UI刷新) │ └─────────────┘ └─────────────┘ ↑ ↑ 执行下载任务 after(100ms)轮询 不碰UI控件 操作UI控件(安全)
6.3 进度回调
核心下载类用_print_progress静态方法输出进度。GUI 模式下临时替换它,把进度投递到队列:
def _download_worker(self): orig = BiliDownloader._print_progress BiliDownloader._print_progress = self._gui_print_progress # 替换 try: # ... 执行下载 ... finally: BiliDownloader._print_progress = orig # 恢复 def _gui_print_progress(self, name, done, total): frac = done / total self.progress_queue.put((frac, f"{name} {frac*100:.0f}%"))6.4 打包改用 --windowed
GUI 程序不需要控制台窗口:
pyinstaller --onefile --name bili-downloader \ --windowed \ # ← 改成 windowed,不弹控制台 --clean --noconfirm \ --collect-data certifi \ --add-binary "ffmpeg.exe;." \ bili_downloader.py
阶段3成果:97MB 的 exe,双击弹出 GUI 窗口。
七、阶段4:稳定性修复(最关键的一节)
GUI 版交给用户测试,用户反馈"点下载没反应"。这是整个项目踩得最痛的坑。
7.1 坑2:windowed 模式 stdio 全是 None
AttributeError: 'NoneType' object has no attribute 'write'
根因:PyInstaller--windowed模式打包的 exe 没有控制台,sys.stdout、sys.stderr、sys.stdin全部是None。代码里任何print()或sys.stdout.write()都会崩溃。
修复:模块顶部立即用_NullStream替换 None:
class _NullStream: """空流,丢弃所有写入。""" def write(self, *args, **kwargs): pass def flush(self, *args, **kwargs): pass def reconfigure(self, *args, **kwargs): pass def _fix_stdio(): """修复 windowed 模式下 stdio 为 None 的问题。""" for name in ("stdout", "stderr", "stdin"): if getattr(sys, name) is None: setattr(sys, name, _NullStream()) # 模块加载时立即执行! _fix_stdio()这条代码是 GUI 程序的救命代码,必须在所有其他代码之前执行。放在模块顶部,import 之后第一件事。
7.2 坑3:后台线程异常被静默吞掉
用户反馈"点下载没反应",但代码其实崩溃了,只是异常被 tkinter 后台线程吞掉了。
修复:下载线程加顶层 try/except + traceback 输出 + 弹窗:
def _download_worker(self): try: # ... 下载逻辑 ... except Exception as e: import traceback self.log_queue.put(f"[致命错误] {e}") self.log_queue.put(traceback.format_exc()) self.root.after(0, lambda: messagebox.showerror("下载失败", str(e))) finally: self.root.after(0, lambda: self._set_busy(False))教训:tkinter 后台线程的异常不会显示在 GUI 上,必须自己捕获。否则用户看到的就是"点了没反应"。
7.3 坑4:进度条显示 110%
用户反馈下载完成后进度条显示 110%。
根因:多线程分块下载失败重试时,progress["done"]已经累加了失败那次的部分字节,重试又重新累加,导致done > total。
修复(三管齐下):
# 1. 重试前回滚已下载的字节 def fetch_range(start, end, idx): received_this = 0 # 本次尝试的字节数 try: # ... 下载 ... received_this += len(data) progress["done"] += len(data) except Exception: # 回滚! with progress["lock"]: progress["done"] -= received_this # 2. 显示时钳制到 [0, 1] pct = max(0.0, min(1.0, done / total)) # 3. GUI 进度也钳制 frac = max(0.0, min(1.0, done / total)) self.progress_queue.put((frac, f"{name} {frac*100:.0f}%"))阶段4成果:稳定的 GUI 版本,下载不再崩溃,错误有提示,进度正常。
八、阶段5:功能增强(扫码登录)
用户要下载 1080P+ 需要 SESSDATA 登录态。手动粘贴太麻烦,加扫码登录。
8.1 扫码登录流程
1. 调 passport 接口获取二维码 URL + qrcode_key 2. GUI 弹出窗口,用 qrcode 库生成二维码 3. 每 2 秒轮询扫码状态 ├─ 86101: 未扫码 ├─ 86090: 已扫码待确认 ├─ 86038: 二维码过期 └─ 0: 登录成功 4. 成功后从返回 URL 的参数解析 SESSDATA 5. 存到 JSON 文件,下次启动自动加载
8.2 二维码显示(不依赖 Pillow)
import qrcode def show_qr_window(self, qr_url): qr = qrcode.QRCode(box_size=4, border=2) qr.add_data(qr_url) qr.make(fit=True) matrix = qr.get_matrix() # 用 tkinter Canvas 画黑白方块,不依赖 Pillow cell = 6 # 每格 6 像素 size = len(matrix) * cell canvas = tk.Canvas(self.qr_window, width=size, height=size) canvas.pack() for y, row in enumerate(matrix): for x, val in enumerate(row): if val: canvas.create_rectangle( x*cell, y*cell, (x+1)*cell, (y+1)*cell, fill="black", outline="black" )
8.3 会话持久化
def save_session(self, sessdata): config = {"sessdata": sessdata} config_path = os.path.expanduser("~/.bili_downloader_config.json") with open(config_path, "w") as f: json.dump(config, f) def _load_saved_session(self): config_path = os.path.expanduser("~/.bili_downloader_config.json") if os.path.isfile(config_path): with open(config_path) as f: config = json.load(f) self.sessdata_var.set(config.get("sessdata", ""))8.4 坑5:tkinter 变量类型不能混
清晰度选择用 Combobox 绑定 IntVar,但下拉值是字符串("1080P (80)"),类型冲突导致变量损坏。
修复:用两个独立变量分离——StringVar 做显示,IntVar 存数值:
# ❌ 错误:一个变量混用 self.qn_var = tk.IntVar() combobox = ttk.Combobox(textvariable=self.qn_var, values=["1080P (80)"]) # ✅ 正确:两个变量分离 self.qn_display_var = tk.StringVar() # 显示用 self.qn_var = tk.IntVar(value=80) # 存值用 combobox = ttk.Combobox(textvariable=self.qn_display_var)
九、阶段6:UI 美化(品牌化)
用户嫌 UI 丑。这里有个重要经验:UI 改动先出方案让用户选,不要直接改完让用户看。
9.1 品牌配色方案
参考 B站官方设计语言:
# B站品牌色 BILI_PINK = "#FB7299" # 主色(粉) BILI_BLUE = "#00AEEC" # 辅色(蓝) BILI_BG = "#F4F5F7" # 背景灰 BILI_CARD = "#FFFFFF" # 卡片白 BILI_TEXT = "#18191C" # 文字黑
9.2 渐变标题栏
用 Canvas 画粉→蓝横向渐变:
def _draw_header(self, canvas, width, height): for x in range(width): ratio = x / width r = int(0xFB + (0x00 - 0xFB) * ratio) g = int(0x72 + (0xAE - 0x72) * ratio) b = int(0x99 + (0xEC - 0x99) * ratio) canvas.create_line(x, 0, x, height, fill=f"#{r:02x}{g:02x}{b:02x}")9.3 ttk 样式定制
def _setup_style(self): style = ttk.Style() style.theme_use("clam") # clam 主题最易自定义 # 全局背景 style.configure(".", background=BILI_BG, foreground=BILI_TEXT, font=("Microsoft YaHei UI", 10)) # 卡片容器 style.configure("Card.TFrame", background=BILI_CARD) # 粉色进度条 style.configure("Pink.Horizontal.TProgressbar", troughcolor="#E3E5E7", background=BILI_PINK)9.4 药丸标签(清晰度选择)
不用 Combobox,改用点击式药丸标签,更符合 B站风格:
def _make_chip(self, parent, text, value): chip = tk.Label(parent, text=text, padx=14, pady=6, bg="#F4F5F7", fg="#666", cursor="hand2", font=("Microsoft YaHei UI", 10)) chip.bind("<Button-1>", lambda e: self._select_qn(chip, value)) return chip def _select_qn(self, selected_chip, value): # 取消其他选中 for chip, _ in self.qn_chips: chip.config(bg="#F4F5F7", fg="#666") # 选中当前 selected_chip.config(bg=BILI_PINK, fg="white") self.qn_var.set(value)十、阶段7:细节打磨
10.1 坑6:标题栏还是 tkinter 默认羽毛图标
def _icon_path(): """获取图标路径,兼容 PyInstaller。""" if getattr(sys, "frozen", False): base = sys._MEIPASS else: base = os.path.dirname(os.path.abspath(__file__)) return os.path.join(base, "app_icon.ico") # 在 GUI 初始化时设置 self.root.iconbitmap(_icon_path())
打包命令加--add-data "app_icon.ico;."把图标打进 exe。
10.2 坑7:双击 exe 闪黑框
根因:os.system("chcp 65001")会弹出一个 cmd 子进程。
修复:
# ❌ 会弹黑框 os.system("chcp 65001 > nul 2>&1") # ✅ 纯 Win32 API,不弹窗 import ctypes ctypes.windll.kernel32.SetConsoleOutputCP(65001)ffmpeg 的 subprocess 也要加CREATE_NO_WINDOW:
kwargs = {"check": True, "capture_output": True, "text": True} if sys.platform == "win32": kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW subprocess.run(cmd, **kwargs)凡是会创建子进程的调用都要检查,否则在 windowed 模式下都会闪黑框。
10.3 坑8:Windows 图标缓存不刷新
换了新图标,exe 还是显示旧图标。最简单的解决方案:换个文件名。
cp bili-downloader.exe "哔哩哔哩视频下载器.exe"
中文名副本绕过 Windows 图标缓存,立即显示新图标。
十一、阶段8:体积优化
exe 从 94MB 涨到 103MB,用户反馈太大。
11.1 分析体积构成
先分析 exe 里都装了什么:
ffmpeg.exe (full build) 89.5 MB 87% ← 大头 Python + tkinter 7.2 MB 7% Pillow (未使用!) 4.0 MB 4% requests + SSL 证书 3.1 MB 3% 其他 0.5 MB 1%
坑9:PyInstaller 会过度收集依赖。代码里没
import PIL,但它把 Pillow 打进去了 4MB。
11.2 优化方案
| 优化项 | 节省 | 做法 |
|---|---|---|
| ffmpeg 换 essentials 版 | -52 MB | 97MB 版包含所有常用编码,够用 |
| 排除 Pillow | -4 MB | --exclude-module PIL |
| 排除 numpy/pandas | -2 MB | 没用到的大库全排除 |
11.3 最终打包命令
pyinstaller --onefile --name bili-downloader \ --windowed --clean --noconfirm \ --icon app_icon.ico \ --collect-data certifi \ --hidden-import qrcode \ --exclude-module PIL \ --exclude-module numpy \ --exclude-module pandas \ --exclude-module matplotlib \ --add-binary "ffmpeg.exe;." \ --add-data "app_icon.ico;." \ bili_downloader.py
结果:103 MB →47 MB,缩减 54%。
十二、10 个坑汇总
| # | 坑 | 疼痛指数 | 根因 | 修复 |
|---|---|---|---|---|
| 1 | 多线程写同一文件损坏 | 10 | 并发 seek+write 竞态 | 分片文件+拼接 |
| 2 | windowed 模式 stdio=None | 9 | 无控制台 stdout 为 None | _NullStream 替换 |
| 3 | 后台线程异常被吞 | 8 | tkinter 不显示线程异常 | try/except+traceback |
| 4 | 进度条显示 110% | 7 | 重试时计数重复累加 | 回滚+钳制 |
| 5 | 变量类型冲突 | 5 | IntVar 绑字符串显示 | 两变量分离 |
| 6 | 标题栏默认羽毛图标 | 4 | 没设 iconbitmap | iconbitmap+add-data |
| 7 | 双击闪黑框 | 6 | os.system 弹子进程 | ctypes API+CREATE_NO_WINDOW |
| 8 | 图标缓存不刷新 | 4 | Windows 缓存机制 | 换中文名绕过 |
| 9 | PyInstaller 过度收集 | 4 | 自动分析依赖 | --exclude-module |
| 10 | GitHub 下载超时 | 6 | 直连慢 | 镜像+多线程分块 |
十三、可复用的开发方法论
从这个项目中,我提炼出了一套桌面工具开发的通用流程:
四条核心原则
先 CLI 后 GUI:核心逻辑独立成类,先在命令行跑通,再套 UI
渐进式优化:MVP → 打包 → UI → 稳定性 → 体验 → 体积,每步交付可验证产物
每步都打包验证:开发环境正常 ≠ 打包后正常
用户反馈驱动:用户是最佳测试员
开发阶段模板
1. 核心逻辑 (CLI) → 业务逻辑正确 2. 打包 exe → 能独立运行 3. 加 GUI → 用户能双击使用 4. 稳定性修复 → 处理打包后的坑 5. 功能增强 → 登录、配置等 6. UI 美化 → 品牌化设计 7. 细节打磨 → 图标、黑框、进度 8. 体积优化 → 精简打包
通用救命代码
# 1. windowed 模式 stdio 修复(模块顶部) class _NullStream: def write(self, *a, **k): pass def flush(self, *a, **k): pass def reconfigure(self, *a, **k): pass def _fix_stdio(): for name in ("stdout", "stderr", "stdin"): if getattr(sys, name) is None: setattr(sys, name, _NullStream()) _fix_stdio() # 2. 防黑框(所有 subprocess 调用) kwargs = {"check": True, "capture_output": True, "text": True} if sys.platform == "win32": kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW subprocess.run(cmd, **kwargs) # 3. 进度钳制(防超 100%) frac = max(0.0, min(1.0, done / total)) # 4. GUI 线程通信(queue + after 轮询) def _poll_queues(self): try: while True: msg = self.log_queue.get_nowait() self.log_text.insert("end", msg + "\n") except queue.Empty: pass self.root.after(100, self._poll_queues)十四、总结
开发一个桌面工具,真正花在核心逻辑上的时间可能只有 30%,剩下 70% 都在处理:
打包适配:stdio、路径、子进程
线程安全:UI 线程 vs 后台线程
用户体验:错误提示、进度显示、视觉设计
体积优化:精简依赖、选择合适的二进制
这些坑踩过一次就有经验了。希望这篇文章能帮你少走弯路。
关键就一句话:先 CLI 后 GUI,每步打包验证,用户反馈驱动。
完整源码已开源,如果这篇文章对你有帮助,点个赞支持一下 👍
有问题欢迎评论区交流,我会逐一回复。
