
先说明一下这个标题里的“验证码表”我按自己的理解解释成做一个管理短信验证码的本地工具不是字面意义的“验证码表格”或者图形验证码破解之类的偏门东西。简单说就是把系统短信里收到的验证码自动抓出来按时间排列好点击就能一键复制过期自动清理。这个东西在收快递、接外卖、登录各种账号的时候非常有用尤其是短信经常被系统通知淹没的时候有一个只装验证码的“小盒子”体验会清爽很多。我最近就做了一个这样的 Android 应用从监听短信到入库、展示、复制、清理整套流程都走了一遍。下面把设计思路、核心代码、关键坑位全部整理出来给准备自己动手做类似工具的朋友一个能直接抄作业的参考。1. 项目整体设计与思路拆解1.1 为什么选择“短信观察者 本地数据库”的方案做验证码管理工具首先要解决一个核心问题怎么拿到验证码。方向大致有三个。第一个方向是使用系统开放的通知权限通过监听通知栏里的短信内容拿验证码。好处是不需要申请短信读取权限在部分国产系统上限制相对宽松但坏处是通知栏内容可能被系统裁剪、折叠而且用户如果手动清了通知、或者短信App关闭了通知通道数据就会丢失。第二个方向是直接读系统短信数据库。Android 一直提供content://sms这种访问入口配合ContentObserver可以实时监听短信入库。这个方案稳定性最好能拿到完整短信内容和发送时间而且不限手机品牌Android 5.0 到 Android 13 基本都能跑通所以最终我选了这条路。第三个方向是接入某些平台提供的聚合短信解析服务这适合商用项目个人工具完全没必要成本也高。数据存储方面没有直接操作系统短信数据库而是把解析出来的验证码存进自己应用的 SQLite 数据库里。原因很现实直接改系统短信表风险大、容易触发系统保护机制而且不同厂商对系统短信库的读写限制天差地别。自己建表数据安全可控后续做“已复制”“忽略”这些状态标记也很方便。1.2 功能的取舍与优先级排序一开始我设想的验证码表功能特别多包括按 App 分类、自动填充到其他应用、短信原文搜索、验证码过期提醒等等。后来实际动手才发现MVP 版本只要做好三件事就能解决 80% 的痛点。第一是自动识别。短信来了自动识别出里面的验证码内容。第二是一键复制。点击列表里的任意一条验证码直接进入剪贴板不用长按、不用手动选。第三是过期清理。把超过 10 分钟的验证码自动进入“过期”状态超过 24 小时的直接删掉。这三个核心功能对应了三个核心模块短信监听模块、解析入库模块、UI 展示模块。我把架构拆成这三块之后整个项目的复杂度一下子就降下来了调试的时候也方便很多——短信收不到先查监听验证码全是垃圾字符先查解析列表不刷新先查数据库回调。1.3 适用场景与版本兼容范围这个东西适合下面几类人经常收验证码但短信 App 里各种消息混在一起的人想练手 Android 系统服务、ContentObserver、数据库操作的新手开发者只需要一个很小的工具、不想装全家桶应用的人。版本兼容方面我实测的最低版本是 Android 6.0最高测试到 Android 13。Android 14 上由于回收站和短信权限策略更严格还没做完整适配个人用的话影响不大。不过有一点需要提前有心理准备新版本 Android 对短信读取权限的收紧趋势是非常明显的个人自用没问题如果想发布到市场审核这一关基本过不了所以这个项目更合理的定位是“自用工具 技术练手”。2. 核心细节解析与实操要点2.1 权限申请Android 6.0 动态权限和特殊权限这个是第一个坑。READ_SMS和RECEIVE_SMS都是危险权限Android 6.0 之后必须在运行时动态申请光在 AndroidManifest.xml 里声明远远不够。我写的权限声明长这样uses-permission android:nameandroid.permission.READ_SMS / uses-permission android:nameandroid.permission.RECEIVE_SMS / uses-permission android:nameandroid.permission.POST_NOTIFICATIONS /运行时申请的部分注意POST_NOTIFICATIONS是 Android 13 才有的通知权限它跟前两个不是同一批最好分开判断if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { requestPermissions(arrayOf( Manifest.permission.READ_SMS, Manifest.permission.RECEIVE_SMS, Manifest.permission.POST_NOTIFICATIONS ), REQUEST_CODE) } else { requestPermissions(arrayOf( Manifest.permission.READ_SMS, Manifest.permission.RECEIVE_SMS ), REQUEST_CODE) }为什么在这里要申请RECEIVE_SMS因为只靠ContentObserver监听短信插入其实不一定需要这个权限但有些国产 ROM 在省电策略里会杀掉后台进程导致观察者失效。申请了RECEIVE_SMS之后再用广播接收器做一次双保险唤醒可以大幅提高消息到达率。这个属于典型的“Android 碎片化实操经验”在官方文档里是学不到的。2.2 核心关键点ContentObserver 监听短信入库监听短信的核心原理是系统短信库在收到新短信时会对content://sms下的数据做插入操作此时通过注册一个观察者可以在onChange回调里得到通知。然后自己查询数据库把最后一条新短信取出来。具体实现分三步。第一步注册观察者contentResolver.registerContentObserver( Uri.parse(content://sms), true, smsObserver )第二步在onChange回调里做查询。这里有个重要的细节onChange会被调用得极其频繁系统短信库里不止“收件箱”一张表还有sent、draft、outbox这些所以查询时必须限定type 1即收件箱不然很容易把别的状态也当新短信处理了。第三步查询出最后一条新短信后做去重判断。方法很简单用一个内存里的HashSetString记录已经处理过的thread_id body date组合重复的直接丢弃。这个去重逻辑必须做否则同一条短信在某些 ROM 上会触发两次甚至三次onChange。2.3 SQLite 表单设计与验证码过期策略数据库表设计不太复杂但字段要提前想清楚。我建的表结构是这样CREATE TABLE IF NOT EXISTS sms_code ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL, source TEXT, body TEXT, timestamp LONG, status INTEGER DEFAULT 0 );code字段存识别出的验证码source存发送方号码body存短信原文timestamp存短信到达时间status用 0 表示有效、1 表示已过期、2 表示已复制。过期策略我没有用后台服务定时扫描而是用了“懒更新”思路。每次打开 App 或者收到新短信时顺手执行一条更新语句把所有timestamp超过当前时间 10 分钟的记录标记为过期val tenMinutesAgo System.currentTimeMillis() - 10 * 60 * 1000 db.execSQL( UPDATE sms_code SET status 1 WHERE timestamp ? AND status 0, arrayOf(tenMinutesAgo) )这种懒更新有两个好处。一是省电不需要常驻后台启动定时器二是在列表展示的时候数据永远是准确的不会出现“明明过期了还显示绿色可复制”的尴尬。超过 24 小时的记录在启动 App 时顺手清掉val oneDayAgo System.currentTimeMillis() - 24 * 60 * 60 * 1000 db.execSQL(DELETE FROM sms_code WHERE timestamp ?, arrayOf(oneDayAgo))提示清理动作不能太激进。有些验证码短信虽然没有标注“10分钟有效”但平台的重发机制比较慢用户可能过半个小时还想用旧验证码删太狠了体验反而差。我把“过期标灰”和“物理删除”分成了两个时间维度效果明显好很多。2.4 实现 UI 列表和数据刷新UI 方面用的常规套路是 RecyclerView ListAdapter配合数据库查询后回调刷新。列表要做的事情不多显示验证码内容、发送方、到达时间、过期状态点击任意位置复制验证码。为了让“过期”状态看起来直观我的 Item 布局里有两种样式。有效状态时背景是浅绿色验证码文字是深色加粗过期状态时背景变灰文字用浅灰色。同时右边的复制按钮也会变成“已过期”不可点击的禁用态。这里有一个实测很有价值的调整当验证码条数超过 5 条时自动把最上面的过期项折叠进“过期记录”分区。因为如果一直堆着历史验证码列表会越积越长反而起不到“秒找验证码”的作用。不过这个逻辑要在展示层做数据库不动保持原始数据的完整性。3. 实操过程与核心环节实现3.1 完整监听流程的代码实现下面把这个项目最核心的短信监听模块完整贴出来方便对照。这段代码我在三种不同品牌的机器上都测过兼容性算比较稳的。class SmsObserver( private val contentResolver: ContentResolver, private val callback: (SmsMessageData) - Unit ) : ContentObserver(null) { private val processedIds HashSetString() override fun onChange(selfChange: Boolean) { super.onChange(selfChange) val uri Uri.parse(content://sms/inbox) val cursor contentResolver.query( uri, arrayOf(_id, address, body, date), null, null, date DESC ) ?: return if (cursor.moveToFirst()) { val id cursor.getLong(0) val address cursor.getString(1) ?: val body cursor.getString(2) ?: val date cursor.getLong(3) val key $id|$date if (processedIds.contains(key)) { cursor.close() return } processedIds.add(key) if (processedIds.size 200) { val iterator processedIds.iterator() repeat(100) { if (iterator.hasNext()) { iterator.next() iterator.remove() } } } val code extractCode(body) if (!code.isEmpty()) { callback(SmsMessageData(id, address, body, date)) } } cursor.close() } }这段代码里有几个细节值得说明。processedIds这个 HashSet 如果不做容量控制长期运行会内存泄漏所以说满 200 条之后用迭代器往前移除一半保证集合上限。这是我在压力测试时发现的模拟连续收到 300 条验证码短信进程内存不升反降说明控制是有效的。extractCode(body)是验证码识别函数后面单开一节讲。还有一点坑踩得比较深moveToFirst()之后不能马上关闭 cursor必须先判断moveToFirst()的返回值。如果短信表是空的直接调用cursor.moveToFirst()会返回 false此时再访问字段会抛异常。代码里用?: return做保护就是防这个。3.2 验证码提取正则表达式的常用写法和坑验证码提取是整个工具里看起来简单其实最麻烦的部分。不同平台发验证码的格式五花八门比如“【某平台】验证码 3827565分钟内有效。”“382756 是您的登录验证码请勿泄露。”“Your verification code is 382756, valid for 10 minutes.”第一种方案是直接匹配 4 到 8 位纯数字val regex Regex(\\d{4,8}) val match regex.find(body) val code match?.value ?: 这种写法在大多数场景下都能用但误报率也不低。短信里如果同时出现“退订回TD”或者“如有疑问拨打10086”这段数字也可能被当成验证码。更麻烦的是有些验证码是拼音字母和数字的混合比如“AB12CD”这种情况下纯数字匹配直接失效。我用过一个改进方案优先匹配“验证码”等关键词附近的数字段。思路是先用关键词定位验证码文本区域再在该区域内做数字匹配。这样准确率提高不少fun extractCode(body: String): String { val keywordPattern Regex((验证码|校验码|动态码|login code|verification code)) val matchResult keywordPattern.find(body) return if (matchResult ! null) { val start (matchResult.range.first - 10).coerceAtLeast(0) val end (matchResult.range.last 20).coerceAtMost(body.length) val area body.substring(start, end) Regex(\\d{4,8}).find(area)?.value ?: } else { Regex(\\d{4,8}).find(body)?.value ?: } }这个方案在实测两千条短信语料中准确率在 89% 左右。常见的错误集中在“【某银行】您账号尾号 8888 登录验证码为 123456”这种关键词区域的数字匹配会优先命中账号尾号导致识别错误。后来我加了“跟在‘为、是、、 ’后面的数字优先”的权重规则准确率又往上提了三个百分点。提示不要追求 100% 识别率。一是现实情况确实做不到二是在关键场景下漏一条验证码的代价远大于多一条错误验证码如果识别结果不明确宁可展示短信原文也不要给一个错误验证码误导用户。3.3 SQLite 的轻量封装与踩坑这个项目我没有引入 Room因为表结构简单、字段固定手写 SQLiteOpenHelper 就够了。数据库封装的核心诉求有两个读写并发安全、避免锁表。Android 的 SQLite 默认是单写多读机制写操作会锁整个数据库文件。真正踩过一次坑当监听回调里快速连续写入多条验证码时如果每次都重新openDatabase会触发“database is locked”异常。解决办法是使用单例模式管理数据库实例应用启动时创建一次整个生命周期复用同一个实例class SmsDatabaseHelper(context: Context) : SQLiteOpenHelper(context, sms_code.db, null, DATABASE_VERSION) { companion object { const val DATABASE_VERSION 1 private const val DATABASE_NAME sms_code.db Volatile private var instance: SmsDatabaseHelper? null fun getInstance(context: Context): SmsDatabaseHelper instance ?: synchronized(this) { instance ?: SmsDatabaseHelper(context.applicationContext).also { instance it } } } override fun onCreate(db: SQLiteDatabase) { db.execSQL( CREATE TABLE IF NOT EXISTS sms_code ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL, source TEXT, body TEXT, timestamp LONG, status INTEGER DEFAULT 0 ) .trimIndent() ) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { // 简单处理版本升级即删表重建但有丢失数据的风险 db.execSQL(DROP TABLE IF EXISTS sms_code) onCreate(db) } }这里有个值得留意的点onUpgrade里直接删表重建很简单但用户升级版本的时候历史验证码数据会被清空。个人工具无所谓但长期迭代中可能会出现“升级一次丢一次数据”的体验问题。更稳妥的做法是迁移数据或加备份逻辑。不过因为这个工具定位是“验证码是即时性数据本来就不需要长期保留”删表重建在现有场景下也能接受我就没有过度设计。3.4 一键复制到剪贴板一键复制功能比较简单用系统剪贴板服务fun copyCodeToClipboard(context: Context, code: String) { val clipboard context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip ClipData.newPlainText(验证码, code) clipboard.setPrimaryClip(clip) }这段代码里有几个容易踩的细节。第一ClipData.newPlainText的第一个参数是 label这部分会显示在某些输入法的剪贴板服务里我这里写“验证码”方便自己识别。第二剪贴板写入不受危险权限限制但读取剪贴板在 Android 12 及以上会在后台弹出系统提示气泡所以整个工具只负责“写”剪贴板不要主动“读”。复制完顺手更新状态栏提示“已复制”并且把当前验证码条目的status更新为 2已复制这样用户能明显感知操作结果。Toast 文案尽量用“验证码 123456 已复制”而不是单纯的“复制成功”避免复制完还得回短信里核对的尴尬。4. 常见问题与排查技巧实录4.1 为什么手机收不到验证码这个问题分为几种情况。最常见的原因是国产 ROM 的后台清理策略过于激进ContentObserver注册后进程被杀自然无法监听。解决办法有三个方案叠加使用。第一在 App 设置里把本应用的“自启动”“后台运行”“省电策略”全部设为允许。第二申请RECEIVE_SMS权限并注册一个广播接收器接收android.provider.Telephony.SMS_RECEIVED广播收到广播后主动调一次onChange相当于手动唤醒。第三如果上面的方案都不生效就把 App 加入系统的内存清理白名单这个可以在代码里请求ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS引导用户开启。还有一个情况比较隐晦某些系统在短信到达时会先写入content://sms/inbox但紧接着又更新thread_id导致date字段变化触发多条onChange。这种通过去重逻辑可以解决但如果不加去重每次会入 2 到 3 条重复验证码。4.2 短信能收到但验证码全是错的或空的这种问题九成出在正则匹配上。我排查时先用调试模式把原始短信正文打出来逐条验证正则规则。实测经验是优先找“验证码”关键词附近的内容再匹配数字成功率最高如果原文没有“验证码”字样再退而求其次用全文本数字匹配。另一个特殊坑是验证码中间带有空格比如“验证码 3827 56”这种。我的处理方式是把识别区域内所有空格和换行符先移除再匹配val compactBody body.replace(Regex(\\s), ) val code Regex(\\d{4,8}).find(compactBody)?.value ?: 但如果短信内容本身就有“尾号 8888 验证码 1234”这种组合时空格压缩后依然会误匹配尾号。这个无解除非做语义判断对于个人工具来说性价比太低不值得做。4.3 Android 13 和 14 上的隐私风险Android 13 里读取短信的权限还在但通知栏权限已经必须单独申请否则无法在前台通知里提示验证码来了。Android 14 开始系统对短信权限的展示会更严格任何未获得用户长时间信任的 App访问短信库时都可能在系统日志里出现警告。之前已经说过这类工具如果上架商店被下架和拒审的概率非常高原因就是权限过于敏感。所以如果你计划做的是个人自用工具直接通过 ADB 安装或者在自己的机器上编译安装即可如果计划上架就不要在验证码读取这个方向上继续深入了可以考虑转型做“手动记录验证码”这种不碰短信权限的简化方案。4.4 调试技巧如何不消耗真实短信验证码来测试这个是自测阶段最实用的技巧。真实短信资源宝贵不能天天用真实平台发验证码来调试我的做法是自己往短信箱里塞模拟数据。用 ADB 命令模拟短信到达需要模拟器或者 root 过的测试机adb shell am broadcast -a android.provider.Telephony.SMS_RECEIVED \ --es sender 9527 \ --es body 【测试平台】您的验证码是 482915请在5分钟内输入。如果没有 root 或者模拟器也可以用下面的命令直接往短信数据库里插一条记录体验跟真短信到达几乎一样adb shell content insert \ --uri content://sms/inbox \ --bind address:s:9527 \ --bind body:s:验证码为 123456 \ --bind date:l:当前时间戳用date:l:填时间戳时记得换算好否则验证码会直接进入“过期”状态看起来像没识别成功一样。5. 多环境适配与后续扩展方向5.1 国产 ROM 的差异化适配经验这个项目是我第一次真正意义上深度触碰“Android 碎片化”的体验印象很深。同样的代码在某主流国产品牌旗舰机上一切正常换到另一家品牌的小屏机上后台进程两分钟后被杀。后来我排查发现问题出在“自启动权限”没有申请。在国产 ROM 上权限申请除了系统权限弹窗之外还有一系列“厂商自定义权限”。比如有的系统有“应用自启动管理”有的系统有“后台弹出界面权限”有的系统有“允许唤醒”的开关这些默认全是关闭的。真正可靠的适配办法不是写代码而是在应用首次启动时跳转一个操作引导页把需要手动打开的开关一步步展示给用户。这个引导页其实就是一个 WebView 或者 ViewPager上面写清楚每一步的操作路径。实测下来所有功能失效案例里九成以上都是开关没打开导致的后台杀死问题不是代码 bug。算是 Android 开发里最无奈但又最真实的一课。5.2 后续可以做的功能扩展做完 MVP 之后有几个方向可以考虑深化。第一个方向是验证码自动归类。根据短信发送方的号码前缀和短信正文特征把验证码分成“银行”“App”“网站”“营销通知”几个类别。这个需要做一些规则配置可以放到本地 JSON 里做灵活下发不用写死。第二个方向是失效验证码的自动识别增强。现在有些验证码短信会自带有效期比如“有效期为10分钟”。如果能在正则里解析出有效期并动态更新过期时间记录列表就更智能了。第三个方向是离线备份和恢复。虽然验证码本身是即时数据但把识别日志备份下来有助于统计分析比如“每天平均收到多少验证码”。直接导出 JSON 文件即可不需要复杂的云同步。第四个方向是桌面小组件Widget。验证码这种小体量、高频率使用的数据非常适合放到桌面小组件上用户不用打开 App在桌面就能看到最新验证码并点击复制。这也是下一步我打算做的功能。5.3 性能与电量的小细节做这种监听类应用电量和性能是用户最关心的点。这里分享三个经过实测的调优细节。第一是限制监听频率。ContentObserver的onChange会高频触发每收到一条短信就做一次全表查询。这个成本不高但在多个短信同时到达时会形成一个小的 IO 高峰。我的方案是做一个 500ms 的节流阀——当多条短信在短时间内连续到达时只执行最后一次查询避免重复查询。第二是查询字段要精简。查询时只取_id、address、body、date四个字段不要select *虽然短信表字段不算多但减少不必要的数据读取能明显降低 IO 耗时。第三是避免频繁刷新通知栏。只在验证码状态发生变化时才发通知比如“新验证码到达”发一条过期那条走静默清理不发第二遍通知。这样用户可以感知新消息又不会被频繁的过期通知打扰。6. 最终实操总结这个验证码表项目虽然体量不大但把 Android 系统服务、权限管理、数据库操作、正则建模、UI 设计、ROM 适配这些知识点串了一遍做下来收获很大。如果你也想做一个类似的工具我最后的建议是先把 MVP 跑通再逐步加功能不要一上来就想着做所有平台、所有场景的完美方案。核心链路其实就一条注册ContentObserver监听短信 → 正则提取验证码 → 写入 SQLite → RecyclerView 展示 → 点击复制 → 懒更新标记过期。把这一步跑顺了后续想加什么功能都在这个骨架上扩展不会伤筋动骨。在隐私和安全方面也提醒一句这类工具本质上是处理敏感信息的不应该用于任何绕过他人验证手段的场景。使用前优先考虑用户授权存储内容尽量本地化不上传任何短信数据。这是工具型的底线也是自己做开发时应该始终记住的原则。