Repository Wiki
ChanIok/SpinningMomo

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,从而:

  1. 零安装:不需要在设备上留下任何 APK、组件或持久状态,ADB 断开后进程自然消亡;
  2. 完整框架 API 访问:运行在 app_process 内即拥有 framework 类路径,可直接使用 MediaProjection 之外的系统级捕获 API(模块内的 Workarounds / FakeContext 即为绕过隐藏 API 限制的支撑件);
  3. 进程级 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

Loading diagram...

结构要点(均可回溯到源码):

  • 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 上看到可诊断的堆栈而不是静默崩溃。

java
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):

java
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 字节,全部大端序:

字段类型说明
magicint固定 0x4D4F4D4F("MOMO")
versionushort当前 1
typeushort帧类型
requestIdint请求关联 ID
flagsint复用(如 IMAGE_RESPONSE 里回传 format)
timestamplong微秒时间戳
payloadSizeint载荷长度,上限 128 MiB
payloadbyte[]载荷体

帧类型常量表(CaptureProtocol.java#L14-L26):

常量值方向用途
READY1Android→Windows连接建立后的就绪宣告
SCREENSHOT_REQUEST2Windows→Android请求截图(2 字节载荷:format, quality)
IMAGE_RESPONSE3Android→Windows截图结果,flags 回传 format
ERROR4Android→WindowsUTF-8 错误文本
SHUTDOWN5Windows→Android请求关闭控制连接
SHUTDOWN_ACK6Android→Windows确认关闭
START_RECORD7Windows→Android启动录制(fps/bitrate/codecType)
RECORD_READY8Android→Windows声明全部流格式与时间基准
VIDEO_SAMPLE9Android→Windows视频采样帧
STOP_RECORD10Windows→Android停止录制
RECORD_FINISHED11Android→Windows录制结束确认
AUDIO_SAMPLE12Android→Windows音频采样帧
AUDIO_ENDED13Android→Windows音频轨提前结束

读写实现是严格对称的,且读侧做了三重防御:

java
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 命令的实现,关键点有三:

java
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

  1. 双套接字分离控制面与数据面:<name> 承载轻量截图指令,<name>-record 承载重数据量录制流。分离的动机是让截图请求永远不必排在视频采样帧后面等待,也避免单条 socket 上多路复用带来的解析复杂度。
  2. 录制会话串行处理:momo-record-listener 线程 accept 后同步调用 RecordSession.handle,即同一时刻最多一个录制会话——录制本身独占编码器资源,串行化是最简单且正确的并发策略。
  3. 控制面"一个客户端生命周期"模型:主循环 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() 用注释编号的七个阶段完整刻画了从握手到收尾的状态机:

java
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 是该类最重要的设计取舍——音频初始化失败时降级为纯视频,而不是让整个录制失败:

java
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/封装所需的一切元数据,按顺序为:

字段类型说明
timeOriginUslong双流共享的微秒时间基准
width / heightint ×2显示尺寸,且做了 & ~1 偶数对齐(H.264 宏块要求)
fpsint帧率
codecTypebyte0=H.264,1=H.265
videoConfigLen + videoConfigint + bytes视频编码器 SPS/PPS
hasAudiobyte1/0
sampleRate / channels / audioConfigLen / audioConfigint ×3 + bytes仅 hasAudio=1 时有效,否则写 0 占位

(字段序列见 RecordSession.java#L94-L119。)"一次性声明全部流格式与时间基准"让 Windows 端在收到这一帧后即可完全确定封装参数,后续只需消费 sample 帧,不需要再协商。

StreamSink:双编码器统一出口

java
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

以一次完整录制的时序收束全部组件交互:

Loading diagram...

时序中的两条并行推流箭头对应 startDraining(sink, timeOriginUs) 的两路回调;两条 socket(X 与 X-record)在图中分别承担控制面与数据面,互不阻塞。

配置与参数

本模块没有配置文件,所有参数都以命令行或协议帧载荷形式由宿主端传入:

参数载体类型默认值说明
--socket <name>server 命令行string必填控制套接字名,录制套接字自动为 <name>-record
--formatscreenshot 命令行stringjpeg可选 png / jpeg / jpg
--qualityscreenshot 命令行int100仅 JPEG 有效,0..100
fpsSTART_RECORD 载荷int60载荷缺失时使用
bitrateSTART_RECORD 载荷int16_000_000载荷缺失时使用
codecTypeSTART_RECORD 载荷byte0(H.264)1 = H.265
MAX_PAYLOAD_SIZE协议常量int128 MiB单帧载荷上限
VERSION协议常量int1帧协议版本

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 体系提供了现成的多源选择扩展点(见音频采集专题页)。