我们每天要跑十几次「注册 / 登录 / 取验证码」的自动化:平台保活、账号开通、巡检。这条链路上我先后踩塌过两次,第一次以为是网络问题,第二次以为是 App 的 bug,最后发现根因是同一个认知错误:短信通道不是一条,是两条,各有各的死法,而我把两者的角色搞反了。
先把结论摆出来:ADB 直读适合「人在机器旁调试」,转发 App 适合「长期无人值守」。反过来用,就是你要熬一整晚的那种故障。
| 维度 | ADB 直读(读手机 inbox) | 转发 App(短信转发器 → 邮箱) |
|---|---|---|
| 依赖什么 | 手机 USB 连在这一台机器上,且调试授权有效 | 手机联网即可,与本机物理无关 |
| 延迟 | 秒级(轮询 inbox) | 通常 2~10 秒 |
| 失效形态 | 手机一拔线就归零,静默 | App 被系统杀掉就不到,同样静默 |
| 能不能远程扩容 | 不能,必须有人在机器旁插线 | 能,加收件人即可,一台机器坏了另一台还能读 |
| 适合 | 现场调试、批量取码、临时救急 | 7×24 无人值守 |
| 最容易错的地方 | 以为「一直连着」 | 以为「一直活着」 |
我们真实的部署是:手机常年插在主机上充电,所以我一开始把 ADB 当主通道,还写过「关了转发器本机也不受影响」的结论。后来被现实纠正了——那台手机是日常在用的手机,不是专用测试机。只要它被拿走一次,主通道立刻变成 0,而且不报任何错,只是永远取不到码。这就是第一坑。
判断通道是否可用,不能看「上一次成功过」,只能看这一次设备列表里有没有唯一的那一行:
adb devices
# 正常: <serial> device
# 异常: (空) → 线 / USB 口 / 驱动
# 异常: <serial> unauthorized → 手机弹了授权框没人点
unauthorized 特别容易骗人:设备是在的,命令也不报错,但每次读 inbox 都返回空。换 USB 口、换线、手机重启之后都会重弹授权框,你以为勾过「一律允许」就永远有效,其实不是。
正确的做法是:取码脚本的第一件事不是读短信,是自检设备;设备不在就立刻降级到邮箱通道,并把「本次已降级」写进日志。沉默降级比报错危险得多——报错你会去修,沉默降级你会以为「今天没人发验证码」。
转发 App(短信转发器,包名 cn.ppps.forwarder,不是网上很多教程里写的那个包名)靠后台常驻工作。在 Android 13 + 厂商 ROM 上,它会被四层机制分别杀掉,而普通 ADB 只能碰到其中两层:
| 层 | 机制 | 普通 adb 能不能改 |
|---|---|---|
| Doze 低电耗 | deviceidle 白名单 | 能:dumpsys deviceidle whitelist +<pkg> |
| 待机分桶 | standby bucket 被降级到 rare | 能:am set-standby-bucket <pkg> active |
| 后台运行限制 | appops RUN_IN_BACKGROUND | 不能:Android 13 普通 adb 报 MANAGE_APP_OPS_MODES 权限拒绝,要 root |
| 厂商层 | 自启动管理 / 耗电管理 / 最近任务清理 | 不能,必须人在手机上点 |
所以「用 ADB 把转发 App 加固到杀不掉」这件事,做不到 100%。能做的只是降低被杀概率,最后三层必须人工点:最近任务里长按卡片锁定(最有效,防误划)、电池→应用耗电管理→不限制、手机管家→自启动管理→开启。
判断它是否还活着,别猜,看 pid:
adb shell pidof cn.ppps.forwarder # 有输出 = 活着
adb shell monkey -p cn.ppps.forwarder -c android.intent.category.LAUNCHER 1 # 拉起来
adb shell dumpsys deviceidle whitelist +cn.ppps.forwarder # 补白名单
顺便纠正一个常见误解:App 内的「通用设置」里没有「前台服务 / 自启动」这类开关,只有蓝牙、GPS、短信指令、听筒关屏等增强功能。找不到不是版本问题,是根本没有,别再翻了。
这条纯粹是踩坑记录,但很费时间:转发 App 的「收件人邮箱」字段填逗号分隔的多个地址,保存时直接报「地址不合法」。它并非不支持多收件人,而是不支持在一行里写多个。正确做法是在字段上方点「添加收件人」,加一行,再填第二个地址。
为什么值得折腾?因为双收件人是这套方案里最便宜的一层冗余:一条短信、两处可读,主控机和备用机各读一个邮箱,任何一台机器挂了都能取到码。比加机器、加监控便宜得多。
要直接读 inbox,命令长这样:
adb shell "content query --uri content://sms/inbox --projection address,body,date --sort 'date desc limit 5'"
关键点:limit 不是 content query 的参数,它是 --sort 字符串的一部分,必须写在引号「里面」。写成 --sort 'date desc' limit 5 会报错,或者干脆忽略它、把整张表吐出来——几万条短信一次灌进管道,脚本就卡死了,看起来像「adb 挂了」。
另外,返回空不等于没短信:先确认收件箱真的有,再确认 shell 读短信的权限没被 ROM 收回。顺序反了,你会把时间花在错的层上排查。
取码规则通常写成两层:优先匹配「验证码 / 校验码 / 动态码 / code」后面的 4~8 位数字;匹配不到,兜底取「独立的 6 位数字」。
第二层在真实短信里非常危险。同一条短信里往往同时出现订单号、金额、日期、卡号尾数、链接里的参数。「您尾号 6765 的信用卡欠款 221118.94,请于 11 月 01 日前还款」——独立的六位数字候选不止一个,兜底会稳稳地取到一个「长得像验证码」的东西。然后你在登录页面前反复重试,以为是平台的问题,其实你从来没取到真正的码。
我们的处理办法是把兜底收窄:只在「正文含验证码类关键词,但主规则没抓到」时才启用兜底;纯通知类短信直接判无码。取不到就明确失败,不要返回一个猜出来的数字——猜出来的码会把「通道故障」伪装成「验证码错误」,这是最难查的一类假象。
有时不得不让脚本去点 App(改配置、加收件人)。用 uiautomator dump + input tap 时,下面四种现象都真实发生过:
| 现象 | 根因 | 做法 |
|---|---|---|
| 点了没反应,字打到别处 | 键盘弹出/收起使布局整体上移,脚本还在用上一步的坐标 | 每一步操作后重新 dump 取坐标,绝不缓存 |
input text 被吞 | 目标输入框没有获得焦点 | 先确认 focused=true;tap 后等 1~2 秒再 dump |
| 输入框里混进怪字符 | 滚动 swipe 的终点落在输入框上,被识别成滑行输入 | 滚动路径避开所有输入框区域 |
| 填到一半整页退出、内容全丢 | input keyevent 4(BACK) 在键盘已关闭时等于「退出页面」 | 收键盘改成点非输入区,不要用 BACK |
这四条的共同点:都是「脚本写得对,环境在动」。UI 自动化里最贵的不是写动作,是每次动作后重新确认状态。
自动化里真正贵的不是「取到码」,而是「取不到时上层知道该干什么」。所以我们把接口定成三条硬约定:
| 结果 | 输出 | 退出码 |
|---|---|---|
| 成功 | stdout 只打印纯数字码(可直接管道给上层),来源信息走 stderr | 0 |
| 无码 | stderr 打 NO_CODE_FOUND,stdout 什么都不给 | 1 |
| 设备 / 通道异常 | stderr 说明是哪种异常(设备不在 / 已降级到邮箱 / 权限不足) | 非 0 |
配合三个开关:--code 取 15 分钟内最新一条;--code --wait 120 点完「发送验证码」后轮询最多 120 秒;--code --from <平台名> 按发件人/正文关键词过滤。
为什么一定要 --from?双卡双待的手机在同一分钟里完全可能收到另一张卡的银行验证码。不过滤就有概率取到「别人的码」,然后你会在平台侧排查「为什么验证码错误」——而真正的原因是它根本不是这个平台的码。
adb devices 有且仅有一行 device;出现 unauthorized 视为不可用。pidof 有输出),Doze 白名单与 standby bucket 已设。--from 能挡住另一张卡的码。它解决的是「机器怎么可靠地拿到一条短信」,不解决人机验证。滑块、点选、行为风控这类东西,ADB 和转发 App 都越不过去;能过的只有人工。所以整条链路的正确设计是:机器负责 95% 的常规取码,剩下 5% 明确抛给人,而不是把失败重试到天亮。
另一个容易忽略的成本:一台长期插着 USB 的日常手机,本身就是不稳定因素——被拿走、被小孩玩、厂商更新后权限策略变化。把它当「基础设施」之前,先想清楚谁负责让它一直在线。
如果你也在做「无人值守 + 自动取码」这一类自动化,或者手上有一堆机器需要被统一管起来(巡检、自启收敛、故障自愈),可以直接看我们的服务口径:
上线前可以先拿这份免费清单自检一遍,9 项验收 + 3 条边界,不用留联系方式:下载《自动化与设备托管:上线前 9 项验收清单》→
相关实录:多机 SSH 批量运维的 9 类故障分诊 · 无人值守 kiosk 的看门狗与自愈
读 inbox 不需要。Android 13 上 content query --uri content://sms/inbox 通过 adb shell 的 shell 权限通常可直接读。需要 root 的是改 appops 这类跨应用权限设置。
它依赖手机能收到那条指令短信,一旦短信通道本身不可靠就形成循环依赖。我们只用它做最后手段的通知,不作为控制面。
有,而且必须做:定时 pidof 比「等一封测试短信」便宜得多。判据要写成「连续 N 次 pid 为空 → 告警并尝试拉起」,单次为空可能只是重启瞬间。
ADB 同一时刻只服务一个 adb server 连接,另一台机器要读就得走网络 adb(需配对)或干脆让转发 App 发两个邮箱。后者的维护成本更低。
看客户能接受多少人工。如果「取不到码就转人工」是允许的,它够用且便宜;如果要求 100% 全自动、零人工,任何基于「一部手机」的方案都不该承诺,因为手机是世界上最不听话的服务器。