简介:面向需要在真实 iOS 设备上完成屏幕采集与共享的开发者与测试人员,这套基于 Python 的录制工具围绕 Apple QuickTime 协议实现,可通过 USB 连接设备,支持 30~60fps 高帧率、高画质与低延迟传输,适合投屏演示、自动化测试取证及教学录屏等场景。压缩包共 24 个文件、约 23KB,以 19 个 Python 源码为主体,另含 cfg 配置、txt 说明、md 文档、sh 脚本与 license 许可文件,体量轻便但结构完整。已有 458 人学习,读者可从中了解协议解析、异步与同步通信、核心媒体处理及 H.264 数据转发与录制等实现思路,并据此搭建自己的采集流程或做二次开发。源码按功能拆分,便于按模块阅读和定位问题,对想研究 iOS 屏幕共享底层机制的初中级开发者具有参考价值。
1. 从一根数据线到一帧画面:QuickTime 录屏协议到底解决了什么
把一台真实 iOS 设备插上数据线,用 Python 通过 QuickTime 屏幕录制协议把屏幕画面和声音拉回主机,这件事在自动化测试、缺陷复现、录课场景里非常实用。模拟器录不到相机预览、DRM 画面和真实 GPU 渲染,无线镜像又受抖动影响;ios-screen-record 这类方案走的是 USB 上的 QuickTime 录制通道,免改 App、系统级取流。下面从协议栈一路推到最小链路、参数、避坑和验收,新手能照着跑通,熟手能看到服务发现、NAL 边界和时间戳这些容易翻车的点。
2. 协议栈拆解:usbmuxd、QuickTime 服务与流式帧的握手顺序
2.1 为什么不用无线镜像或 App 内录制,而走 USB 上的 QuickTime 录制通道
选型先看约束。无线镜像方案需要接收端常驻,画面经过一次实时编码再解码,延迟和花屏都不可控;App 内录制方案要在目标 App 里集成录制 SDK,录不到其他 App,也录不到系统弹窗。QuickTime 屏幕录制协议走 USB,设备侧直接把屏幕合成后的帧编码成 H.264/HEVC,音频单独成流,主机拿到的是接近原始的编码数据。对自动化测试来说,这意味着不用改被测 App,不用连同一网段,插线就能取流。
| 方案 | 是否改 App | 延迟量级 | 能否录系统画面 | 主要限制 |
|---|---|---|---|---|
| 无线镜像 | 否 | 百毫秒级 | 能 | 依赖网络,接收端常驻 |
| App 内录制 | 是 | 低 | 只能录 App 内 | 需要集成 SDK |
| USB QuickTime 录制 | 否 | 十毫秒级 | 能 | 需要配对信任和设备服务权限 |
这张表不是绝对性能榜,而是选型边界。USB 通道的优势是稳定和可脚本化,代价是设备必须信任主机,部分系统版本还要挂载开发者镜像才能把服务拉起来。实际做自动化流水线时,我一般把「配对、挂载、起服务」做成三步幂等操作,避免每次换设备都重新摸一遍。
2.2 服务发现与端口转发:从 usbmuxd 到屏幕录制服务
iOS 设备通过 USB 暴露的不是普通串口,而是一组命名服务。usbmuxd 负责把这些服务多路复用成 TCP 连接,主机上的 Python 进程只要连到对应的本地端口或服务名,就能和設備侧进程对话。常见链路是:先做配对信任,再通过 lockdown 查询设备信息和服务,最后按服务名建立 socket。屏幕录制服务名随系统版本变化,不要硬编码,先从设备服务列表或抓包结果里确认。
先确认设备能被看见:
pymobiledevice3 usbmux list输出里重点看 UDID、设备名称和配对状态。多台设备同时插着时,后面所有命令都带--udid,不要让库自动选第一台,否则流水线会偶发录错机器。
配对和查询设备信息:
pymobiledevice3 lockdown pair --udid <UDID> pymobiledevice3 lockdown info --udid <UDID>pair会触发设备上的信任弹窗,必须手动点信任。lockdown info能拿到产品版本、设备名称等字段,用来判断后续要不要挂开发者镜像。配对记录一般落在主机的用户目录下,换一台主机就要重新配对,这不是脚本能绕过的步骤。
用 Python 建立服务连接时,核心是start_service:
import os from pymobiledevice3.lockdown import create_using_usbmux # 服务名不要写死,从设备服务列表或抓包结果里取 SERVICE = os.environ["IOS_SCREEN_SERVICE"] def open_screen_service(udid: str | None = None): ld = create_using_usbmux(serial=udid) conn = ld.start_service(SERVICE) return conncreate_using_usbmux(serial=udid)里的serial就是 UDID,传None时库会自己挑一台,生产环境不建议。start_service返回的对象通常具备 socket 语义,有sendall和recv;不同库版本对返回对象的包装不一样,如果方法名对不上,取它底层的 socket 或按库文档换调用方式。服务名是这条链路里最容易翻车的参数,名字错一个后缀就是连接超时,不是设备坏。
提示:先用
pymobiledevice3 usbmux list确认配对状态,再起服务。配对没完成时,start_service往往表现为超时而不是明确报错,容易误判成服务名错误。
2.3 帧格式与时间戳:H.264/HEVC 与 PCM/AAC 的封装差异
视频流通常是 Annex-B 格式的 H.264 或 HEVC。Annex-B 用00 00 00 01或00 00 01作为起始码分隔 NAL 单元,SPS/PPS 在流开头,IDR 帧携带关键画面。录屏流里 SPS/PPS 不一定每帧重复,如果中途丢了一组,后面的 P 帧就会花屏或绿屏。音频流常见为 48 kHz、16-bit、双声道的 PCM,每个采样点按小端存放;部分实现会协商成 AAC,这时每帧有 1024 个采样点,需要按帧头算时长。
| 流类型 | 常见编码 | 边界标识 | 时间戳来源 | 封装注意 |
|---|---|---|---|---|
| 视频 | H.264 / HEVC | Annex-B 起始码 | 设备包头时间字段 | 需要 SPS/PPS,关键帧间隔影响 seek |
| 音频 | PCM s16le / AAC | PCM 无帧头,AAC 有帧头 | 采样计数或设备时间 | 采样率、声道数必须与封装参数一致 |
时间戳是音画同步的命门。设备侧给的可能是单调时钟计数,也可能是微秒级 presentation timestamp,单位不统一。直接用主机time.time()给每包打时间,短录看不出问题,录到几分钟就会因为 USB 抖动和 Python 调度累积出音画偏移。我的做法是:先原样保存设备包头里的时间字段,封装阶段再统一换算;实在拿不到设备时间,就用单调时钟按包到达顺序插值,并接受小范围漂移。
3. 最小可跑链路:用 Python 拉起设备端录制会话
3.1 环境与依赖:pymobiledevice3、ffmpeg、USB 权限
主机侧需要三样东西:Python 环境、设备通信库、音视频封装工具。Python 用虚拟环境隔离,避免和系统包打架;设备通信库负责配对、服务发现和 socket;ffmpeg 负责把裸流封成 MP4,也用来做验收。Linux 主机还要处理 USB 权限,常见做法是把当前用户加入 USB 访问组,重新插拔设备;Windows 主机需要装设备驱动,否则 usbmuxd 看不到设备;桌面系统一般不用额外驱动。
python3 -m venv .venv source .venv/bin/activate pip install pymobiledevice3 ffmpeg -versionffmpeg -version能输出版本信息就说明封装工具可用。不要在这一步省事,很多「录出来只有画面没有声音」最后查出来是 ffmpeg 没装全或者编码器缺失。设备通信库升级后命令参数可能变,流水线里最好把命令封装成脚本,升级时只改一处。
3.2 设备配对与开发者镜像挂载的最小命令
配对是信任关系,开发者镜像是部分系统版本拉起 DVT 类服务的前置条件。屏幕录制服务不一定每次都依赖开发者镜像,但先把这一步做掉能少一半玄学问题。命令按顺序执行:
pymobiledevice3 usbmux list pymobiledevice3 lockdown pair --udid <UDID> pymobiledevice3 mounter auto-mount --udid <UDID>usbmux list看设备是否出现;lockdown pair触发信任弹窗;mounter auto-mount挂载开发者镜像。挂载失败时先看设备是否解锁、是否已经信任主机、主机上是否有多个 usbmuxd 实例在抢设备。流水线里可以把这三条命令包成一个prepare_device.sh,每次录制前跑一遍,失败就快速退出,不要带着半残状态去起服务。
3.3 启动录制会话的 Python 脚本骨架
下面这段代码只保留协议骨架:建立服务连接、发配置、按长度前缀收包。配置字段名和包头格式随设备实现变化,不要把它当成固定协议,先按这个结构把原始包 dump 下来,再用 hexdump 校准。
import os import plistlib import struct from pymobiledevice3.lockdown import create_using_usbmux SERVICE = os.environ["IOS_SCREEN_SERVICE"] # 填设备实际暴露的屏幕录制服务名 MAX_PACKET = 16 * 1024 * 1024 def open_service(udid: str | None = None): ld = create_using_usbmux(serial=udid) return ld.start_service(SERVICE) def send_config(conn, cfg: dict): body = plistlib.dumps(cfg, fmt=plistlib.FMT_BINARY) conn.sendall(struct.pack(">I", len(body)) + body) def recv_exact(conn, n: int) -> bytes: buf = bytearray() while len(buf) < n: chunk = conn.recv(n - len(buf)) if not chunk: raise ConnectionError("device closed the connection") buf.extend(chunk) return bytes(buf) def read_packet(conn) -> bytes: length = struct.unpack(">I", recv_exact(conn, 4))[0] if length <= 0 or length > MAX_PACKET: raise ValueError(f"bad packet length: {length}") return recv_exact(conn, length) if __name__ == "__main__": conn = open_service() cfg = { "width": 720, "height": 1280, "fps": 30, "bitrate": 4_000_000, "audio": {"sampleRate": 48000, "channels": 2, "format": "pcm_s16le"}, } send_config(conn, cfg) with open("dump.bin", "wb") as f: for _ in range(300): pkt = read_packet(conn) f.write(struct.pack(">I", len(pkt)) + pkt)send_config把配置字典序列化成二进制 plist,前面加 4 字节大端长度。recv_exact解决 socket 粘包和半包问题,不能假设一次recv就能拿满。read_packet先读 4 字节长度,再读 body,长度上限用来防止协议错位后申请巨大内存。cfg里的字段是语义示意,真实字段名以设备返回的配置模板为准;先发一个最小配置,把设备回包 dump 出来,再按模板补字段,比对着文档猜要快。
注意:第一次跑不要直接封装 MP4,先 dump 原始包。用
xxd -g1 -l 128 dump.bin看前 128 字节,确认长度前缀、plist 头和 NAL 起始码的位置。黑匣子先留住,后面排错才有后悔药。
3.4 把裸流写进 MP4:时间戳与封装参数
拿到原始包后,先按包头类型拆成视频裸流和音频裸流。视频写video.h264,音频写audio.pcm,这一步只做落盘,不做封装,目的是把协议问题和封装问题分开。拆包时按 Annex-B 起始码切 NAL,遇到 SPS/PPS 就缓存,遇到 IDR 前把缓存补进去,避免花屏。
def split_annex_b(data: bytes) -> list[bytes]: # 只做示意:按 00 00 01 / 00 00 00 01 切 NAL out, i, n = [], 0, len(data) while i < n - 3: if data[i:i + 4] == b"\x00\x00\x00\x01": start, i = i + 4, i + 4 elif data[i:i + 3] == b"\x00\x00\x01": start, i = i + 3, i + 3 else: i += 1 continue j = i while j < n - 3 and data[j:j + 4] != b"\x00\x00\x00\x01" and data[j:j + 3] != b"\x00\x00\x01": j += 1 out.append(data[start:j]) i = j return outsplit_annex_b不处理转义和特殊 NAL,只适合录屏流这种结构相对规整的数据。拆出来的 NAL 按类型判断:SPS/PPS 缓存,IDR 前补缓存,普通 P 帧直接写。音频 PCM 没有帧头,按采样率和声道数算字节数,双声道 16-bit 每帧 4 字节,48 kHz 一秒就是 192000 字节。
封装命令:
ffmpeg -y -f h264 -framerate 30 -i video.h264 \ -f s16le -ar 48000 -ac 2 -i audio.pcm \ -c:v copy -c:a aac -b:a 128k -shortest out.mp4-framerate 30只影响裸流输入的时间基,不会改变实际帧率;-f s16le告诉 ffmpeg 音频是小端 16-bit PCM;-ar 48000 -ac 2必须和录制参数一致,填错会变调或加速;-c:v copy直接复制视频编码,不二次压缩;-c:a aac把 PCM 转成 AAC,减小体积;-shortest防止音频尾巴比视频长。音画有固定偏移时加-itsoffset,不要用剪辑软件手动拖,流水线里拖一次就不可复现。
4. 参数怎么调:分辨率、码率、帧率与音频采样
4.1 屏幕分辨率与设备缩放:Retina 下的像素对齐
录屏分辨率不是随便填 1080p。设备屏幕是 Retina 时,逻辑分辨率和物理像素不一致,屏幕录制服务通常按物理像素出图。常见做法是先用 720x1280 跑通链路,再改成设备原生分辨率。原生宽度可能是 1170、1284、1290 这类非 16 倍数,H.264 要求宽高为偶数,奇数会直接编码失败或花屏。
def even(n: int) -> int: return n - (n % 2) width, height = even(1170), even(2532)这段对齐逻辑很简单,但能省掉一次「为什么配置发过去设备不回包」的排查。除了偶数,还要注意宽高比,强行把 1170x2532 改成 1080x1920 会让画面拉伸,后续做图像比对时模板全部对不上。自动化测试里宁可用原生分辨率加高码率,也不要用缩放后的画面。
| 参数 | 常用起点 | 调整方向 | 注意 |
|---|---|---|---|
| width/height | 720x1280 | 先小后大 | 必须偶数,保持宽高比 |
| fps | 30 | 录屏很少上 60 | 高帧率增加 USB 带宽 |
| bitrate | 4 Mbps 起 | 文字多往上加 | 设备端编码能力有上限 |
| keyframe interval | 1~2 秒 | 便于 seek | 太短会增大码率 |
| audio | 48 kHz / 16-bit / 2ch | 单声道改 1 | 必须和封装参数一致 |
这张表是起点,不是标准答案。设备端编码器对分辨率和码率有协商范围,填了不支持的值可能被静默回退,表现为「配置写的是 1080p,出来是 720p」。排查时以设备回包和实际码流为准,不要只看自己发的配置。
4.2 码率与关键帧间隔:录屏不是直播
码率估算可以用每像素比特数(bpp)做粗略参考:bitrate ≈ width * height * fps * bpp。屏幕内容大面积纯色和文字,bpp 取 0.05~0.15 比较常见。720p30 用 4 Mbps 能看,1080p30 用 8 Mbps 更稳,原生分辨率 30 fps 建议 12 Mbps 起步。码率太低时文字边缘会出现明显振铃,做 OCR 或图像比对会直接失败。
关键帧间隔决定 seek 和丢包恢复能力。录屏不是直播,不需要为了低延迟把 GOP 压到很短;1~2 秒一个关键帧比较平衡,-g 60对应 30 fps 下的 2 秒。设备端编码参数不一定开放,如果 plist 里协商不动,就在封装阶段转码:
ffmpeg -y -i in.h264 -c:v libx264 -preset veryfast -crf 20 \ -g 60 -keyint_min 60 -sc_threshold 0 out.mp4-crf 20是恒定质量,适合录屏这种对清晰度敏感的内容;-g 60固定关键帧间隔;-keyint_min 60防止场景切换插入过多关键帧;-sc_threshold 0关闭场景切换检测。转码会增加 CPU 开销,实时录制时慎用,离线整理可以接受。
4.3 音频通道:麦克风、系统声与混音的现实边界
音频是最容易承诺过头的部分。USB QuickTime 录制通道通常能拿到设备麦克风或当前 App 的音频,系统全局声音受平台限制,不是所有场景都能录。做方案评估时先明确:要录的是讲解人声、App 内播放声,还是两者混音。只要麦克风就简单,单声道 48 kHz 足够;要 App 内声音就看设备是否允许,不要假设一定能拿到。
ffmpeg -y -f s16le -ar 48000 -ac 1 -i mic.pcm -c:a aac -b:a 96k mic.m4a-ac 1把双声道降成单声道,讲解场景够用,体积减半。要做混音时先统一采样率和声道数,再用amix:
ffmpeg -y -i mic.wav -i app.wav \ -filter_complex "[0:a]aresample=48000[a0];[1:a]aresample=48000[a1];[a0][a1]amix=inputs=2:duration=longest[a]" \ -map "[a]" mix.m4aaresample=48000保证两路采样率一致,amix默认会做增益调整,声音一大一小是正常的,必要时用volume滤镜手动配平。回声消除不要指望一行命令解决,麦克风录到外放声音时,先物理隔离或戴耳机,比后期用afftdn硬降噪靠谱。
5. 避坑与排查:设备能识别却录不出画面的 5 种情况
5.1 连上了但服务超时:信任、配对与开发者镜像
现象是usbmux list能看到设备,start_service一直等到超时,没有任何报错。原因通常有三类:设备没点信任、配对记录失效、需要开发者镜像的服务没挂载。解决顺序是先重新pair,再mounter auto-mount,最后确认设备解锁。配对弹窗没出现时,拔插数据线并重启主机上的 usbmuxd 进程,不要反复重装库。
pymobiledevice3 lockdown pair --udid <UDID> pymobiledevice3 mounter auto-mount --udid <UDID> pymobiledevice3 lockdown info --udid <UDID>lockdown info能通说明基础通道没问题,问题在目标服务或镜像。换 USB 口也有用,有些扩展坞的供电和带宽不稳定,设备会被识别但服务起不来。
5.2 第一包就断:配置 plist 字段和设备模板不一致
现象是连接建立后,发完配置立刻被设备断开,或者只回一个错误包。原因多半是配置字段名、类型或取值范围不对。不同系统版本对分辨率、码率、音频格式的接受范围不一样,硬编码一套配置换设备就翻车。解决方法是先发最小配置,把设备回包原样保存,用plistlib试着解析。
import plistlib raw = open("first_packet.bin", "rb").read() try: print(plistlib.loads(raw)) except Exception as exc: print("not a plist:", exc) print(raw[:64].hex(" "))能解析出字典就按模板补字段;解析失败就看前 64 字节,确认是不是长度前缀没去掉。不要一上来就抄一份「看起来像」的配置,设备端校验比想象中严格。
5.3 花屏或绿屏:SPS/PPS 丢失与 NAL 边界错误
现象是前几秒正常,后面突然大面积绿块,或者 ffmpeg 报no frame!。原因是 SPS/PPS 丢失、NAL 起始码切错、IDR 帧前没补参数集。录屏流里参数集不一定重复,拆包时要把最近的 SPS/PPS 缓存下来,遇到 IDR 前先写缓存。用split_annex_b切完 NAL 后,检查每个 NAL 的第一个字节,类型 7 是 SPS,类型 8 是 PPS,类型 5 是 IDR。
def nal_type(nal: bytes) -> int: return nal[0] & 0x1F if nal else -1如果 NAL 类型全是 0 或乱值,说明起始码切错了,回到 dump 文件用xxd看边界。花屏问题九成在拆包,不在编码器。
5.4 音画不同步:用本地墙钟给每包打时间戳
现象是短录正常,录到三分钟以上声音比画面慢半秒。原因是用了time.time()给每包打时间,USB 抖动和 Python 调度让时间戳和真实采样进度脱节。解决是优先用设备包头里的时间字段,拿不到就用单调时钟按采样数推进音频时间,视频按帧序号推进。封装阶段用-itsoffset修正固定偏移。
ffmpeg -y -itsoffset 0.120 -i video.h264 -f s16le -ar 48000 -ac 2 -i audio.pcm \ -c:v copy -c:a aac -shortest out.mp4-itsoffset 0.120表示音频整体延后 120 毫秒,正负方向先试一次,用ffprobe看两路时长差。不要用剪辑软件手动拖,流水线里拖一次就不可复现。
5.5 几分钟后掉帧:USB 带宽、同步写盘与 GIL
现象是前几十秒流畅,后面帧率往下掉,音频开始断续。原因可能是 USB 2.0 带宽不够、Python 同步写盘阻塞接收循环、或者主机 CPU 被转码占满。解决是接收和写盘分离,用队列把包交给写盘线程;录制阶段只 dump 不转码;尽量插 USB 3 口,不要经过劣质扩展坞。
import queue import threading q: queue.Queue[bytes] = queue.Queue(maxsize=256) def writer(path: str): with open(path, "wb") as f: while True: pkt = q.get() if pkt is None: break f.write(pkt)队列长度不要无限大,256 个包足够吸收抖动,太大只会把内存吃满。写盘线程只做write,不做解析和转码,解析放到离线阶段。USB 带宽问题用dmesg看有没有掉设备,掉设备就不是代码问题。
6. 进阶:把录屏做成可验证的流水线
录屏脚本能跑通只是第一步,真正上线要能验收。我一般固定录 10 秒,跑三项检查:视频帧数是否接近 300、音视频时长差是否小于 100 毫秒、关键帧间隔是否不超过 2 秒。用ffprobe输出 JSON,再用 Python 解析,避免人眼看。
ffprobe -v error -show_entries stream=codec_type,width,height,r_frame_rate,duration \ -of json out.mp4import json import subprocess def probe(path: str) -> dict: cmd = [ "ffprobe", "-v", "error", "-show_entries", "stream=codec_type,width,height,r_frame_rate,duration", "-of", "json", path, ] return json.loads(subprocess.check_output(cmd)) info = probe("out.mp4") streams = {s["codec_type"]: s for s in info["streams"]} video = streams.get("video", {}) audio = streams.get("audio", {}) print(video.get("width"), video.get("height"), video.get("r_frame_rate")) print("duration gap:", abs(float(video.get("duration", 0)) - float(audio.get("duration", 0))))| 验收项 | 命令/方法 | 合格线 | 不合格先查 |
|---|---|---|---|
| 视频帧数 | ffprobe -count_frames | 10 秒约 300 帧 | 接收循环是否阻塞 |
| 音画时长差 | 对比两路duration | 小于 100 ms | 时间戳来源是否统一 |
| 关键帧间隔 | ffprobe -show_frames | 不超过 2 秒 | GOP 参数是否生效 |
| 音频是否空轨 | 看codec_type=audio | 必须存在 | 音频服务是否单独连接 |
这张表可以直接塞进 CI。每次改配置先跑 10 秒验收,比录完一小时再发现音轨是空的省时间。我现在的习惯是:任何参数改动先过一遍 10 秒流水线,再上长录;原始 dump 至少保留一次,出了问题还能回看包头。希望帮到你。
本文还有配套的精品资源,点击获取