简介:面向安卓开发者的手机验证码获取演示项目PhoneNumberVerifyDemo,特别适合初、中级开发者学习客户端验证码交互流程,常见于用户注册、登录验证、密码找回等业务场景。压缩包共51个文件,整体约1.39MB,其中class文件为编译后的字节码,xml涵盖界面布局与应用配置,png为界面图标资源,java为源码,jar为依赖库,apk为可直接安装的测试包,dex为安卓可执行文件,目录结构清晰易懂。已有719人学习下载。项目覆盖了动态申请短信权限、使用HTTP库发起验证码请求、注册BroadcastReceiver监听短信并提取验证码、通过CountDownTimer实现倒计时更新等关键技术点,并给出了网络异常、验证码过期等情况的错误处理范例。项目不包含服务器端实现,但客户端流程完整,便于在此基础上对接自建后端或第三方短信服务,同时提醒开发者遵循HTTPS通信和用户隐私保护等最佳实践。
1. android手机验证码的获取:为什么这件事值得认真做
做 App 自动化测试的人几乎都遇到过同一个断点:跑到登录环节,系统把验证码发到手机里,人工抄码、填码、继续跑,整条流水线被卡住十几秒。我见过不少团队把自动化测试挂着半自动状态,人肉盯着屏幕输验证码,美其名曰「保活」。真正的问题是,android手机验证码的获取这件事本身是一套成熟技术路径,完全可以做成自动链路——用无障碍服务监听通知栏,或直接读取短信数据库,把验证码提取出来喂给测试脚本。这套能力不仅服务自动化回归,也常用于内部工具、设备自检和账号自助找回流程。本文按我实际落地过的方案讲:先是链路取舍,再给两套可抄的实现,最后是权限适配和真正的坑。适合正在做自动化测试、设备托管或内部工具链的 Android 开发者,也适合刚被验证码卡住的新手照着搭。
2. 验证码从哪个通道来:链路选型决定稳定性
2.1 消息到达手机的三条路径:通知栏、短信表、运营商侧
短信验证码的获取,本质上回答一个问题:消息到达用户界面之前,系统在哪里把内容交给了应用层。以 Android 的架构来说,验证码文本到达最终可被程序读取的状态,一共有三条路径。第一条是系统短信应用收件箱,也就是短信数据库中的 inbox 表,任何有 READ_SMS 权限的应用都能直接查表读取,这条路径最底层、最原始,消息在 SMS 应用展示之前就已经落库。第二条是系统通知栏,短信应用收到消息后会发出一条 Notification,任何有 NotificationListenerService 或无障碍服务权限的应用都能从通知的 extras 里拿到文本,这条路径对用户几乎无感。第三条是运营商侧或云服务侧,通过短信转发、云端同步或服务端回调拿到验证码,典型的例子是某些手机厂商的云同步服务或企业对公短信通道,这条路径门槛最高,需要业务合同和服务器对接。
实际工程里,前两条为主,第三条属于另一套业务系统,不在本文范围内。选型时最容易被忽略的一点是:通知栏路径不申请敏感短信权限,但依赖系统通知机制是否稳定;短信表路径权限敏感,但读取最确定。两条路的本质区别是「拿到消息的时间点」和「权限审查的严格度」。通知栏发生在消息展示给用户的同时或略晚,短信表则更早。如果目标是把验证码回填到自动化脚本里做 UI 操作,通知栏路径完全够用;如果目标是旁路拦截并做数据分析,短信表更可靠。
这里先给一个结论性的选型表,后面两章分别展开代码:
| 对比项 | 无障碍/通知监听 | READ_SMS 短信表直读 |
|---|---|---|
| 权限敏感度 | 低,需要辅助功能开关 | 高,运行时敏感权限 |
| 时效性 | 通知出现时触发 | 短信落库即可查询 |
| 抗系统杀进程 | 中,国产 ROM 可能被限制 | 高,短时查询即可完成 |
| 适用于自动化回填 | 适用 | 适用 |
| 适用于后台长期监听 | 需保活策略 | 需保活策略 |
2.2 为什么优先做无障碍服务这条链路
我一般会优先实现无障碍服务监听通知这条链路,而不是直接申请 READ_SMS。原因很务实:自动化测试场景里,目标设备上跑着被测应用本身,再叠加一个读取短信的权限,审核和运行时弹窗都更麻烦。而无障碍服务只需要用户在系统设置里手动开启一次,之后应用就能在后台持续监听通知栏事件,不需要额外的运行时授权弹窗。尤其在做设备集群管理时,多台测试机同时自动化,每台设备少一个授权弹窗,就少一个失败点。
另一个理由是,Android 系统对 READ_SMS 的限制一直在收紧。新设备上 targetSdk 较高的应用申请读短信权限,用户侧感知极其明显,Play 商店还会要求说明用途。做内部自动化工具的目标是少被打断,无障碍服务这种用户主动授权的机制反而更抗变化。不过无障碍服务也有它的软肋,包括事件分发时机、部分 ROM 对辅助功能的后台限制,这些会在避坑章节专门展开。
链路选型还有一个容易被忽略的细节:通知栏数据来源不只有系统短信应用。很多验证码其实是 App 内推送的,比如某平台 App 自己弹出的「验证码已发送」通知,或者即时通讯工具里的验证码消息。这种情况下短信表里根本没有内容,而通知栏路径能一网打尽。所以做获取机制时,别只盯着短信应用的通知,要把事件源放宽到所有应用。这个细节对后面的事件过滤逻辑设计影响很大。
2.3 两套方案的最低硬件与系统要求
开始写代码之前,先明确一下运行环境边界。最少需要一台 Android 7.0 以上的真机,模拟器上的短信通道不稳定,而且做自动化测试大概率要真机环境。无障碍服务方案不挑系统版本,Android 7 到最新的 Android 14 都适用,但不同版本对通知事件extra字段的传递方式略有差异,提取文本时要做空值兜底。短信表直读方案则要特别注意:Android 13 开始 READ_SMS 权限的弹窗文案更严厉,Android 14 对 targetSdk 较高的应用后台读取短信也增加了一些限制。如果测试机系统版本跨度大,建议按 Android 11 作为分界点做兼容分支。
设备数量上,单机调试用一台即可,集群自动化建议先把单机跑通再复制到其他机器,因为无障碍服务的开启动作无法通过 ADB 一步完成,需要配合 UI 自动化点击系统设置项。这里也算一个隐形成本:无论选哪条链路,首次在每一台新设备上都需要一次人工开启辅助功能开关。可以把这一步写进设备初始化文档,避免测试同学拿到新设备后一脸懵。
3. 用无障碍服务监听通知栏:最小实现与事件过滤
3.1 声明无障碍服务:XML 配置与事件类型选择
无障碍服务是通过 AccessibilityService 实现的,系统会在指定事件发生时回调服务类。先创建一个继承 AccessibilityService 的类,然后在 res/xml 目录下写配置文件,最后在 AndroidManifest 里声明。配置里最关键的两个参数:android:accessibilityEventTypes 决定监听哪些事件类型,android:notificationTimeout 决定事件合并窗口。通知监听场景只需要 TYPE_NOTIFICATION_STATE_CHANGED,也就是通知栏状态变化事件。
<accessibility-service android:accessibilityEventType="typeNotificationStateChanged" android:accessibilityFeedbackType="feedbackGeneric" android:canRetrieveWindowContent="true" android:notificationTimeout="100" android:description="@string/accessibility_description" android:settingsActivity=".SmsCaptureSettingsActivity" />这段配置的逻辑是:系统只把通知状态变化事件交给服务处理,避免收到大量窗口内容变化事件导致性能和功耗问题。canRetrieveWindowContent 设为 true 是为了拿到通知里的文本内容。notificationTimeout 设 100 毫秒,意思是系统在 100 毫秒内合并同类事件,这个值过大会导致消息延迟,过小则增加事件回调频率。description 字段会显示在系统辅助功能列表里,要写清楚用途,否则用户在设置里看到不明权限不敢开。
3.2 服务类实现:拿到通知文本与验证码缓存
服务类的核心是 onAccessibilityEvent 回调,事件类型是 TYPE_NOTIFICATION_STATE_CHANGED 时,从事件里取 Parcelable 通知对象,再取 EXTRA_TEXT 字段。这一步有个需要注意的地方:不同短信应用往通知里塞文本的 key 不同,主流的 key 是 Intent.EXTRA_TEXT,但有些国产短信应用会塞到 EXTRA_BIG_TEXT 里。稳妥做法是把两种 key 都取一遍,取到哪个用哪个。
class SmsCaptureService : AccessibilityService() { companion object { const val EVENT_NOTIFICATION = AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED.toLong() } override fun onAccessibilityEvent(event: AccessibilityEvent?) { if (event == null || event.eventType != EVENT_NOTIFICATION) return val notification = event.parcelableData as? Notification ?: return val extras = notification.extras ?: return val text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() ?: extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString() ?: return // 只关心可能含验证码的文本,减少误提取 if (text.contains("验证码") || text.contains("动态码") || text.contains("校验码")) { val code = SmsCodeExtractor.extract(text) ?: return SmsCodeStore.save(code) // 通过本地广播或回调通知自动化测试框架 LocalBroadcastManager.getInstance(this) .sendBroadcast(Intent("captured_sms_code").putExtra("code", code)) } } override fun onInterrupt() { // 系统中断服务时调用,这里不需要额外处理 } }这段代码最关键的设计在于:不是所有通知都做提取,而是先做关键词粗筛,再做正则精提取。如果不做粗筛,自动化测试期间每一条通知都会触发正则解析,虽然性能开销不大,但会把无关通知里的数字误识别为验证码。SmsCodeExtractor 的具体逻辑下一章单独讲。LocalBroadcastManager 发送的是应用内广播,只有本应用能收到,适合把验证码分发给正在等待的测试脚本。SmsCodeStore 建议用内存缓存加文件持久化两层,防止测试脚本还没消费,验证码就被下一次通知覆盖。
3.3 用 ADB 快速验证事件是否到达服务
写完代码先别急着接自动化框架,先用 ADB 验证事件链路是否通。验证分两步:打开应用的辅助功能开关,然后给设备发一条真实短信,观察日志。打开辅助功能开关这一步没法用一条命令搞定,通常是先拉起系统设置页再配合坐标点击,但验证阶段手动开一次即可。
# 启动无障碍设置页面 adb shell am start -a android.settings.ACCESSIBILITY_SETTINGS # 查看服务是否注册成功,服务名替换为自己的包名和类名 adb shell settings get secure enabled_accessibility_services # 同时查看偏好设置 adb shell settings get secure accessibility_enabledenabled_accessibility_services 输出里能看到自己应用的完整服务组件名,包括包名和类名路径,表示已经开启。接着发一条真实短信到设备,然后在 logcat 里过滤关键字:
adb logcat -s SmsCaptureService看到验证码相关日志输出,说明事件链路通了。如果日志毫无输出,第一嫌疑是服务没有真正开启,第二嫌疑是通知事件类型没配对。这里有一个血泪经验:有些设备上,辅助功能开启后系统会延迟几秒才生效,刚开完立刻测试容易误判为代码问题。
4. 直读短信数据库:广播接收与内容解析的完整代码
4.1 动态注册广播接收器:拿到新到短信的 Intent
通知栏方案之外,另一条常见路径是注册 BroadcastReceiver 监听新短信广播。新短信到达时系统会发一个 action 为 android.provider.Telephony.SMS_RECEIVED 的广播,广播里携带完整短信协议数据单元。需要强调的是:这个广播只通知新短信到达,具体的文本内容不在广播的 extras 里,要从短信数据库去查。这跟很多人想象的「广播自带内容」不一样,也是个很常见的误解。
val filter = IntentFilter(Telephony.Sms.Intents.SMS_RECEIVED_ACTION) filter.priority = IntentFilter.SYSTEM_HIGH_PRIORITY registerReceiver(smsReceiver, filter)这里把广播优先级设为系统最高,目的是在事件分发时优先被应用处理。优先级在多个接收器竞争同一个广播时有效,但现代 Android 版本上生杀大权还是看应用是否处于活跃状态。注册方式上,我建议用动态注册而不是在 Manifest 里静态注册一个针对 SMS_RECEIVED 的接收器,原因很简单:Android 系统迭代中对隐式广播限制不断收紧,动态注册是未来兼容性最好的方式。
4.2 从 inbox 表查询最新验证码短信:ContentResolver 与排序
广播只是触发器,拿到广播后真正要做的是查内容表。查询 inbox 表本质上就是查 content://sms/inbox 这个 URI,按时间倒序取最新一条含关键词的消息即可。这里有一个优化点:不要每次都查整个收件箱,用日期字段过滤最近两分钟的消息,再排序取第一条,这样即使设备短信量大也几乎瞬时返回。
fun queryLatestSms(contentResolver: ContentResolver): String? { val uri = Uri.parse("content://sms/inbox") val projection = arrayOf("_id", "body", "address", "date") val selection = "date > ?" val twoMinutesAgo = System.currentTimeMillis() - 2 * 60 * 1000 val selectionArgs = arrayOf(twoMinutesAgo.toString()) val sortOrder = "date DESC" contentResolver.query(uri, projection, selection, selectionArgs, sortOrder)?.use { cursor -> if (cursor.moveToFirst()) { val body = cursor.getString(cursor.getColumnIndexOrThrow("body")) val address = cursor.getString(cursor.getColumnIndexOrThrow("address")) val date = cursor.getLong(cursor.getColumnIndexOrThrow("date")) return body } } return null }这条查询规避了两个典型问题。第一是不用 selection 里的 address 过滤具体号码,因为验证码来源号码各平台不同,有些是短号,有些是长号,还有境外号码,过滤条件越严越容易漏。第二是只用时间窗口过滤,配合广播触发的时机,基本不会拿到旧短信。真正需要留意的反而是时序问题:广播到达时短信可能尚未完全写入数据库,直接查询可能查不到。常见做法是广播回调里延迟 500 毫秒再查,或者做一次重试,第一次查不到就 200 毫秒后再查一次。
4.3 READ_SMS 权限申请:时机、拒绝处理与 Android 版本差异
直读短信表绕不开 READ_SMS 权限。这个权限是运行时敏感权限中最高等级之一,申请时会看到系统明确提示「允许应用读取短信内容」。申请时机上有讲究:不要在应用启动时弹窗,最好是用户进入短信监听功能页时主动点击开启,强烈的用户意图可以减少拒绝率。同时要处理好拒绝分支:被拒绝后展示说明页,提示用户「此功能依赖短信读取权限,用于验证码自动提取,不上传任何内容」。
版本差异这块目前比较明确:Android 12 及以下,只要用户授予权限,应用可以在后台任意读取短信;Android 13 及以上,系统对 targetSdk 较高的应用增加了权限使用限制,尤其是后台读取场景。具体限制行为各厂商有差异,但结论一致:如果测试机系统版本较新,尽量选择通知栏方案,把 READ_SMS 留作备用降级路径。另外,每次应用更新后权限不会被重置,但用户可能在系统设置里手动关掉权限,代码里要对 SecurityException 做兜底,查询时 catch 住,一旦出现异常就引导用户重新授权。
5. 验证码提取规则与自动化回填:边界与避坑
5.1 正则提取策略:为什么不能只取连续六位数字
验证码提取是整个方案里最考验细节的环节。很多入门实现直接写\d{6}匹配六位数字,结果在真实环境中不断误匹配。原因很简单:验证码短信的文本里通常包含手机号、卡号尾号、有效期等一串数字,第一个连续六位数字未必是验证码。而且现在不少短信平台的验证码是「数字+字母」混合,像 ab12cd,纯数字正则彻底失效。
我常用的一层策略是:先做关键词上下文定位,找「验证码」「动态码」「校验码」这类词出现的句子,从该句范围内提取可能的验证码;然后在句中优先匹配「验证码[是为:]+[A-Za-z0-9]+」的结构;如果没有这类结构,再退回到整条短信里找前后没有手机号特征的数字串。另外,验证码通常紧跟关键词之后,所以关键词后面的第一个连续字母数字串就是目标,这个规则的准确率在实际短信样本里能达到九成以上。
object SmsCodeExtractor { private val CODE_REGEX = Regex("(?:验证码|动态码|校验码|安全码)[是为::\\s]*([A-Za-z0-9]{4,8})") fun extract(text: String): String? { val normalized = text.replace("【", "").replace("】", "") val match = CODE_REGEX.find(normalized) ?: return null val candidate = match.groupValues[1] // 排除纯手机号或纯日期形态 return candidate.takeIf { !it.matches(Regex("^\\d{11}$")) && !it.matches(Regex("^\\d{8}$")) } } }这里 normalize 阶段是全角转半角和过滤符号的关键,短信原文里经常出现「验证码:123456」和「验证码为 123456」两类写法,正则里同时处理冒号和「为」字。排除手机号规则是因为有些短信句式是「您的号码 138xxxx5678,本次验证码为 123456」,如果不排除,正则很可能先匹配到手机号尾号。常用验证码长度是 4 到 8 位,超过这个区间的候选串基本不是验证码。
5.2 提取结果去向:缓存、回调与自动化测试框架对接
验证码提取成功后,下一个问题是把它送进自动化流程。常见做法有两种。第一种是针对设备上运行被测应用的场景,直接把验证码填入当前页面中的输入框,这需要额外的一层无障碍手势模拟操作,根据控件文本找到输入框并设置文本;第二种是把验证码放进本地内存缓存或写文件,让测试脚本通过 ADB 或本地服务来读取。我更推荐第二种,理由是把「获取验证码」和「填充验证码」解耦,获取逻辑专注获取,填充逻辑属于自动化框架层。
对接时的常见约定是:应用启动后先清空缓存,进入验证码等待状态;收到验证码后写入缓存并打一个时间戳;测试脚本轮询或通过广播感知新验证码到达。时间戳极其重要,验证码通常五分钟内有效,缓存里只保留最近的验证码,超过五分钟直接丢弃,避免旧码被新流程误用。集群设备场景下,验证码缓存还要加上设备标识,防止多台设备共用一个缓存目录时互相覆盖。
5.3 验证码获取的常见问题:监听不到、乱码与重复上报
问题一:监听不到任何通知。先检查无障碍服务是否真的开启,再看事件过滤条件。我发现最常见的翻车点是开发调试时把 region 配置写成了只监听特定应用包名,结果其他应用的通知全部被过滤掉,验证码恰好来自未被监听的应用。解决方案是事件过滤先放全部应用,提取逻辑里再做关键词筛选。
问题二:提取出来的验证码是乱码。这种情况多出现在对方短信是 GSM 编码格式而系统转 Unicode 时出现编码异常。常见做法是拿到文本后先做 ISO-8859-1 转 UTF-8 的尝试,但这属于玄学,不同设备表现不一致。真正的防线是保证 AccessibilityService 配置里没把字符集搞乱,并在提取前做空值和非法字符过滤。
问题三:重复上报同一验证码。通知栏方案里,一条短信可能会触发两次通知事件(一次系统短信通知,一次可能来自其他应用的通知),加上短信表查询路径也可能捞到同一条消息。处理方式是引入消息指纹,取文本的前 20 个字符加上通知到达时间的分钟级时间戳做哈希,作为去重键。缓存过期时间设置为五分钟,五分钟内相同指纹不上报第二次。
6. 权限适配与后台存活避坑:4 条必看的踩坑记录
6.1 通知栏方案坑:广播能收到但文本为空
现象:无障碍服务已开启,logcat 也验证了事件到达,但 from 的文本字段一直是 null。排查发现,部分国产 ROM 上系统短信应用发送的 Notification 采用了自定义布局,文本没有放进标准的 EXTRA_TEXT,而是放在 RemoteViews 里。解决:两位优先做兜底——从事件的 text 字段列表里取内容,AccessibilityEvent 本身自带字符序列列表,许多自定义通知的文案会出现在这里;其次才是 Notification extras。血的教训是提取逻辑要按多个来源依次尝试,不能只依赖一种路径。
6.2 短信表查询坑:广播到达但数据库查不到
现象:SMS_RECEIVED 广播触发后立刻查 inbox 表,返回空。原因:广播发送时短信尚未写入数据库,系统是先发广播再做落库,还是先落库再发广播,不同版本行为不一致。解决:查询前固定延迟 300 到 500 毫秒,并写一个重试机制,首次查不到就在 150 毫秒后重试,最多三次。注意延迟不要弄太长,验证码等待界面通常只有几十秒的耐心。
6.3 权限被系统回收坑:长时间运行后突然失效
现象:设备跑了一晚上自动化,第二天短信监听失效,logcat 里出现 SecurityException。原因:部分新系统版本在应用长时间未被使用时自动回收敏感权限,尤其 READ_SMS,无障碍服务也有被系统关闭的情况。解决:做一个定时心跳检测,每小时检查一次权限和服务开关状态,发现关闭时发送通知提醒操作员。这不是一个完全自动化的方案,因为无障碍服务被关后应用无法自启,但至少能第一时间发现问题。
6.4 目标设备不同 ROM 的后台限制坑
现象:同一套程序在 A 型号手机上稳定运行,换到 B 品牌手机上完全不触发。原因:国产 ROM 普遍有后台清理策略,无障碍服务进程被冻结或杀死后不再接收通知事件。解决:引导用户在系统设置里把应用加入电池优化白名单,同时保活策略上只保留一个前台服务通知,用最小化通知把进程优先级抬高。注意不要引导用户关闭整个系统的省电策略,这会影响测试环境真实性。绕不开的话,可以先在目标设备上跑一个 24 小时稳定性验证再全量铺开。
7. 进阶技巧:把「能拿到码」变成「拿得难缠又快」,并守住合规边界
做到这里,一个可用的验证码获取服务已经能稳定工作。但距离真正投入自动化链路还有最后一步:把获取验证码的渠道做成「限时等待」的同步接口,而不是让测试脚本去轮询缓存。我通常的做法是在应用内起一个轻量本地服务,请求参数带上会话标识,服务内部阻塞等待验证码缓存被写入或被超时中断。伪代码逻辑如下。
class SmsCodeWaiter(private val store: SmsCodeStore) { fun await(sessionId: String, timeoutMs: Long = 30000): String { val start = System.currentTimeMillis() while (System.currentTimeMillis() - start < timeoutMs) { val code = store.consume(sessionId) if (code != null) return code Thread.sleep(200) } throw TimeoutException("验证码等待超时,请检查短信通道是否正常") } }这段代码的思路是把验证码缓存从「写一次就丢」改成「按会话取一次」,自动化脚本传入自己的会话标识,拿到的验证码天然与当前测试用例绑定,不会出现多个用例抢同一条验证码的问题。接口超时时间建议 30 到 45 秒,短于验证码有效期,也足够覆盖短信到达的时间波动。
最后必须说清楚边界。验证码获取能力的本质是用户在自己的设备上授权应用辅助处理短信数据,做自动化测试、设备自检、回归验证都在合理范围内。但如果把这套能力用于批量获取他人短信、恶意注册、绕过身份验证或撞库,性质就完全不同了,Android 系统对 SMS 权限的持续收紧也正是在封堵这条路的滥用。我自己的习惯是:只在测试机、自有样本设备和明确授权的内部工具链上跑这套逻辑,拿到数据不下发到任何外部服务,日志里不做明文敏感信息留存。守住这条线,技术方案才谈得上长期稳定——毕竟权限审核、系统更新和应用商店政策,才是这条链路真正的暗礁。希望帮到你。
本文还有配套的精品资源,点击获取