用 ADB + FFmpeg 搭 Android 设备农场:10 到 50 台设备的自动化 UI 测试

An Android Device Farm with ADB + FFmpeg: Automated UI Testing Across 10–50 Devices

中文译文 · 11k 字

一句话摘要

标题主题:用 ADB 和 FFmpeg 在 10 到 50 台设备上做自动化 UI 测试

用 ADB + FFmpeg 搭建一个 Android 设备农场:在 10–50 台设备上进行自动化 UI 测试 2026 年 7 月 7 日 · 9 分钟阅读 · 查看原文 ↗ Design Automation Finance Hardware 当你的 App 必须在一大堆真机上运行——不同厂商、不同 Android 版本、不同屏幕分辨率——手动测试很快就会变成噩梦。下面是一套 Python 流水线:它能自己发现每一台已连接的设备,在所有设备上并行安装 APK,运行插桩测试,录下每一次运行的视频,然后用 FFmpeg 把这些录像拼接成一份单一的视频报告。 整个技术栈 > Python + ADB + FFmpeg < 都是标准 QA 工具。没有魔法,只是把重复劳动自动化。 流水线架构 adb devices ──► 序列号列表 │ ▼ APK 安装(在所有设备上并行,ThreadPoolExecutor) │ ▼ 对每台设备: screenrecord(后台)→ am instrument(跑测试)→ 停止并拉取视频 │ ▼ FFmpeg:给每个片段叠加序列号 + concat ──► test_report.mp4 你需要什么 ADB(Android Debug Bridge),来自 Android Platform Tools -> 设备控制。 Python 3.10+ -> 编排(我用 list[str]、tuple[...],不需要 from future)。 FFmpeg -> 视频处理与拼接。 开启了 USB 调试的设备,通过 USB(或通过 adb tcpip 走 Wi-Fi)连接。 贯穿全部代码的一条原则:我以列表形式向 subprocess 传参数,并且不使用 shell=True。这样更安全(不会通过文件名注入),而且在路径里有空格或特殊字符时也不会出问题。 1. 设备发现 adb devices 也会列出处于 unauthorized / offline 状态的设备。我们只保留真正处于 device 状态的。 import subprocess def get_devices() -> list[str]: """Return the serials of all devices in the 'device' state.""" out = subprocess.run( ["adb", "devices"], capture_output=True, text=True, check=True, ).stdout serials: list[str] = [] for line in out.splitlines()[1:]: # first line is the "List of devices" header line = line.strip() if line.endswith("\tdevice"): # drop unauthorized / offline serials.append(line.split("\t")[0]) return serials 2. 并行安装 APK 在 50 台设备上一台一台安装太慢了。我们把工作分摊到一个线程池上:每个 adb install 都是一个独立进程,所以这里用线程效果很好(我们在等 I/O,而不是烧 CPU)。 from concurrent.futures import ThreadPoolExecutor, as_completed def install_apk(serial: str, apk_path: str) -> tuple[str, bool, str]: r = subprocess.run( ["adb", "-s", serial, "install", "-r", "-g", apk_path], capture_output=True, text=True, ) ok = r.returncode == 0 and "Success" in r.stdout return serial, ok, (r.stdout + r.stderr).strip() def install_on_all(apk_path: str, serials: list[str]) -> None: with ThreadPoolExecutor(max_workers=len(serials) or 1) as pool: futures = [pool.submit(install_apk, s, apk_path) for s in serials] for f in as_completed(futures): serial, ok, log = f.result() print(f"[{'OK' if ok else 'FAIL'}] {serial}") if not ok: print(f" {log}") 参数:-r -> 保留数据重新安装,-g -> 立即授予所有运行时权限(很方便,这样测试就不会卡在权限弹窗上)。 3. 运行插桩测试 am instrument 在设备上运行 Espresso/JUnit 测试。它会在 stdout 打印 OK(成功)和 FAILURES!!!(失败)——这就是我们判断结果的方式。 def run_instrumented_tests( serial: str, package: str, runner: str = "androidx.test.runner.AndroidJUnitRunner", ) -> tuple[str, bool]: r = subprocess.run( ["adb", "-s", serial, "shell", "am", "instrument", "-w", f"{package}/{runner}"], capture_output=True, text=True, ) ok = "FAILURES!!!" not in r.stdout and r.returncode == 0 return serial, ok package 是测试包 ID,通常是 com.example.app.test。 4. 在测试过程中录制屏幕 screenrecord 直接在设备上录视频。需要记住的限制:单个文件大约 3 分钟上限,并且没有音频。我们在后台启动录制,跑测试,然后干净地停止它,并把文件拉回宿主机。 停止录制最可靠的方式不是给本地 adb 发信号,而是在设备本身上用 pkill —— 这样 screenrecord 才能正确地收尾 MP4 容器。 import time def start_recording(serial: str, remote: str = "/sdcard/run.mp4") -> subprocess.Popen: return subprocess.Popen( ["adb", "-s", serial, "shell", "screenrecord", remote] ) def stop_recording( serial: str, proc: subprocess.Popen, remote: str = "/sdcard/run.mp4", local: str = "run.mp4", ) -> None: # SIGINT on the device makes screenrecord close the file properly subprocess.run(["adb", "-s", serial, "shell", "pkill", "-SIGINT", "screenrecord"]) proc.wait(timeout=10) time.sleep(1) # give the device a moment to finalize the container subprocess.run(["adb", "-s", serial, "pull", remote, local], check=True) 5. 用 FFmpeg 拼接视频报告 不同设备有不同的屏幕分辨率,所以你不能直接用 -c copy 把它们串起来。我们先把每个片段归一化成统一格式(1080×1920),并在这过程中用 drawtext 叠加序列号。之后所有片段都一致了,最终拼接就是一次无需重新编码的快速 concat。 def label_clip(src: str, dst: str, label: str) -> None: """Scale to 1080x1920 and overlay a label (the device serial).""" vf = ( "scale=1080:1920:force_original_aspect_ratio=decrease," "pad=1080:1920:(ow-iw)/2:(oh-ih)/2," f"drawtext=text='{label}':x=20:y=20:fontsize=42:" "fontcolor=white:box=1:[email protected]" ) subprocess.run( ["ffmpeg", "-y", "-i", src, "-vf", vf, "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", dst], check=True, ) def concat_report(clips: list[str], out: str = "test_report.mp4") -> None: with open("concat_list.txt", "w") as f: for c in clips: f.write(f"file '{c}'\n") subprocess.run( ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", "concat_list.txt", "-c", "copy", out], check=True, ) 6. 把所有拼在一起 def main() -> None: apk = "app-debug.apk" package = "com.example.app.test" runner = "androidx.test.runner.AndroidJUnitRunner" serials = get_devices() if not serials: print("No devices found. Check USB and the output of 'adb devices'.") return print(f"Devices found: {len(serials)}") install_on_all(apk, serials) labeled: list[str] = [] for serial in serials: proc = start_recording(serial) _, ok = run_instrumented_tests(serial, package, runner) stop_recording(serial, proc, local=f"{serial}.mp4") print(f"[{'PASS' if ok else 'FAIL'}] tests on {serial}") out = f"{serial}_labeled.mp4" label_clip(f"{serial}.mp4", out, serial) labeled.append(out) concat_report(labeled, "test_report.mp4") print("Done: test_report.mp4") if __name__ == "__main__": main() 这里「录制 + 测试」的循环是跨设备顺序执行的 -> 这样更易读。对于一个真实农场,你会想把这个块也包进 ThreadPoolExecutor,让所有设备同时测试;逻辑和第 2 节的安装是一样的。 不要重复造轮子:现成工具 scrcpy -> 从你的 PC 实时镜像并控制一台设备。调试失败测试时不可或缺。 Appium / Espresso / UI Automator -> 完整的 UI 测试框架;上面的 am instrument 是它们的引擎。 Gradle Managed Devices -> 直接从构建里在模拟器上跑测试,无需手动折腾 ADB。 Firebase Test Lab / AWS Device Farm -> 如果你不想自己维护硬件,就用云端真机群。 GNU parallel -> 如果你更愿意用 bash 而不是 Python 来编排。 经济账:花多少钱、省多少钱 测试自动化不是「凭空变出钱」——它砍掉的是最贵的两个成本项:人时和云端分钟。下面是三种典型规模下的估算。数字是示例性的,取决于地区、设备厂商和服务商定价,购买前请核对当前费率。 自己的农场 -> 一次性投入 项目 | 10 台设备 | 30 台设备 | 50 台设备 二手 Android 手机(约 $60 每台)| ~$600 | ~$1,800 | ~$3,000 带供电的 USB 集线器 | ~$100 | ~$250 | ~$400 迷你 PC / 宿主机 | ~$400 | ~$400 | ~$500 线材、机架、杂项 | ~$80 | ~$150 | ~$250 一次性总计 | ~$1,200 | ~$2,600 | ~$4,150 每月电费 | 几分钱 | ~$10–20 | ~$20–40 这是资本支出:付一次钱,农场就能以几乎为零的成本跑上好几年。 云端 -> 按分钟付费 Firebase Test Lab、AWS Device Farm、BrowserStack 之类的都是按设备分钟计费 -> 大约每个设备分钟 $0.05–0.20。在 30 台设备上跑一次回归测试,每台 5 分钟,就是 150 设备分钟,也就是每次约 $7.5–30。 再乘以 CI 强度: 运行频率 | 每月运行次数 | 成本(约 $15/次) 每天 2 次 | ~44 | ~$660/月 每天 10 次 | ~220 | ~$3,300/月 每次 push 都跑(活跃团队)| 500+ | $7,500+/月 盈亏平衡点:一个 30 台设备的农场(约 $2,600),以每天仅 2 次的运行频率,大约 4 个月就能回本;在活跃 CI 下,不到一个月。之后云账单每月还在滴血,而农场不会。 人工劳动 -> 释放了什么 在 30 台设备上手动跑一个回归场景,大致相当于一名 QA 工程师的一个工作日。每周跑两次回归,一个月就累计约 8 个人日。按 QA 成本大约 $1,600–2,700/月 计算,这是薪水里相当可观的一块,而这条流水线把它解放出来去做有意义的工作,而不是「连接–安装–戳–录制」这个循环 ×30。 这如何转化成钱 这里没有来自「脚本本身」的直接收入——但有三个间接但非常真实的机制: 更快的发布。回归从一天变成几分钟 → 你更频繁地发布功能 → 你更快响应市场。对一个订阅制产品来说,这直接关系到留存和收入。 生产环境里更少的 bug。在发布前在一台特定型号的三星手机上抓住一个崩溃,成本是几分钱;同样的崩溃到达用户那里,意味着应用商店评分下滑、流失和退款。每一个早发现的 bug,都是一批永远不会被写下的差评。 它可以作为服务出售。搭建设备农场和 CI 测试,是自由职业和外包里一个抢手的岗位。上面的流水线就是这类服务现成的核心。 这里跟「刷互动套路」的关键区别在于:那边,钱来自欺骗算法,结局是被封。这里,钱来自省下的小时数和避免的损失。前者会崩塌;后者是一个你敢于拿给客户看的、可持续的商业案例。 收尾 结果是一条可复现的流水线:一条命令,App 就在整支设备舰队上跑一遍,最后生成一份视频报告,展示它在每一台具体设备上的行为。 收藏并订阅,这样你就不会错过那些能帮你省钱的实用文章 📝 标签:# X # Design # Automation # Finance # Hardware # Thread 相关文章 The Grok Bot Team's Own Workflow: 11 steps to the roster they actually run (full course) "There wasn't anything to learn. It was just like bringing on a coworker. No automations to set up, no product quirks, no intricate naming. You're just chatting with a friend." Grok Bot Slack Design Automation

原文参考:https://maxed.wiki/posts/an-android-device-farm-with-adb-ffmpeg-automated-ui-testing-across-10-50-devices/ (Maxed.wiki,本页为站内中文整理)