[iOS][XCUITest] 仿 WDA MJPEG 截图流:在 RemoteControlTest 中实现实时 MJPEG 推流
参考 Appium XCUITest Driver 的 MJPEG Screenshot Stream 文档,先梳理其实现原理,再在自研的 RemoteControlTest(XCUITest + Swifter HTTP 服务)中实现等价的实时屏幕 MJPEG 推流能力,可用于浏览器实时预览或 ffmpeg 录屏。
一、WDA MJPEG 实现原理
MJPEG 把「截图」从按需单次 HTTP 请求改成持续广播的 JPEG 帧流,本质是 WDA 在设备上跑一个 TCP MJPEG 广播服务。
整体架构
| 层级 |
组件 |
职责 |
| 设备端 |
WDA FBMjpegServer |
定时截屏、JPEG 编码、缩放,向所有客户端推送 |
| 传输 |
multipart/x-mixed-replace |
长连接 HTTP 风格的帧边界协议 |
| 宿主机 |
Appium Driver |
端口转发、消费流(截图 / ffmpeg 录屏) |
设备端生产者(FBMjpegServer)
- WDA 启动时(
FBWebServer.startServing → initScreenshotsBroadcaster)在 mjpegServerPort(默认 9100)上用 FBTCPSocket 监听,delegate 设为 FBMjpegServer。
- 在独立串行队列上跑
streamScreenshot 循环:
- 帧率控制:按
mjpegServerFramerate(默认 10fps,上限 60)用 dispatch_after 维持节奏。
- 无客户端不截图:
listeningClients 为空时只调度下一帧,不实际截屏(省电)。
- 截屏 + 编码:
FBScreenshot.takeInOriginalResolution... 输出 JPEG,质量由 mjpegServerScreenshotQuality(默认 25)控制。
- 缩放:
FBImageProcessor 按 mjpegScalingFactor(默认 100%)异步缩放。
- 失败退避:连续失败时指数退避(1s~10s)。
MJPEG over HTTP 协议格式
客户端连上后,WDA 先发响应头:
HTTP/1.0 200 OK
Content-Type: multipart/x-mixed-replace; boundary=--BoundaryString
之后每帧:
--BoundaryString\r\n
Content-type: image/jpeg\r\n
Content-Length: <size>\r\n
\r\n
<JPEG bytes>\r\n\r\n
与 IP 摄像头相同,多个客户端可同时订阅;慢客户端有写超时避免无限缓冲。
宿主机消费者(Appium Driver)
Session 启动时 handleMjpegOptions():
- 端口转发
allocateMjpegServerPort:真机把设备 9100 映射到本机;模拟器无需转发。
- 可选 MJPEG 截图客户端:若设置
appium:mjpegScreenshotUrl,用 mjpeg.MJpegStream(基于 axios stream + mjpeg-consumer)连接,只保留最后一帧在内存。
两条消费路径:
- Screenshot 命令:
getScreenshot() 若有 mjpegStream 则直接返回 lastChunkPNGBase64()(最新帧),无帧时回退到 WDA /screenshot。
- 录屏:
startRecordingScreen 默认 videoType=mjpeg,启动 ffmpeg:
ffmpeg -f mjpeg -reconnect 1 -i http://<host>:<mjpegServerPort> -vcodec mjpeg -y out.mp4
关键配置项
| 配置 |
默认 |
说明 |
mjpegServerPort |
9100 |
广播端口 |
mjpegServerFramerate |
10 |
帧率(1–60) |
mjpegServerScreenshotQuality |
25 |
JPEG 质量(1–100) |
mjpegScalingFactor |
100 |
缩放百分比(1–100) |
二、在 RemoteControlTest 中的等价实现
RemoteControlTest 用 Swifter HTTP 服务器 + CommandBroker 把后台请求桥接到主线程执行 XCUIScreen.main.screenshot()。MJPEG 移植思路:主线程作生产者按帧率截图编码 JPEG,HTTP 长连接作消费者用 multipart/x-mixed-replace 扇出最新帧,无人观看时不截图。
实现对照
| WDA 组件 |
RemoteControlTest 对应 |
FBMjpegServer(帧缓冲 + 客户端列表 + fan-out) |
MJpegBroadcaster(线程安全最新帧 + sequence + 客户端计数) |
streamScreenshot 后台截屏循环(无客户端不截) |
driveMjpegStream() 在主循环按帧率截图,hasClients 为 false 时跳过 |
sendScreenshot 写 multipart 块 |
mjpegStreamResponse(_:) 的 .raw 流式 writer |
mjpegServerFramerate/Quality/ScalingFactor |
Config + /api/mjpeg 查询参数 framerate/quality/scale |
FBTCPSocket 独立端口监听 |
复用现有 Swifter 服务器的 /api/mjpeg 路由 |
关键设计:主线程生产者
由于 XCUIScreen.main.screenshot() 必须在主测试线程执行(不能像 WDA 那样后台队列截屏),让主循环充当生产者,每个 HTTP 连接线程只负责转发字节。
// 主线程生产者:有客户端且到帧间隔才截图
private func driveMjpegStream() {
guard broadcaster.hasClients else { return }
let settings = broadcaster.currentSettings
let interval = 1.0 / Double(max(1, settings.framerate))
let now = Date()
guard now >= nextMjpegFireDate else { return }
nextMjpegFireDate = now.addingTimeInterval(interval)
if let frame = captureJpegFrame(quality: settings.quality, scalingFactor: settings.scalingFactor) {
broadcaster.publish(frame)
}
}
private func captureJpegFrame(quality: CGFloat, scalingFactor: CGFloat) -> Data? {
let image = XCUIScreen.main.screenshot().image
let source = scalingFactor < 1.0 ? image.scaledByPixelFactor(scalingFactor) : image
return source.jpegData(compressionQuality: quality)
}
流式响应(消费者)
与 WDA 完全一致的协议格式,消费者用 sequence 去重,只在有新帧时写出:
return .raw(200, "OK", headers) { [weak self] writer in
guard let self else { return }
self.broadcaster.clientConnected()
defer { self.broadcaster.clientDisconnected() }
var lastSequence: UInt64 = 0
while !self.broker.shouldExit {
let interval = 1.0 / Double(max(1, self.broadcaster.currentSettings.framerate))
guard let (frame, sequence) = self.broadcaster.snapshot(),
sequence != lastSequence else {
Thread.sleep(forTimeInterval: interval / 2.0)
continue
}
lastSequence = sequence
let partHeader = "--BoundaryString\r\nContent-Type: image/jpeg\r\nContent-Length: \(frame.count)\r\n\r\n"
try writer.write(Data(partHeader.utf8))
try writer.write(frame)
try writer.write(Data("\r\n\r\n".utf8))
}
}
主循环的 broker.next 超时做了自适应(有客户端时缩短到半个帧间隔),保证能跑到目标帧率。
与原 /api/screenshot 的区别
/api/screenshot:单次请求-响应,PNG,会附加到 .xcresult 包。
/api/mjpeg:长连接持续推流,JPEG,不附加到结果包(否则 10fps 会撑爆 bundle),适合实时预览和 ffmpeg 录屏。
三、用法
# 浏览器实时预览
open "http://<device-ip>:18200/api/mjpeg?framerate=15&quality=50"
# ffmpeg 录屏(与文档里 -f mjpeg -i <url> 一致)
ffmpeg -f mjpeg -i "http://<device-ip>:18200/api/mjpeg" -vcodec libx264 out.mp4
帧率/质量/缩放可通过查询参数(framerate 1–60、quality 1–100、scale 1–100)或环境变量 MJPEG_FRAMERATE、MJPEG_QUALITY、MJPEG_SCALING_FACTOR 配置,默认值与 WDA 相同(10fps / 25% / 100%)。
四、小结
- WDA MJPEG = 设备端 TCP 广播服务(XCTest 底层截屏 + JPEG 编码 + multipart fan-out)+ 宿主机端口转发与消费(内存最新帧截图 / ffmpeg 录屏)。
- RemoteControlTest 受限于「XCUITest 截图必须在主线程」,改为主循环生产者 + Swifter 长连接消费者,协议与配置项与 WDA 对齐,复用现有 HTTP 端口而非独立 9100。
参考:MJPEG 官方文档、WDA FBMjpegServer、Appium MJpegStream
[iOS][XCUITest] 仿 WDA MJPEG 截图流:在 RemoteControlTest 中实现实时 MJPEG 推流
一、WDA MJPEG 实现原理
MJPEG 把「截图」从按需单次 HTTP 请求改成持续广播的 JPEG 帧流,本质是 WDA 在设备上跑一个 TCP MJPEG 广播服务。
整体架构
FBMjpegServermultipart/x-mixed-replace设备端生产者(FBMjpegServer)
FBWebServer.startServing→initScreenshotsBroadcaster)在mjpegServerPort(默认 9100)上用FBTCPSocket监听,delegate 设为FBMjpegServer。streamScreenshot循环:mjpegServerFramerate(默认 10fps,上限 60)用dispatch_after维持节奏。listeningClients为空时只调度下一帧,不实际截屏(省电)。FBScreenshot.takeInOriginalResolution...输出 JPEG,质量由mjpegServerScreenshotQuality(默认 25)控制。FBImageProcessor按mjpegScalingFactor(默认 100%)异步缩放。MJPEG over HTTP 协议格式
客户端连上后,WDA 先发响应头:
之后每帧:
与 IP 摄像头相同,多个客户端可同时订阅;慢客户端有写超时避免无限缓冲。
宿主机消费者(Appium Driver)
Session 启动时
handleMjpegOptions():allocateMjpegServerPort:真机把设备 9100 映射到本机;模拟器无需转发。appium:mjpegScreenshotUrl,用mjpeg.MJpegStream(基于 axios stream +mjpeg-consumer)连接,只保留最后一帧在内存。两条消费路径:
getScreenshot()若有mjpegStream则直接返回lastChunkPNGBase64()(最新帧),无帧时回退到 WDA/screenshot。startRecordingScreen默认videoType=mjpeg,启动 ffmpeg:关键配置项
mjpegServerPortmjpegServerFrameratemjpegServerScreenshotQualitymjpegScalingFactor二、在 RemoteControlTest 中的等价实现
RemoteControlTest用 Swifter HTTP 服务器 +CommandBroker把后台请求桥接到主线程执行XCUIScreen.main.screenshot()。MJPEG 移植思路:主线程作生产者按帧率截图编码 JPEG,HTTP 长连接作消费者用multipart/x-mixed-replace扇出最新帧,无人观看时不截图。实现对照
FBMjpegServer(帧缓冲 + 客户端列表 + fan-out)MJpegBroadcaster(线程安全最新帧 + sequence + 客户端计数)streamScreenshot后台截屏循环(无客户端不截)driveMjpegStream()在主循环按帧率截图,hasClients为 false 时跳过sendScreenshot写 multipart 块mjpegStreamResponse(_:)的.raw流式 writermjpegServerFramerate/Quality/ScalingFactorConfig+/api/mjpeg查询参数framerate/quality/scaleFBTCPSocket独立端口监听/api/mjpeg路由关键设计:主线程生产者
由于
XCUIScreen.main.screenshot()必须在主测试线程执行(不能像 WDA 那样后台队列截屏),让主循环充当生产者,每个 HTTP 连接线程只负责转发字节。流式响应(消费者)
与 WDA 完全一致的协议格式,消费者用
sequence去重,只在有新帧时写出:主循环的
broker.next超时做了自适应(有客户端时缩短到半个帧间隔),保证能跑到目标帧率。与原 /api/screenshot 的区别
/api/screenshot:单次请求-响应,PNG,会附加到.xcresult包。/api/mjpeg:长连接持续推流,JPEG,不附加到结果包(否则 10fps 会撑爆 bundle),适合实时预览和 ffmpeg 录屏。三、用法
帧率/质量/缩放可通过查询参数(
framerate1–60、quality1–100、scale1–100)或环境变量MJPEG_FRAMERATE、MJPEG_QUALITY、MJPEG_SCALING_FACTOR配置,默认值与 WDA 相同(10fps / 25% / 100%)。四、小结
参考:MJPEG 官方文档、WDA FBMjpegServer、Appium MJpegStream