简介面向安卓开发者的手机验证码获取演示项目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:accessibilityEventTypetypeNotificationStateChanged android:accessibilityFeedbackTypefeedbackGeneric android:canRetrieveWindowContenttrue android:notificationTimeout100 android:descriptionstring/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 权限的持续收紧也正是在封堵这条路的滥用。我自己的习惯是只在测试机、自有样本设备和明确授权的内部工具链上跑这套逻辑拿到数据不下发到任何外部服务日志里不做明文敏感信息留存。守住这条线技术方案才谈得上长期稳定——毕竟权限审核、系统更新和应用商店政策才是这条链路真正的暗礁。希望帮到你。本文还有配套的精品资源点击获取