ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android短信数据库深入解析:表结构、字段与查询实战

Android短信数据库深入解析:表结构、字段与查询实战 很多人第一次接触 Android 系统短信数据库其实是被一个问题勾过来的应用收到短信了怎么把里面的验证码自动填到输入框里或者是我想把自己发的短信导出来做个统计但短信应用里根本没有导出功能。等真正打开系统数据库一看发现根本不像想象中一张表存所有短信那么简单。这套表结构涉及会话聚合、状态标记、协议类型还有一堆容易踩的坑。这篇文章基于我自己折腾短信相关功能的一段经历把 Android 系统短信数据库从外到内完整拆一遍重点是让刚入门的同学能看懂表结构、搞清楚字段含义并且知道动手查询时最容易被哪些细节坑到。我默认你已经有 Android 开发基础知道 ContentProvider 的概念会用 adb 或者 Android Studio 连调试。如果你只是想写个 App 读写短信这篇同样适用因为无论你用哪种方式操作短信底层都是这张库表。文章所有 SQL 和数据示例来自 AOSP/常见国产业务机的实际结构部分细节在不同厂商 ROM 上略有差异我会特别标注。1. 短信库在系统里的位置与访问路径先把这个最基础也最关键的问题说清楚Android 的短信数据不是放在普通 App 能直接访问的路径下的而是由系统进程持有的 SQLite 数据库路径通常是/data/data/com.android.providers.telephony/databases/mmssms.db。注意这个路径在不同版本的 Android 上会有差异但数据库文件名基本都叫mmssms.db里面同时管理短信SMS和彩信MMS两张核心业务的数据。由于/data/data目录在非 root 条件下不可读日常开发压根不需要、也没办法直接打开这个文件正确姿势是通过系统暴露的 ContentProvider 接口来查。这个 Provider 的 authority 是com.android.providers.telephony短信的 URI 固定为content://sms会话的 URI 是content://sms/conversations已发送的 URI 是content://sms/sent草稿是content://sms/draft。你可以把 ContentProvider 理解成系统给外部开的一个受控窗口所有 App 只能通过窗口看数据不能直接凿墙进库房。// 通过 ContentResolver 查询收件箱里的短信按时间倒序 val uri Uri.parse(content://sms/inbox) val cursor contentResolver.query( uri, arrayOf(_id, address, body, date, read), null, null, date DESC ) cursor?.use { while (it.moveToNext()) { Log.d(SMS, 来自 ${it.getString(1)}: ${it.getString(2)}) } }上面这段代码是绝大多数短信读取功能的基础模板。我第一次写的时候以为查content://sms就能拿到所有短信实际测试发现这个 URI 默认返回的就是所有短信只是系统内部会按type字段区分收件箱、发件箱、草稿等。而content://sms/inbox这类子路径其实是 Provider 内部帮你加了type 1的过滤条件本质是一回事。这里有一个很多刚入门的人会忽略的点直接查content://sms拿到的_id不是连续的因为删除短信后_id不会回填。你需要按date排序而不是按_id排序来保证时间顺序正确。另外date字段存的是毫秒时间戳但有些老设备在迁移数据后会出现秒级时间戳这个坑我在后面专门讲。2. 核心表字段逐一拆解从消息本身到会话聚合短信数据库虽然包含多张表但 90% 的日常开发只需要关心sms、threads、canonical_addresses这三张。sms存每一条消息threads存会话摘要canonical_addresses存会话对应的联系人地址。我刚接触这套结构时最大的困惑就是为什么不能直接在一张表里把联系人和短信都查出来非要拆成三张表等你理解了一条短信属于一个会话一个会话对应一个人或一群人这个模型就明白拆表是为了避免联系人信息在每条短信里重复存储。sms表的核心字段大致如下字段名含义典型值/说明_id自增主键5, 6, 7... 注意删除后不连续thread_id会话 ID关联threads._id同一联系人发的短信 thread_id 相同address对方号码也可能存 12345678901 或带 86 前缀person联系人姓名已废弃老版本用现在一般查联系人表关联date时间戳毫秒1392312345000 对应 2014-02-14date_sent短信发送时间仅对发送成功的短信有意义read是否已读0 未读1 已读type消息类型1 收件2 发件3 草稿4 发送中5 发送失败6 队列中body短信正文纯文本彩信内容不在这里status状态码-1 默认0 接收完成64 待发送...locked是否锁定保护0 未锁1 锁定防批量删除error_code错误码发送失败时填充比如 0 表示无错误sub_idSIM 卡槽 ID双卡手机区分哪张卡收发-1 表示未知service_center短信中心号码一般是 8613800xxx 这样的号码creator创建来源包名某些 ROM 上才有用于区分来源应用threads表字段相对少但它决定会话列表的展示顺序和未读计数。核心字段有_id、date最近一条消息时间、message_count消息总数、read该会话是否有未读、recipient_ids关联联系人地址的 ID 串多人会话用逗号分隔、type会话类型普通单聊是 0群聊是 1、snippet会话预览文本。snippet 是系统自动取最近一条消息的 body 生成的查询会话列表时就靠它显示摘要不需要自己再去关联sms表。canonical_addresses表更加轻量就两个核心字段_id和address。这张表存的是去重后的号码地址threads.recipient_ids存的就是它的_id列表。多个人发垃圾短信轰炸时这个表会快速增长因为它按唯一号码去重。我把三张表的关系画成一句话sms.thread_id指向threads._idthreads.recipient_ids里的数字指向canonical_addresses._idcanonical_addresses.address才是你看到的电话号码。开发时常见的查某个号码的短信操作实际要分两步走先根据号码反查 canonical_addresses 拿到 ID再查 threads 找 thread_id最后去 sms 表过滤。系统提供了一些捷径 URI比如content://sms/threadID可以直接传 thread_id 查消息避免自己拼 SQL join。-- 查询一个号码对应的所有短信的等价逻辑 SELECT s.* FROM sms s JOIN threads t ON s.thread_id t._id WHERE t.recipient_ids ( SELECT _id FROM canonical_addresses WHERE address 10086 );3. type 与 status 字段决定消息流向和状态的关键sms.type是整个短信台最容易被误解的字段之一新手经常把它和短信协议里的消息类型混为一谈。这里说的 type 是数据库业务类型表示这条消息在用户视角里处于什么状态和status字段表示传输层的投递状态是两码事。我见过不少人把 type 和 status 一起查出来然后拿 type4 当发送失败处理结果全部判断错误因为 type4 是正在发送中真正失败是 type5。整理一下 type 的完整取值type 值含义备注0全部仅用于查询过滤不是实际存储值1收件箱INBOX最常见的短信2发件箱SENT已发送成功3草稿DRAFT用户编辑未发送4发送中OUTBOX客户端提交后还没收到确认5发送失败FAILED例如无信号、被拦截6待发送队列QUEUED系统排队等待发送status 字段的值来自 Android 官方SmsManager.STATUS_*系列常量比 type 复杂得多。常见的有STATUS_NONE-1未知、STATUS_COMPLETE0成功、STATUS_PENDING32待处理、STATUS_FAILED64失败。但在实际 ROM 上status 经常不准因为不同厂商对短信中心回执的处理逻辑不同。比如某个运营商的短信中心在超时后才返回失败回执系统可能已经把 type 改成 5 了status 还是 0。所以判断一条短信是否发送成功第一优先级看 type第二才是 status。还有一个小坑发送成功和发送失败的短信在数据库里可能同时存在多条记录。比如你连续发了 3 条第一条失败、后两条成功那么content://sms/sent会显示 3 条第 1 条也会出现在 sent 里content://sms/failed显示 1 条。查询时如果需要精确统计实际成功了几条必须同时用 type2 AND status0 过滤不能只看 sent URI。体验过双卡双待手机的人会注意到一个现象同一联系人的短信在系统短信应用里是合并在一个会话里的但数据库层面可能分布在不同的sub_id上。系统在查询时会自动合并但如果你自己做导出工具就要小心按 sub_id 分开存储否则同一会话下不同卡的消息导出来顺序会乱。sub_id这个字段在 Android 5.0 之后才稳定存在老库可能没有这个值查询时要做好空值兼容。4. 动手实践用 adb 和 SQLite 直接查看短信数据理论讲完接下来进入实操。最快速、最直观的学习方式不是写 App而是用 adb 连上手机直接查数据库。前提条件手机已开启开发者选项和 USB 调试如果设备是生产机且未 root那么/data/data/com.android.providers.telephony/databases/mmssms.db是读不到的但你可以用run-as com.android.providers.telephony在 debug 模式下尝试仅 debuggable 应用支持系统应用不一定允许。我自己的调试环境是模拟器或者已经 root 的测试机用 adb shell 进入后执行# 进入短信数据库目录 adb shell su cd /data/data/com.android.providers.telephony/databases ls -la mmssms.db # 使用 sqlite3 打开数据库 sqlite3 mmssms.db # 查看所有表 .tables # 查看 sms 表结构 .schema sms如果你不想 root还有一个间接办法ContentProvider 本身支持把查询结果以 JSON 格式输出。在 App 里用contentResolver.query查出来后转成 JSON 打印效果跟直接查库差不多还能避免权限问题。更简单的是用 Android Studio 的 App Inspection 工具连接设备后在 Database Inspector 里直接能看到 mmssms.db 的所有表和内容可视化程度高适合初学者观察字段变化。在模拟器上执行一段 SQL观察不同操作对数据的影响这是我认为最有效的入门方法-- 查看最近 20 条短信 SELECT _id, thread_id, address, date, type, read, substr(body, 1, 20) AS body_preview FROM sms ORDER BY date DESC LIMIT 20;第一次在真实设备上跑这个查询时你可能会发现返回的date值大得离谱比如1728034567123。这个数字除以 1000 才是 Unix 秒。很多人在这一步被坑拿这个值直接丢给格式化函数得到的是 1970 年的时间戳。正确的转换方式是除以 1000 转成秒再格式化或者直接用java.text.SimpleDateFormat处理毫秒值。实验完别急着拔线再试一个联动查询把sms、threads、canonical_addresses三张表 join 起来看看每条消息对应的会话和联系人地址是怎么关联的SELECT s._id, s.address, c.address AS canonical_address, t.recipient_ids, t.message_count, t.snippet FROM sms s LEFT JOIN threads t ON s.thread_id t._id LEFT JOIN canonical_addresses c ON c._id t.recipient_ids LIMIT 10;注意这个 join 写法在实际生产中是有问题的因为recipient_ids在群聊里是逗号分隔的多个 ID不能直接相等关联。这里只是为了让你直观看到三者的对应关系真正开发时要用content://sms/threadID这类专门接口。想深入了解的同学可以在测试机上发送一条彩信再回去看看 mms 相关的表pdu、part、addr等短信和彩信虽然共用一个 db但表结构差异很大。5. 写入与更新短信的合理姿势含权限现状说明读完上面的内容你可能会想既然我理解了表结构能不能自己直接往sms表插入一条短信答案是可以但不是用一个简单的insert就能搞定而且现在的 Android 版本对这块的权限收得越来越紧。老版本 AndroidAPI 19可以用SmsManager或直接往content://sms插入伪造的接收短信。新版系统加入了SMS_READ权限检测第三方 App 默认没有读取短信的权限插入数据也会抛SecurityException。如果想做短信备份恢复类的应用必须申请READ_SMS并让用户主动授权很多手机厂商还会在权限页二次弹窗警告。如果只是往发件箱里记录一条自己发出的消息可以这么做模拟短信发送成功的记录val values ContentValues().apply { put(address, 10086) put(body, 测试短信) put(date, System.currentTimeMillis()) put(type, 2) // 已发送 put(read, 1) put(status, 0) } contentResolver.insert(Uri.parse(content://sms/sent), values)这段代码在 Android 8 之前的老设备上可能有效但在新设备上大概率没效果或者权限不足。原因有两层一是WRITE_SMS权限变成了高危权限默认不授予二是很多国产 ROMMIUI、EMUI 等在系统短信应用之外额外做了一层写入拦截单纯靠 ContentProvider 写入无法触发系统通知栏的新短信提醒。我实测过 MIUI 12 上即便手动授予权限插入收件箱的记录也不会出现在系统短信应用里因为系统有内部的conversation缓存索引没有同步更新。所以现在做短信类应用主流方案是两条路要么在系统短信应用基础上做比如用默认短信应用身份申请defaultSmsApp角色要么干脆不做写入只做读取和展示。如果是研究目的建议直接在模拟器上做实验真机调试写入功能会很痛苦。更新短信状态也有讲究。最常见的是把未读短信标记为已读val values ContentValues().apply { put(read, 1) } contentResolver.update( Uri.parse(content://sms/inbox), values, _id ?, arrayOf(smsId.toString()) )这个操作在短信拦截类 App 里很常用把垃圾短信自动标记为已读避免通知栏一直弹。但注意 Android 8.0 之后READ_SMS权限要求从运行时权限升级到了特殊权限的候选普通应用很难再静默执行批量已读操作。如果你只是给个人工具用可以通过辅助功能AccessibilityService模拟点击来让系统应用自己标记已读绕开权限限制。6. 从 date 到 thread_id五个高频坑与排查方法这部分是把前面提到的坑集中展开每个坑都是我实际开发中遇到过、排查过的不是坐在电脑前拍脑袋编的。第一个坑是 date 字段的毫秒和秒之争。原生 Android 的sms.date是毫秒时间戳但部分国内 ROM特别是某些从 iOS 迁移工具导入的数据会出现秒级时间戳。怎么判断看数值位数13 位是毫秒10 位是秒。如果发现查询结果按日期排序乱序优先怀疑这个。统一转换方法long date cursor.getLong(cursor.getColumnIndex(date)); if (String.valueOf(date).length() 10) { date * 1000L; // 秒转毫秒 }第二个坑是 thread_id 无中生有的问题。在使用content://sms/threadID查询消息时如果传入一个不存在的 thread_id返回结果不是空而是有可能创建一个新的空会话。我在一个导出工具里踩过遍历联系人号码时误传了错误 ID结果系统短信应用里多出一堆空白会话用户差点投诉。正确做法是先查询threads表确认 ID 存在再去查消息。第三个坑是多卡手机的 sub_id 混乱。同一张 SIM 卡卸载后重新插上sub_id 可能变化数据库里旧消息的 sub_id 还是原来的值新消息用新值。如果做双卡统计建议不要直接用 sub_id 做分组最好结合service_center或 SIM 卡显示名称来区分。第四个坑是 body 被截断或加密。Android 10 之后系统对部分敏感场景的短信正文做了保护某些查询场景下body字段返回空串或脱敏内容。比如读取验证码短信时有些 ROM 在应用无焦点的情况下会返回脱敏后的 body。这不是 bug是系统隐私机制。解决方法让应用获得焦点或者申请SYSTEM_ALERT_WINDOW悬浮窗权限或者引导用户去系统短信应用里查看。第五个坑是彩信内容不在 sms 表里。很多人查了半天 sms 表发现没有图片、音频数据那是因为彩信的内容存储在pdu和part表中sms表只存纯文本短信。想读取彩信需要走content://mms系列 URI解析part表的_data字段拿到文件路径再通过ContentResolver.openFileDescriptor读取。这个流程比查 sms 复杂一个量级新手最好先从短信入手彩信内容单独开专题。// 读取彩信文本部分的示意代码部分字段 val uri Uri.parse(content://mms) val cursor contentResolver.query(uri, arrayOf(_id, date, thread_id), null, null, null) // 再根据 pdu id 查询 part 表获取实际内容7. 周边扩展会话索引、草稿箱与应用的联动理解了主表结构之后可以顺手把周边机制也摸一圈。短信数据库中还有几张容易被忽略的表words提供全文搜索的索引表、sms_restricted某些 ROM 上存放被限制访问的消息、pending_msgs排队待发消息。这些表在正常开发中很少直接碰到但查询性能优化时会牵扯到。words表尤其值得注意。它本质是短信内容的倒排索引把一段 body 按分隔符拆成单词每个单词对应一条记录。系统短信应用的搜索功能就是查这张表而不是全表 LIKE 匹配。如果你自己用SELECT * FROM sms WHERE body LIKE %验证码%做搜索在大数据量下会非常慢上千万条记录时能卡到秒级。正确姿势是用系统提供的content://sms/searchURI它内部走words索引val uri Uri.parse(content://sms/search).buildUpon() .appendQueryParameter(query, 验证码) .build()草稿箱的机制也有意思用户在短信应用里输入一半内容退出系统会在sms表插入一条 type3 的记录body 是你输入的文字。再次进入会话时系统通过thread_id找到这条草稿并恢复显示。所以草稿箱看似是独立功能实际还是复用 sms 表只是多了一个 type 标记。顺便提醒一下做短信统计类 App 的朋友统计某号码发来多少条短信时很多人直接用address字段分组。这里有个坑——同一联系人可能有两个 address比如一个是 8613800138000一个是 13800138000运营商下发时会混用。分组前先做号码归一化去掉 86、去掉空格、去掉横线否则统计结果会偏低。我这边的做法是建一个号码清洗函数所有查询都先过一遍再分组public static String normalizePhoneNumber(String raw) { if (raw null) return ; String cleaned raw.replaceAll([^0-9], ); if (cleaned.startsWith(86) cleaned.length() 11) { cleaned cleaned.substring(3); } return cleaned; }8. 最后分享几条实战经验文章写到这儿核心内容已经全部铺完了。最后聊几条我做完短信相关功能后沉淀下来的体会算是对后学者的几点忠告。第一条是关于权限的。做短信功能前先想清楚应用定位如果只是个人小工具老老实实让用户手动授予READ_SMS权限就行如果是上架应用除了权限还要在隐私政策里明确说明为什么要读取短信、数据用在哪里。现在各应用商店对短信权限审核很严格尤其在国内没有明确场景说明很容易被拒。我自己曾经因为一个验证码读取功能被要求补充一堆隐私说明最终把读取范围限定到只读收件箱最近 10 条才过审。第二条是注意 ROM 适配。同一套代码在原生 Android 和国内定制 ROM 上行为差异很大特别是读已读状态、sub_id、删除短信这几块。我建议有条件就多拿几台不同品牌设备做真机测试模拟器只能用来调逻辑不能代表真机表现。第三条是性能问题别忽视。短信数据量大时全表扫描和递归查询会非常卡。用 SQLite 直接操作遇到慢查询时先看看是否有index_sms_thread_id和index_sms_date这类索引可用。系统默认建的索引是够用的但如果你自己建了辅助表做数据统计记得同步建索引否则几万条数据就能卡到肉眼可见的延迟。第四条是测试数据别用真实短信。调数据库结构时最容易出事的就是拿熟人、同事的真实短信做实验一旦误删或者写乱恢复成本极高。我的做法是在模拟器里先用假号码如 10086、95588批量生成测试短信逻辑验证通过后再上真机小范围验证。生成假短信可以用一个 ContentValues 循环插入收件箱每条间隔几毫秒效果和真实短信没有差别。说到底Android 短信数据库的结构并不复杂难点全在细节和权限上。把这套表结构吃透短信读取、统计、备份、清理这些功能都能顺手做出来。后续如果大家对彩信解析、短信备份格式或者短信拦截的具体实现感兴趣我可以再单独拆几个专题展开讲。
返回列表