Colibri:本地优先低延迟局域网屏幕共享与WebSocket推流
2026/9/18 4:20:22 网站建设 项目流程

蜂鸟这个意象,是我给这套工具起名 Colibri 的全部理由:体积小、悬停准、翅膀振频高到肉眼几乎看不清。Colibri 就是一个本地优先的轻量屏幕共享工具,跑在你自己的机器上,把当前桌面以低延迟推给同一局域网里的另一台设备看。它解决的不是"远程控制别人电脑"这种重型需求,而是"我想把这块屏幕给旁边那台平板、那台备用机、那台接在投影上的机器看一眼"这种每天都可能发生的小事。市面上远程桌面软件不少,但它们要么太重,要么在局域网里绕一大圈云中转,延迟和隐私都让人不踏实。这套东西适合三类人:手上有两台以上设备、经常需要在设备之间"递一眼画面"的人;想学屏幕采集、帧编码、WebSocket 推流这条链路,需要一个能跑起来的最小骨架的人;以及不想为了看个画面就装几百兆客户端的人。下面我把从选型到跑通、从参数计算到踩坑排查的全过程摊开讲,代码可以直接抄。

1. Colibri 到底在解决什么问题

1.1 从"共享个屏幕要等三秒"说起

事情的开头很朴素。我桌面上有一台主力开发机,一台放在旁边的旧笔记本用来查文档和看日志,还有一块平板用来当第二块参考屏。日常最高频的动作不是远程操作,而是"把开发机上的某个窗口给旁边那台看一眼"。传统做法有三个:截图发过去、开远程桌面软件、或者干脆把窗口拖过去。截图的问题在于它是静态的,日志在滚、进度条在动,截一张图等于什么都没说;远程桌面软件的问题在于它默认假设你要"控制",握手、鉴权、建立会话、协商编码,整套流程走完三五秒过去了,而且大部分商业方案会优先走公网中转,局域网里反而绕远路。我要的东西很简单:一条从 A 机器屏幕到 B 机器浏览器的单向通道,尽量少的中间环节,打开网页就能看到,延迟低到能看清光标移动。

这就是 Colibri 的产品边界。它只做单向画面推送,不做键鼠回传,不做文件传输,不做多用户会话管理。砍掉这些之后,整个系统可以压缩到几百行代码,启动时间从"秒级"降到"毫秒级",而且没有任何需要注册账号的环节。我把这个取舍叫做"只保留振翅的那部分"——蜂鸟能悬停,靠的不是把所有鸟的功能都堆上去,而是把振翅这一件事做到极致。

1.2 为什么名字叫 Colibri,而不是叫 ScreenShare

命名这件事看起来无关紧要,其实它决定了你后面每一个技术决策的倾向。叫 ScreenShare,你的潜意识会往"通用、完整、可配置"的方向走,最后一定会加上分辨率下拉框、码率滑块、多显示器切换、加密开关,然后变成一个半成品的小号远程桌面。叫 Colibri,你脑子里锚定的是"轻"和"快",遇到"要不要加这个功能"的时候,答案天然就是不加。这个心理锚点在实际开发里非常管用。我中途至少有三次想加功能:一次是想加剪贴板同步,一次是想加多观看端,一次是想加录制。每次我都问自己一句"蜂鸟会背着一台摄像机飞吗",然后就砍掉了。最后留下的功能只有三个:选定显示器、调节画质、开始/停止。这三件事覆盖了我 95% 的使用场景。

顺带说一句,Colibri 这个名字在开源圈里被好几个项目用过,有浏览器、有建模工具、有音视频方案。你如果去搜,会看到一堆不相干的结果。这不影响什么,项目名只对自己的使用场景负责。

1.3 明确不做什么,比明确做什么更重要

把"不做什么"写下来,是这套工具能在一周内跑起来的关键。不做键鼠回传,意味着不需要处理输入事件的序列化和时序对齐;不做多观看端,意味着服务端不需要维护订阅者列表和广播队列,一个连接一个采集循环就够了;不做公网访问,意味着不需要考虑鉴权、TLS、穿透这些能把项目周期拉长三倍的东西。只在局域网里用,这是 Colibri 的硬边界。

有人会问,那我在外面想用怎么办。我的回答是:这种需求用一个更重的工具去解决,别让 Colibri 变成一个什么都能干但什么都干不好的东西。工具的价值往往来自它的克制,而不是它的功能表长度。

2. 整体架构与技术选型:每一层为什么这么拼

2.1 三层结构:采集层、传输层、渲染层

Colibri 的骨架就三层,中间用一条明确的数据流串起来:采集层负责把屏幕像素抓下来,传输层负责把像素搬到对面,渲染层负责把像素画到浏览器上。三层之间只传递一种数据——编码后的帧字节。这个约束很重要,它让每一层都可以独立替换。比如采集层从 X11 换成 DXGI,传输层完全不用改;传输层从 WebSocket 换成 WebRTC,渲染层也完全不用改。我在实际开发中就换过两次:第一次是把采集从 PIL 的 ImageGrab 换成 mss,帧率直接从 8 提到 30;第二次是把编码从纯 Python 的 JPEG 换成 numpy 预处理加 Pillow 编码,单帧耗时降了大概 40%。如果当初把三层揉在一起写,这两次替换都得重写整个项目。

分层的另一个好处是排障。延迟高了,你可以在每一层的边界打时间戳,一眼就能看出时间花在哪。这个习惯我是从做后端服务的时候带过来的,屏幕共享这种实时性敏感的东西,没有分层计时基本等于盲调。

2.2 采集方案横向对比:别一上来就选最复杂的

采集这块,不同平台的路子差别很大。Linux 下最通用的是 X11 的抓屏接口,mss 这个库底层走的就是 XGetImage/XShmGetImage 这条路,简单、跨发行版、装个包就能用。Windows 下更现代的是 DXGI Desktop Duplication,它直接拿到 GPU 合成后的帧,效率高,还能拿到脏矩形信息,但只能在 Windows 8 以上用,而且代码量比 mss 大一个数量级。macOS 上老接口是 CGDisplayStream,新的是 ScreenCaptureKit,后者性能好但要求较新的系统版本。

平台方案上手难度帧率上限是否提供脏矩形
Linuxmss / XShmGetImage30-60否,需要自己算
Linux(Wayland)PipeWire + portal30-60
WindowsDXGI Desktop Duplication60+
macOSScreenCaptureKit60
跨平台浏览器扩展 getDisplayMedia30

我第一版选的是 mss,理由是它跨平台、API 极简、出错信息清楚。选它的时候我很清楚它拿不到脏矩形、也拿不到硬件加速,但第一版的目标是"跑通",不是"跑快"。这个顺序特别重要——我见过太多人卡在第一步,因为一上来就想选那个理论上最优的方案,结果光编译环境就折腾了三天。

2.3 传输协议:WebSocket 和 WebRTC 各自适合什么场景

传输层的选择,本质是在"简单"和"低延迟"之间做权衡。WebSocket 是 TCP 之上的,好处是浏览器原生支持、服务端几十行就能写出来、穿透性好(因为在同一个 HTTP 端口体系里,局域网直接连就行)。坏处也很明显:TCP 的可靠性保证意味着丢包要重传,队头阻塞会让延迟累积。WebRTC 走 UDP,天生为实时媒体设计,有拥塞控制、有丢包隐藏、有专门的数据通道,延迟表现明显更好。代价是信令流程复杂、服务端要么用成熟的媒体服务器要么自己实现 ICE 那一套,代码量翻好几倍。

我的实际选择是:局域网内、观看端在同一台交换机下、目标是 30fps 的桌面画面,WebSocket 完全够用。因为局域网丢包率极低(通常是 0),TCP 重传几乎不会触发,队头阻塞也就无从谈起。真正需要 WebRTC 的场景是链路质量不可控的时候,比如跨城市、跨运营商。这个判断帮我省掉了至少两周的开发时间。等你确确实实遇到了 WebSocket 撑不住的场景,再换也不迟,因为三层结构摆在那儿,传输层是最好替换的一层。

2.4 编码策略:为什么先用 MJPEG,再考虑 H.264

编码这块我走过一段弯路。一开始想直接上 H.264,因为压缩率摆在那儿,同画质下码率能低一个数量级。上手之后才发现问题:H.264 有帧间依赖,P 帧要参考前面的 I 帧,一旦某一帧丢了或者到晚了,后面一串帧全都画不对,得等下一个 I 帧才能恢复。在 WebSocket 这种有序传输里还好,但一旦队列开始堆积,你会看到画面花屏然后突然清晰然后继续花屏,体验比单纯的卡顿还糟。而且 H.264 的软件编码在 1080p 下对 CPU 的占用不低,硬件编码又要处理不同平台的接口差异。

MJPEG 就笨多了:每一帧都是独立完整的 JPEG,丢掉一帧,下一帧照样能画,延迟永远不会累积。压缩率是不如 H.264,但桌面内容的特性帮了大忙——大片纯色背景、规则的文字和线条,JPEG 在这类内容上的压缩效率相当可观。质量参数 70 的时候,一张 1080p 的桌面截图大概 80 到 200KB,取决于屏幕上有多少复杂内容。这个数字换算下来,30fps 也就 2.4 到 6MB/s,千兆局域网轻轻松松。

所以 Colibri 的编码策略是:先 MJPEG 跑通全部链路,把延迟压到能接受的范围;等到确实需要降码率或者提帧率的时候,再去评估 H.264 硬件编码。这个顺序反过来的话,你会在调试花屏问题的时候浪费掉大量时间,而那本来是可以避免的。

3. 核心模块实现细节与关键参数

3.1 帧尺寸与数据量:先算清楚再动手

动手写采集之前,先把数据量算一遍,这个动作能帮你排除掉很多不切实际的方案。假设计算的是 1920x1080 的屏幕,未压缩的像素格式是 BGRA,每个像素 4 字节:

  • 单帧原始大小:1920 × 1080 × 4 = 8,294,400 字节,约 8.29 MB
  • 30fps 每秒数据量:8.29 × 30 = 248.8 MB/s,换算成带宽约 1.99 Gbps
  • 60fps 每秒数据量:约 3.98 Gbps

这个数字意味着什么?意味着哪怕在千兆局域网里,未压缩传 60fps 也是不可能的,30fps 也占满了整条链路还有余。所以编码不是优化项,是必需项。再看编码之后:按单帧 150KB 估算,30fps 是 4.5MB/s,也就是 36Mbps,千兆网的利用率不到 4%。这就是为什么 MJPEG 这种"压缩率不算高"的方案在这个场景下完全够用——瓶颈根本不在带宽上,而在 CPU 编码能力和延迟上。

如果屏幕是 2560x1440,单帧原始数据涨到 14.7MB,30fps 是 442MB/s,编码之后按 250KB/帧算,30fps 是 7.5MB/s,仍然在可接受范围。但如果上了 4K,单帧原始数据就 33MB 了,这时候 JPEG 软件编码会成为明显的瓶颈,就必须考虑硬件编码或者降采样。

3.2 帧率、画质和 CPU 占用三者的平衡点

这三个参数是互相拉扯的。帧率上去了,每帧留给编码的时间就少了,而 JPEG 编码的耗时基本和像素量成正比,跟帧率无关,所以帧率越高 CPU 占用越高。画质参数越高,压缩后的体积越大,CPU 编码耗时也越长。想让三个都好看,唯一的路是硬件编码或者降低分辨率。

我在主力开发机(一台四核八线程的老机器)上实测的数据是这样的:

分辨率画质帧率单帧编码耗时单核 CPU 占用
1920x10807030约 12ms约 40%
1920x10807060约 12ms约 75%
1920x10809030约 22ms约 65%
1280x7207030约 6ms约 22%
1280x7207060约 6ms约 40%

看这张表能得出两个结论。第一,1080p 下 60fps 是勉强的,单帧 12ms 意味着光编码就要占掉 720ms 的每秒时间,加上采集和传输,CPU 基本上被吃满了,而且留给系统调度其他进程的余量很小。第二,降到 720p 之后余量非常充裕,60fps 也只用了 40% 的单核。所以我的默认配置是 1080p / 画质 70 / 30fps,需要更流畅的场景手动切到 720p / 60fps。

注意:这里的 CPU 占用是单核的,如果编码放在独立线程里,多核机器上对整体系统的影响会小很多。但 Python 的 GIL 会让多线程编码收效有限,真要多核并行得用多进程。

3.3 脏矩形:把带宽省下来的第一手段

桌面画面有个非常明显的特征:大部分时间大部分区域是不动的。你在打字,变化的可能只有光标附近那一小块;你在看文档,整屏可能十几秒都不变。如果老老实实每帧都传完整画面,等于 99% 的带宽浪费在了没变的地方。

脏矩形的思路很直接:把屏幕切成固定大小的块,每帧只传发生变化的块。块的粒度我选的是 64x64。1080p 下的块数量是 1920/64 = 30 列,1080/64 ≈ 17 行,一共 510 块。每帧对这 510 个块算一次哈希,跟上一帧对比,找出变化的块。

510 次哈希听起来多,实际上非常快。用 xxHash32 这类非加密哈希,1080p 一帧的哈希计算大概 2 到 4ms,比 JPEG 编码的 12ms 便宜多了。也可以用更粗暴的办法:把每块像素做一次采样(比如每 8 个像素取一个)再做比较,速度更快但可能漏掉细微变化。我选的是哈希,因为它不会漏。

实际效果很惊人。纯文字工作场景下,变化块通常只有 5 到 20 个,也就是原来数据的 1% 到 4%。带宽从 4.5MB/s 直接降到 200KB/s 以内。代价是前端要维护一张"块画布",收到块之后只更新对应区域,而不是整屏重绘。这部分前端的复杂度上去了,但换来的是带宽和 CPU 的双重下降,非常划算。

3.4 背压处理:延迟不累积的核心机制

这一节是我认为整个项目里最重要的一节。屏幕共享最糟糕的体验不是卡,而是"延迟越积越高"——你看到画面里鼠标在动,但那是三秒前的鼠标。出现这个现象的原因几乎总是同一个:生产帧的速度超过了消费帧的速度,队列没有上限,帧在队列里排长队。

假设服务端每秒采集 60 帧,客户端每秒只能解码显示 30 帧,多出来的 30 帧如果排队等候,一秒之后队列就积了 30 帧,两秒之后积了 60 帧。你看到的画面会永远落后两秒,而且会持续恶化。解决方式有两种。第一种是丢帧:队列长度超过阈值就丢掉。第二种更激进——只保留最新帧,队列长度为 1。我选的是第二种,因为屏幕共享的语义决定了"旧帧没有任何价值"。三秒前的画面哪怕是完整的,也不如当前这一刻的最新画面有价值。

具体实现上,服务端维护一个长度为 1 的缓冲区,新帧来了直接覆盖旧帧,覆盖即丢弃。客户端也做同样的处理:收到帧之后先放进 pending 变量,如果正在绘制,pending 被新帧覆盖,绘制完成后再取 pending。这样无论中间哪个环节慢下来,延迟都不会超过一帧的时间,也就是最多 33ms。这个机制我称之为"最新帧优先",它让 Colibri 在网络抖动或者 CPU 抖动的时候依然能保持"看起来是实时的",代价只是丢掉了中间的若干帧,而丢帧在视觉上表现为轻微的不连贯,比延迟累积好接受得多。

4. 从零跑通:完整实操步骤

4.1 环境准备与依赖安装

先说环境。我用的是一台 Ubuntu 22.04 的开发机,Python 3.10。需要装的包不多:

pip install mss pillow websockets numpy

四个包的职责分别是:mss 负责抓屏,pillow 负责 JPEG 编码,websockets 负责 WebSocket 服务端,numpy 负责像素格式转换。如果是在 Windows 上跑,mss 一样能用,只是底层走的接口不同;pillow 和 websockets 都是纯 Python 友好的,装起来没有额外麻烦。

关于 Python 版本,我建议 3.9 以上。原因在于 websockets 库在 3.9 之后对异步接口做了一些调整,老版本里serveserve_forever的用法有区别,跟着老教程走容易踩坑。另外一个建议是给这个项目单独建一个虚拟环境,因为 mss 和 pillow 都是需要编译或者有平台特定的二进制包,混在系统环境里升级的时候容易出问题。

python3 -m venv venv source venv/bin/activate pip install -r requirements.txt

4.2 服务端最小可运行版本

第一版服务端我只写了四十行,目标是能跑起来看到画面,不管性能。

import asyncio import io import time import mss from PIL import Image import websockets QUALITY = 70 TARGET_FPS = 30 async def push(ws): sct = mss.mss() monitor = sct.monitors[1] interval = 1.0 / TARGET_FPS while True: start = time.perf_counter() shot = sct.grab(monitor) img = Image.frombytes("RGB", shot.size, shot.rgb) buf = io.BytesIO() img.save(buf, format="JPEG", quality=QUALITY, optimize=False) payload = buf.getvalue() await ws.send(payload) elapsed = time.perf_counter() - start await asyncio.sleep(max(0.0, interval - elapsed)) async def main(): async with websockets.serve(push, "0.0.0.0", 8765, max_size=None): await asyncio.Future() if __name__ == "__main__": asyncio.run(main())

有几个细节值得单独说。sct.monitors[1]指的是主显示器,monitors[0]是所有显示器拼起来的虚拟大屏,用哪个取决于你的需求。shot.rgb这个属性会自动把 BGRA 转成 RGB,方便但多了一次内存拷贝,追求极致性能的话可以直接用shot.bgra加 numpy 切片,省掉这次拷贝。optimize=False是刻意关掉的,因为 Pillow 的 optimize 会尝试多种霍夫曼表来减小体积,代价是编码耗时明显上升,在实时场景下非常不划算。max_size=None是必须的,否则 websockets 库会对单条消息大小做限制,稍微复杂一点的画面就会直接断连。

这套代码在我那台机器上跑 1080p 的实测是 25 到 30fps,单帧端到端大约 45ms。

4.3 前端接入与渲染时机控制

前端第一版更短:

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Colibri</title> <style> html, body { margin: 0; background: #111; height: 100%; } canvas { display: block; width: 100%; height: auto; } </style> </head> <body> <canvas id="screen"></canvas> <script> const canvas = document.getElementById('screen'); const ctx = canvas.getContext('2d'); const ws = new WebSocket('ws://127.0.0.1:8765'); ws.binaryType = 'blob'; let pending = null; let drawing = false; ws.onmessage = (event) => { pending = event.data; if (drawing) return; drawing = true; drain(); }; async function drain() { while (pending) { const blob = pending; pending = null; const bitmap = await createImageBitmap(blob); if (canvas.width !== bitmap.width) { canvas.width = bitmap.width; canvas.height = bitmap.height; } ctx.drawImage(bitmap, 0, 0); bitmap.close(); } drawing = false; } </script> </body> </html>

pendingdrawing这个组合就是前面说的背压处理在前端的落地。消息来了先塞进 pending,如果正在绘制就立刻返回,pending 被后来的消息覆盖掉;绘制循环每次只取当前的 pending,取完就清空,然后接着循环。这样无论消息来得多快,同一时刻最多只有一帧在等待绘制。

createImageBitmap而不是直接给img.src赋值,原因有两个。第一,img.src = URL.createObjectURL(blob)每帧都要创建并销毁一个对象 URL,垃圾回收压力大,跑个几分钟就能看到内存曲线锯齿状上蹿下跳。第二,createImageBitmap返回的是一个可以精确控制生命周期的对象,用完调close()显式释放,内存占用平稳得多。这个改动看起来小,实际对长时间运行的体验影响很大——改之前跑十分钟就开始掉帧,改之后连续跑几个小时都很平稳。

4.4 让服务端随系统启动

Colibri 的使用场景决定了它应该是"开机就在那儿,打开网页就能用",而不是每次手动去终端敲命令。Linux 下用 systemd 用户服务是最省事的:

[Unit] Description=Colibri screen push service After=graphical-session.target [Service] Type=simple WorkingDirectory=/home/you/colibri ExecStart=/home/you/colibri/venv/bin/python server.py Restart=on-failure RestartSec=3 [Install] WantedBy=default.target

放到~/.config/systemd/user/colibri.service,然后systemctl --user enable --now colibri。这里有个坑:如果服务在图形会话建立之前就启动,抓屏会失败,因为 X11 的显示变量还没设置好。After=graphical-session.target就是为了解决这个次序问题,但如果你的桌面环境不提供这个 target,可能得改成显式设置DISPLAY=:0环境变量。我在自己的机器上两种情况都遇到过,最后是加了一个启动时的重试循环,抓屏失败就等两秒重试,比死磕 systemd 的依赖关系省事得多。

5. 常见问题排查实录

5.1 画面延迟越积越高,最后差了好几秒

这是我遇到过的第一个、也是最典型的问题。症状是刚连上的时候画面很跟手,跑了两三分钟之后延迟越来越大,最后鼠标动一下要等三四秒才能看到反应。排查思路很固定:在服务端的发送处和客户端的接收处各打一个时间戳,比对两者的差值,如果差值持续增长,就是队列堆积。

根因几乎必然是队列没有上限。我第一版用的是asyncio.Queue(),默认是无限队列,采集端使劲往里塞,发送端慢慢往外取,差值就这么越拉越大。改成asyncio.Queue(maxsize=1)并且在put之前先尝试get_nowait清空,问题立刻消失。客户端的pending机制是同一件事的另一半。这两个地方必须同时改,只改一边的话延迟还是会在另一边堆积。

提示:判断延迟有没有累积,有个不需要写代码的办法。在屏幕上放一个显示毫秒级时间的时钟,观看端和源端各拍一张照片对比。差值稳定就是正常的,差值越来越大就是队列积了。

5.2 颜色错乱、画面偏色或者整体发白

颜色问题的根源永远是通道顺序。屏幕采集拿到的原始数据通常是 BGRA 顺序(蓝、绿、红、透明度),而 JPEG 编码器期望的是 RGB(红、绿、蓝)。这两个顺序搞混,红色和蓝色就会互换,画面上人脸会变成蓝色,天空会变成橙色,非常明显。mss 的shot.rgb属性会自动做这个转换,所以用它的路径一般不会出问题。但如果为了性能改用shot.bgra加 numpy 手动转换,就很容易在切片索引上写错:

import numpy as np # BGRA -> RGB,去掉 alpha 通道并把 B 和 R 调换 arr = np.frombuffer(shot.bgra, dtype=np.uint8).reshape(shot.height, shot.width, 4) rgb = arr[:, :, [2, 1, 0]] img = Image.fromarray(rgb, mode="RGB")

另一个容易忽略的点是Image.fromarray的返回对象可能是只读的,后续如果想在上面做任何原地操作会直接报错。用rgb.copy()或者np.ascontiguousarray处理一下更保险。整体发白通常是 alpha 通道被当成颜色通道参与了编码,检查一下 reshape 的维度是否包含第 4 通道。

5.3 多显示器和高 DPI 缩放的坑

多显示器第一坑:mss.monitors返回的列表里,索引 0 是所有显示器的合并区域,从索引 1 开始才是各个独立的显示器。如果你以为索引 0 是主屏,抓出来的会是一个横跨所有显示器的超宽画面,而且坐标原点是所有显示器的左上角,可能包含负数区域。这个设计反直觉,但文档里有说明。

高 DPI 缩放是第二个坑,Windows 上尤其明显。系统缩放设成 125% 时,抓屏拿到的是物理像素(比如 2400x1350),但浏览器里 CSS 像素是逻辑像素(1920x1080)。你把 2400 宽的 canvas 塞进一个 1920 宽的容器,画面会被拉伸,文字发虚。解决办法是在 canvas 上分别设置绘图缓冲尺寸和 CSS 显示尺寸:canvas.width = bitmap.width设的是缓冲尺寸,canvas.style.width = '100%'设的是显示尺寸,两个都设对,浏览器就会自己处理好缩放。如果想让画面更锐利,可以把容器的 CSS 尺寸设成物理像素的整数分之一,避免非整数缩放带来的插值模糊。

5.4 常见问题速查表

现象最可能的原因快速验证方式处理办法
延迟持续增长帧队列无上限打印队列长度队列长度设为 1,覆盖旧帧
画面全黑抓屏权限或显示变量单独跑抓屏脚本检查 DISPLAY 或换成门户接口
红蓝互换像素通道顺序错误对比源端截图BGRA 转 RGB 时交换第 0 和第 2 通道
帧率卡在个位数编码耗时超过帧间隔单帧计时降分辨率、降画质、关掉 optimize
跑久了内存上涨对象 URL 未释放看浏览器内存曲线改用 createImageBitmap 并 close
画面撕裂绘制与显示不同步快速拖动窗口观察用 requestAnimationFrame 对齐刷新
连接立刻断开单条消息超限看服务端日志设置 max_size=None
字迹模糊缓冲尺寸与显示尺寸不匹配看 canvas 属性同时设置 width 和 style.width

这张表里的每一条都是我实际撞过的,其中"连接立刻断开"那条卡了我最久,因为报错信息很不直观,看起来像是网络问题,实际上是消息大小超限触发了库的保护机制。

6. 性能调优和长时间运行的一些体会

6.1 把延迟拆开看,才知道该优化哪里

在动手优化之前,先在链路的每个边界打上时间戳。这套数据我从实测里拿到的是这样的:采集一帧大约 5 到 12ms,取决于屏幕内容复杂度和有没有用共享内存;JPEG 编码 1080p 画质 70 大约 10 到 14ms;WebSocket 发送在局域网里 1 到 3ms;浏览器收到之后的解码 3 到 8ms;绘制加上浏览器的合成大约 5 到 16ms,这里的大头是垂直同步等待。加起来的端到端延迟在 25 到 50ms 之间,和我用毫秒时钟对比法实测的结果基本吻合。

看这个拆解能发现,最大的两块是编码和绘制,各占三分之一左右。编码这块的优化路径很明确:上多进程(绕开 GIL)、上硬件编码、或者降分辨率。绘制这块的优化空间在于别让浏览器等垂直同步。用requestAnimationFrame把绘制对齐到刷新周期,能把抖动压下去,但总延迟的下限还是由显示器刷新率决定,60Hz 的屏幕理论下限就是 16.7ms。如果你对延迟极其敏感,把显示器刷到 120Hz,这块直接砍一半。

采集这块也有一个容易忽略的优化点:mss 在 Linux 下默认走 XGetImage,每次抓屏都要把像素从服务端拷贝到客户端。如果 X 服务和程序在同一台机器上,可以用共享内存(XShm)省掉这次拷贝,mss 在某些版本里会自动检测,但不确定的时候可以手动指定。这个改动在 1080p 下能省 3 到 5ms。

6.2 几个投入产出比很高的优化

第一个,把 JPEG 的编码放在独立进程里。Python 的多线程受 GIL 限制,编码这种纯 CPU 密集的操作放线程里几乎没收益,放进程里才能真并行。用multiprocessing加一个进程池,把采集到的原始像素丢进池子,编码完再通过管道传回来,整体帧率能提 30% 到 50%。代价是像素数据要跨进程传输,1080p 一帧 8MB,跨进程拷贝本身也要几毫秒,所以这个方案更适合 4K 或者高帧率场景,1080p 30fps 的收益不算明显。

第二个,根据内容自适应调整画质。屏幕内容差异很大:看代码的时候是白底黑字,压缩率天然很高;看视频或者玩游戏的时候画面复杂,同样的画质参数下体积会翻好几倍。可以简单统计一下上一帧的压缩后大小,如果超过阈值就把这一帧的画质调低一档,反之调高一档。这个自适应逻辑二十行代码就能写完,效果比固定画质参数好不少,尤其在帧率忽高忽低的场景下。

第三个,也是我最后悔没有早点做的:给前端加一个"暂停"按钮。要求很低,就是停止接收和绘制新帧。听起来鸡肋,但实际用起来非常高频。我在写代码的时候经常需要盯着某个画面看很久,这时候推送还在继续,CPU 白白浪费,风扇呼呼转。加了暂停之后,服务端检测到客户端暂停就自动把帧率降到 1fps 的心跳,CPU 占用几乎归零,点继续之后一秒内恢复到满帧。这个功能的实现成本不到半小时,但它带来的日常体验提升比前面所有优化加起来都大。

最后分享一个我在调试时反复用到的技巧:在画面上叠加一个帧序号和时间戳的水印,直接画在服务端编码之前的图像上。这样任何时候截一张图,你都能立刻知道它是第几帧、什么时候产生的,延迟是多少,有没有跳帧。这个信息在排查疑难问题的时候特别有用,比翻日志快多了。我现在这个水印默认是关的,但调试开关一直留着。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询