简介:基于Qt的Android实时投屏软件源码项目,专为需要将Android手机画面实时同步至PC桌面的开发者准备,适配毕业设计、课程大作业、初期项目演示等场景,也适合具备一定C++/Qt基础的学生进阶学习。资源包共31个文件,包含12个头文件、10个C++源文件、7个工程配置pri文件、1个pro项目文件及gitignore,整包仅33KB,结构紧凑。源码覆盖ADB设备连接与进程管理、视频流解码、YUVOpenGL渲染控件、输入控制转发、Server服务器与DeviceSocket通信等核心模块,目录按render、adb、decoder、server、inputControl、common清晰划分,可以快速定位各功能单元。项目代码已经过运行验证,既能作为Qt网络编程与Android投屏完整参考实现,也方便在此基础上扩展分辨率自适应、多设备管理等功能,目前已有413人学习下载。同时提供可直接构建运行的Qt工程配置,便于导入后快速编译验证,是理解Android投屏协议及Qt多线程网络编程的实用案例。
1. 为什么有人愿意为「手机画面搬上电脑」写一套 Qt 源码
做嵌入式或桌面端的工程师第一次接触 Android 投屏时,通常想的是「这事不难:截个图、传过去、显示出来」。等真动手才发现,实时投屏压根儿不是截图轮询,而是把帧采集、编码、传输、解码、显示和触摸回传串成一条低延迟流水线。Android 的 SurfaceFlinger 根本不开放给普通应用直接读帧,能选的路无非是 screencap 轮询、MediaProjection 取纹理、或者 screenrecord 管道输出,每一条路的延迟和帧率特性都不同。Qt 在这件事上确实是合适的载体:它自带跨平台网络库、多媒体接口、OpenGL 窗口集成,又天然支持 Android 交叉编译,写一套 C++ 逻辑就能同时跑 Windows 端和 Android 端。「基于Qt的Android实时投屏软件源码.zip」这个标题在检索里频繁出现,说明大家真正找的不是能运行的 APK,而是一份能讲清「帧从哪来、码流怎么走、延迟在哪产生」的可读工程骨架。这篇博文就顺着这条线,把采集、传输、显示和事件回传讲透,再落一份可抄的源码级实现结构。
2. 采集端的选型:screenrecord 管道与 MediaProjection 的取舍
2.1 为什么先说采集端,直接决定延迟天花板
实时投屏的端到端延迟,由四段组成:Android 端帧生成间隔 + 编码耗时 + 网络传输耗时 + PC 端解码渲染耗时。前两段占大头,而后两段可以通过协议优化压到 20ms 内。也就是说,选错采集方案,后面做得再精细也救不回来。我在做过一轮对比测试后,结论是:screenrecord 管道 在 Android 7.0 以上配合 H.264 硬编,是「普通 App 权限下」延迟最低的公开方案;MediaProjection 则是唯一能拿到系统级画面(包含状态栏、Toast、输入法弹窗)的合法途径,但拿到的帧是 Surface 纹理,需要二次处理。两者并不冲突,成熟的 Qt 投屏实现会把它们封装成两个采集后端,编译期或运行期二选一。
2.2 screenrecord + QProcess 的管道采集实现
screenrecord 是 Android 自带命令行工具,可输出 H.264 裸流到 stdout,原理是直接复用 MediaCodec 的硬件编码器,自身不进用户的 Java 层,因此开销极低。Qt 侧用 QProcess 启动它,并监听 readyReadStandardOutput 把字节流缓存成 Annex-B 格式的 H.264 分片。这块的工程细节是:screenrecord 默认编码码率只有 4Mbps,且输出分辨率固定为屏幕真实分辨率。如果网传带宽不足,需要在命令行里显式指定 bit-rate 和 size。
QProcess *record = new QProcess(this); QStringList args; args << "--size" << "1920x1080" << "--bit-rate" << "6000000" << "--time-limit" << "1800" << "--yuv" << ""; // 不要加这一行,加上会停止编码为H.264 record->start("screenrecord", args); connect(record, &QProcess::readyReadStandardOutput, this, [=]() { QByteArray chunk = record->readAllStandardOutput(); emit h264DataReady(chunk); });需要说明的是:--size参数作用是让 SurfaceFlinger 在合成前先做一次分辨率缩放,编码器输出分辨率会随之变化。PC 端解码器收到的 SPS/PPS 里的宽高就是缩放后的值,不用再做裁剪。--bit-rate的单位是 bps,不是 KB/s,6Mbps 在 720p 下画质基本不可感知损失,在 1080p 下静态画面够用、动态画面会出马赛克。更多时候我不会直接固定码率,而是用--bit-rate配合网络实时探测结果去动态重启 screenrecord,但这会引入切换黑屏的坑,后续会讲替代做法。
2.3 MediaProjection 方案为什么常被当作进阶路线
MediaProjection 是官方 SDK 的投屏接口,需要用户弹窗授权,拿到 VirtualDisplay 后既可以把帧送到 Surface,也可以配合 ImageReader 读取像素缓冲。用 ImageReader 拿到的 RGBA 数据直接丢给 Qt 侧显示是最容易想到的路子,但实际跑起来延迟极高:ImageReader 默认拿的是软件拷贝,一帧 1080p 的 RGBA 是 8MB 左右,再做一次 YUV 转换和编码,单帧耗时可能超过 80ms。正确姿势是把它作为「保底/兼容方案」:在无法执行 screenrecord 的机型(少数厂商裁剪了该工具)或者不需要低延迟的场景下,用 MediaProjection + 表面纹理 + 手动 requestRender 来控制帧节奏。我这里给一个 Qt for Android 下用 JNI 调 MediaProjection 的代码片段,核心是声明 VirtualDisplay 并把 Surface 传给 Qt 的 OpenGL 纹理。
// Kotlin 侧 mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data) virtualDisplay = mediaProjection?.createVirtualDisplay( "QtDisplay", width / 2, height / 2, // 降采样一半,缓解带宽压力 densityDpi, Surface(textureView.surfaceTexture), // surfaceTexture 由 Qt 侧传入 null, null ) // Qt 侧用 QAndroidJniObject 调用后,拿到 surfaceTexture 的 id // 通过 QOpenGLTexture::setNativeHandle 绑到 Qt 渲染管线这段代码里,createVirtualDisplay的宽高填的是屏幕尺寸的一半,因为 MediaProjection 的 VirtualDisplay 可以低成本降采样,而 screenrecord 的--size也是同理。densityDpi 不传或传 0 会导致 UI 元素尺寸异常,需要注意。这套方案的优点是能拿到全界面,缺点是 Java 层回传 Surface 后,Qt 侧要么走 OpenGL 直接采样,要么把纹理回读到内存,模块复杂度会明显上升。在源码工程里,通常的做法是AbstractFrameSource接口定义start() / stop() / frameReady(QByteArray),screenrecord 和 MediaProjection 各实现一个,互不污染。
2.4 画面采集时最容易忽视的同步问题
QProcess 的管道读取不能保证每次读到的都是完整的 NAL 单元。screenrecord 输出的 H.264 裸流,每个 NAL 以00 00 00 01起始码分隔,Qt 端必须自己做缓冲切分,不能想当然地认为一次 readyReadStandardOutput 就是一帧。一个稳妥的切分策略是:按起始码把缓冲切分,第一次遇到00 00 00 01之前的数据丢给上一条帧的尾部,之后的每条数据作为一个新的接入单元。同时要缓存 SPS 与 PPS,因为它们不是每帧都带,而 PC 端解码器必须先收到 SPS/PPS 才能出画。这里给出一个按起始码切分的 Qt 实现。
QByteArray nalBuffer; void onH264Chunk(const QByteArray &chunk) { nalBuffer.append(chunk); int offset = 0; while (true) { int start = findStartCode(nalBuffer, offset); if (start < 0) break; int next = findStartCode(nalBuffer, start + 4); if (next < 0) { // 还没拼完下一帧,滞留缓冲 break; } QByteArray nal = nalBuffer.mid(start + 4, next - start - 4); dispatchNal(nal); // 根据 nal[0] & 0x1F 判断类型并分发 offset = next; } if (offset > 0) nalBuffer.remove(0, offset); }注意dispatchNal里对 SPS(type 7)和 PPS(type 8)的处理不能只解一次:有的 Android 编码器会在码率自适应切换或 I 帧间隔变化时重新输出 SPS/PPS,所以缓存要更新而不是丢弃。另外,findStartCode需要兼容 4 字节和 3 字节两种起始码,否则个别帧会花屏。这个切分逻辑是后续所有处理的基础,写得粗糙的话,玩到解码环节就会遇到一帧一花屏的诡异现象。
3. 网络传输层:基于 QUdpSocket 的私有协议与丢包重传
3.1 为什么 TCP 不是实时投屏的首选
很多第一次写投屏的人会自然选择 QTcpSocket,理由是 H.264 对丢包很敏感,TCP 可靠。这个直觉在「低帧率、低码率」场景下没有错,但一旦码率来到 4Mbps 以上,TCP 的拥塞控制和队头阻塞会导致延迟在几百毫秒到一秒之间震荡,动一下鼠标处处拖影。实时投屏真正需要的是一个「可控丢弃」的通道:视频帧尾部的 NAL 丢了可以不掉帧,但头部丢了必须快速重传。UDP 天然适合这事。我一般实现的私有协议如下,不做任何加密和鉴权,面向局域网 ADB 连接:
- 包头 12 字节固定:帧号(4)、NAL 序号(2)、NAL 总数(2)、类型(1)、关键帧标记(1)、时间戳(2)
- 每个 UDP 报文最多承载 1200 字节,避免 IP 分片
- 接收端收到非关键帧的 NAL 缺失时,直接丢弃该帧,等待下一关键帧
- 关键帧(IDR)的 NAL 只发一次,不做重传队列,因为下一帧关键帧间隔最多 2 秒(可在采集端调小)
struct UdpHeader { quint32 frameIndex; quint16 nalIndex; quint16 nalCount; quint8 payloadType; // 0:H264, 1:控制指令, 2:触摸事件 quint8 isKeyFrame; quint16 timestampOffset; };在 Qt 的 QUdpSocket 里,发送侧每次 writeDatagram 对应一个 UDP 报文,不需要粘包;接收侧pendingDatagramSize()配合readDatagram能精确拿到报文边界。这个设计相比 TCP 的好处是,接收端解析数据报头后立即把 nal 分片按 nalIndex 重组,不需要等待前面所有字节到达,丢包的影响被限定在「当前帧」内。代价是实现复杂度多一层,但投屏场景完全值得。发送端关于帧顺序的控制,需要在采集线程通过QElapsedTimer掐出 33ms 的节奏,不能 send 完就完事,否则码率会不经控制地打满网卡。
3.2 实时码率控制:从静态设置到自适应水线
前面说过,--bit-rate是静止的,但桌面画面切换时静止和运动的熵差可以到 20 倍。更好的方案是让 Qt 发送端每隔一段时间统计实际吞吐,动态决定「是否让前端重新采集」。真正能落地的不是改编码器参数,而是控制采集帧率:高运动画面降到 30fps,静止画面升到 60fps 没必要,反而浪费带宽。这里给出一个简单的自适应反馈逻辑。
// 发送端每 2 秒统计一次最近 2 秒内的平均帧字节数 float avgBytes = totalBytes / frameCount; float targetBytes = bitrate / fps / 8; if (avgBytes > targetBytes * 1.2) { // 画面运动激烈,主动降帧率,避免带宽打满导致乱序 requestedFps = qMax(15, requestedFps - 5); } else if (avgBytes < targetBytes * 0.7) { // 画面静止,保持或略微升帧率,提升细腻度 requestedFps = qMin(30, requestedFps + 5); }注意,这个逻辑的作用对象不是编码器,因为 screenrecord 不提供动态帧率接口。实际做法是:Android 端另开一个轻量级别线程,按requestedFps的频率去丢帧——也就是「采集照常,但发射端每隔 N 帧只发 1 帧」。screenrecord 的 30fps 输入流保持不变,Qt 发送端按需跳过 I/P 帧,这样就模拟出动态帧率。丢帧时要遵循规则:不能丢关键帧,且连续丢帧数不能超过 GOP 的一半,否则 PC 端会长时间无法恢复参考帧。参数上,初次连接可以用 8Mbps 的码率和 30fps 帧率,后续按带宽水线自动切换。在局域网 5GHz Wi-Fi 下这个方案能把延迟稳定在 80ms 内,实际体感接近「跟手」。
3.3 PC 端接收与重组:把 UDP 报文还原成 H.264 帧
接收端重组逻辑其实比传输更考验细节。UDP 报文到达顺序可能乱掉,frameIndex对应的 NAL 分片需要先存进按 frameIndex 索引的哈希表。等一个 frame 的所有 nalIndex 到达后,按 nalIndex 排序拼接,再交给解码器。如果有关键帧分片缺失,要直接丢弃这一整帧并置一个waitForKeyFrame标志——这是视频解码界通用规则。下面是重组代码骨架,它处理了乱序与缺失:
QMap<quint32, FramePacket> frameCache; void onUdpPacket(const QByteArray &datagram) { UdpHeader hdr = decodeHeader(datagram); auto &fp = frameCache[hdr.frameIndex]; fp.nalCount = hdr.nalCount; fp.isKeyFrame = hdr.isKeyFrame; fp.nalData.insert(hdr.nalIndex, datagram.mid(12)); if (fp.nalData.size() == fp.nalCount) { QByteArray fullNal; for (int i = 0; i < fp.nalCount; i++) { fullNal += fp.nalData[i]; // 按序拼接 } emit frameComplete(fullNal); frameCache.remove(hdr.frameIndex); } }frameCache需要设置上限,一般 32 帧就够了,超过的直接把最老的 frameIndex 丢弃,避免弱网下内存堆积。这里引出一个经典教训:接收端不要在emit frameComplete里直接做解码,因为解码耗时可能超过 10ms,会阻塞 QUdpSocket 的 readyRead 事件循环,造成后续 UDP 报文在系统缓冲区积压,延迟瞬间暴涨。正确做法是发射信号后由解码线程处理,socket 线程保持越快越好。实战里碰到「画面卡顿但 CPU 不高」的怪象,八成就是这里没做线程分离。
4. 解码与渲染:Qt 侧吃下 H.264 并显示出来的最小闭环
4.1 解码器的集成方式选择
Qt 6 弃用了 QtMultimedia 里对 H.264 硬解码的默认支持,自己带解码器要么走 FFmpeg 动态库,要么调用系统 MediaCodec / 显卡硬件加速接口。在四类典型工程源码里,最普遍的做法是内嵌 FFmpeg 的libavcodec与libavformat,解码后把 YUV 帧转成 QImage 显示。这个方案虽然引入两个动态库,但胜在解码参数可调、兼容 PC 的 NVIDIA/Intel 硬解,而且不依赖特定 Qt 模块。
const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_H264); AVCodecContext *ctx = avcodec_alloc_context3(codec); ctx->thread_count = 4; ctx->lowres = 0; ctx->flags2 |= AV_CODEC_FLAG2_FAST; avcodec_open2(ctx, codec, nullptr);thread_count设为 4 时,FFmpeg 会使用 Frame Threads 模式,多帧并行解码,Snapdragon 平台的 feed 测试里能把 1080p30 的解码耗时从 28ms 降到 18ms。但这个值不是越大越好,部分 H.264 的 slice 结构不允许在帧间并行,thread_count 过大反而造成线程切换开销。AV_CODEC_FLAG2_FAST能接受不符合规范的码流,代价是少部分机型花屏概率变大。调试时建议先关掉,排查是否与解码器有关,再决定是否开启。解码后的 AVFrame 数据结构是 YUV420P,不能直接喂给 QWidget 显示,需要先转成 RGB32。这一步不能用 sws_scale 的并发版本,因为它不是线程安全的,正确做法是持有单独的 SwsContext 实例并在解码线程内调用。
4.2 YUV 到 QImage 的高效转换与渲染线程
直接sws_scale到 QImage 每帧要执行一次满幅像素格式转换,性能大约在 2ms 上下,1080p 时偶尔到 5ms。有所提升的替代做法是用 OpenGL 着色器采样 YUV 纹理,把转换从 CPU 搬到 GPU。在源码工程里我看到过两种共存的方式:默认用 QImage 兜底,如果检测到 OpenGL 可用,则切换到 GL 渲染。两种方式在 Qt 里的显示入口都是QWidget::paintEvent或QQuickPaintedItem,区别只在于 paint 时是drawImage还是glDrawArrays。我给出的是 QImage 兜底路径,因为它的代码可读性最高,排错也方便。
void decodeAndShow(QByteArray nalFrame) { AVPacket pkt = {0}; av_new_packet(&pkt, nalFrame.size()); memcpy(pkt.data, nalFrame.constData(), nalFrame.size()); int ret = avcodec_send_packet(ctx, &pkt); if (ret != 0) return; AVFrame *frame = av_frame_alloc(); while (avcodec_receive_frame(ctx, frame) == 0) { // 转格式时目标尺寸与源保持一致即可 sws_scale(swsCtx, frame->data, frame->linesize, 0, ctx->height, rgbBuffer->data, rgbBuffer->linesize); QImage rgbImage(rgbBuffer->data[0], ctx->width, ctx->height, rgbBuffer->linesize[0], QImage::Format_RGB32); emit frameReady(rgbImage.copy()); } av_frame_free(&frame); av_packet_unref(&pkt); }这段代码在流程上没什么问题,但QImage::copy()在每帧里产生一次整图拷贝。拿 1080p 来算,RGB32 每帧数据约 8MB,copy 多出的 memcpy 大概 1~2ms,看起来不多,但放在主线程里足够让 UI 掉帧。所以显示线程要做双缓冲:decode 线程写入 backImage,渲染线程在 paintEvent 里直接交换 frontImage。Qt 里可以用QImage::swap来避免深拷贝,开销只是指针交换。
4.3 窗口自适应与高 DPI 缩放
投屏窗口的显示分辨率不一定等于手机原始分辨率。常见的工程默认是按 50% 缩放显示,这样既减少渲染像素量,又让窗口在小屏幕上不至于占满。做自适应时有个细节:不要在 paintEvent 里直接scaled,那会每帧触发一次高开销缩放。而是在手机分辨率上报后(通过网络控制帧传递),一次性生成目标 QImage 尺寸,后续每次 sws_scale 时直接输出到目标尺寸。下面是参数对应关系。
| 显示模式 | 输出尺寸计算 | 延迟影响 | 适用场景 |
|---|---|---|---|
| 原始分辨率 | 手机宽 x 高 | 低,无需缩放 | 图清晰,但窗口巨大 |
| 等比 50% | 宽/2 x 高/2 | 低,SWS 一次缩放 | 通用推荐 |
| 窗口拖动 | 当前 widget 大小 | 中,动态缩放有开销 | 用户拖拽时使用 |
| 拉伸填充 | 窗口大小不分比例 | 无额外延迟 | 凑合用,不推荐 |
sws_scale支持在转换的同时缩放,即源尺寸与目标尺寸不同时会自动执行缩放插值。如果缩放大小的比例是 2 的整数次幂,开销极低;0.5 到 0.6 之间这种非对齐比例,比整数倍慢 2 倍左右,所以在延迟敏感场景不要采用奇数百分比。窗口拖动可以由用户在交互里按下「适应窗口」按钮才触发一次重算,而不是每帧都按当前 widget 大小动态变化,否则整个投屏的系统负载会剧烈波动。
5. 触摸与控制事件回传:反向通道的协议设计与延迟补偿
5.1 用接入点复用 UDP 通道还是单独开 Socket
多数投屏工具不止显示,还要求能用鼠标控制手机。事件回传的特点是频次高、单包极小,如果与视频流共用同一个 QUdpSocket,会出现视频大数据包与触摸小包互相挤占的问题。我习惯用独立的 QUdpSocket 连接回传触摸事件,端口偏移固定加 1。这样视频通道即使因为带宽打满排队,也不影响触摸包的即时发送,鼠标操作始终跟手。控制协议定义如下表。
| 字段 | 类型 | 长度(字节) | 说明 |
|---|---|---|---|
| type | uint8 | 1 | 1=触摸,2=back,3=home |
| pointerId | uint8 | 1 | 触摸点编号 |
| action | uint8 | 1 | 0=down, 1=move, 2=up |
| x | float | 4 | 相对坐标 0.0~1.0 |
| y | float | 4 | 相对坐标 0.0~1.0 |
| timestamp | uint32 | 4 | 事件产生时间 |
x 与 y 记录的是相对坐标而不是绝对像素,是为了屏蔽 Android 端屏幕分辨率变化。Android 端收到这包数据后,在 Java 层把它转换为MotionEvent.obtain并注入到InputManager。如果源码工程只做了显示没做回传,往往是把这部分当加分项跳过了,但投屏的「实时」体感有一半来自回传是否跟手。
5.2 触摸事件的时间戳补偿算法
从 PC 端鼠标点击产生事件,到 Android 端真正执行点击,中间隔了一个网络往返时间 RTT。如果不做补偿,用户总会觉得「点下去有一点点迟滞」。成熟的实现会用 NTP 简化的时间同步:Android 端把自己的系统时间戳放进视频流头部的 timestampOffset 字段,PC 端记录收到时刻并计算二者差值。这个差值用于修正触摸事件里的时间戳,让 Android 端注入事件的时间尽可能贴近本机系统真实时间。
// PC 端每次收到视频帧时,估算一次单向传输时间 qint64 oneWayDelay = (receiveMsec - frameCaptureMsec) / 2; // 触摸事件发送前,把本地时间加上这个单向延迟 TouchEvent ev; ev.timestamp = currentMsec + oneWayDelay; sendControlPacket(ev);不需要精确到毫秒级,RTT 波动在局域网内通常只有几毫秒,粗略补偿已经能把问题从「物理上感知到」降低到「几乎无感」。有一个容易忽视的点:Android 端注入时间戳不能直接用 PC 传过去的绝对值,因为两台机器时钟并不同步,这个值只用于让 InputManager 判断事件顺序。真正负责延迟补偿的是 oneWayDelay 的估算,用它来调整 PC 端发送动作的时刻即可。如果投屏只用于演示、没有触摸需求,这个模块可以剪掉,但作为源码工程保留它往往能让项目完整度上一个档次。
6. 用假帧验证链路与优化 CPU 占用的三板斧
6.1 在无手机环境下用本地录像文件模拟采集源
调试时不必每次都连着真机,可以把手机屏幕录像存成 H.264 文件,在 Qt 工程里用QFile读取后走完全相同的onH264Chunk流程。这里的技巧是:把文件读出的字节直接拼进 nalBuffer,每次读 64KB 并 sleep 2ms 模拟实时到达,就能在 PC 上验证切片、解码、渲染,完全脱离 Android 设备。这样一来,采集端写了一个可替换的FileSource,与ScreenRecordSource实现同一个AbstractFrameSource接口。修改的只有启动参数,其他模块一行不动。遇到「PC 上花屏但真机不花屏」的时,这个假源也能辅助判断是不是传输丢包导致的——因为文件源不存在 UDP 丢包。
class FileSource : public AbstractFrameSource { protected: void run() override { QFile f("/tmp/sample.h264"); if (!f.open(QIODevice::ReadOnly)) return; while (!isInterruptionRequested()) { QByteArray chunk = f.read(64 * 1024); if (chunk.isEmpty()) { f.seek(0); continue; } emit h264DataReady(chunk); QThread::msleep(2); // 限速,模拟实时帧 } } };文件源验证通过后,整个链路里就只剩采集和网络两个未知变量。再配合ping看 RTT、用 Android 端 logcat 观察 screenrecord 是否在跑,就能快速定位故障点。这是我在工程里最常用的一套「由下而上」的验证顺序,比对着真机抓包效率高好几倍。
6.2 CPU 占用过高时,先看这三个参数而不是换渲染方案
投屏工具 CPU 居高不下,大多数不是 Qt 的问题,而是前面某些环节没做约束。第一刀切在sws_scale的调用频率上:如果解码线程和渲染线程没有节流,FFmpeg 的 receive_frame 会按编码器的真实帧率往外吐,但 QImage 转换和绘制不一定要按这个频率执行。可以设定最高渲染帧率上限为 30fps,用QElapsedTimer判断距上次绘制是否超过 33ms,超过才画。这能直接砍掉一半无意义的缩放与绘制操作,CPU 立降。第二刀是降低采集端码率设置:bit-rate从 8Mbps 降到 4Mbps,解码帧的像素量不变,但熵解码耗时降低明显。第三刀是检查 UDP 接收缓冲是否溢出:用socket->pendingDatagramSize()的瞬时值峰值来判断,如果超过 1MB 则说明接收端处理不够快,需要上调initialDatagramSize或调整解码线程优先级。三个参数按顺序调过一轮,大多数「CPU 高」的场景都能稳定在 15% 以内(以 i5-9500 为基准)。
6.3 延迟量化:用光源计时画面而非体感判断
最后给一个可复现的延迟验证技巧,比单纯体感可靠得多。在电脑屏幕上用 Qt 画一个高精度秒表,分秒级刷新;手机端打开相机对着电脑屏幕拍摄,相机画面同时显示手机拍照前的秒表数值和屏幕上的真实秒表数值。拍摄一张照片后,对比两个秒表读数差,差值即为端到端延迟近似值。重复 5 次取平均值,结果比「我觉得还行」有说服力得多,也能验证调参效果。这个方法需要两台设备外加一部相机,但成本几乎为零。实际测下来,screenrecord 方案端到端延迟约在 60 至 120ms 之间,其中网络占 5ms 左右,其余主要在编码缓冲和显示链路;如果测出来超过 200ms,基本可以断定不是调参能解决的,要回头检查采集端是否无意中走了软件编码,或者视频帧在 Qt 侧积压。
本文还有配套的精品资源,点击获取