Android 实时画面与操作录制源码分析¶
分析版本:Midscene 1.10.5,基线提交
cfc7c100分析日期:2026-07-16 范围:Android Playground 的 scrcpy 实时预览、截图回退、浏览器手动控制和 Playground Recorder
1. 结论摘要¶
Midscene Android 当前有三条容易被统称为“录屏”的链路,但它们的用途和产物完全不同:
| 能力 | 输入 | 产物 | 是否调用 AI | 是否生成视频文件 |
|---|---|---|---|---|
| scrcpy 实时预览 | 设备 H.264 流 | 浏览器 Canvas 连续画面 | 否 | 否 |
| 截图轮询/任务截图 | ADB 或 scrcpy 单帧 | PNG/JPEG/Base64 | 执行任务时可能交给 AI | 否 |
| Playground Recorder | 手动点击、拖拽、输入等事件 | 结构化事件、前后截图、语义描述 | 描述目标元素时调用 | 否 |
因此,当前 Android Playground 没有把设备画面编码并持久化成 MP4/WebM 的屏幕录像功能。它提供的是:
- scrcpy H.264 实时串流,用于在 Playground 中连续观看设备画面。
/screenshot轮询回退,用于浏览器不支持 WebCodecs 等场景。- Recorder 记录用户对预览画面的操作和操作前后截图,用于生成可回放的自然语言/Journey 数据。
仓库中确实有 MediaRecorder,但位于 Visualizer 的“将报告回放导出为品牌 WebM 视频”功能,与 Android 真机录屏无关,见 export-branded-video.ts。
2. 能力总览¶
flowchart TD
Device["Android 真机 / 模拟器"]
ScrcpyDevice["设备端 scrcpy-server"]
H264["带帧元数据的 H.264 包"]
Sidecar["Node ScrcpyServer sidecar"]
Socket["Socket.IO 二进制 video-data"]
Decoder["浏览器 WebCodecsVideoDecoder"]
Canvas["Canvas 实时预览"]
Polling["GET /screenshot 轮询"]
Snapshot["PNG/JPEG 单帧"]
Overlay["DeviceInteractionLayer"]
Interact["POST /interact"]
Recorder["Playground Recorder"]
Events["事件 + 前后截图 + 语义"]
Device --> ScrcpyDevice --> H264 --> Sidecar --> Socket --> Decoder --> Canvas
Device --> Polling --> Snapshot
Canvas --> Overlay
Snapshot --> Overlay
Overlay --> Interact --> Device
Interact -. "Recorder 开启时异步采集" .-> Recorder --> Events
3. scrcpy 实时预览¶
3.1 服务组成¶
Android Playground 启动两个 HTTP 服务:
- 通用 Playground Server:默认端口
5800,负责会话、AI 执行、截图和手动交互。 - Android
ScrcpyServersidecar:使用单独端口,负责设备发现、scrcpy 生命周期和视频包转发。
ScrcpyServer 同时创建 Express、HTTP Server 和 Socket.IO。它只允许无 Origin、loopback 或私有网段 Origin,避免任意公网网页直接控制本机 ADB 设备。
ADB 连接固定访问本机 127.0.0.1:5037:
设备列表可以来自外部 ScrcpyDeviceListSource,否则直接调用 ADB Server。列表变化时通过 devices-list 广播;当前设备断开后会清空选择,并优先自动选择第一个 device 状态的设备。
3.2 设备端 scrcpy 启动¶
startScrcpy() 的流程是:
- 从包内读取
bin/scrcpy-server。 - 通过
AdbScrcpyClient.pushServer()推送到设备。 - 使用
AdbScrcpyOptions3_3_3启动服务。 - 等待
videoStream和视频元数据。 - 持续读取 framed packet,并转发给当前 Socket.IO 客户端。
主要参数:
| 参数 | 当前值 | 作用 |
|---|---|---|
audio |
false |
不传输音频 |
control |
true |
创建 scrcpy 控制通道,当前 UI 手动操作仍主要走 /interact |
maxSize |
1024 |
限制视频长边,降低带宽和解码压力 |
sendFrameMeta |
true |
区分 configuration 与 data packet |
videoBitRate |
2_000_000 |
约 2 Mbps 视频码率 |
推送、ADB 连接、服务启动、视频流和元数据等待都有独立超时,避免设备异常后 Promise 永久悬挂。
3.3 视频包协议¶
设备端流先返回 metadata,然后返回 configuration/data packet。Node 服务转发的核心事件如下:
| 事件 | 方向 | 数据 | 用途 |
|---|---|---|---|
connect-device |
浏览器 -> sidecar | deviceId, maxSize |
选择设备并启动 scrcpy |
preview-status |
sidecar -> 浏览器 | phase 和 message | 展示连接阶段 |
video-metadata |
sidecar -> 浏览器 | codec、width、height | 创建解码器和确定画面比例 |
video-data |
sidecar -> 浏览器 | Uint8Array, type, keyFrame |
传输 H.264 配置包或帧 |
error |
sidecar -> 浏览器 | message | 触发错误 UI 和重连 |
devices-list |
sidecar -> 浏览器 | devices、currentDeviceId | 更新设备列表 |
processStream() 保留原始 Uint8Array,由 Socket.IO 作为 binary frame 传输。这里刻意不执行 Array.from():2 Mbps 视频若把每个字节膨胀成 JS Number,会在低内存主机上快速耗尽 V8 old space。
如果底层 reader 正常返回 done,服务仍会主动发出 video stream ended 错误并关闭 scrcpy client。否则 Socket 看起来仍连接,但浏览器会永远停留在最后一帧。
3.4 浏览器解码和 Canvas 渲染¶
sequenceDiagram
participant UI as ScrcpyPanel
participant IO as Socket.IO
participant Sidecar as ScrcpyServer
participant Device as scrcpy-server
participant Decoder as WebCodecsVideoDecoder
participant Canvas as Canvas Renderer
UI->>IO: connect(serverUrl)
UI->>Sidecar: connect-device(deviceId, maxSize=1024)
Sidecar->>Device: push + start scrcpy
Device-->>Sidecar: metadata + H.264 packets
Sidecar-->>UI: video-metadata(codec, width, height)
UI->>Decoder: create(codec, WebGL/Bitmap renderer)
loop 每个 framed packet
Sidecar-->>IO: video-data(binary)
IO-->>Decoder: ScrcpyMediaStreamPacket
Decoder-->>Canvas: decoded VideoFrame
end
createScrcpyVideoStream() 把 Socket.IO 事件包装成 ReadableStream<ScrcpyMediaStreamPacket>。它会暂存先于 configuration 到达的 data packet,收到 configuration 后再按顺序放行,避免解码器缺少 SPS/PPS 时直接失败。
ScrcpyPanel 收到 metadata 后:
- 优先选择
WebGLVideoFrameRenderer,不支持时使用BitmapVideoFrameRenderer。 - 创建
WebCodecsVideoDecoder。 - 把 decoder 自带 Canvas 放入预览容器。
- 将
ReadableStreampipe 到 decoder 的 writable。 - 用视频原始宽高通知外层,作为设备比例的真实来源。
断流、metadata 超时、解码异常都会销毁 Socket、decoder 和 Canvas,再按默认 3 秒间隔重连。React StrictMode 下连接动作延迟一个 tick,避免开发环境的探测挂载创建一条无效 scrcpy 会话。
3.5 截图轮询回退¶
WebCodecs 在不安全的 LAN HTTP 页面中可能不可用。PreviewRenderer 在以下情况改用 /screenshot:
- runtime preview 本来就是
screenshot。 - runtime 声明
scrcpy,但WebCodecsVideoDecoder.isSupported为 false。
GET /screenshot 调用当前 Agent 的 interface.screenshotBase64(),返回 { screenshot, timestamp }。如果页面/设备会话已关闭且错误属于可恢复类型,Server 会尝试重建 Agent 后重试一次。
该回退提供的是离散图片刷新,不具备视频帧率和低延迟保证。
4. 浏览器手动控制¶
实时预览自身只负责“看”。浏览器中的点击、滑动、滚轮和键盘操作由透明的 DeviceInteractionLayer 捕获,再通过 Playground SDK 调用 POST /interact。
4.1 坐标投影¶
浏览器坐标到设备逻辑坐标的核心关系是:
contentRect 取实际图片/Canvas 内容区域,而不是包含状态提示和留白的外层容器。scrcpy 场景优先使用 video-metadata 的 intrinsic size;这样可避免 /interface-info.size 与视频 buffer 相差几个像素时出现边缘偏移。
4.2 /interact 执行¶
POST /interact 绕过 AI 规划、任务锁和常规 dump:
- 校验当前 Agent 和
actionType。 - 如果设备暴露
inputPrimitives,调用dispatchPointer()。 - 否则从
actionSpace()找同名动作并直接调用。 - 浏览器 chrome 的
Stop等动作有单独分支。 - 成功响应后,如果 Recorder 已开启,异步排队采集事件。
支持能力来自 /interface-info 返回的 actionTypes。前端只有发现 Tap 时才开启 pointer overlay,拖拽、输入、键盘和滚轮也分别按动作能力启用。
5. Playground Recorder¶
5.1 Recorder 记录什么¶
Recorder 的数据模型定义在 recorder.ts。单条事件可以包含:
type:click、drag、scroll、input、navigation、keydown等。actionType和原始/interactpayload。pageInfo、URL、标题和时间戳。screenshotBefore、screenshotAfter和带点击标记的截图。- 目标点/矩形。
- AI 或 heuristic 生成的元素描述、回放指令、摘要和置信度。
它记录的是**操作语义和证据截图**,不是连续视频帧。
5.2 录制时序¶
sequenceDiagram
participant UI as Recorder UI
participant Server as PlaygroundServer
participant Agent as AndroidAgent
participant Device as Android device
participant AI as aiDescribe
UI->>Server: POST /recorder/start(sessionId)
Server->>Agent: screenshotBase64 + size
Server-->>UI: supported=true
UI->>Server: POST /interact(Tap x,y)
Server->>Device: dispatchPointer / actionSpace.call
Server-->>UI: 200 OK
Server->>Server: queue recorder capture
Server->>Agent: 延迟后截图 + 页面状态
Server->>Server: 组装 event 和点击标记图
UI->>Server: GET /recorder/events?since=N
Server-->>UI: events + nextIndex
opt 需要语义描述
UI->>Server: POST /recorder/describe-event
Server->>AI: screenshot + logical point
AI-->>Server: 元素描述 + 可选定位校验
Server-->>UI: enriched event + trace
end
UI->>Server: POST /recorder/stop
Server->>Server: 等待事件队列完成并停止收集
5.3 状态和事件队列¶
Server 使用以下字段保存内存态:
_recorderSessionId:是否正在录制以及当前录制会话。_recorderEvents:已经完成的事件。_recorderEventQueue:串行化“动作后等待、截图、组装事件”的异步队列。_recorderPendingCaptures:防止并发动作复用不稳定的前置快照。_studioPreviewRecorderLastScreenshot:下一次操作的 before screenshot。_studioPreviewRecorderLastTargetPoint:让后续 Input/KeyboardPress 继承最后点击目标。
开始录制时,startStudioPreviewRecorder() 先保存初始截图和页面状态;Web 页面有 URL 时还会生成 initial navigation 事件。
每次 /interact 成功后,storeStudioPreviewRecorderEvent() 延迟一小段时间,等待 UI 完成变化,再采集 after screenshot。事件严格进入串行 Promise 队列,避免快速连续操作的截图和事件顺序交叉。
停止录制会先 waitForRecorderIdle(),确保已经排队的截图不会丢失,然后清空活动 session id,但保留 _recorderEvents 供客户端最后拉取。
5.4 AI 元素描述¶
Recorder 事件初始可以处于 semantic.status = pending。POST /recorder/describe-event 会调用 describeElementAtPoint():
- 选取事件的 before/after screenshot。
- 使用事件
pageInfo和逻辑坐标[x, y]。 - 让 AI 描述该坐标对应的元素。
- 对需要校验的事件执行 deep locate/中心点距离校验。
- 返回
elementDescription、replayInstruction、actionSummary、confidence 和 trace。
导航和 viewport 事件没有稳定 UI 目标,因此不会调用元素描述模型;它们使用 heuristic 语义或被明确标记为跳过。
5.5 Recorder API¶
| API | 作用 |
|---|---|
GET /recorder/capabilities |
判断当前 Agent 是否支持手动交互录制 |
POST /recorder/start |
初始化 session、初始截图和页面状态 |
GET /recorder/events?since=N |
增量读取事件 |
POST /recorder/describe-event |
用 AI 为事件补充元素语义和 trace |
POST /recorder/stop |
等待队列清空并停止采集 |
Recorder 的 supported 条件是当前 Agent 存在,并且暴露 inputPrimitives 或非空 actionSpace(),见 getRecorderCapabilities()。
6. “真正录屏”当前缺失什么¶
在 Android、Android Playground、Playground 和 Playground App 的执行链路中,没有以下实现:
adb shell screenrecord。- 浏览器
MediaRecorder对 scrcpy Canvas/MediaStream 的持久化。 - H.264 packet 写文件并通过 MP4 muxer 封装。
- 录像文件生命周期、下载 API、报告附件和清理策略。
如果后续需要真实录像,最贴合现有架构的方案是让 ScrcpyServer 在转发 framed H.264 的同时写入一个独立录制 sink,再使用 FFmpeg/muxer 封装为 MP4。需要额外设计:
- 一条设备是否允许多个预览/录制消费者。
- configuration packet、首个关键帧和时间戳的处理。
- 录制开始前是否等待 IDR。
- 文件大小、最长时长、异常中止和磁盘清理。
- 报告只链接视频,还是把短视频内嵌到 HTML。
- 并发测试时录像与设备租约的绑定。
不建议把 Recorder 的操作事件数组命名为“视频录屏”,两者应保留独立状态和 API。
7. 故障、性能与安全边界¶
7.1 常见故障¶
| 表现 | 优先检查 |
|---|---|
| 一直等待视频 metadata | ADB、scrcpy server push、设备选择、启动/metadata 超时 |
| 画面停在最后一帧 | 是否收到 video stream ended、Socket 是否重连 |
| LAN HTTP 下没有实时画面 | WebCodecs 是否因非安全上下文不可用;应回退截图轮询 |
| 点击偏移 | metadata 宽高、Canvas 内容区、设备方向和 /interface-info.size |
| Recorder 少事件 | 是否已开始 session、/interact 是否成功、停止前是否等待 idle |
| Recorder 前后截图错位 | 快速连续操作是否进入同一个串行 capture queue |
7.2 性能边界¶
- 视频包必须保持二进制,不能转成 JS number 数组。
maxSize=1024和 2 Mbps 是预览清晰度、带宽与浏览器解码负载的折中。- 每个 Socket 连接当前维护自己的 scrcpy client;多个浏览器连接同一设备会增加设备和主机负载。
- Recorder 只对动作前后采样,不会保存每个视频帧,存储成本显著低于视频录制。
- Recorder AI 描述是额外模型请求;纯事件采集和
/interact本身不消耗 AI token。
7.3 安全边界¶
- Scrcpy sidecar 允许 loopback 和私有网段 Origin,但这不是完整身份认证。
- Playground
/interact可以直接操作设备且绕过 AI;暴露到多人网络前需要网关鉴权和设备租约。 - Recorder 可能保存账号、验证码或支付页面截图,平台化时必须设计脱敏、保留期限和访问控制。
8. 测试覆盖和调试入口¶
现有测试主要覆盖:
server-interact.test.ts:手动操作、Recorder 启停、事件与 AI 描述。scrcpy-stream.test.ts:binary packet 到ReadableStream的转换。scrcpy-panel.test.tsx:连接、metadata、decoder 与错误状态。scrcpy-manager.test.ts:Android Core 中用于截图的另一条 scrcpy 链路。
建议端到端验证至少覆盖:
- 真机连接后连续预览 5 分钟,观察内存是否稳定。
- 旋转屏幕后 Canvas 比例和点击位置是否同时更新。
- 禁用 WebCodecs 时是否自动回退
/screenshot。 - 快速执行 Tap -> Input -> Swipe,Recorder 顺序、before/after screenshot 是否正确。
- 录制中断开 ADB,再恢复设备,确认 UI 和 Recorder 的错误表现。
9. 关键源码索引¶
| 模块 | 文件 | 责任 |
|---|---|---|
| scrcpy sidecar | scrcpy-server.ts |
ADB、scrcpy、Socket.IO、设备列表 |
| 视频流适配 | scrcpy-stream.ts |
binary packet -> ReadableStream |
| 浏览器解码 | ScrcpyPanel.tsx |
WebCodecs、Canvas、重连 |
| 预览选择 | PreviewRenderer.tsx |
scrcpy/MJPEG/截图回退 |
| 坐标投影 | DeviceInteractionLayer.tsx |
浏览器坐标 -> 设备逻辑坐标 |
| 手动操作与 Recorder | server.ts |
Recorder API、/interact、截图采集 |
| Recorder 数据模型 | recorder.ts |
事件、语义和生成代码结构 |