
验证码这种玩意平时看着不起眼真到做项目、调试接口、跑自动化测试的时候就成了卡脖子的东西。尤其做 Android 开发App 里收验证码、填验证码、验验证码一套流程下来手机上短信箱早就被各种通知淹没了。想找一条十分钟前的验证码翻半天还不一定找得到更别提一边在电脑上改代码、一边还要拿起手机看短信这种操作有多打断节奏。我之前在做一个内部测试用的跨平台项目时就深受其害。每次联调都要跟测试同学在群里互相喊“验证码发了吗是多少”效率低得离谱。后来我干脆在 Android 端做了一个“专门的验证码表”把接收、解析、存储、展示、复制这一整条链路打通用起来是真的爽。这篇文章就把我从零到一实现这个验证码表的完整思路、核心代码、踩坑记录都捋一遍给有同样需求的朋友做个参考。1. 内容整体设计与思路拆解1.1 核心需求到底什么场景需要“验证码表”先说清楚这个“验证码表”不是给普通用户做短信验证码收集的而是面向开发者、测试人员、以及需要频繁处理验证码的场景。我当时的真实需求有几点能实时接收 App 注册、登录、找回密码等流程下发的短信验证码。自动从短信内容里把纯数字或字母数字混合的验证码提取出来。生成一张结构化的表格按时间倒序排列一眼能看到最近收到的验证码。点击某条记录就能复制验证码到剪贴板省得手动记。历史记录要能存下来测试环境经常同一手机号反复收码翻旧账是常事。如果你也在做账号体系联调、爬虫验证、或者自动化测试脚本这个表就是个超级好用的辅助工具。它跟普通短信 App 最大的区别在于普通短信 App 是“读消息”这个表是“提取关键信息并管理关键信息”。有些人可能会问直接用系统短信不好吗一是短信列表被各种营销信息污染二是验证码和上下文文案混在一起跟接口联调时根本没法快速定位。专门做一张表等于是给验证码单独建了个索引效率完全不在一个量级。1.2 方案选型为什么用广播接收器 SQLite RecyclerView这个项目的技术选型我前后对比了好几条路。第一版我想得非常简单直接用系统自带的短信数据库读取telephony的content://sms然后筛选。但问题很明显从 Android 4.4 开始短信权限就收紧了普通应用读取短信数据库需要READ_SMS权限而且在高版本系统上这种常驻读取行为很容易被系统标记为“滥用权限”而且每次读数据库都慢不符合“实时”的需求。第二版我考虑过用第三方云平台做短信转发比如把验证码转发到服务端再回调。但这引入了网络依赖而且我本来就是要做一个纯客户端小工具没必要绕这么大一圈。最终敲定的方案是监听系统短信广播android.provider.Telephony.SMS_RECEIVED收到短信后立即在广播里解析。用 SQLite 做本地存储记录验证码、发送方号码、时间、短信原文。用 RecyclerView 做列表展示外加一个搜索框和复制按钮。用广播或 LiveData 通知界面刷新。这个方案的好处在于响应实时短信到了就是到了App 在后台也能立刻处理。不依赖网络纯本地逻辑数据安全也好控制。结构清晰后续想加筛选、加导出都很容易。一句话总结本地监听 本地存储 列表展示就是验证码表最务实的架构。1.3 权限与合规这块的坑必须提前说开发这类工具最容易翻车的就是权限和合规。下面这些点建议你开工前就理清RECEIVE_SMS是危险权限必须动态申请。Android 6.0 之后不动态申请直接崩溃没有任何商量余地。READ_SMS权限不是必选的但如果你要读取手机里已有的历史短信来补录验证码那就需要。我实际做的时候果断放弃了历史短信补录只处理“从今往后”收到的验证码权限只申请RECEIVE_SMS省了一大堆隐私合规问题。如果你的 App 是面向普通用户上架应用商店的短信权限是敏感权限用途说明必须写清楚而且绝大多数应用商店尤其是海外渠道对短信权限的审核极其严格。我这个表是内部测试工具不属于公开分发场景所以走得通。如果目标设备是 Android 13 及以上部分厂商对短信广播的限制会更狠实测下来有些定制 ROM 需要手动授予“常驻通知”或“后台运行”权限否则收不到广播。另一个必须拎清楚的点这个工具不能做成恶意收集用户短信的“短信窃听器”。我自己的使用场景就是测试手机 测试号码所有验证码都是自己触发的本质上像个“测试辅助仪器”。做工具可以做黑产就是另一回事了这个度得把握好。2. 核心细节解析与实操要点2.1 短信广播接收器不是光注册就能收到实现实时接收的关键就是写一个继承自BroadcastReceiver的类然后在AndroidManifest.xml里声明接收SMS_RECEIVED广播。广播接收器的基础代码长这样public class SmsReceiver extends BroadcastReceiver { Override public void onReceive(Context context, Intent intent) { if (intent.getAction() ! null intent.getAction().equals(Telephony.Sms.Intents.SMS_RECEIVED_ACTION)) { // 处理收到的短信 } } }需要注意的细节非常多广播是不允许做耗时操作的比如网络请求、复杂数据库写入。我实测下来广播里直接批量写入 SQLite 偶尔会 ANR尤其短信密集到达的时候。正确做法是广播里只做解析和数据的轻量转发比如发个LocalBroadcastManager或者LiveData给后台线程去落库。多个短信会被塞在同一个 Intent 里要用Telephony.Sms.Intents.getMessagesFromIntent(intent)解析出来不然一条多段短信只取到了第一段。有些定制 ROM 对SMS_RECEIVED广播做了限制尤其是电池优化、后台限制方面。我测试用的某国产手机上第一次装上跑得好好的过两天就没反应了后来才发现是省电策略把应用的后台广播给掐了。针对低版本和高版本还有一个细节差异Android 8.0 之后静态注册的广播接收器对隐式广播有限制但SMS_RECEIVED是例外它仍然允许静态注册。所以这块不用额外加setComponent之类的操作。2.2 验证码解析正则只是入门异常情况才是大头验证码提取很多人第一反应就是写个\d{4,6}正则完事。实际做下来会发现这玩意儿的坑比想象中多得多。我整理常见的验证码短信格式大概有这么几类纯数字验证码如“您的验证码是123456请勿泄露。”数字字母混合如“验证码 Ab3fG9 有效期为5分钟。”带标点分割如“验证码123-456请输入。”英文场景如“Your verification code is 123456. Do not share it.”特殊平台格式如“【某公司】验证码为5678工作人员不会索要。”所以正则不能只写一个死板模板而是要分层处理。经验做法是先用关键词定位验证码附近的位置比如“验证码”“code”“کد波斯语虽然少见但国际化要考虑”。在关键词附近截取一段文本比如关键词后 20 个字符以内。对截取出来的文本去掉常见噪音符号-、空格、冒号、中文全角符号。提取连续的数字或数字字母串优先匹配较长的那一段。如果还是提取不到回退到全文匹配 4~8 位纯数字或 6 位数字字母组合。我这边的解析代码大概长这样public class CodeParser { private static final Pattern PATTERN_DIGIT Pattern.compile((\\d{4,8})); private static final Pattern PATTERN_ALNUM Pattern.compile(([A-Za-z0-9]{4,8})); public static String parse(String message) { if (message null || message.isEmpty()) { return ; } // 第一优先验证码等关键词附近捕获 String[] keywords {验证码, 校验码, verification code, code}; for (String keyword : keywords) { int idx message.toLowerCase().indexOf(keyword.toLowerCase()); if (idx 0) { String window message.substring(idx, Math.min(message.length(), idx 30)); String cleaned window.replaceAll([-\\s:,。;], ); Matcher m PATTERN_ALNUM.matcher(cleaned); if (m.find()) { String candidate m.group(); // 过滤掉明显不是验证码的长串比如链接 if (candidate.length() 4 candidate.length() 8) { return candidate; } } } } // 第二优先全文提取数字 Matcher m PATTERN_DIGIT.matcher(message); if (m.find()) { return m.group(); } return ; } }这里有几个坑我实际填了很久链接干扰短信里经常会带“点击链接 http://xxx”。链接里的数字如果正好是四到八位容易被误提取。我最后的处理办法是在解析前先剔除 URL把http://、https://开头的连续非空格字符整体拿掉。验证码里带横杠以前遇到过“123-456”这种直接把横杠去掉再匹配稳妥。中英文标点混用中文短信里经常出现全角冒号、全角逗号建议replaceAll([\\p{Punct}\\s], )把标点全清掉再提取。语音验证码这个是硬伤语音验证码收不到短信广播无解。后来我实测发现某些平台的语音验证码其实也会附带一条文本短信所以这种场景不强求。2.3 数据存储SQLite 的选型和表结构设计验证码表的核心存储我没有引入 Room 或者 GreenDAO 这类重量级 ORM直接用原生 SQLite。原因很简单这个表就一张表数据量撑死几千条功能就插入、查询、删除用 ORM 属于杀鸡用牛刀而且会引入一堆依赖。我的表结构设计如下CREATE TABLE verify_code ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL, phone TEXT, message TEXT, received_time INTEGER NOT NULL );code解析出来的验证码必须非空。phone发送方号码有可能是营销号段也可能是服务号码。message短信原文留着方便回看上下文。received_time接收时间戳毫秒为单位排序和展示都用它。加索引的话我会在received_time上建个索引因为主查询就是按时间倒序排列CREATE INDEX idx_time ON verify_code(received_time);数据库操作这里我直接用SQLiteOpenHelper管理简单可靠。需要强调的是数据库的写操作不能在主线程执行。我封装了一个CodeRepository内部用单线程的ExecutorService串行处理所有写操作避免并发写库导致SQLiteDatabaseLockedException。public class CodeRepository { private final ExecutorService executor Executors.newSingleThreadExecutor(); public void insert(VerifyCode code) { executor.execute(() - { // 执行 insert }); } }有的人可能觉得这不就是个数据库吗有啥好说的。但这个工具的实际体验恰恰就取决于数据库操作是否流畅。短信广播密集时每秒可能进来好几条如果全走主线程界面直接卡死连验证码都看不到了。2.4 UI 展示这张表要长成什么样UI 我做得比较朴素核心就是一个列表页。列表项展示三样东西第一行验证码 复制按钮。第二行发送方号码。第三行接收时间。具体布局就是一个RecyclerView每一条 item 都是 CardView 包裹的线性布局。复制按钮单独放在右侧点击时把验证码写入剪贴板。RecyclerView 的 adapter 我用了ListAdapter配合DiffUtil这样数据更新之后能高效地增量刷新避免整个列表闪烁重绘。数据源我用的是LiveDataListVerifyCode仓库里返回的查询结果直接扔给 UI 层观察简单且不用自己管生命周期。界面上还加了两个实用功能搜索框根据验证码、号码、短信原文做模糊搜索用LIKE查询实现。联调的时候想找某一次测试的验证码输个号码前缀就出来了。清空按钮一键清空历史记录。测试时经常刷出几十条垃圾记录一键清空比一条条滑删方便太多。颜色和动效我没做过多的设计毕竟这是工具性页面不是产品首页。唯一做的细节是验证码文字字号调大加粗方便从远处瞄一眼就知道是什么。复制成功之后弹个 Toast或者按钮上短暂显示个“已复制”状态都行。3. 实操过程与核心环节实现3.1 环境准备与工程结构我用的开发环境是 Android Studio语言用的是 Java。如果你更习惯 Kotlin实现逻辑完全一样把BroadcastReceiver、RecyclerView换成 Kotlin 写法即可核心思路没有区别。工程结构大概是这样的app/src/main/java/com/example/codetable/ ├── receiver/ │ └── SmsReceiver.java ├── db/ │ ├── CodeDbHelper.java │ └── VerifyCode.java ├── repository/ │ └── CodeRepository.java ├── ui/ │ ├── MainActivity.java │ ├── CodeListAdapter.java │ └── activity_main.xml这里要注意包名不要照抄我示例里的com.example正式工具一定要换成自己的包名否则打包签名的时候会各种报错。3.2 权限声明与动态申请Manifest 里要加这几条权限和 receiver 声明uses-permission android:nameandroid.permission.RECEIVE_SMS / uses-permission android:nameandroid.permission.READ_SMS / application ... receiver android:name.receiver.SmsReceiver android:exportedtrue android:permissionandroid.permission.BROADCAST_SMS intent-filter android:priority1000 action android:nameandroid.provider.Telephony.SMS_RECEIVED / /intent-filter /receiver /application细节说明一下android:exportedtrue是必需的因为系统要能把广播发进来。android:permissionandroid.permission.BROADCAST_SMS这行是保护这个 receiver 不被其他应用乱触发加上更安全。android:priority1000是提高接收优先级。如果手机里同时装了好几个短信拦截 App优先级高的会先拿到广播。我实测下来优先级太重要了某些安全软件会抢先消费掉这个广播导致我的工具收不到。动态申请权限的代码我也顺便贴下标准写法if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECEIVE_SMS) ! PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.RECEIVE_SMS}, REQUEST_CODE_SMS); }记得要在onRequestPermissionsResult里处理拒绝的情况不能让 App 无声无息地废掉。用户拒绝后最好弹个设置引导不然很多人根本不知道去哪开权限。3.3 短信广播接收与入库串联整个数据流的串联我在SmsReceiver里做的是public class SmsReceiver extends BroadcastReceiver { Override public void onReceive(Context context, Intent intent) { if (!Telephony.Sms.Intents.SMS_RECEIVED_ACTION.equals(intent.getAction())) { return; } Serializable messages intent.getSerializableExtra(pdus); // 这里实际要用 Telephony.Sms.Intents.getMessagesFromIntent(intent) SmsMessage[] smsMessages Telephony.Sms.Intents.getMessagesFromIntent(intent); for (SmsMessage sms : smsMessages) { if (sms null) continue; String message sms.getMessageBody(); String address sms.getOriginatingAddress(); String code CodeParser.parse(message); if (!TextUtils.isEmpty(code)) { // 排队写库 CodeRepository.getInstance().insert( new VerifyCode(code, address, message, System.currentTimeMillis())); } } } }这里有一个我正在用的辅助工具类封装了数据库的插入public class CodeRepository { private static CodeRepository instance; private final CodeDbHelper dbHelper; private final ExecutorService executor Executors.newSingleThreadExecutor(); private CodeRepository(Context context) { dbHelper new CodeDbHelper(context.getApplicationContext()); } public static synchronized CodeRepository getInstance(Context context) { if (instance null) { instance new CodeRepository(context); } return instance; } public void insert(VerifyCode code) { executor.execute(() - { SQLiteDatabase db dbHelper.getWritableDatabase(); ContentValues values new ContentValues(); values.put(code, code.getCode()); values.put(phone, code.getPhone()); values.put(message, code.getMessage()); values.put(received_time, code.getReceivedTime()); db.insert(verify_code, null, values); }); } public LiveDataListVerifyCode getAllCodes() { // 用 MutableLiveData 查询线程 } }任何首次接触的人可能都会问为什么用单线程 Executor因为 SQLite 本身只允许一个写连接而广播接收可能短时间内到达多个onReceive如果同时开启多线程写库很容易出现database is locked。单线程串行写入虽然牺牲了一丁点并发性能但换来的是稳定性这笔账怎么算都划算。3.4 列表展示与复制交互MainActivity 里搭界面属于常规操作但有几个细节我反复打磨过。RecyclerView 的 item 布局我设计成横向两段式左侧权重 1显示验证码、号码、时间。右侧固定宽度放一个带图标文字的“复制”按钮。复制功能核心代码ClipboardManager clipboard (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); ClipData clip ClipData.newPlainText(verify_code, item.getCode()); clipboard.setPrimaryClip(clip);高版本 Android 上复制之后系统会弹“已复制”的提示部分机型不需要自己再弹 Toast 了。但我实测下来小米和华为的定制系统提示不太一样为了统一我还是自己弹了个轻量 Toast确保用户一定能看到反馈。还有个小细节列表时间显示要人性化。存的是毫秒时间戳但直接显示成一串数字没人看得懂。我做了个转换private String formatTime(long timestamp) { SimpleDateFormat sdf new SimpleDateFormat(MM-dd HH:mm:ss, Locale.getDefault()); return sdf.format(new Date(timestamp)); }如果时间跨度超过一天就显示日期如果只是今天可以加个“今天”前缀。这种细节对工具类应用的使用频率影响很大。看起来无所谓实际上你每次都翻昨天的情况没有格式化时间会觉得非常痛苦。3.5 手动补录一个看似多余但救命的功能这个功能是后加的但实测下来帮了大忙。有些验证码短信是 App 内部自定义通道下发的不经过系统短信还有的验证码是语音播报的永远进不了短信广播。针对这些漏网之鱼我在主界面塞了一个“手动添加”的入口弹个对话框让你手填验证码和备注然后插进表里。实现其实就是一个普通的AlertDialog 编辑框 insert()但价值在于这表里的数据完整性。测试的时候如果某个码是从别处拿到的手动补录进去之后整个测试流程的记录就齐全了。表格不再只是“短信验证码表”而是一个“全渠道验证码管理表”。4. 常见问题与排查技巧实录4.1 收不到验证码广播怎么办这是最常遇到的问题也是第一批反馈里最多的。我把实测过的排查步骤列一下确认权限已授权不是只点过一次就以为成功了。很多定制系统会在授权后再次弹窗问“是否允许后台接收短信”必须确认。确认系统没有把 App 加入电池优化白名单。在设置里搜索“电池优化”把应用设置为“不优化”否则系统深度休眠时广播根本发不进来。确认没有其他应用抢先拦截短信。尤其手机自带的安全管家或者装有短信拦截工具会吃掉广播导致优先级低的接收器拿不到。我实测android:priority1000能解决大多数抢广播的问题但不是 100%。重启手机后有些定制系统的广播恢复需要一段时间或者根本不恢复这时候需要手动打开 App 一次让进程活跃起来。如果把这几项都排查完了还是收不到大概率是定制系统限制了第三方应用的短信广播。终极保底方案是在 App 内提供一个手动刷新按钮动态读取系统短信数据库需要READ_SMS权限把最近五分钟内的验证码补录到表里。这样就算广播被掐了手动刷新也能兜底。4.2 验证码解析不准、漏码或错码解析不准的原因我总结成三种短信里有链接、有数字账户余额等信息正则被长数字串干扰。处理方案先剥离 URL 再解析。多段短信拼接时getMessagesFromIntent返回的数组需要逐条拼接而不是只取第一条。很多多段长短信就是把验证码放在最后一段只取第一条自然永远解析不到。验证码是纯英文字母的组合比如“Your code is aBcDeF”。纯数字正则根本不匹配要加字母数字混合正则。我还遇到过一种比较隐蔽的情况某银行发的验证码格式是“您正在申请开通服务验证码为 8888如非本人操作请忽略。退款验证码也请勿泄露”。这里有两个“码”字我的正则如果优先定位第一个关键词可能取到“为 8888”没问题但如果短信里既有订单号又有验证码就会选错。解决办法是多关键词匹配时取最后一个出现的验证码关键词因为通常验证码都在句尾强调。4.3 列表卡顿、数据更新不及时列表卡顿一般不是 RecyclerView 的问题而是每次短信过来都调notifyDataSetChanged()导致整个列表重绘。我用ListAdapterDiffUtil后就再没遇到这个问题。数据更新不及时的表现是短信已经进来了但页面没弹出新数据。这是因为我早期用的是在onReceive里直接更新 UI但广播回调不一定在主线程而且 Activity 可能没在前台。后来我把查询结果改成LiveData在仓库里操作完数据库后通过LiveData.postValue()通知 UI这样无论 App 在前台后台、屏幕亮灭数据一到都能正确刷新。postValue和setValue还有个区别值得说setValue只能在主线程调用而postValue可以从后台线程调用。仓库里的线程池写库完成后用postValue是标准做法。4.4 数据库越来越大的清理策略历史验证码看着是小事但天长日久也会膨胀。我加了两条清理策略默认只保留最近 30 天的数据启动时或者插入数据时检查一次删除超过 30 天的旧记录。表里加了个开关打开“自动清理”后每次插入新记录都会顺手删掉最老的 N 条保证表里最多只留 500 条。删除的 SQL 很简单DELETE FROM verify_code WHERE received_time ?这个一个定时逻辑就够插一条删一批数据量完全可控。对 UI 来说列表最多 500 条滑动也很流畅。4.5 高版本 Android 的适配细节现在新手机基本都是 Android 13、Android 14 了有几个适配问题必须提前注意Runtime Permission弹窗在部分系统上只能弹一次拒绝后不再自动弹出。第二次要引导用户去“应用设置”里开权限我加了个跳转到Settings.ACTION_APPLICATION_DETAILS_SETTINGS的入口。Android 13 起部分设备对RECEIVE_SMS权限增加了“不常用应用”的自动回收机制。如果 App 长时间不打开权限会被自动撤掉。工具类 App 天天都要用问题不大但如果偶尔才用一次要注意重新授权。Android 12 之后系统会限制后台应用的某些操作但短信广播本身不受影响反而是一些厂商的省电策略影响最大。这也是我为什么要单独把“电池优化白名单”写进排查清单的原因。如果你的编译目标版本是 Android 14SmsMessage.getOriginatingAddress()依旧可用没有废弃放心用。5. 从“能用”到“好用”的几个增益改造基础版做完之后我用了一周左右又陆续加了几个小功能使用体验上了一个台阶。第一个是验证码关联上下文。很多验证码是跟业务场景绑定的光看到一串数字不知道是哪次操作触发的。我在短信原文解析时把关键词上下文也存进表里比如“您正在【某平台】进行登录操作验证码为 123456”那我提取验证码的同时也把“某平台登录”提取出来放到备注字段。这样表格上第一眼看到的就不只是验证码还有场景提示效率加成非常明显。第二个是验证码一键填入。这个功能在调试 App 内表单时极其好用。点击列表项不仅复制验证码到剪贴板还自动尝试填到当前输入框如果当前 App 有悬浮窗权限的话。但悬浮窗功能的权限申请又是一堆麻烦事后来我保留的是“复制后自动打开目标测试 App”的配置配合剪贴板读取实现“复制即填入”的近似体验。第三个是导出 CSV。联调结束后需要跟后端同学对账或者把测试记录贴到缺陷单里直接把 CSV 文件导出到本地就不用手动截图拼表格了。导出的列正好是验证码、号码、时间、原文跟缺陷单里要贴的信息完全对得上。这三个功能单独看都不复杂但合在一起这个验证码表就从一个“查看器”变成了一个“工作台”。我后来的工作流变成了手机开启悬浮窗小表在电脑上发起一条测试短信手机弹出新验证码我拿起手机点一下验证码就进剪贴板了再回到电脑粘贴。整个过程不超过三秒效率提升立竿见影。最后再聊个细节。很多人做这类工具做完了自己用几天就丢一边了。但我建议你把解析正则、权限适配这几块单独做成配置项放到一个本地配置文件里因为不同测试环境的短信格式真的千差万别。今天这个平台验证码是六位数字明天那个平台就是四位的今天关键词是“验证码”明天英文场景是“dont share it with anyone”。把这些做成可配置项之后团队里其他人拿去用不用改代码、不用重新打包改完配置重启就生效运维成本趋近于零。我做这个小工具最大的体会是验证码看起来是件小事但它是账号链路里最常被打断的环节。把这条链路单独抽出来做成一张清晰的表不光是省了翻短信的时间整个联调节奏都会顺畅很多。如果你的测试环境也经常被验证码困扰不妨按这个思路搞一个保证你再也不想回到满屏短信里扒验证码的日子。