
凌晨一点四十打包机刚把新一版 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.9airtest1.3.5 及以上1.2.x 在部分新 ROM 上shell返回值处理有 bugpocoui1.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_RESTRICTEDUSB 调试安全设置开发者选项允许通过 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 devicesAndroid 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(fsettings put {ns} {key} {val}) back dev.shell(fsettings 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(fsettings 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-packageadb -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(rtext([^]*)[^]*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, rounds6, interval1.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, threshold0.75, # 压缩/分辨率差异大时降到 0.7 target_pos5) # 定位到模板中心 if exists(tpl): touch(tpl) def wait_dialog(dev, timeout12): try: wait(Template(r./tpl/btn_continue.png, threshold0.75), timeouttimeout, interval0.5) touch(Template(r./tpl/btn_continue.png, threshold0.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_versionNone): 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 Trueinstall()底层走的是adb install -r如果你需要-t或者--streaming就得绕一层用dev.adb_client.raw_install之类的接口或者干脆自己拼 shell 命令def raw_install(dev, apk_path): size os.path.getsize(apk_path) cmd finstall -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(fdumpsys package {pkg}) m re.search(rversionName(\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(filenamef{tag}_{ts}.jpg) with open(finstall_{tag}.log, a, encodingutf-8) as f: f.write(f--- {ts} ---\n) f.write(dev.shell(fdumpsys 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, fFAIL: {e} def run_all(serials, apk, pkg, workers6): with ThreadPoolExecutor(max_workersworkers) 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_dialogINSTALL_FAILED_VERIFICATION_FAILURE校验器判定失败或校验超时关闭校验开关后重装INSTALL_PARSE_FAILED_NO_CERTIFICATES包未签名或签名被剥离检查打包流程是否漏了签名INSTALL_FAILED_UPDATE_INCOMPATIBLE签名与已装版本不一致先 uninstall 再 installINSTALL_FAILED_VERSION_DOWNGRADE目标版本号低于已装版本加-d或先卸载INSTALL_FAILED_TEST_ONLYdebug 包带 testOnly 标记加-tINSTALL_FAILED_INSUFFICIENT_STORAGE存储不足或大包走临时目录失败清理空间改用--streamingINSTALL_FAILED_NO_MATCHING_ABISsplit 的 ABI 与设备不匹配按ro.product.cpu.abi选 splitINSTALL_FAILED_INTERNAL_ERROR安装会话残留状态机错乱重启设备或pm install-abandonINSTALL_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里的开关写入和版本校验结果做成设备的健康检查在装机任务开始前先跑一遍哪台设备的开关写不进去就把它标红跳过而不是等安装失败再去排查。这一改之后整批任务的失败率从百分之十几降到了百分之二左右因为绝大部分失败其实在安装之前就已经注定了。