Android 捕获服务:app_process 运行的轻量 jar
由 app_process 直接加载运行的 Android 端捕获服务 jar(android/capture/src/com/spinningmomo/capture),不依赖 APK 安装与 Activity,通过 LocalServerSocket 向 Windows 宿主端提供设备信息查询、屏幕截图与音视频录制的常驻能力。
Purpose and Scope
本页覆盖该轻量 jar 的进程模型、入口分发、二进制帧协议、常驻截图服务与录制会话生命周期,即 Main / CaptureServer / CaptureProtocol / RecordSession 四个核心类构成的端到端服务骨架,以及它们对 ScreenCapture、ScreenRecorder、AudioEncoder、StreamSink 的调用契约。
以下内容由兄弟页面承接,本页只引用其接口边界:
- 屏幕捕获的内部实现(虚拟显示器与
FakeContext的取屏细节):由屏幕捕获专题页覆盖。 - 音频采集源选择(
AudioCaptureSource/AudioDirectCapture/AudioPlaybackCapture的策略与降级链):由音频采集专题页覆盖。 - Windows 宿主端的 ADB 通道、jar 推送与连接管理:由 Windows 端 ADB 专题页覆盖。
Overview
传统 Android 投屏/录制方案通常需要一个已安装的 APK(含前台服务)或依赖 screenrecord 这类受限的系统命令。本模块的取舍是:用 app_process 直接以 root/shell 身份运行一个仅包含 class 文件的 jar,从而:
- 零安装:不需要在设备上留下任何 APK、组件或持久状态,ADB 断开后进程自然消亡;
- 完整框架 API 访问:运行在 app_process 内即拥有 framework 类路径,可直接使用
MediaProjection之外的系统级捕获 API(模块内的Workarounds/FakeContext即为绕过隐藏 API 限制的支撑件); - 进程级 stdout 纪律:
info命令的 stdout 只承载一份 JSON、screenshot命令的 stdout 只承载原始图片字节,日志与错误一律走 stderr——这是为了配合adb exec-out把二进制原样传回 Windows。
模块提供三条顶层命令:
| 命令 | 形态 | 生命周期 |
|---|---|---|
info | 一次性 | 打印设备与编码器信息 JSON 后 exit(0) |
screenshot | 一次性 | 向 stdout 写出一张 PNG/JPEG 后 exit(0) |
server --socket <name> | 常驻 | 绑定 <name> 与 <name>-record 两个本地套接字,服务一条控制连接直至其结束 |
Architecture
结构要点(均可回溯到源码):
Main是唯一入口:负责命令行分发,并在任何命令前先执行Workarounds.apply()(Main.java#L12-L45)。- 双套接字、双线程模型:
CaptureServer.run同时绑定控制套接字与-record套接字;截图请求在主线程串行处理,录制会话在独立的momo-record-listener线程上被 accept(CaptureServer.java#L16-L56)。 CaptureProtocol是与 Windows 端共享的唯一定长头协议,截图与录制两种业务复用同一帧格式(CaptureProtocol.java#L9-L92)。RecordSession是单次录制的聚合根:一个实例对应一条全双工 socket,统管ScreenRecorder与AudioEncoder的创建、推流与停止(RecordSession.java#L15-L47)。StreamSink是编码器输出的统一出口:两个编码器通过同一 lambda 汇入RecordSession.sendFrame,保证VIDEO_SAMPLE/AUDIO_SAMPLE共享一套时间基准。
命令行入口与进程模型
Main 是 app_process 直接加载的类,其 main 方法在 try/catch 中同时捕获 Exception 与 LinkageError——后者是刻意为之:与隐藏 framework API 交互时,反射绑定失败常以 LinkageError 形式抛出,捕获它可以让宿主端在 stderr 上看到可诊断的堆栈而不是静默崩溃。
1public static void main(String[] args) {
2 try {
3 Workarounds.apply();
4
5 String command = args.length == 0 ? "info" : args[0];
6 if ("info".equals(command)) {
7 if (args.length > 1) {
8 usageAndExit();
9 return;
10 }
11 printInfo();
12 exit(0);
13 return;
14 }
15
16 if ("screenshot".equals(command)) {
17 captureScreenshot(args);
18 exit(0);
19 return;
20 }
21
22 if ("server".equals(command)) {
23 runServer(args);
24 exit(0);
25 return;
26 }
27
28 usageAndExit();
29 } catch (Exception | LinkageError error) {
30 System.err.println("Momo capture failed: " + error);
31 error.printStackTrace(System.err);
32 exit(1);
33 }
34}Source: Main.java
注意 exit(int) 不是简单调用 System.exit 的包装,其注释解释了 WHY:直接让 main 返回会进入 VM 销毁后的原生清理路径,已在 MuMu Android 15 的 __cxa_finalize 中观察到崩溃,因此所有出口都主动 System.exit(Main.java#L112-L116)。这是典型的"对特定模拟器发行版做防御性收尾"的设计决策。
info:stdout 只承载一份 JSON
printInfo 输出 protocolVersion、设备字段(manufacturer/model/androidRelease/sdkInt/supportedAbis/uid/pid)以及 CodecReport.collect() 汇总出的编码器清单。代码注释明确说明该纪律:info 命令的 stdout 只承载一份 JSON,日志和运行错误走 stderr(Main.java#L47-L65)。Windows 端因此可以直接对 adb exec-out 的 stdout 做 JSON 解析而不需要先剥离杂散输出。
screenshot:stdout 只承载原始图片字节
screenshot 支持 --format png|jpeg|jpg 与 --quality 0..100,把格式映射成 ScreenCapture.capture 需要的 formatCode(PNG=0,JPEG=1),随后直接 System.out.write(image, 0, image.length):
1byte[] image = ScreenCapture.capture(formatCode, quality);
2// exec-out 将 stdout 原样传回 Windows;绝不能通过 println 或 JSON 包装图片。
3System.out.write(image, 0, image.length);
4System.out.flush();Source: Main.java
这里严禁使用 println 或 JSON 包装——注释中的这条红线正是 adb exec-out(而非 adb shell)能拿到无损二进制流的先决条件。不支持 --quality 与 --format 之外的参数,遇到未知参数立即 usageAndExit()(exit code 2),保持 CLI 契约严格。
CaptureProtocol:定长头二进制帧协议
Windows 与 Android 共用同一协议类。帧头共 24 字节,全部大端序:
| 字段 | 类型 | 说明 |
|---|---|---|
| magic | int | 固定 0x4D4F4D4F("MOMO") |
| version | ushort | 当前 1 |
| type | ushort | 帧类型 |
| requestId | int | 请求关联 ID |
| flags | int | 复用(如 IMAGE_RESPONSE 里回传 format) |
| timestamp | long | 微秒时间戳 |
| payloadSize | int | 载荷长度,上限 128 MiB |
| payload | byte[] | 载荷体 |
帧类型常量表(CaptureProtocol.java#L14-L26):
| 常量 | 值 | 方向 | 用途 |
|---|---|---|---|
READY | 1 | Android→Windows | 连接建立后的就绪宣告 |
SCREENSHOT_REQUEST | 2 | Windows→Android | 请求截图(2 字节载荷:format, quality) |
IMAGE_RESPONSE | 3 | Android→Windows | 截图结果,flags 回传 format |
ERROR | 4 | Android→Windows | UTF-8 错误文本 |
SHUTDOWN | 5 | Windows→Android | 请求关闭控制连接 |
SHUTDOWN_ACK | 6 | Android→Windows | 确认关闭 |
START_RECORD | 7 | Windows→Android | 启动录制(fps/bitrate/codecType) |
RECORD_READY | 8 | Android→Windows | 声明全部流格式与时间基准 |
VIDEO_SAMPLE | 9 | Android→Windows | 视频采样帧 |
STOP_RECORD | 10 | Windows→Android | 停止录制 |
RECORD_FINISHED | 11 | Android→Windows | 录制结束确认 |
AUDIO_SAMPLE | 12 | Android→Windows | 音频采样帧 |
AUDIO_ENDED | 13 | Android→Windows | 音频轨提前结束 |
读写实现是严格对称的,且读侧做了三重防御:
1static Frame readFrame(DataInputStream input) throws IOException {
2 final int magic;
3 try {
4 magic = input.readInt();
5 } catch (EOFException error) {
6 throw error;
7 }
8 if (magic != MAGIC) {
9 throw new IOException("Invalid capture protocol magic");
10 }
11 if (input.readUnsignedShort() != VERSION) {
12 throw new IOException("Unsupported capture protocol version");
13 }
14
15 int type = input.readUnsignedShort();
16 int requestId = input.readInt();
17 int flags = input.readInt();
18 long timestamp = input.readLong();
19 int payloadSize = input.readInt();
20 if (payloadSize < 0 || payloadSize > MAX_PAYLOAD_SIZE) {
21 throw new IOException("Capture payload exceeds protocol limit");
22 }
23
24 byte[] payload = new byte[payloadSize];
25 input.readFully(payload);
26 return new Frame(type, requestId, flags, timestamp, payload);
27}Source: CaptureProtocol.java
三项防御分别是:magic 不匹配即视为字节流错位、版本不匹配即拒绝协商、payloadSize 越界(负数或超过 128 MiB)即抛错,避免对端异常导致本端试图分配超大缓冲。writeFrame 侧同样先校验长度再落盘,并在写完 payload 后立即 flush(),保证帧的原子可见性。
CaptureServer:常驻控制面与录制监听
CaptureServer.run 是 server 命令的实现,关键点有三:
1static void run(String socketName) throws Exception {
2 LocalServerSocket serverSocket = new LocalServerSocket(socketName);
3 LocalServerSocket recordServerSocket = new LocalServerSocket(socketName + "-record");
4
5 Thread recordListenerThread = new Thread(() -> {
6 while (!Thread.currentThread().isInterrupted()) {
7 try {
8 LocalSocket recordClient = recordServerSocket.accept();
9 RecordSession.handle(recordClient);
10 } catch (IOException e) {
11 break;
12 }
13 }
14 }, "momo-record-listener");
15 recordListenerThread.start();
16 ...
17}Source: CaptureServer.java
- 双套接字分离控制面与数据面:
<name>承载轻量截图指令,<name>-record承载重数据量录制流。分离的动机是让截图请求永远不必排在视频采样帧后面等待,也避免单条 socket 上多路复用带来的解析复杂度。 - 录制会话串行处理:
momo-record-listener线程 accept 后同步调用RecordSession.handle,即同一时刻最多一个录制会话——录制本身独占编码器资源,串行化是最简单且正确的并发策略。 - 控制面"一个客户端生命周期"模型:主循环 accept 一个客户端后,先回
READY帧再进入serveClient;serveClient返回后run直接return,随后finally块关闭两个 server socket 并 interrupt 监听线程。连接断开即服务退出,这正是"ADB 期间常驻"语义的实现方式:ADB 通道消失,进程随之终结,不需要宿主端显式清理(CaptureServer.java#L32-L56)。
serveClient 的指令循环只认两类帧:SCREENSHOT_REQUEST 走 handleScreenshot,SHUTDOWN 回 SHUTDOWN_ACK 后返回,其余类型一律回 ERROR 帧并附带不支持的消息类型号(CaptureServer.java#L58-L73)。
handleScreenshot 在调用捕获前做了两层前置校验——载荷必须恰好 2 字节、format 必须是 0 或 1——任何失败都以 ERROR 帧回给调用方而不是让异常穿透导致整个服务退出(CaptureServer.java#L75-L96)。捕获成功时 format 通过 flags 字段回传,复用协议字段而不扩展载荷结构。
RecordSession:单次录制会话生命周期
RecordSession 是录制能力的聚合根,一个实例绑定一条全双工 LocalSocket。run() 用注释编号的七个阶段完整刻画了从握手到收尾的状态机:
1// 1. 等待来自 Windows 端的 START_RECORD 协议帧
2CaptureProtocol.Frame startFrame = CaptureProtocol.readFrame(input);
3if (startFrame.type != CaptureProtocol.START_RECORD) {
4 sendError("Expected START_RECORD as first message");
5 return;
6}
7
8int fps = 60;
9int bitrate = 16_000_000;
10int codecType = 0; // 0 = H.264, 1 = H.265
11if (startFrame.payload.length >= 8) {
12 ByteBuffer bb = ByteBuffer.wrap(startFrame.payload);
13 fps = bb.getInt();
14 bitrate = bb.getInt();
15 if (bb.remaining() >= 1) {
16 codecType = bb.get() & 0xff;
17 }
18}Source: RecordSession.java
START_RECORD 载荷设计为前向兼容:fps 与 bitrate 必填(前 8 字节),codecType 只有在还有剩余字节时才读取。老版本宿主端发送 8 字节载荷依然能工作,这比固定长度结构更耐协议演进。
音频轨的"干净降级"
阶段 3 是该类最重要的设计取舍——音频初始化失败时降级为纯视频,而不是让整个录制失败:
1byte[] audioConfig = null;
2try {
3 audioEncoder = new AudioEncoder();
4 audioEncoder.prepare();
5 audioConfig = audioEncoder.fetchConfig(2000);
6} catch (Throwable t) {
7 System.err.println("Audio initialization failed, proceeding with video only: " + t.getMessage());
8 if (audioEncoder != null) {
9 audioEncoder.stop();
10 audioEncoder = null;
11 }
12}Source: RecordSession.java
这里捕获的是 Throwable 而非 Exception:音频路径涉及 REMOTE_SUBMIX 等系统服务,任何一层抛出的错误都不应让屏幕录制不可用。失败时先把半初始化的 encoder stop() 再置空,避免泄漏 MediaCodec 实例。
RECORD_READY:一次性声明全部流格式
阶段 4 构建的 RECORD_READY 载荷是 Windows 端合成 MP4/封装所需的一切元数据,按顺序为:
| 字段 | 类型 | 说明 |
|---|---|---|
| timeOriginUs | long | 双流共享的微秒时间基准 |
| width / height | int ×2 | 显示尺寸,且做了 & ~1 偶数对齐(H.264 宏块要求) |
| fps | int | 帧率 |
| codecType | byte | 0=H.264,1=H.265 |
| videoConfigLen + videoConfig | int + bytes | 视频编码器 SPS/PPS |
| hasAudio | byte | 1/0 |
| sampleRate / channels / audioConfigLen / audioConfig | int ×3 + bytes | 仅 hasAudio=1 时有效,否则写 0 占位 |
(字段序列见 RecordSession.java#L94-L119。)"一次性声明全部流格式与时间基准"让 Windows 端在收到这一帧后即可完全确定封装参数,后续只需消费 sample 帧,不需要再协商。
StreamSink:双编码器统一出口
1StreamSink sink = (type, reqId, flags, timestamp, payload) -> {
2 if (isRunning) {
3 sendFrame(type, reqId, flags, timestamp, payload);
4 }
5};
6
7screenRecorder.startDraining(sink, timeOriginUs);
8if (hasAudio) {
9 audioEncoder.startDraining(sink, timeOriginUs);
10}Source: RecordSession.java
StreamSink 是单方法函数式接口(ScreenRecorder 与 AudioEncoder 的 startDraining 都接收它)。两个编码器在各自线程上回调同一个 lambda,isRunning 是 volatile 字段,停止后到达的尾部帧会被丢弃;sendFrame 内部用 writeLock(synchronized)串行化对 DataOutputStream 的写入,这是本模块处理跨线程写同一 socket 的唯一并发原语(RecordSession.java#L17-L24)。
停止序列
主循环在收到 STOP_RECORD 或读帧抛异常(连接断开)后退出,随后按序执行:置 isRunning = false → 停 screenRecorder → 停 audioEncoder → 尽力发送 RECORD_FINISHED(失败被吞掉,因为连接可能已断)。handle 的 finally 保证 session.release() 与 client.close() 在任何路径下都会执行(RecordSession.java#L30-L47)。
Core Flow
以一次完整录制的时序收束全部组件交互:
时序中的两条并行推流箭头对应 startDraining(sink, timeOriginUs) 的两路回调;两条 socket(X 与 X-record)在图中分别承担控制面与数据面,互不阻塞。
配置与参数
本模块没有配置文件,所有参数都以命令行或协议帧载荷形式由宿主端传入:
| 参数 | 载体 | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
--socket <name> | server 命令行 | string | 必填 | 控制套接字名,录制套接字自动为 <name>-record |
--format | screenshot 命令行 | string | jpeg | 可选 png / jpeg / jpg |
--quality | screenshot 命令行 | int | 100 | 仅 JPEG 有效,0..100 |
| fps | START_RECORD 载荷 | int | 60 | 载荷缺失时使用 |
| bitrate | START_RECORD 载荷 | int | 16_000_000 | 载荷缺失时使用 |
| codecType | START_RECORD 载荷 | byte | 0(H.264) | 1 = H.265 |
MAX_PAYLOAD_SIZE | 协议常量 | int | 128 MiB | 单帧载荷上限 |
VERSION | 协议常量 | int | 1 | 帧协议版本 |
Failure Modes, Edge Cases & Concurrency
- 帧流错位 / 版本漂移:
readFrame对 magic、version、payloadSize 三重校验,任何不匹配立即抛IOException;控制面客户端异常会被serveClient外层的 catch 吞掉并回到 accept 循环,而录制会话则直接终止该会话。 - 未知帧类型:控制面回
ERROR帧继续运行;录制面要求第一帧必须是START_RECORD,否则发错误后结束会话。 - 音频初始化失败:捕获
Throwable后降级为纯视频(RECORD_READY中hasAudio=0且写 0 占位),不中断录制。 - 非偶数分辨率:
RECORD_READY中的宽高做& ~1对齐,规避编码器对奇数尺寸的拒绝。 - 跨线程写 socket:视频/音频编码线程共用一个
StreamSink,sendFrame由writeLock串行化;isRunning为volatile,停止后尾部帧直接丢弃。 - VM 原生清理崩溃:所有退出路径显式
System.exit,规避 MuMu Android 15 中__cxa_finalize观察到的崩溃。 - 连接断开即退出:
CaptureServer主循环在serveClient返回后直接 return,finally关闭两个 server socket;录制会话读帧异常同样视为停止信号。 - stdout 纪律:
info只输出一份 JSON、screenshot只输出图片字节,其余一律 stderr,保证adb exec-out二进制完整性。
Extension Points
- 新增控制面指令:在
CaptureServer.serveClient的帧分发处增加新的CaptureProtocol类型常量并实现 handler,协议版本保持为 1(旧常量值不可复用)。 - 新增录制参数:扩展
START_RECORD载荷尾部字段,并保留"载荷不足则用默认值"的兼容读取模式(参考codecType的读取方式)。 - 替换采集源:
ScreenRecorder/AudioEncoder通过startDraining(StreamSink, long)与会话解耦,AudioCaptureSource体系提供了现成的多源选择扩展点(见音频采集专题页)。
Related Links
- Main.java — 命令行入口与进程退出策略
- CaptureServer.java — 常驻双套接字服务
- CaptureProtocol.java — 二进制帧协议
- RecordSession.java — 录制会话状态机