Android投屏源码实战:基于Qt的H.264实时采集、UDP传输与渲染
发布时间:2026/9/11 23:09:22 作者:尧图编辑部 阅读量:1,286

简介基于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/s6Mbps 在 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里对 SPStype 7和 PPStype 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标志——这是视频解码界通用规则。下面是重组代码骨架它处理了乱序与缺失QMapquint32, 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 每帧数据约 8MBcopy 多出的 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。这样视频通道即使因为带宽打满排队也不影响触摸包的即时发送鼠标操作始终跟手。控制协议定义如下表。字段类型长度字节说明typeuint811触摸2back3homepointerIduint81触摸点编号actionuint810down, 1move, 2upxfloat4相对坐标 0.0~1.0yfloat4相对坐标 0.0~1.0timestampuint324事件产生时间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 侧积压。本文还有配套的精品资源点击获取