返回博客
Architecture14 min read

屏幕镜像:低延迟投屏架构设计

本文完整介绍 PlainApp 屏幕镜像方案的端到端设计:Android 端如何用 MediaCodec 硬件编码 H.264/Opus,如何通过自定义二进制协议经 WebSocket 传输,Web 端如何用 WebCodecs + WebGL2 实现 0 拷贝 GPU 直渲,以及丢包检测、横屏旋转、系统投屏生命周期等工程难点的解决思路。

目录

总体架构

PlainApp 屏幕镜像是一个端到端的低延迟投屏系统:Android 设备捕获屏幕内容,硬件编码为 H.264 视频流和 Opus 音频流,通过 WebSocket 以自定义二进制协议推送到 Web 端;Web 端用 WebCodecs API 硬件解码,通过 WebGL2 直接渲染到 Canvas,全程 GPU 零拷贝。

没有 WebRTC,没有 RTMP,没有中间服务器。整个链路是:

Android VirtualDisplay → MediaCodec H.264 Encoder → WebSocket →
WebCodecs VideoDecoder → WebGL2 Texture → Canvas

为什么不用 WebRTC?

WebRTC 是为实时通信设计的,它的 ICE/STUN/TURN 协商、拥塞控制、抖动缓冲对于局域网投屏来说太重了。PlainApp 的场景是:

  • 同一局域网,延迟 < 5ms,无需 NAT 穿透
  • 追求极致低延迟,不要抖动缓冲
  • 需要高画质,码率可以很高(8Mbps)
  • 需要屏幕控制(触摸回传),WebRTC 的 DataChannel 增加了不必要的复杂度

自研二进制协议 + WebSocket 的方案在局域网场景下更轻量、更可控。

组件地图

Android 端Web 端
屏幕捕获MediaProjection + VirtualDisplay
视频编码MediaCodec H.264 硬件编码器
音频编码MediaCodec Opus 硬件编码器
传输WebSocket 二进制事件WebSocket 接收
视频解码WebCodecs VideoDecoder
音频解码WebCodecs AudioDecoder<audio>
渲染WebGL2 纹理直渲
控制AccessibilityService 触摸注入触摸事件 → GraphQL

视频编码管线(Android)

编码参数调优

针对局域网低延迟投屏场景专门调优的编码参数:

参数说明
KEY_FRAME_RATE6060fps,保证流畅
KEY_I_FRAME_INTERVAL10IDR 间隔 10 秒,减少关键帧开销
KEY_BIT_RATE_MODEVBR可变码率,场景自适应
KEY_PRIORITY0实时优先级
KEY_LATENCY1低延迟模式

码率按画质模式分级:

模式码率分辨率
HD8 Mbps1080p
Smooth4 Mbps1080p
Low2 Mbps720p

编码器低延迟配置

codec.setParameters(bundleOf(
    MediaCodec.KEY_PRIORITY to 0,
    MediaCodec.KEY_LATENCY to 1,
    MediaCodec.KEY_FRAME_RATE to 60,
    MediaCodec.KEY_I_FRAME_INTERVAL to 10,
    MediaCodec.KEY_BIT_RATE to bitrateBps,
))

KEY_PRIORITY=0KEY_LATENCY=1 是低延迟的关键——告诉编码器优先保证实时性而非压缩率。

关键帧请求

Web 端可以通过 GraphQL requestScreenMirrorKeyFrame mutation 主动请求 IDR 帧来恢复丢包。Android 端通过 MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME 响应:

fun requestKeyFrame() {
    val params = Bundle().apply {
        putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
    }
    codec.setParameters(params)
}

SPS/PPS 与关键帧广播

编码器启动后首先输出 SPS/PPS(CODEC_CONFIG 标志),然后输出第一个 IDR 帧。Android 端缓存这两个,在新编码器首次输出 IDR 时,将 SPS/PPS + IDR 打包成一个 screen_mirror_video_codec WebSocket 事件发送给 Web 端,Web 端一次性完成解码器配置和首帧解码。

VideoPacket 协议设计

视频帧和音频帧都通过统一的 VideoPacket 二进制协议封装,经 WebSocket 传输。

协议格式

+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
| MAGIC  | FLAGS  |           FRAME_ID (4 bytes, big-endian)            |              TIMESTAMP (8 bytes, LE)              |  DATA  |
| 0x56   |        |   byte2   |   byte3   |   byte4   |   byte5   |  byte6  |  byte7  | ...  |  byte13 |        payload...        |
+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
 \- 1B -/ \- 1B -/ \-------------------- 4B ----------------------/ \----------------------- 8B ------------------------/ \- var -/
字段大小说明
MAGIC1 byte固定 0x56,用于校验
FLAGS1 byte0x01=关键帧, 0x02=配置帧, 0x04=音频帧
FRAME_ID4 bytes单调递增的帧序号(big-endian,无符号)
TIMESTAMP8 bytes编码器 PTS(little-endian)
DATA变长H.264 NAL 单元或 Opus 数据

设计要点

  • FRAME_ID 无符号解析((buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]) >>> 0 — 必须用 >>> 0 保证无符号,否则 frameId > 2^31 会被解析为负数,导致丢包检测误判。
  • FRAME_ID 不重置:横屏旋转重建编码器时,frameId 继续递增。这让 Web 端能通过 frameId gap 检测到旋转过程中的丢帧。
  • TIMESTAMP 用编码器 PTS:不依赖 Web 端的时钟,避免时钟漂移导致 A/V 不同步。

视频解码管线(Web)

WebCodecs VideoDecoder

Web 端使用 WebCodecs APIVideoDecoder 进行硬件解码。相比 MediaSource ExtensionsWebRTC,WebCodecs 提供了对解码过程的精细控制——没有抖动缓冲,没有封装层,解码后的 VideoFrame 可以直接上传为 WebGL 纹理。

const decoder = new VideoDecoder({
    output: (frame) => renderer.renderFrame(frame),
    error: (e) => { decoderNeedsReset = true },
})
decoder.configure({
    codec: 'avc1.42c01e',
    avc: { format: 'annexb' },
    optimizeForLatency: true,
    hardwareAcceleration: 'prefer-hardware',
})

关键配置:

  • optimizeForLatency: true — 告诉解码器优先低延迟,不缓冲帧
  • hardwareAcceleration: 'prefer-hardware' — 优先使用 GPU 解码
  • avc: { format: 'annexb' } — 使用 Annex-B 格式,SPS/PPS 内联在每个 IDR 前

绿屏问题与启动时序

编码器在 VirtualDisplay 创建前就生成了第一个 IDR 帧——这是一个空白帧(全绿)。Web 端如果解码这个帧,用户会看到绿色闪烁,直到屏幕内容变化触发新帧。

解决方案:Web 端启动时不解码 GraphQL 拉取的初始关键帧,而是调用 requestIdr() 设置 waitingForIdr = true 丢弃所有 P 帧,然后通过 requestKeyFrame() 请求新的 IDR。等到新 IDR 到达时,VirtualDisplay 已经有真实屏幕内容了。

video.requestIdr()      // 丢弃 P 帧,等待 IDR
await requestKeyFrame() // 请求新 IDR

onFirstFrameRendered 回调绑定在 renderFrame 而非 handleVideo 上,确保 UI 只在真实帧渲染后才更新。

WebGL2 渲染

0 拷贝 GPU 直渲

解码后的 VideoFrame 直接上传为 WebGL2 纹理,全程不经过 CPU:

VideoDecoder → VideoFrame → gl.texImage2D(VideoFrame) → Canvas

gl.texImage2D 接受 VideoFrame 作为像素源,浏览器内部处理 YUV→RGB 转换和 GPU 上传,没有 ImageData 的 CPU 拷贝。

desynchronized 上下文

const gl = canvas.getContext('webgl2', {
    desynchronized: true,       // 绕过合成器,直写屏幕
    preserveDrawingBuffer: true, // 保留 buffer 供截图
    alpha: false,
    antialias: false,
    depth: false,
    stencil: false,
    premultipliedAlpha: false,
})

desynchronized: true 绕过浏览器合成器,直接写入屏幕,省去约 1 帧的显示延迟(~16ms @ 60fps)。

preserveDrawingBuffer: true 保留绘制缓冲,使 canvas.toDataURL() 截图能正确读取内容。默认 false 时合成后 buffer 被清空,截图为黑屏。

Canvas 自适应

Canvas 的 backing store 尺寸由 VideoFrame.displayWidth/Height 决定,CSS 尺寸通过 fitCanvasToWrapper() 按 aspect ratio 适配 wrapper 容器。使用 ResizeObserver 监听容器尺寸变化,保持画面比例不拉伸。

丢包检测与错误恢复

FrameId Gap 检测

每个视频帧携带单调递增的 frameId。解码器跟踪 lastFrameId,如果新帧的 frameId > lastFrameId + 1,说明中间有帧丢失:

if (!this.waitingForIdr && this.lastFrameId > 0
    && packet.frameId > this.lastFrameId + 1) {
    // 丢包:丢弃后续 P 帧,请求新 IDR
    this.waitingForIdr = true
    this.onRequestKeyFrame?.()
}

waitingForIdr 状态机

waitingForIdr 是一个简单的状态标志:

状态行为
false正常解码所有帧
true丢弃所有 P 帧,仅解码 IDR 帧;IDR 到达后重置为 false

触发 waitingForIdr = true 的场景:

  1. 启动时:跳过陈旧的 GraphQL 关键帧,等待真实 IDR
  2. 丢包时:丢弃无法解码的 P 帧,等待 IDR 恢复
  3. 解码器错误时:重置解码器后等待 IDR
  4. 配置变更时:横屏旋转后丢弃残留 P 帧

解码器错误恢复

VideoDecoder.onerror 触发时,设置 decoderNeedsReset = true。后续帧中遇到 IDR 帧时,用缓存的 SPS/PPS 重新配置解码器:

if (decoderNeedsReset) {
    if (!packet.isKeyFrame || !cachedConfig) return
    video.configure(cachedConfig)
    decoderNeedsReset = false
}

时间戳去重

解码器渲染后记录 lastRenderedPts。如果新帧的 PTS 小于已渲染的 PTS(乱序到达),直接丢弃:

if (packet.timestamp < this.lastRenderedPts && !packet.isKeyFrame) {
    return
}

横屏旋转处理

编码器重建

OrientationEventListener 检测到方向变化时,ScreenMirrorPipeline 重建编码器:

  1. 创建新编码器(新尺寸,如横屏 1920×1080)
  2. 切换 VirtualDisplay.surface 到新编码器的输入 Surface
  3. 停止旧编码器
  4. VirtualDisplay.resize() 到新尺寸

Surface 切换在 resize 之前——确保新编码器先收到帧,旧编码器被停止后不会收到错误尺寸的帧。

配置变更通知

新编码器首次输出 SPS/PPS 时,pendingConfigBroadcast 被设置。第一个 IDR 帧到达时,Android 端将 SPS/PPS + IDR 打包成 screen_mirror_video_codec 事件发送给 Web 端。

Web 端收到后:

  1. 用新 SPS/PPS 重新配置解码器
  2. 解码 bundled IDR 帧
  3. 调用 requestIdr() 丢弃可能残留的旧 P 帧
  4. 调用 requestKeyFrame() 请求一个干净的新 IDR

第 3-4 步是安全网——即使新编码器的第一个 IDR 尺寸不正确(resize 异步生效的窗口期),Web 端也会快速恢复到正确尺寸。

系统投屏生命周期监听

问题

用户可能通过 Android 系统通知栏关闭系统级投屏(MediaProjection),而不是通过 app 的 UI。此时 ScreenMirrorService 不知道投屏已停止——running 仍为 true,Web 端查 screenMirrorState 也得到 true,但没有视频帧到达,页面卡在 loading。

MediaProjection.Callback

MediaProjection 提供 Callback.onStop() 回调,当系统停止投屏时触发。Android 端注册这个回调,在 onStop() 中调用 ScreenMirrorService.instance?.stop()

projection.registerCallback(object : MediaProjection.Callback() {
    override fun onStop() {
        ScreenMirrorService.instance?.stop()
    }
}, null)

Service.stop() 的职责

stop() 是显式停止点,负责通知 Web 端并停止 service:

fun stop() {
    if (!running) return  // 防递归(onStop → stop → onDestroy → pipeline.stop → projection.stop → onStop)
    running = false
    sendEvent(WebSocketEvent(EventType.SCREEN_MIRRORING, """{"running":false}"""))
    stopForeground(STOP_FOREGROUND_REMOVE)
    stopSelf()
}

if (!running) return guard 防止递归:onStop()stop()stopSelf()onDestroy()pipeline.stop()projection.stop()onStop()stop()(此时 running=false,直接返回)。

Web 端处理

Web 端收到 {"running":false} 事件后,reset 到 idle 状态,显示启动按钮:

const onScreenMirroring = (data: any) => {
    if (data?.running === false) {
        cleanupFn()
        fullReset()
        return
    }
    // running=true → 连接投屏
}

音频管线

Android 端 Opus 编码

MediaCodecAudioEncoder 使用 MediaProjection 捕获系统音频,编码为 Opus 格式。编码后的 Opus 包通过 VideoPacket 封装(FLAG_AUDIO 标志),与视频帧共享同一 WebSocket 通道。

Web 端 Opus 解码

ScreenMirrorAudioPipeline 使用 WebCodecs AudioDecoder 解码 Opus 数据,输出 AudioData 直接写入 AudioWorklet 或通过 <audio> 元素播放。音频帧的 timestamp 用于 A/V 同步——与视频帧共享同一时间基(编码器 PTS)。

性能优化

0 拷贝路径

路径方式
VirtualDisplay → 编码器 SurfaceGPU direct,Surface 直接传递
VideoDecoder → VideoFrame → WebGL 纹理gl.texImage2D(VideoFrame),GPU direct
WebSocket 接收 → VideoPacket 解析Uint8Array.subarray() 是 view,不拷贝

avccToAnnexB 优化

部分 Android 编码器输出 AVCC 格式(4 字节长度前缀),需要转换为 Annex-B 格式(00 00 00 01 起始码)供 WebCodecs 解码。

早期实现使用 ArrayList<Byte> 逐字节装箱,50KB 的 IDR 帧产生 50000 次 java.lang.Byte 装箱,GC 压力巨大。优化为两遍扫描 + copyInto(JVM 上映射到 System.arraycopy intrinsic):

// 第一遍:计算输出大小
var outSize = 0
// 第二遍:批量拷贝
val out = ByteArray(outSize)
avcc.copyInto(out, writeOff + 4, off + 4, off + 4 + len)

P 帧丢弃策略

解码器在初始化阶段可能较慢,如果 P 帧队列过长会导致延迟累积。解码队列大小阈值设为 > 5(而非 > 2),避免在硬件解码器初始化期间过度丢帧。

IDR 请求防重复

waitingForIdr guard 确保每次丢包只请求一次 IDR,避免在等待 IDR 期间重复发送请求。

设计模式回顾

模式位置原因
状态机waitingForIdr 标志明确的 P 帧丢弃/恢复状态转换
Guard 防递归stop()if (!running) return防止 onStop → stop → onDestroy → pipeline.stop → projection.stop → onStop 递归
0 拷贝管道VideoFrame → gl.texImage2DGPU direct 纹理上传,无 CPU 拷贝
两遍扫描avccToAnnexB预计算大小,一次性分配 + 批量拷贝,消除装箱
Bundle 事件SPS/PPS + IDR 打包配置变更时一个事件完成重配 + 首帧解码
回调链分离onFirstFrame vs onStreamReady vs onDisconnected明确区分首帧渲染、流就绪、断连三种事件
安全网requestIdr() + requestKeyFrame()配置变更后丢弃残留帧 + 请求干净 IDR
PTS 去重timestamp < lastRenderedPts丢弃乱序到达的旧帧
FrameId GapframeId > lastFrameId + 1无需 ACK 的丢包检测
desynchronized 上下文WebGL2 desynchronized: true绕过合成器,省 1 帧延迟

进一步阅读

  • WebCodecs API — MDN 文档,详解 VideoDecoder/AudioDecoder 接口。