☰
用AirTest改造ADB装机流水线:绕过vivo安装验证实现APK自动安装
2026/10/1 1:06:30 网站建设 项目流程

凌晨一点四十,打包机刚把新一版 APK 吐到共享目录,Jenkins 的流水线立刻触发装机任务,12 台并排插在 USB HUB 上的 vivo 测试机同时收到adb install指令。五分钟后,我在家打开远程桌面一看,一多半的设备屏幕上齐刷刷停着同一个画面:一个转圈的"安装验证"进度条,转完之后有的告诉我"安装失败",有的干脆什么提示都没有,包也没装上。这套流水线在别的品牌设备上跑了大半年,唯独在 vivo 上翻车,原因就是 vivo 的系统里多了一道安装验证环节,它既不认 CI 的非交互环境,也不吃adb install的默认参数。

这篇要聊的就是这件事:用 AirTest 作为主控,把 ADB 装机流程改造成一条无人值守的流水线,让"安装验证"这一步在自有测试设备上不再挡路。核心关键词包括语音识别?不,是AirTest、APK、自动安装、vivo 安装验证。适合三类人看:一是正在搭移动端自动化测试机架的同学,二是经常要批量给测试机刷包、比对版本的分发同学,三是手上有一堆真机、每次装包都要人肉点"允许"的手工测试同学。脚本层面我会给到可以直接抄的 Python 代码,原理层面我会把 vivo 那道验证到底卡在哪一环讲清楚,这样你换到别的厂商 ROM 上也能自己推演。

1. 先把卡点定位清楚:装机链路里到底多了一道什么

1.1 从 adb install 到应用落地,中间发生了什么

很多人把adb install当成一个原子操作,其实它在系统内部是一整条会话流程。ADB 把 APK 推到设备后,实际调用的是 PackageManagerService 的安装会话接口:先install-create建一个会话,再install-write把数据流写进去,最后install-commit提交。提交这一瞬间才是真正的分水岭——系统会依次做签名校验、包名与已装包一致性校验、目标 SDK 校验,然后进入验证环节。

原生 Android 的验证环节由 PackageVerifier 负责,默认实现会把包信息交给一个"校验器"应用去判断安全性。厂商 ROM 通常会在这一层做替换或叠加,vivo 这边叠加的就是大家熟悉的"安装验证":它会扫描包体、联网比对、给用户一个进度提示,并且在部分场景下要求用户手动确认。到了这一步,CI 环境里没有人去点屏幕,流程自然就断了。另一道坎是"USB 安装"权限,第一次通过 ADB 装包时,ROM 会弹一个"是否允许通过 USB 安装应用"的确认框,这个框如果没被点掉,后续所有安装都会以INSTALL_FAILED_USER_RESTRICTED收场。

所以本质上要解决两件事:一是让系统在 ADB 安装时不启动那套交互式验证,二是保证首次授权类弹窗不会拦路。前者靠系统设置开关,后者靠 AirTest 的 UI 兜底。

1.2 三条可行路线,我为什么选第二条

把问题摊开之后,能走的路线其实只有三条,我做过对比:

路线做法优点致命缺点稳定性
A. 纯 UI 点弹窗保留验证,用图像识别点"继续安装""允许"不改系统设置,最贴近真实用户行为每台设备分辨率/DPI 不同要维护多套素材,验证联网慢则超时中低
B. 关校验开关 + 原生 adb install关掉系统校验开关,用参数化 adb 安装,UI 只做兜底速度快、无素材依赖、脚本跨机型可复用需要设备长期保持在调试状态高
C. 预置为系统应用把包 push 到 /system 分区完全无验证需要 root 或改分区,维护成本极高,量产机不可行理论最高,实际不可用

我最终选 B 为主、A 为辅的混合方案。理由很实在:路线 A 的素材维护是个无底洞,vivo 的验证弹窗还有可能因为系统版本不同而改文案和布局;路线 C 在普通测试机上根本走不通。而路线 B 里那些开关,本身就是系统为开发者调试预留的正规入口,关掉之后安装耗时从实测的 6 到 10 秒降到 1.5 到 3 秒,12 台并行跑一批包,整体时长能压缩到原来的三分之一。

提示:本文讨论的所有操作,都只适用于你自己或公司名下的测试设备。关闭系统校验开关会让设备失去这层防护,千万不要在生产环境或个人主力机上照抄。

2. 环境准备:工具链版本和设备侧开关都要对

2.1 版本选择这件事,比想象中重要

我踩过的第一个坑就是版本。AirTest 的 Python 包和 platform-tools 的版本如果不匹配,会在跑批过程中随机出现设备掉线。下面是我目前在用、连续跑了三个月没出问题的组合:

组件版本说明
Python3.9.x3.12 上部分依赖编译会出问题,保守起见用 3.9
airtest1.3.5 及以上1.2.x 在部分新 ROM 上shell返回值处理有 bug
pocoui1.0.90 及以上用来做应用内元素的补充定位
platform-tools33.0.3 及以上低版本 adb 不支持--streaming和部分安装参数
打包产物含base.apk的拆分包或单包AAB 产物要先用 bundletool 转成 apks

安装命令很朴素:

pip install airtest pocoui -i https://pypi.tuna.tsinghua.edu.cn/simple

装完之后别急着写脚本,先跑一句python -m airtest version确认可用,再用adb devices -l看看设备列表里有没有带model和device信息的行。如果adb devices输出是空的但设备管理器里能看到手机,八成是别的软件占用了 5037 端口,把手机助手类程序的 adb 进程全部结束掉再重试。

提示:机架上同时插多台设备时,强烈建议在每台设备的开发者选项里关闭"USB 调试授权超时",并勾选"始终允许来自这台计算机",否则每次重启 adb server 都要人工点一次授权。

2.2 设备侧必须确认的开关清单

这部分的开关分散在开发者选项、安全设置和应用权限三个地方,我整理成一张清单,新机进架前照着点一遍就行:

开关所在位置作用备注
USB 调试开发者选项基础通信必开
USB 安装开发者选项允许 ADB 触发安装不开必报 USER_RESTRICTED
USB 调试(安全设置)开发者选项允许通过 ADB 修改系统设置关校验开关依赖它
安装验证 / 安全检测类开关安全设置或安装器设置内关闭交互式校验不同 ROM 名称不一致
未知来源应用安装安全设置允许非商店渠道安装部分 ROM 已合并
电池优化白名单电池设置防止后台被冻结导致掉线多机跑批必做
屏幕常亮开发者选项避免灭屏后部分 ROM 限制后台有就开
锁屏方式设为无安全设置避免锁屏后弹窗无法点击测试机专用

这里有个细节值得说:"USB 调试(安全设置)"这个开关在部分 vivo 机型上需要登录账号才能打开,如果你拿到的是新机,先把这一步做了,否则后面settings put会静默失败,命令返回 0 但设置根本没写进去。检查方法很简单,写入之后立刻读回来:

adb shell settings get global verifier_verify_adb_installs

如果回读还是 1,说明写入被拦了,回去检查安全设置开关。

2.3 USB 和无线调试怎么选

机架固定不动的话,优先用 USB,稳定性最好。但设备数量超过 8 台之后,USB HUB 供电和带宽会开始互相干扰,掉线率明显上升,这时候无线调试就更划算。两种连接方式的命令我列在下面。

USB 转 TCP(推荐,配对一次即可长期使用):

adb -s <serial> tcpip 5555 adb connect 192.168.1.101:5555 adb devices

Android 11 及以上的原生无线调试(每次重启都要重新配对):

adb pair 192.168.1.101:37xxx # 配对端口和配对码在无线调试页面里 adb connect 192.168.1.101:55xxx # 连接端口是另一个,别搞混

配对码是六位数字,在设备的无线调试页面里点"使用配对码配对设备"就能看到。我实际用下来,USB 转 TCP 这条路更省事,因为tcpip 5555之后端口是固定的,脚本里可以直接拼 IP,不用每次去读配对码。代价是设备重启后需要重新插一次 USB 执行tcpip 5555,所以我把这一步做成了机架上电后的第一个初始化动作。

3. 核心实现:用 AirTest 串起一条无人值守的装机流水线

3.1 装包之前,先做三件"静默"的事情

整个方案的地基就是安装前的那几条设置写入。这几条命令的作用不是骗过系统,而是把系统里本来就存在的调试开关切到非交互状态。我封装成一个函数:

from airtest.core.api import * # 关闭安装校验相关开关,只对自有测试设备使用 SILENT_SETTINGS = [ ("global", "verifier_verify_adb_installs", "0"), ("global", "package_verifier_enable", "0"), ("secure", "install_non_market_apps", "1"), ("global", "adb_install_need_confirm", "0"), ("global", "upload_apk_enable", "0"), ] def prepare_device(dev): """把设备切到非交互安装状态,并回读校验""" for ns, key, val in SILENT_SETTINGS: dev.shell(f"settings put {ns} {key} {val}") back = dev.shell(f"settings get {ns} {key}").strip() if back not in (val, "null"): print(f"[warn] {key} 写入未生效,当前值 {back}") # 关闭动画,减少 UI 干扰 for k in ("window_animation_scale", "transition_animation_scale", "animator_duration_scale"): dev.shell(f"settings put global {k} 0.0")

要注意的是,adb_install_need_confirm和upload_apk_enable这两个键并不是所有 ROM 都存在,settings put对不存在的键不会报错,回读会返回null,这属于正常现象,脚本里不要把它当成失败。真正需要保证生效的是前两条校验开关。

接下来是安装参数。很多人只写adb install xxx.apk,在自动化场景里这是不够的,下面这张表把常用参数逐个拆开:

参数含义什么时候必须加
-r覆盖安装,保留数据版本迭代必加
-t允许安装 test-only 包装 debug 包时不加必失败
-g授予所有运行时权限免去首次启动的权限弹窗
-d允许版本降级回归测试要装旧版本时
--user 0指定用户多用户设备上避免装错空间
--streaming流式安装包体超过 200MB 时显著提速
-i <pkg>指定安装来源部分 ROM 靠它决定走不走商店通道
--bypass-low-target-sdk-block绕过低 targetSdk 拦截老项目遗留包

我最终用的组合是-r -t -g -d,加上--user 0。至于-i,实测在 vivo 上填一个非商店的包名反而更省事,因为系统会走"外部来源"路径,不会去调商店校验器。

对于 AAB 拆出来的安装包,单条adb install是不行的,要用install-multi-package:

adb -s <serial> install-multi-package -r -t -g \ base.apk split_config.arm64_v8a.apk split_config.zh.apk

顺序上base.apk必须放第一个,split 的顺序无所谓。这一步我建议在打包环节就把 split 列表固化成一行文本,脚本直接读,不要指望安装脚本自己去猜哪个 split 属于哪个设备——ABI 不匹配会直接报INSTALL_FAILED_NO_MATCHING_ABIS。

3.2 AirTest 在这里的真正价值:做一只"看门狗"

关掉开关之后,正常情况确实不会再有验证弹窗了。但真机这个东西,永远会有意外:系统升级后开关被重置、某个包触发了额外的安全提示、权限授权弹窗刚好盖住安装器、USB 授权框在 adb server 重启后重新弹出。所以我从来不把"关开关"当成终点,而是让 AirTest 在旁边当看门狗。

AirTest 有几个能力在装机场景里特别好用:一是device().shell()直通 ADB,方便抓系统状态;二是图像识别Template,可以处理任何可见的弹窗;三是wait()的轮询机制,天生适合等一个不确定什么时候出现的界面。

不过这里有个很关键的认知:系统级弹窗不属于被测应用的 UI 树,Poco 通常抓不到它。我一开始用 Poco 去定位安装器的"继续安装"按钮,怎么都定位不到,后来才反应过来,安装器是独立进程,Poco 的 UI 树是挂在被测应用上的。解决办法是绕过 UI 树,用uiautomator dump把整屏层级导出来自己解析:

import re import time BTN_TEXTS = ["继续安装", "安装", "允许", "确定", "同意", "仍要安装", "下一步"] def dump_ui(dev): dev.shell("rm -f /sdcard/window_dump.xml") dev.shell("uiautomator dump /sdcard/window_dump.xml") return dev.shell("cat /sdcard/window_dump.xml") def parse_nodes(xml): """从 dump 出来的 XML 里提取 text 和 bounds""" nodes = [] for m in re.finditer(r'text="([^"]*)"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml): text = m.group(1).strip() if not text: continue x1, y1, x2, y2 = map(int, m.groups()[1:]) nodes.append((text, (x1 + x2) // 2, (y1 + y2) // 2)) return nodes def kill_dialog(dev, rounds=6, interval=1.0): """轮询若干次,遇到确认类弹窗就点掉""" for _ in range(rounds): try: xml = dump_ui(dev) except Exception as e: print("dump 失败", e) return False for text, cx, cy in parse_nodes(xml): if text in BTN_TEXTS: print(f"命中弹窗按钮:{text} @ ({cx},{cy})") dev.touch((cx, cy)) time.sleep(0.8) return True time.sleep(interval) return False

这段代码有两个实践要点。uiautomator dump在低端机上偶尔会失败并返回ERROR: could not get idle state,所以外面要包 try 并重试;另外bounds的解析不要依赖元素顺序,因为不同 ROM 的属性顺序不一样,用正则按属性名匹配更稳。用dev.touch((x, y))直接点坐标,比用 Template 匹配图片要快,也不怕分辨率变化。

那什么时候还需要图像识别?两种情况:一是弹窗是 WebView 或者自绘控件,dump 里根本没有 text 节点;二是弹窗出现得极快,dump 那一秒它就消失了。这时候可以用 AirTest 的模板匹配兜底:

from airtest.core.api import * def kill_by_image(dev, ): tpl = Template(r"./tpl/btn_continue.png", threshold=0.75, # 压缩/分辨率差异大时降到 0.7 target_pos=5) # 定位到模板中心 if exists(tpl): touch(tpl) def wait_dialog(dev, timeout=12): try: wait(Template(r"./tpl/btn_continue.png", threshold=0.75), timeout=timeout, interval=0.5) touch(Template(r"./tpl/btn_continue.png", threshold=0.75)) return True except Exception: return False

模板图片一定要在同一台设备、同一分辨率、同一系统字体大小下截取,跨机型复用模板是很多新手翻车的根源。我的做法是给每个机型建一个tpl/<model>/目录,脚本启动时按dev.get_property("ro.product.model")自动选目录。

3.3 安装、校验、留证,三步一条龙

把前面几块拼起来,一次完整安装的流程是:准备设备 → 挂上看门狗 → 执行安装 → 解析返回值 → 校验版本 → 留证据。安装这一步我建议直接用 AirTest 的install(),因为它内部就是走 adb,而且失败会抛异常,方便统一捕获:

from airtest.core.api import * def install_apk(dev, apk_path, pkg_name, expect_version=None): prepare_device(dev) try: install(apk_path) # 等价于 adb install -r except Exception as e: print("首次安装异常,尝试清理弹窗后重试:", e) kill_dialog(dev) uninstall(pkg_name) install(apk_path) if expect_version: actual = get_version(dev, pkg_name) if actual != expect_version: raise AssertionError(f"版本不符 期望{expect_version} 实际{actual}") return True

install()底层走的是adb install -r,如果你需要-t或者--streaming,就得绕一层,用dev.adb_client.raw_install之类的接口,或者干脆自己拼 shell 命令:

def raw_install(dev, apk_path): size = os.path.getsize(apk_path) cmd = f"install -r -t -g -d --user 0 --streaming {apk_path}" out = dev.adb_client.raw_install(apk_path) if size < 100 * 1024 * 1024 else None ...

版本校验这块,我不建议用grep过滤dumpsys输出,因为不同 ROM 的 toybox 对grep -m的支持不一致,直接在 Python 里正则更保险:

import re def get_version(dev, pkg): out = dev.shell(f"dumpsys package {pkg}") m = re.search(r"versionName=(\S+)", out) return m.group(1) if m else None

顺便说一句,如果你的测试机架上要同时存在两个版本的同一个应用(比如对比新旧版本的 UI 差异),靠包名相同是做不到的,必须走克隆或者用测试专用的包名后缀,这一点在打包阶段就要规划好。

留证据这一步别省。我在安装前后各截一张图,再把dumpsys package的关键行写进日志文件,一旦某台设备装失败,能立刻知道是没装上还是装上了版本不对:

def snapshot_and_log(dev, tag, pkg): ts = time.strftime("%Y%m%d_%H%M%S") dev.snapshot(filename=f"{tag}_{ts}.jpg") with open(f"install_{tag}.log", "a", encoding="utf-8") as f: f.write(f"--- {ts} ---\n") f.write(dev.shell(f"dumpsys package {pkg} | head -n 40") + "\n")

3.4 多机并行:别用全局 device()

单机跑通之后,下一步一定是并行。这里有个坑几乎人人都会踩:AirTest 的device()返回的是全局唯一实例,在多线程里同时调用会互相覆盖,出现 A 线程的指令发到 B 设备上的诡异现象。

正确做法有两种。一种是每台设备用独立进程,用 AirTest 的命令行入口跑:

airtest run install_case.air --device Android:///192.168.1.101:5555 --log log_101

另一种是在进程内为每台设备创建独立的Android对象:

from concurrent.futures import ThreadPoolExecutor from airtest.core.android.android import Android def job(serial, apk, pkg): dev = Android(serial) try: install_apk(dev, apk, pkg) return serial, "OK" except Exception as e: return serial, f"FAIL: {e}" def run_all(serials, apk, pkg, workers=6): with ThreadPoolExecutor(max_workers=workers) as pool: futures = [pool.submit(job, s, apk, pkg) for s in serials] for f in futures: print(f.result())

并发数不要贪多。我实测下来,USB 连接时 6 台并发是甜点值,超过 8 台 adb server 会开始出现device offline;无线连接时为了控制带宽,我把并发压到 4。另外一个细节:并行安装时最好错开 1 到 2 秒启动,让 adb server 有个缓冲,同时起 12 个 install 会话很容易把 server 打爆。

4. 常见报错与排查速查表

4.1 安装类错误码对照

装机脚本写到最后,百分之七十的工作量其实在处理这些报错。下表是我这两年攒下来的对照表,按出现频率排序:

报错码真实原因处理方式
INSTALL_FAILED_USER_RESTRICTEDUSB 安装开关关闭,或授权弹窗未点打开 USB 安装,kill_dialog 兜底
INSTALL_FAILED_ABORTED安装过程被打断,通常是弹窗未处理重试并先执行 kill_dialog
INSTALL_FAILED_VERIFICATION_FAILURE校验器判定失败或校验超时关闭校验开关后重装
INSTALL_PARSE_FAILED_NO_CERTIFICATES包未签名或签名被剥离检查打包流程是否漏了签名
INSTALL_FAILED_UPDATE_INCOMPATIBLE签名与已装版本不一致先 uninstall 再 install
INSTALL_FAILED_VERSION_DOWNGRADE目标版本号低于已装版本加-d或先卸载
INSTALL_FAILED_TEST_ONLYdebug 包带 testOnly 标记加-t
INSTALL_FAILED_INSUFFICIENT_STORAGE存储不足,或大包走临时目录失败清理空间,改用--streaming
INSTALL_FAILED_NO_MATCHING_ABISsplit 的 ABI 与设备不匹配按ro.product.cpu.abi选 split
INSTALL_FAILED_INTERNAL_ERROR安装会话残留,状态机错乱重启设备或pm install-abandon

INSTALL_FAILED_VERSION_DOWNGRADE这个报错我遇到过一次很典型的场景:回归测试要验证旧版本的一个修复,结果脚本一直失败,查了半天才发现是设备上已经装着新版本。这种场景下最干净的做法是每次回归都uninstall之后再装,而不是加-d硬降级,因为降级安装有时会留下新旧数据混用的问题,反而影响测试结论。

4.2 连接与稳定性问题的排查顺序

连接类问题我有一套固定的排查顺序,比盲目重插线高效得多。第一步看adb devices里设备状态是不是device,如果是offline或者unauthorized,先解决授权;第二步看是不是有多个 adb server 在跑,Windows 上尤其容易和各类手机助手冲突;第三步看 USB 线材和 HUB 供电,劣质线材在并发安装时数据中断的概率非常高;第四步看无线连接的信号和交换机端口,丢包会导致install-write传到一半断掉。

adb kill-server adb start-server adb devices -l netstat -ano | findstr 5037

最后一条命令在 Windows 上能列出占用 5037 端口的进程,把非 adb 的进程结束了再启动 server,成功率极高。这个技巧我在很多次"莫名其妙所有设备都掉线"的场景里都用上了。

4.3 我实际踩过的几个坑

第一个坑是开关会被系统重置。vivo 在系统更新、安全补丁安装、甚至某些清理动作之后,会把校验开关恢复成默认值。我的处理是把prepare_device放在每次装机任务的最前面,而不是只在新机初始化时跑一次。

第二个坑是首次安装某个包时会额外弹一次确认。即使开关都关了,某些系统版本在第一次装某个来源的包时,还是会弹一个"是否允许"的框。这时候看门狗必须在线,也就是安装和 kill_dialog 要并发跑,不能等安装返回之后再处理弹窗——那时候adb install已经报超时了。我的做法是把 kill_dialog 放在一个独立线程里,安装期间持续轮询。

第三个坑是大包传输超时。超过 300MB 的包用默认方式推,遇到传输中断会报一个很含糊的错误。改成--streaming之后成功率明显提升,因为它是边推边写安装会话,不需要先在/data/local/tmp里放一份完整的临时文件。如果--streaming在某些老 ROM 上不支持,就退回到install-create/install-write/install-commit三步手动会话,允许中途重试写块。

提示:无论用哪种方式装大包,装完之后记得清理/data/local/tmp下的临时残留,否则跑几百次之后测试机的存储会被慢慢吃满,然后所有设备一起报存储不足。

第四个坑是日志比脚本重要。装失败的设备如果没有留截图和dumpsys输出,你只能靠猜。我现在的脚本在每次安装失败时都会自动保存三样东西:屏幕截图、dumpsys package片段、以及最近 200 行logcat。这三样凑齐之后,绝大多数失败都能在五分钟内定位。

5. 从单机脚本到流水线:几个能省时间的扩展思路

单机跑通、并行也跑通之后,真正省钱的是把整件事接进 CI。我用的是 Jenkins 参数化构建,参数就三个:APK 地址、目标包名、期望版本号。构建脚本调airtest run跑用例,跑完把log目录归档,报告里能直接看到失败设备的截图。

这里有个优化点值得单独说:不要让流水线把 APK 拷到每台设备上。12 台设备各拷一份 300MB 的包,光拷贝就要好几分钟。我的做法是把包放在内网的静态文件服务上,设备端直接拉:

adb -s <serial> shell "curl -s -o /data/local/tmp/app.apk http://10.0.0.20/builds/app-release.apk" adb -s <serial> shell "pm install -r -t -g -d /data/local/tmp/app.apk" adb -s <serial> shell "rm -f /data/local/tmp/app.apk"

这个方式还有个附带好处:脚本只要传 URL 就行,本地磁盘不用为每台设备准备副本,多分支并行构建时不会互相抢文件。设备端如果没有 curl,用wget或者 busybox 的wget替代也可以,先探测一下有哪些命令可用。

另一个扩展方向是把装机结果做成看板。我在每台设备上装完之后会跑一次dumpsys package抓 versionName 和 lastUpdateTime,汇总成一张表回写到一个共享目录里,谁想看当前机架上各设备装的是哪个版本,打开就是一个表格。这个表看起来简单,但在多人共用机架的团队里能省掉大量"你那台机器上装的是哪版"的沟通成本。

最后分享一个我最近才开始用的小技巧:把prepare_device里的开关写入和版本校验结果做成设备的"健康检查",在装机任务开始前先跑一遍,哪台设备的开关写不进去就把它标红跳过,而不是等安装失败再去排查。这一改之后,整批任务的失败率从百分之十几降到了百分之二左右,因为绝大部分失败其实在安装之前就已经注定了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询