目录
总体架构
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_RATE | 60 | 60fps,保证流畅 |
KEY_I_FRAME_INTERVAL | 10 | IDR 间隔 10 秒,减少关键帧开销 |
KEY_BIT_RATE_MODE | VBR | 可变码率,场景自适应 |
KEY_PRIORITY | 0 | 实时优先级 |
KEY_LATENCY | 1 | 低延迟模式 |
码率按画质模式分级:
| 模式 | 码率 | 分辨率 |
|---|---|---|
| HD | 8 Mbps | 1080p |
| Smooth | 4 Mbps | 1080p |
| Low | 2 Mbps | 720p |
编码器低延迟配置
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=0 和 KEY_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 -/
| 字段 | 大小 | 说明 |
|---|---|---|
MAGIC | 1 byte | 固定 0x56,用于校验 |
FLAGS | 1 byte | 0x01=关键帧, 0x02=配置帧, 0x04=音频帧 |
FRAME_ID | 4 bytes | 单调递增的帧序号(big-endian,无符号) |
TIMESTAMP | 8 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 API 的 VideoDecoder 进行硬件解码。相比 MediaSource Extensions 或 WebRTC,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 的场景:
- 启动时:跳过陈旧的 GraphQL 关键帧,等待真实 IDR
- 丢包时:丢弃无法解码的 P 帧,等待 IDR 恢复
- 解码器错误时:重置解码器后等待 IDR
- 配置变更时:横屏旋转后丢弃残留 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 重建编码器:
- 创建新编码器(新尺寸,如横屏 1920×1080)
- 切换
VirtualDisplay.surface到新编码器的输入 Surface - 停止旧编码器
VirtualDisplay.resize()到新尺寸
Surface 切换在 resize 之前——确保新编码器先收到帧,旧编码器被停止后不会收到错误尺寸的帧。
配置变更通知
新编码器首次输出 SPS/PPS 时,pendingConfigBroadcast 被设置。第一个 IDR 帧到达时,Android 端将 SPS/PPS + IDR 打包成 screen_mirror_video_codec 事件发送给 Web 端。
Web 端收到后:
- 用新 SPS/PPS 重新配置解码器
- 解码 bundled IDR 帧
- 调用
requestIdr()丢弃可能残留的旧 P 帧 - 调用
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 → 编码器 Surface | GPU 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.texImage2D | GPU direct 纹理上传,无 CPU 拷贝 |
| 两遍扫描 | avccToAnnexB | 预计算大小,一次性分配 + 批量拷贝,消除装箱 |
| Bundle 事件 | SPS/PPS + IDR 打包 | 配置变更时一个事件完成重配 + 首帧解码 |
| 回调链分离 | onFirstFrame vs onStreamReady vs onDisconnected | 明确区分首帧渲染、流就绪、断连三种事件 |
| 安全网 | requestIdr() + requestKeyFrame() | 配置变更后丢弃残留帧 + 请求干净 IDR |
| PTS 去重 | timestamp < lastRenderedPts | 丢弃乱序到达的旧帧 |
| FrameId Gap | frameId > lastFrameId + 1 | 无需 ACK 的丢包检测 |
| desynchronized 上下文 | WebGL2 desynchronized: true | 绕过合成器,省 1 帧延迟 |
进一步阅读
- WebCodecs API — MDN 文档,详解
VideoDecoder/AudioDecoder接口。