第 9 章:控制系统——从 SDL 事件到 Android 系统服务
控制不是单向写 socket。它是一套双向、异步、有界队列系统:桌面产生动作,设备执行;设备的剪贴板、ACK 和 HID output 再经同一 socket 返回。
一、完整控制路径
反向路径则是 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:
- producer 在 mutex 下 push;
- 空 → 非空时 signal 条件变量;
- sender 线程等待队列,取出一条;
- 在锁外序列化并
net_send_all; - 释放消息拥有的动态内存。
实现见 controller.c 与 run_controller()。
队列为什么限制为 60
鼠标移动等输入可能比设备消费速度快。无界排队会同时增加内存与“操作回放延迟”:用户已经停下鼠标,设备仍在执行几秒前的轨迹。队列达到 60 后,大多数消息直接丢弃,让系统回到当前状态。
哪些消息绝不能丢
UHID_CREATE 与 UHID_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 状态。设备端:
- 将位置映射到真实/虚拟 display;
- 用
PointersState把任意 64 位 pointer id 分配为 Android pointer index; - 维护所有活跃 pointer 的 properties/coords;
- 在 up 后释放该 pointer;
- 构造包含当前全部 pointer 的
MotionEvent注入。
因此多指不是每根手指独立发单点事件,而是每次都提交当前 pointer 集合。实现入口见 Controller.injectTouch()。
八、剪贴板为什么需要 ACK
普通 SDK paste 可在 set clipboard 消息里带 paste=true,设备设置后直接注入 Android PASTE key。但 HID 键盘的 Ctrl+V 走另一后端,可能先于控制 socket 中的 clipboard 更新生效。
scrcpy 用 sequence 建立同步屏障:
- 客户端发送 set clipboard(sequence=N);
- 设备设置完成后返回 ACK(N);
- receiver 唤醒
acksync; - 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 三条完全不同的路径。