Skip to content

[iOS][XCUITest] 仿 WDA MJPEG 截图流:在 RemoteControlTest 中实现实时 MJPEG 推流 #55

Description

@nzcv

[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.startServinginitScreenshotsBroadcaster)在 mjpegServerPort(默认 9100)上用 FBTCPSocket 监听,delegate 设为 FBMjpegServer
  • 在独立串行队列上跑 streamScreenshot 循环:
    • 帧率控制:按 mjpegServerFramerate(默认 10fps,上限 60)用 dispatch_after 维持节奏。
    • 无客户端不截图listeningClients 为空时只调度下一帧,不实际截屏(省电)。
    • 截屏 + 编码FBScreenshot.takeInOriginalResolution... 输出 JPEG,质量由 mjpegServerScreenshotQuality(默认 25)控制。
    • 缩放FBImageProcessormjpegScalingFactor(默认 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()

  1. 端口转发 allocateMjpegServerPort:真机把设备 9100 映射到本机;模拟器无需转发。
  2. 可选 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_FRAMERATEMJPEG_QUALITYMJPEG_SCALING_FACTOR 配置,默认值与 WDA 相同(10fps / 25% / 100%)。

四、小结

  • WDA MJPEG = 设备端 TCP 广播服务(XCTest 底层截屏 + JPEG 编码 + multipart fan-out)+ 宿主机端口转发与消费(内存最新帧截图 / ffmpeg 录屏)。
  • RemoteControlTest 受限于「XCUITest 截图必须在主线程」,改为主循环生产者 + Swifter 长连接消费者,协议与配置项与 WDA 对齐,复用现有 HTTP 端口而非独立 9100。

参考:MJPEG 官方文档WDA FBMjpegServerAppium MJpegStream

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions