第 5 章:线协议——三条 socket 上究竟发送什么
scrcpy 没有引入 Protobuf、WebRTC 或容器协议。它使用固定大端字段、长度前缀与少量位标志,把协议开销压到最低,也把版本同步责任留给源码本身。
一、协议总览
| 通道 | 方向 | framing | 主要内容 |
|---|---|---|---|
| video | 设备 → 桌面 | 4 字节 codec id + 12 字节 session/packet 头 | H.264/H.265/AV1 压缩包 |
| audio | 设备 → 桌面 | 4 字节 codec id + 12 字节 packet 头 | Opus/AAC/FLAC/PCM |
| control | 双向 | 1 字节 type + 类型特定字段 | 输入、剪贴板、UHID、设备 ACK |
在这些流之前,第一条启用的 socket 还可能承担 dummy byte 和固定长度设备名。它们属于连接握手,不属于媒体帧。
二、字节序和字符串规则
多字节数值统一按 big-endian 写入。Java 的 DataInputStream/DataOutputStream 默认就是大端;C 端则用 sc_write16be/sc_read32be 等辅助函数显式转换。
字符串不是 NUL 结尾,而是:
[length: 1/2/4 bytes][UTF-8 bytes...]长度字段宽度由消息类型决定。文本通常使用 4 字节长度;UHID 名称和 app 名用 1 字节;HID report data 用 2 字节。这样常见小消息不必支付固定 4 字节长度,但 schema 必须预先知道类型。
三、媒体流的开场:codec id
每条媒体流首先写 4 字节 codec id。它不是 FFmpeg enum,而是可读 ASCII 压成整数:
| 字节/数值 | codec |
|---|---|
h264 | H.264 |
h265 | H.265 |
av1 | AV1 |
vp8 / vp9 | VP8 / VP9 |
opus / aac / flac | 压缩音频 |
raw | PCM S16LE |
0 | 设备主动禁用该流,可降级 |
1 | 设备配置错误,必须失败 |
C 端映射见 sc_demuxer_to_avcodec_id(),Java 端写入见 Streamer.writeVideoHeader()。
用 0/1 复用 codec id 位置传递失败语义非常紧凑:客户端甚至还没创建解码器就能区分“缺音频继续”与“配置非法终止”。
四、12 字节头的双重身份
视频流中的 12 字节块可能是 session header,也可能是 media packet header,由最高位区分。
4.1 Session header
byte 0..3 flags: bit31=session, bit0=client_resized
byte 4..7 width (u32 BE)
byte 8..11 height (u32 BE)
payload 无它只出现在视频流,用于首个尺寸和后续旋转/resize 会话。client_resized 告诉桌面窗口:这次尺寸变化是用户主动调整虚拟显示导致的,不要再自动改窗口形成反馈循环。
4.2 Media packet header
byte 0..7 pts_and_flags (u64 BE)
bit62 = codec config
bit61 = key frame
low 61 bits = PTS in microseconds
byte 8..11 payload length (u32 BE)
payload MediaCodec 输出字节服务端打包见 Streamer.writeFrameMeta(),客户端解析见 demuxer.c。
为什么 config 包没有普通 PTS
编码器输出的 SPS/PPS、Opus header 等 codec-specific data 不是可播放帧。C 端把 config 标志映射成 AV_NOPTS_VALUE,统一用 FFmpeg 的“无时间戳”语义表示。H.264/H.265 解码前,packet_merger 还会把 config 数据前置到下一媒体包,兼容期望 Annex B 连续数据的下游。
五、动态会话而不是重建连接
旋转或虚拟显示 resize 会导致编码器重置和尺寸改变。scrcpy 不关闭 video socket,也不新建 decoder 管线,而是在同一字节流中插入新的 session header:
demuxer 遇到 session header 就调用所有 packet sink 的 push_session;decoder 更新自己的 session,再继续向 frame sink 传播。连接保持,订阅关系也保持。
六、桌面到设备:23 类控制消息
控制消息总是以 1 字节 type 开始。类型值 0–22 在 C 与 Java 中逐一对齐,见 control_msg.h 和 ControlMessage.java。
几个代表布局:
| 消息 | type 后字段 | 设计点 |
|---|---|---|
| inject keycode | action:u8, keycode:u32, repeat:u32, meta:u32 | 固定 14 字节 |
| inject text | len:u32, utf8 | 最大 300 字节 |
| touch | action:u8, pointer:u64, x/y:i32, screen w/h:u16, pressure:u16, buttons | 坐标携带源尺寸 |
| scroll | position, h/v:i16 fixed-point, buttons:u32 | 压缩浮点范围 |
| set clipboard | sequence:u64, paste:u8, len:u32, utf8 | 支持异步 ACK |
| UHID create | id/vendor/product:u16, name:u8+bytes, report:u16+bytes | 在设备端创建虚拟硬件 |
| resize display | width:u16, height:u16 | 最新值覆盖旧请求 |
完整 C 序列化器在 control_msg.c,Java 解析器在 ControlMessageReader.java。
七、设备到桌面:三类反向消息
反向方向没有复用上述 23 类,而定义独立 enum:
| type | 消息 | 字段 |
|---|---|---|
| 0 | clipboard | len:u32 + UTF-8 |
| 1 | clipboard ACK | sequence:u64 |
| 2 | UHID output | id:u16 + len:u16 + bytes |
Java writer 每写一条就 flush,见 DeviceMessageWriter。低吞吐控制通道宁可多一次 flush,也不等待缓冲攒满。
C 端 receiver 面对 TCP 粘包/拆包,维护一个 256 KiB 缓冲:反序列化器返回“消费字节数、0 表示消息不完整、-1 表示非法”;处理完后把剩余字节前移,见 receiver.c。
八、协议上限既是资源边界,也是安全边界
两方向控制消息最大均为 256 KiB:
- inject text 进一步限制为 300 字节;
- clipboard 允许接近完整 256 KiB;
- scan path 限 256 字节;
- UHID report 受 16 位长度和 HID 内部上限约束。
长度在发送前按 UTF-8 边界截断,而不是任意切字节,避免生成无效文本。协议读端使用 readFully 或“剩余长度不足则等待”保证消息原子性。
九、协议为什么没有显式版本字段
client 和 server 总是来自同一个发行包,客户端启动命令还把版本作为首个参数传给 server。发布流程把它们视为不可分割的一对,因此协议依赖发行版本锁步演进,而不做跨版本协商。
收益是每帧零版本开销、实现简单;代价是不能承诺任意新 client 与旧 server 互操作。修改协议时必须双端、测试和版本一起提交。
十、本章小结
scrcpy 的协议是“最小可用 framing”:媒体用 codec id + 12 字节头,控制用 type + 类型字段,动态尺寸用内嵌 session 包。下一章进入媒体生产端,看 Android 如何把真实显示、相机或虚拟显示统一渲染到编码器 Surface,并在旋转与编码失败时重建会话。