凌晨三点,你的训练脚本还在跑。屏幕上一行行日志疯狂滚动,你只能盯着最后一条发呆——"现在到底跑完多少了?"这种焦虑,每个写过数据处理脚本的人都懂。
作者想要一个漂亮的进度条,却不想拖进一个臃肿的依赖库。于是他从头写了一个,300行Python,顺带把终端控制的底层原理摸了个透。这不是炫技,是对"刚刚好"的执念。
一行代码的欺骗艺术
进度条的本质,是一场视觉欺骗。
你以为它在不断刷新画面?不。它只是同一行文字被反复覆盖。秘密藏在\r——回车符(carriage return)。这个从打字机时代活下来的控制字符,能把光标拽回行首,却不换行。
最简实现只需六行:
```python import sys import time
for i in range(101): sys.stderr.write(f"\r{i}%") sys.stderr.flush() time.sleep(0.02) ```
运行它,你会看到"0%"原地变成"1%""2%"……直到"100%"。没有滚动,没有冗余输出,只有同一行在呼吸。
两个细节暴露老手经验:
为什么用stderr而非stdout?因为标准输出常被重定向到文件或其他程序。执行python script.py > output.txt时,你不会希望进度条污染数据文件。标准错误流始终留在屏幕,这是Unix哲学里的"分离关注点"。
为什么强制flush()?Python对stderr也有缓冲机制。不手动刷新,输出可能批量到达,进度条变成"跳帧动画"。
从百分比到视觉条
纯数字太冷。人类需要填充与空白的对比,需要长度带来的直观感知。
作者选了Unicode块字符:█(U+2588,全填充)和░(U+2591,浅阴影)。现代终端对它们的渲染已经稳定,不再是当年乱码重灾区。
```python def draw_bar(current, total, width=40): pct = current / total filled = int(width * pct) bar = "█" * filled + "░" * (width - filled) return f"[{bar}] {int(pct * 100):3d}%" ```
注意{int(pct * 100):3d}这个格式。它强制百分比占3字符宽度,"1%"显示为" 1%","100%"就是"100%"。没有这个右对齐,进度条长度会随数字抖动,视觉灾难。
40个字符的宽度是经验值。太窄,进度变化不敏感;太宽,小终端会折行。作者没提自适应,但留下了修改入口。
颜色是另一套控制协议
终端不是黑白世界。ANSI转义序列(ANSI escape codes)让它能显示1670万色——前提是你知道怎么说话。
所有颜色指令以\033[开头、m结尾。基础调色板用数字映射:
```python BLUE = "\033[94m" GREEN = "\033[92m" RED = "\033[91m" RESET = "\033[0m" # 重置,必须记得用 ```
91-97是"高亮"版本,31-37是暗淡版。作者选了高亮,因为在深色终端背景上更醒目。这个选择本身就有场景假设:程序员的工作环境。
但基础色只有8种。想要品牌色、渐变、精确反馈?需要24位真彩色:
```python def hex_to_ansi(hex_color): r = int(hex_color[1:3], 16) g = int(hex_color[3:5], 16) b = int(hex_color[5:7], 16) return f"\033[38;2;{r};{g};{b}m" ```
38;2;R;G;B是前景色设置协议。Windows Terminal、iTerm2、GNOME Terminal都支持,但作者留了退路——resolve_color()函数同时处理命名色和十六进制,遇到不支持的环境能优雅降级。
这种防御性编程,是300行代码里看不到的成熟。
ETA:预测的艺术与数学
进度条回答"完成了多少",ETA(预计剩余时间)回答"还要等多久"。后者才是焦虑的真正解药。
简单算法是线性外推:已用时间 ÷ 完成比例 × 剩余比例。但作者没止步于此。实际任务 rarely 匀速——初始化慢、中间快、收尾可能有清理。线性预测会在波动中撒谎。
flashbar用了滑动窗口平均。只取最近N个样本计算速度,过滤掉历史噪音。窗口大小是权衡:太小,对波动过度敏感;太大,反应迟钝。
更细的细节:当剩余时间小于1分钟,显示"42s"而非"0:42"。人类对秒的认知更直接,冒号切分是分钟级的心理模型。这种微交互,是"好用"与"能用"的分水岭。
作者还处理了边界情况:进度为0时无法计算速度,显示"..."而非荒谬数字;任务完成后ETA消失,不占据视觉空间。
多线程的脏活
单线程脚本用上述技巧足够。但现代Python程序常是并发的:主线程干活,进度条需要独立刷新。
这引入了复杂度:线程安全、刷新频率控制、终端竞争。
flashbar的方案是专用刷新线程,用threading.Event做信号协调。主线程更新进度数据,刷新线程按固定间隔(默认100ms)重绘。间隔太短,CPU浪费在无用刷新;太长,用户感知延迟。
关键锁机制保护共享状态。Python的GIL(全局解释器锁)在这里反而是优势——不需要额外同步原语,字典更新天然原子。
但作者没提的是:信号处理。用户按Ctrl+C时,刷新线程需要干净退出,否则终端可能留在奇怪状态(颜色未重置、光标隐藏)。flashbar用上下文管理器包装,确保__exit__时恢复终端原貌。
为什么不用现成的?
PyPI上有tqdm、rich、alive-progress,个个功能强大。作者的选择是刻意的减法:
tqdm很成熟,但代码量超过5000行,支持Jupyter、GUI、多语言接口。flashbar不需要这些。rich是终端UI框架,进度条只是子功能,依赖树庞大。alive-progress动画炫酷,但配置复杂,学习曲线陡峭。
300行代码的代价是功能边界清晰:纯终端、纯Python标准库、零外部依赖。安装就是复制文件,理解就是一次咖啡时间的阅读。
这种"刚刚好"的哲学,在依赖地狱的时代反而稀缺。每个pip install都是信任投票,而信任正在贬值。
终端作为用户界面的复兴
flashbar的底层技术——ANSI转义序列、控制字符、标准流分离——诞生于1970年代。它们比大多数读者都老,却仍在支撑现代开发体验。
这不是守旧,是分层架构的胜利。终端模拟器在GUI时代进化:真彩色、鼠标事件、图像协议(kitty graphics、sixel)。底层接口保持稳定,上层能力持续扩展。
作者的工作揭示了一个被忽视的事实:终端UI不是退而求其次,而是特定场景的最优解。CI/CD日志、远程服务器、容器环境——这些地方没有浏览器,没有Electron,只有字符流。把字符流做到极致,是工程师的体面。
flashbar的GitHub仓库没有星数截图,没有性能对比图表。只有一句话:"You can read the entire thing in one coffee break." 这种自信,来自代码的自解释性。
可复用的工程模式
拆解这个项目,能提取几个跨场景可用的模式:
渐进增强(progressive enhancement)。核心功能用最通用技术实现(\r刷新),高级特性检测环境后启用(24位色、多线程)。不因为10%的场景增加90%的复杂度。
状态机管理。进度条有明确生命周期:未开始、进行中、暂停、完成、错误。每个状态对应不同的渲染逻辑和清理动作。显式状态比布尔标志组合更易维护。
可测试的边界。纯函数(draw_bar、hex_to_ansi)与副作用(终端写入、线程启动)分离。前者单元测试覆盖,后者集成测试验证。
这些模式在300行里密集出现,是小型项目的教学价值所在。大型代码库中,它们被文件结构和抽象层级稀释,反而难学。
一个未被回答的问题
作者在文末开放了完整源码,却没讨论分发策略。flashbar是单文件库,用户复制即可用,但这也意味着版本管理、更新通知、漏洞修复的缺位。
Python社区在这两种哲学间分裂:pip依赖派追求可复现环境,vendor派厌恶不可控的传递依赖。flashbar选择了后者,但作者没有为这个选择辩护。
更深层的问题是:当"不依赖"成为卖点,我们是否在用工程洁癖逃避生态责任?安全补丁、兼容性适配、文档维护——这些成本不会消失,只是转移到了每个复制粘贴的用户身上。
300行代码能读完,但读完之后的决策——用还是不用、改还是不改、分还是不分——没有标准答案。这或许正是技术选择的本质:不是寻找正确答案,而是承担选择的后果。
你的下一个脚本,会为了进度条引入依赖,还是宁愿自己写?
热门跟贴