Skip to content

第 9 章:控制系统——从 SDL 事件到 Android 系统服务

控制不是单向写 socket。它是一套双向、异步、有界队列系统:桌面产生动作,设备执行;设备的剪贴板、ACK 和 HID output 再经同一 socket 返回。

一、完整控制路径

SDL event(主线程) → Screen.handle_event → InputManager:快捷键判定、坐标/文本转换 → Key/Mouse/Gamepad Processor → Controller queue(C) → controller send thread:serialize + socket → ControlMessageReader(Java) → Controller.handleEvent → Device / UhidManager / SurfaceCapture / 系统服务

反向路径则是 DeviceMessageSender → socket → receiver thread → 主线程 task

二、input_manager 是语义边界

SDL 提供宿主平台事件,但它不知道 Android 语义。input_manager 负责:

  • 区分 scrcpy 快捷键与应转发给设备的按键;
  • 将 SDL scancode/keycode 转为内部 Android/HID 表示;
  • 把窗口坐标转为视频 frame 坐标;
  • 处理触摸、多指模拟、滚轮和鼠标绑定;
  • 同步剪贴板并选择 paste 策略;
  • 把文件拖放变成安装 APK 或 push file;
  • 把 gamepad hotplug/axis/button 交给 processor。

事件总分派表见 sc_input_manager_handle_event()。这个边界让后端不必依赖 SDL 的复杂事件结构。

三、客户端 Controller:有界队列 + 单写线程

调用 processor 时仍在 SDL 主线程,直接 send() 可能因对端或 ADB 背压卡住 UI。因此 C 端 controller 使用独立发送线程和 vecdeque

  1. producer 在 mutex 下 push;
  2. 空 → 非空时 signal 条件变量;
  3. sender 线程等待队列,取出一条;
  4. 在锁外序列化并 net_send_all
  5. 释放消息拥有的动态内存。

实现见 controller.crun_controller()

队列为什么限制为 60

鼠标移动等输入可能比设备消费速度快。无界排队会同时增加内存与“操作回放延迟”:用户已经停下鼠标,设备仍在执行几秒前的轨迹。队列达到 60 后,大多数消息直接丢弃,让系统回到当前状态。

哪些消息绝不能丢

UHID_CREATEUHID_DESTROY 不可丢:丢 create 会让后续 input 指向不存在设备;丢 destroy 会让同 id 的下一次 create 失败。判断见 sc_control_msg_is_droppable()

这揭示了通用原则:状态声明/撤销不可丢,连续状态更新可以丢

四、resize 消息为什么绕过普通队列

窗口连续拖动会产生大量 resize,真正有意义的只有最新宽高。controller 不把它们逐条入队,而在 mutex 下维护单独 resize_display 槽位;新请求覆盖旧值,sender 醒来时优先取该槽,见 sc_controller_resize_display()

这是比“队列满后丢”更强的语义合并:中间尺寸从来不需要传输。

五、设备端:一条读取线程分发 23 种动作

Java Controller.control() 阻塞读取消息,直到线程中断或 socket 关闭。handleEvent() 先处理所有 source 都合法的 reset video,再按 display/camera 模式限制消息集合:

  • display 模式:输入、面板、剪贴板、电源、旋转、UHID、启动 app、resize、scan file;
  • camera 模式:torch、zoom;
  • 不匹配的消息触发 assertion,暴露客户端逻辑错误。

分发表见 Controller.handleEvent()

六、按键注入不是简单 keycode

SDK 键盘路径最终构造 Android KeyEvent,还必须处理:

  • action down/up;
  • repeat 与 meta state;
  • 文本到 KeyCharacterMap 的事件序列;
  • 组合字符分解;
  • async / wait-for-result / wait-for-finish 三种注入模式;
  • 指定 display id;
  • 屏幕关闭时 Back 转 Power 的特殊语义。

真正调用隐藏 InputManager.injectInputEvent() 的封装在 wrappers/InputManager.java

七、触摸与多指状态

每条 touch 消息携带 pointer id、坐标、源画面尺寸、pressure 与 button 状态。设备端:

  1. 将位置映射到真实/虚拟 display;
  2. PointersState 把任意 64 位 pointer id 分配为 Android pointer index;
  3. 维护所有活跃 pointer 的 properties/coords;
  4. 在 up 后释放该 pointer;
  5. 构造包含当前全部 pointer 的 MotionEvent 注入。

因此多指不是每根手指独立发单点事件,而是每次都提交当前 pointer 集合。实现入口见 Controller.injectTouch()

八、剪贴板为什么需要 ACK

普通 SDK paste 可在 set clipboard 消息里带 paste=true,设备设置后直接注入 Android PASTE key。但 HID 键盘的 Ctrl+V 走另一后端,可能先于控制 socket 中的 clipboard 更新生效。

scrcpy 用 sequence 建立同步屏障:

  1. 客户端发送 set clipboard(sequence=N);
  2. 设备设置完成后返回 ACK(N);
  3. receiver 唤醒 acksync
  4. AOA/UHID 键盘后端确认 ACK 后再发送 Ctrl+V report。

设备端 ACK 逻辑见 Controller.setClipboard(),客户端接收见 receiver.c

九、反向发送为什么另开线程

clipboard listener 或 UHID fd listener 可能在 Android 系统回调线程触发。如果直接写 socket,会把未知延迟带回系统回调。DeviceMessageSender 使用容量 16 的 ArrayBlockingQueue 和单独 control-send 线程,offer 失败就记录并丢弃,见 DeviceMessageSender

反向消息的业务语义大多是“最新状态”或硬件反馈,因此有界丢弃优于阻塞 Android 系统线程。

十、主线程亲和性

客户端 receiver 线程不能直接调用 SDL clipboard 或处理 HID output 的 UI 关联状态。它用 sc_run_on_main_thread() 投递 task,再转移消息数据所有权。对应代码见 receiver.c

这条规则与 video frame 类似:后台线程只生产事件和数据,SDL 资源始终在主线程触碰。

十一、本章小结

控制系统通过语义翻译、有界队列、不可丢状态消息、最新值合并和双向 ACK,在“输入要快”与“状态要一致”之间做平衡。下一章比较三个具体 processor:同一个按键如何走 Android SDK、设备端 UHID 或电脑直连 USB AOA 三条完全不同的路径。

基于 scrcpy 4.1 源码的独立学习笔记