🎬 你真的需要一个自己写的录屏工具吗?
先说结论:需要,而且非常值得。
市面上的录屏软件要么臃肿、要么收费、要么在某些企业内网环境下根本装不上。作为 Python 开发者,我们手里有 Tkinter、有 OpenCV、有 threading——完全可以在一个下午的时间里,从零撸出一个轻量、可控、可二次开发的屏幕录制工具。
我在给内部团队做技术分享录制时,就踩过这个坑:OBS 太重,ShareX 在某台老机器上崩溃,最后索性自己写。写完之后发现,不过 300 行代码,性能却出乎意料地稳。帧率稳在 25fps,CPU 占用不超过 15%。这篇文章,就把这套思路完整拆给你看。
🧱 技术选型:为什么是这套组合?
核心依赖只有三个:
- • Tkinter:Python 内置 GUI 库,零安装成本,跨平台
- • Pillow(PIL):截图能力,
ImageGrab.grab() 在 Windows 下性能相当可观 - • OpenCV(cv2):视频编码写入,
VideoWriter 支持多种编解码器
有人会问,为什么不用 pyautogui 截图?原因很简单——pyautogui.screenshot() 底层也是调 PIL,但多了一层封装,速度反而更慢。直接用 ImageGrab 是最短路径。
另外,帧率控制这块,咱们用 threading.Event 配合时间戳对齐,而不是简单粗暴地 time.sleep()。这个细节差别很大,后面会详细讲。
🔧 环境准备
1pip install pillow opencv-python numpy
Tkinter 是 Python 标准库的一部分,Windows 下安装 Python 时默认勾选,一般不需要额外安装。如果你用的是精简版 Python 环境,执行 import tkinter 报错的话,重装一遍 Python 并勾选 tcl/tk 组件即可。
🏗️ 整体架构设计
在动手写代码之前,先把架构想清楚。这个录制器分三层:
1┌─────────────────────────────────┐
2│ Tkinter GUI 层 │ ← 用户交互、状态展示
3├─────────────────────────────────┤
4│ 录制控制层 │ ← 线程调度、帧率控制
5├─────────────────────────────────┤
6│ 底层采集 & 编码层 │ ← 截图、帧写入
7└─────────────────────────────────┘
GUI 层和录制逻辑必须跑在不同线程上。这不是可选项,是必须的——录制是 CPU 密集型操作,如果塞在主线程里,界面会直接卡死,按钮点不动,体验极差。
💻 完整代码实现
📁 项目结构
1screen_recorder/
2├── main.py # 入口文件
3├── recorder.py # 录制核心逻辑
4└── ui.py # Tkinter 界面
🎯 第一步:录制核心模块 recorder.py
这是整个项目最关键的部分。帧率控制的精髓在这里。
1import cv2
2import numpy as np
3import threading
4import time
5from PIL import ImageGrab
6
7class ScreenRecorder:
8def __init__(self, output_path, fps=25, region=None):
9"""
10 output_path: 输出文件路径,如 'output.mp4'
11 fps: 目标帧率
12 region: 录制区域 (x1, y1, x2, y2),None 表示全屏
13 """
14 self.output_path = output_path
15 self.fps = fps
16 self.region = region
17 self.is_recording = False
18 self._stop_event = threading.Event()
19 self._thread = None
20 self.frame_count = 0
21 self.actual_fps = 0.0
22
23def _get_screen_size(self):
24"""获取录制区域尺寸"""
25if self.region:
26 x1, y1, x2, y2 = self.region
27return (x2 - x1, y2 - y1)
28# 全屏尺寸
29import tkinter as tk
30 root = tk.Tk()
31 w = root.winfo_screenwidth()
32 h = root.winfo_screenheight()
33 root.destroy()
34return (w, h)
35
36def _capture_frame(self):
37"""截取一帧,转换为 OpenCV 格式"""
38 img = ImageGrab.grab(bbox=self.region)
39# PIL Image (RGB) → numpy array → BGR (OpenCV 格式)
40 frame = np.array(img)
41 frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
42return frame
43
44def _record_loop(self):
45"""核心录制循环,精确帧率控制"""
46 width, height = self._get_screen_size()
47
48# 使用 mp4v 编解码器,兼容性最好
49 fourcc = cv2.VideoWriter_fourcc(*'mp4v')
50 writer = cv2.VideoWriter(
51 self.output_path, fourcc, self.fps, (width, height)
52 )
53
54if not writer.isOpened():
55raise RuntimeError(f"无法创建视频文件: {self.output_path}")
56
57 frame_interval = 1.0 / self.fps
58 start_time = time.perf_counter()
59 self.frame_count = 0
60
61try:
62while not self._stop_event.is_set():
63 frame_start = time.perf_counter()
64
65# 截图 & 写帧
66 frame = self._capture_frame()
67 writer.write(frame)
68 self.frame_count += 1
69
70# 精确帧间隔控制(关键!)
71 elapsed = time.perf_counter() - frame_start
72 sleep_time = frame_interval - elapsed
73if sleep_time > 0:
74 time.sleep(sleep_time)
75
76# 实时计算实际帧率
77 total_elapsed = time.perf_counter() - start_time
78if total_elapsed > 0:
79 self.actual_fps = self.frame_count / total_elapsed
80
81finally:
82 writer.release()
83
84def start(self):
85"""启动录制(非阻塞)"""
86if self.is_recording:
87return
88 self._stop_event.clear()
89 self.is_recording = True
90 self._thread = threading.Thread(
91 target=self._record_loop, daemon=True
92 )
93 self._thread.start()
94
95def stop(self):
96"""停止录制,等待线程结束"""
97if not self.is_recording:
98return
99 self._stop_event.set()
100 self._thread.join(timeout=5)
101 self.is_recording = False
102
103def get_stats(self):
104"""返回当前录制统计信息"""
105return {
106"frame_count": self.frame_count,
107"actual_fps": round(self.actual_fps, 1)
108 }
这里有个细节值得展开说——time.perf_counter() 而不是 time.time()。前者是高精度计时器,在 Windows 下精度能到微秒级;后者在某些系统上精度只有 10-15ms,对帧率控制来说误差太大了。
🎨 第二步:界面模块 ui.py
界面不求华丽,但信息要到位:录制状态、实际帧率、已录帧数,一眼就能看清楚。
1import tkinter as tk
2from tkinter import ttk, filedialog, messagebox
3import threading
4from recorder import ScreenRecorder
5
6class RecorderUI:
7def __init__(self):
8 self.root = tk.Tk()
9 self.root.title("屏幕录制器")
10 self.root.geometry("420x320")
11 self.root.resizable(False, False)
12
13 self.recorder = None
14 self._stats_job = None # 用于取消定时任务
15
16 self._build_ui()
17
18def _build_ui(self):
19"""构建界面布局"""
20 pad = {"padx": 12, "pady": 6}
21
22# ── 输出文件选择 ──
23 file_frame = ttk.LabelFrame(self.root, text="输出文件", padding=8)
24 file_frame.pack(fill="x", **pad)
25
26 self.path_var = tk.StringVar(value="output.mp4")
27 ttk.Entry(file_frame, textvariable=self.path_var, width=30).pack(
28 side="left", padx=(0, 6)
29 )
30 ttk.Button(
31 file_frame, text="浏览", command=self._choose_file
32 ).pack(side="left")
33
34# ── 录制参数 ──
35 param_frame = ttk.LabelFrame(self.root, text="录制参数", padding=8)
36 param_frame.pack(fill="x", **pad)
37
38 ttk.Label(param_frame, text="目标帧率 (FPS):").grid(
39 row=0, column=0, sticky="w"
40 )
41 self.fps_var = tk.IntVar(value=25)
42 ttk.Spinbox(
43 param_frame, from_=5, to=60, textvariable=self.fps_var, width=8
44 ).grid(row=0, column=1, padx=8)
45
46 ttk.Label(param_frame, text="录制区域:").grid(
47 row=1, column=0, sticky="w", pady=(6, 0)
48 )
49 self.region_var = tk.StringVar(value="全屏")
50 region_combo = ttk.Combobox(
51 param_frame,
52 textvariable=self.region_var,
53 values=["全屏", "自定义区域"],
54 state="readonly",
55 width=12
56 )
57 region_combo.grid(row=1, column=1, padx=8, pady=(6, 0))
58
59# ── 状态信息 ──
60 status_frame = ttk.LabelFrame(self.root, text="录制状态", padding=8)
61 status_frame.pack(fill="x", **pad)
62
63 self.status_label = ttk.Label(
64 status_frame, text="就绪", foreground="gray"
65 )
66 self.status_label.pack(anchor="w")
67
68 self.stats_label = ttk.Label(
69 status_frame, text="帧率: -- | 已录帧数: --"
70 )
71 self.stats_label.pack(anchor="w")
72
73# ── 控制按钮 ──
74 btn_frame = ttk.Frame(self.root)
75 btn_frame.pack(pady=10)
76
77 self.start_btn = ttk.Button(
78 btn_frame, text="▶ 开始录制", command=self._start_recording, width=14
79 )
80 self.start_btn.pack(side="left", padx=6)
81
82 self.stop_btn = ttk.Button(
83 btn_frame, text="■ 停止录制", command=self._stop_recording,
84 width=14, state="disabled"
85 )
86 self.stop_btn.pack(side="left", padx=6)
87
88def _choose_file(self):
89 path = filedialog.asksaveasfilename(
90 defaultextension=".mp4",
91 filetypes=[("MP4 视频", "*.mp4"), ("所有文件", "*.*")]
92 )
93if path:
94 self.path_var.set(path)
95
96def _start_recording(self):
97 output = self.path_var.get().strip()
98if not output:
99 messagebox.showwarning("提示", "请先选择输出文件路径")
100return
101
102 fps = self.fps_var.get()
103 region = None # 暂时只支持全屏,自定义区域可扩展
104
105 self.recorder = ScreenRecorder(output, fps=fps, region=region)
106
107try:
108 self.recorder.start()
109except Exception as e:
110 messagebox.showerror("错误", f"录制启动失败:{e}")
111return
112
113 self.start_btn.config(state="disabled")
114 self.stop_btn.config(state="normal")
115 self.status_label.config(text="录制中...", foreground="red")
116
117# 每秒刷新一次状态
118 self._update_stats()
119
120def _stop_recording(self):
121if self.recorder:
122# 停止定时刷新
123if self._stats_job:
124 self.root.after_cancel(self._stats_job)
125 self._stats_job = None
126
127# 停止录制(可能耗时,放子线程避免界面卡顿)
128def _do_stop():
129 self.recorder.stop()
130 stats = self.recorder.get_stats()
131 self.root.after(0, lambda: self._on_stopped(stats))
132
133 threading.Thread(target=_do_stop, daemon=True).start()
134
135def _on_stopped(self, stats):
136"""录制停止后的 UI 更新(必须在主线程执行)"""
137 self.start_btn.config(state="normal")
138 self.stop_btn.config(state="disabled")
139 self.status_label.config(text="录制完成", foreground="green")
140 self.stats_label.config(
141 text=f"帧率: {stats['actual_fps']} | 总帧数: {stats['frame_count']}"
142 )
143 messagebox.showinfo(
144"完成", f"录制完成!\n共 {stats['frame_count']} 帧\n"
145f"实际帧率: {stats['actual_fps']} fps\n"
146f"文件: {self.path_var.get()}"
147 )
148
149def _update_stats(self):
150"""定时刷新录制状态(在主线程调用)"""
151if self.recorder and self.recorder.is_recording:
152 stats = self.recorder.get_stats()
153 self.stats_label.config(
154 text=f"帧率: {stats['actual_fps']} fps | 已录帧数: {stats['frame_count']}"
155 )
156# 1000ms 后再次调用自身
157 self._stats_job = self.root.after(1000, self._update_stats)
158
159def run(self):
160 self.root.mainloop()
🚀 第三步:入口文件 main.py
1from ui import RecorderUI
2
3if __name__ == "__main__":
4 app = RecorderUI()
5 app.run()
就这三行。干净。
⚡ 性能优化:从"能用"到"好用"
帧率为什么对不上?
这是最常见的问题。你设置了 25fps,实际跑出来可能只有 18fps。原因通常有两个:
截图本身耗时。ImageGrab.grab() 在全屏 1080p 下,单次调用耗时大约 20~40ms,本身就快接近一帧的时间预算了(25fps = 40ms/帧)。解决方案是降低录制分辨率,或者缩小录制区域——很多时候你根本不需要录整个屏幕。
编码写入耗时。cv2.VideoWriter.write() 是同步操作,压缩编码会占用一定时间。进阶做法是引入双缓冲队列:截图线程只负责把帧放进队列,另一个编码线程从队列里取帧写入文件,两者互不阻塞。
1# 双缓冲队列示意(进阶版)
2import queue
3
4frame_queue = queue.Queue(maxsize=30) # 最多缓冲 30 帧
5
6def capture_thread():
7while not stop_event.is_set():
8 frame = _capture_frame()
9try:
10 frame_queue.put_nowait(frame)
11except queue.Full:
12pass # 队列满了就丢帧,优先保证实时性
13
14def encode_thread():
15while not stop_event.is_set() or not frame_queue.empty():
16try:
17 frame = frame_queue.get(timeout=0.1)
18 writer.write(frame)
19except queue.Empty:
20continue
这个改动能让截图和编码并行运行,帧率稳定性明显提升。
内存别让它无限涨
队列的 maxsize=30 这个参数很重要。如果不限制,在编码速度跟不上截图速度时,队列会无限积压帧数据,内存蹭蹭往上涨。30 帧大约是 1 秒的缓冲量,超出就主动丢帧——对录屏来说,偶尔丢一两帧远比内存爆掉要好。
🕳️ 踩坑预警
坑一:视频文件打不开。 用 mp4v 编解码器生成的 .mp4 文件,在某些播放器下可能无法正常播放。换成 avc1(H.264)通常能解决,但需要系统安装了对应的编解码器。最稳妥的方案是生成后用 FFmpeg 转一遍:ffmpeg -i output.mp4 -vcodec libx264 final.mp4。
坑二:多显示器截图区域错乱。ImageGrab.grab() 在多显示器环境下,坐标系是以主显示器左上角为原点的。如果你的副屏在主屏左边,坐标会出现负值,需要特殊处理。
坑三:after() 不能在子线程调用。 Tkinter 的 GUI 操作必须在主线程执行。子线程想更新界面,只能通过 root.after(0, callback) 把操作调度回主线程,或者用线程安全的队列传递消息。代码里 _on_stopped 的写法就是标准做法,别嫌麻烦。
坑四:程序退出时线程没清理干净。 录制线程设置了 daemon=True,这意味着主线程退出时它会被强制终止。但 VideoWriter 可能来不及 release(),导致视频文件损坏。建议在窗口关闭事件里显式调用 stop():
1self.root.protocol("WM_DELETE_WINDOW", self._on_close)
2
3def _on_close(self):
4if self.recorder and self.recorder.is_recording:
5 self._stop_recording()
6 self.root.destroy()
🧩 扩展方向
这套基础框架搭好之后,可以往很多方向延伸:
- • 加入音频录制:用
pyaudio 同步采集麦克风或系统音频,再用 FFmpeg 合并音视频轨道 - • 自定义录制区域:在界面上拖拽选框,把坐标传给
ImageGrab.grab(bbox=...) - • GIF 导出:把帧序列用 Pillow 的
save() 方法直接导出为 GIF,适合录制短片段用于文档说明 - • 定时录制:加一个倒计时启动功能,方便录制需要提前准备的操作
📌 三句话总结
- 1. Tkinter + Pillow + OpenCV 这套组合,300 行代码就能实现一个生产可用的录屏工具。
- 2. 帧率稳定的关键在于精确的时间控制(
perf_counter)和截图/编码的解耦(双缓冲队列)。 - 3. GUI 和录制逻辑必须分线程,Tkinter 的 UI 操作必须回到主线程执行——这两条是避坑的核心原则。
#Python#Tkinter#屏幕录制#OpenCV#多线程编程