第 3 章:启动与生命周期——从 main() 到事件循环
实时系统最难的往往不是“开始工作”,而是部分初始化失败和对端断开时仍能完整退出。scrcpy 把这两件事放在同一条显式生命周期主线上。
一、桌面进程入口做了什么
main.c 的职责被刻意限制为进程级准备:
- 记录主线程身份;
- 在 Windows 设置 UTF-8 输出、必要时重建标准输出;
- 初始化 locale 与 socket 网络层;
- 解析 CLI 为
scrcpy_cli_args; - 处理
--help、--version、--list-*等早退分支; - 配置日志级别;
- 调用
scrcpy(&args.opts); - 销毁 CLI 动态数据与网络层。
这里不启动 ADB、不创建窗口,也不碰 FFmpeg。这样 CLI 解析可以在无设备环境中单元测试,进程级清理也不会与运行组件混在一起。
二、struct scrcpy 是组合根
scrcpy() 使用一个静态 struct scrcpy 容纳 server、screen、demuxer、decoder、recorder、controller、file pusher、USB、UHID 与 timeout 等对象,见 scrcpy.c。
它扮演“组合根”(composition root):
- 决定有哪些组件;
- 把 source 与 sink 接起来;
- 选择 SDK/UHID/AOA 的具体实现;
- 决定启动顺序;
- 持有清理所需的状态。
这让依赖图不依赖服务定位器。代价是 scrcpy() 超过 700 行,但系统拓扑因此集中可见。
三、第一阶段:只启动 server 线程
scrcpy() 先只初始化 SDL events,生成 31 位随机 scid,把客户端 options 映射为 sc_server_params,然后调用 sc_server_start(),见 scrcpy() 320–448 行。
为什么不立刻初始化窗口和解码器?因为在知道设备是否选中、server 是否成功启动、哪些 socket 是否连通之前,这些资源没有输入。先建链可把失败面压缩在 server 线程内。
主线程并非阻塞在普通 join 上,而是在 await_for_server() 中等待 SDL 事件:
SC_EVENT_SERVER_CONNECTED:继续装配;SC_EVENT_SERVER_CONNECTION_FAILED:失败退出;SDL_EVENT_QUIT:用户在等待时取消。
因此即使 ADB 较慢,Ctrl+C/窗口退出仍能被处理。事件等待逻辑见 await_for_server()。
四、server 线程的启动状态机
run_server() 可整理为:
scid 同时解决两件事:让多个 scrcpy 实例不争用同一个 socket 名;让 ADB tunnel 能准确指向这一次 server。它被格式化为 8 位十六进制,并在两端生成 scrcpy_<scid> 名称。
五、第二阶段:按能力装配客户端图
连接成功后,scrcpy() 不是固定创建所有组件,而是按 options 计算需要:
5.1 packet 层
video=true才创建 video demuxer;audio=true才创建 audio demuxer;- playback 或 V4L2 需要 decoder;
- record 直接作为 demuxer 的 packet sink;
- decoder 也作为 packet sink。
关键装配代码在 scrcpy.c 532–589 行。这使以下组合自然成立:
| 选项组合 | packet sink | 是否解码 |
|---|---|---|
| 普通播放 | decoder | 是 |
| 只录制 | recorder | 否 |
| 播放 + 录制 | decoder + recorder | 是,但录像用原包 |
| V4L2 | decoder | 是 |
5.2 frame 层
decoder 创建后,screen、audio regulator/player 或 V4L2 被挂成 frame sink。frame 的生命周期由 decoder 推进,UI 使用单帧缓冲跨线程接收。
5.3 控制层
control=true 时创建 controller,再按选项实例化一个键盘、鼠标和手柄 processor。scrcpy.c 使用 kp/mp/gp 三个接口指针隐藏具体实现,见 输入后端选择段。
六、Android 端如何启动任务
设备端 main() 会:设置未捕获异常处理器、必要时从 root 降到 shell UID、准备可退出的主 Looper、解析 options、关闭标准流干扰并初始化日志,见 Server.internalMain()。
进入 Server.scrcpy() 后:
- 校验 Android 版本与能力组合;
- 可选启动
CleanUp守护线程; - 应用设备兼容 workaround;
- 打开
DesktopConnection; - 按 control/audio/video 创建
AsyncProcessor; - 给每个 processor 注册统一终止回调;
- 启动后进入主
Looper.loop(); - 任一 fatal processor 结束就安全退出 Looper;
- finally 中 stop → shutdown socket → join → close。
这里的 Completion 既是计数器也是故障汇聚器:正常情况下等所有 processor 完成;任何 fatal error 可提前结束主 Looper。
七、第三阶段:进入 SDL 事件循环
客户端所有 producer 启动后进入 event_loop()。它只识别少量系统级事件:
| 事件 | 来源 | 结果 |
|---|---|---|
| device disconnected | demuxer/controller | 返回专用断开码 |
| demuxer error | 音视频接收线程 | 失败 |
| controller error | 控制发送/接收线程 | 失败 |
| recorder error | 录制线程 | 失败 |
| AOA open error | USB 线程 | 失败 |
| time limit reached | timeout 线程 | 成功退出 |
| SDL quit | 用户 | 成功退出 |
| 其他 SDL 事件 | 窗口/输入 | 交给 screen |
线程不直接结束主流程,而是投递事件给主线程。这避免跨线程销毁 SDL 对象,也使退出原因集中映射为进程返回码。
八、失败回滚为什么用布尔标记
scrcpy() 为几乎每个资源维护 initialized / started 标记。C 没有 RAII,且任一步都可能失败;标记使单一 end: 区域能安全判断:
- 未初始化对象不能 destroy;
- 已初始化但未 start 的对象不应 join;
- 已 start 的 producer 必须先 stop,再 join;
- sink 的销毁必须晚于最后一个可能 push 的 producer。
例如源码明确要求等 video demuxer 完成后才能销毁 screen,否则 screen 可能在销毁后收到新帧,见 scrcpy.c 清理段。
九、退出是启动的逆拓扑,不只是逆序
真实清理顺序考虑的是“谁还可能调用谁”:
- 停止 timeout、controller、recorder 等主动线程;
- 请求 server 停止并中断 socket,让阻塞 IO 醒来;
- join demuxer,保证不再向 packet/frame sink 推送;
- join USB/AOA;
- 此后才销毁 screen;
- join/destroy controller、recorder、file pusher;
- join server 线程;
- 最终关闭 socket、ADB 和同步原语。
所以退出并非简单把初始化列表倒过来,而是对依赖图做反向拓扑排序。
十、设备端的最后保险
关闭 socket 通常会让 Android 端阻塞调用返回。但某些睡眠设备不会及时唤醒,因此客户端等待 1 秒后会主动终止设备端进程,见 server.c watchdog。
服务端 main() 的 finally 又会显式 System.exit(status),因为 Android SDK 内部可能遗留非 daemon 线程,见 Server.main()。两端都不把“线程最终会自己退出”当作可靠假设。
十一、本章小结
scrcpy 的生命周期设计可以概括为四点:分阶段装配、主线程事件汇聚、显式资源状态、依赖驱动的逆拓扑清理。下一章深入第一阶段内部:ADB 如何既负责部署,又被改造成三个 socket 的透明隧道。