1. 从 mmssms.db 到 XML:Android 短信备份到底在备份什么
Android 短信备份这件事,很多人第一反应是去翻/data/data/com.android.providers.telephony/databases/mmssms.db。这个思路本身没错,那个 SQLite 文件确实是短信的最终落盘位置,但真机没有 root 权限根本读不到它。所以实际开发里更稳的做法是走ContentResolver,把系统暴露出来的content://sms/当成一个标准数据源来查,再用XmlSerializer把结果序列化成 XML 文件。
这套链路能做什么?简单说,它能把手机里收件箱、发件箱、草稿箱里的短信,按你指定的字段(号码、时间、类型、正文)读出来,写成一个结构化的 XML。适合谁?适合需要做本地数据迁移、换机前留档、或者单纯想研究 Android 四大组件里 ContentProvider 怎么用的开发者。它不依赖 root,不依赖第三方云服务,全程在设备本地完成。
我试过直接拿模拟器发几条短信,然后用这套代码导出,文件确实能生成,但中间踩了几个坑:权限没声明导致query直接抛SecurityException;getColumnIndex写错字段名返回 -1,getString(-1)又抛异常;还有短信一多,边读边写如果不及时关 Cursor,内存会涨得很快。所以这篇不打算只贴一段能跑的代码,而是把权限、查询、序列化、校验、排错整条链路拆开讲,让你自己独立完成一次备份。
核心检索词先摆出来:Android 短信备份、ContentResolver 查询 sms Uri、XmlSerializer 序列化落盘。这三个词贯穿全文,你按这个顺序理解就不会乱。
先明确数据模型。content://sms/这张表里,我们关心的字段有四个:address是手机号,date是毫秒时间戳,type区分收发(1 接收、2 发送,还有 3 草稿等),body是短信正文。查询时用 projection 数组指定这四个字段,比SELECT *更可控,也避免拿到一些你不需要的列。
为什么不用直接读 db 文件?因为从 Android 4.4 开始,非系统应用访问其他应用私有目录被严格限制,mmssms.db属于 TelephonyProvider 的私有数据,普通应用没有读权限。而 ContentProvider 是系统专门开的口子,配合READ_SMS权限就能合法查询。这也是为什么标题里强调 ContentResolver,而不是直接文件操作。
XML 作为导出格式的好处是自描述、跨平台、易校验。你导出的文件拿到电脑上,用浏览器或任意文本编辑器都能看,字段名一目了然。相比 CSV,XML 对嵌套和特殊字符(比如短信正文里的换行、引号)处理更自然,XmlSerializer会自动做转义,省得你自己拼字符串时被注入问题坑到。
下面从工程配置开始,一步步把这条链路搭起来。你会看到完整的权限声明、查询投影、序列化代码,以及导出后怎么校验文件结构、怎么在真机上验证读取结果。每一段都可以直接复制进你的项目改改用。
2. 前置准备:权限、依赖与 TaoToken 接入配置
动手之前先把环境和权限理清楚。短信备份涉及两个敏感权限:读短信和写外部存储。从 Android 6.0 开始,这两个都属于危险权限,除了在清单里声明,运行时还得动态申请。很多人代码写得没问题,一跑就崩,八成是漏了运行时申请这一步。
先看清单文件AndroidManifest.xml里要加的内容:
<uses-permission android:name="android.permission.READ_SMS" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />注意WRITE_EXTERNAL_STORAGE我加了maxSdkVersion="28"。因为从 Android 10(API 29)开始,分区存储生效,应用往公共目录写文件要走 MediaStore 或者用应用专属目录,直接Environment.getExternalStorageDirectory()在 29 以上会受限。如果你只打算在旧设备或模拟器上跑,这样写没问题;如果要兼容新系统,导出路径建议换成getExternalFilesDir(),那个目录不需要存储权限。
运行时申请用ActivityCompat.requestPermissions,读短信和写存储一起申请:
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_SMS) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{ Manifest.permission.READ_SMS, Manifest.permission.WRITE_EXTERNAL_STORAGE }, 1001); }依赖方面,XmlSerializer来自org.xmlpull.v1,Android SDK 自带,不用额外引库。ContentResolver、Cursor、Uri也都是 framework 里的,零依赖。这也是这套方案轻量的原因,不需要引入任何第三方库就能完成。
如果你在开发过程中需要调用大模型来辅助生成或审查这段序列化代码,可以用 TaoToken 的 API 做接入。它的 Base URL 是https://taotoken.net/api,兼容 OpenAI 风格的接口。配置方式很简单,在请求里带上 Key 和模型 ID 即可。比如用 curl 验证一下连通性:
curl https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -d '{ "model": "claude-sonnet-4-20250514", "messages": [{"role": "user", "content": "帮我检查这段 XmlSerializer 代码有没有漏掉 endDocument"}] }'Key 在控制台的 API Keys 页面生成,地址是https://taotoken.net/console/api-keys。模型 ID 按你实际要用的填,对话调试可以在模型对话页面试。这套配置和短信备份本身是解耦的,你完全可以离线写完代码,只在需要辅助时接一下。
再强调一个容易忽略的点:READ_SMS在 Google Play 上属于受限权限,上架审核很严,个人练手或内部工具无所谓,商业应用要慎重。本文聚焦本地备份的技术实现,不涉及上架合规讨论。
环境准备好后,下一节进入真正的查询和序列化代码。我会把投影、Cursor 遍历、XmlSerializer 的每个调用都写清楚,包括参数含义和常见写错的地方。
3. 可复制配置:ContentResolver 查询与 XmlSerializer 序列化完整代码
这一节是全文的核心,代码可以直接复制。我把它拆成三块:查询短信、封装数据、序列化落盘。每块都给出完整方法,你按顺序拼进项目即可。
先定义数据实体SmsInfo,字段和数据库列对应:
public class SmsInfo { private String address; private long date; private int type; private String body; public String getAddress() { return address; } public void setAddress(String address) { this.address = address; } public long getDate() { return date; } public void setDate(long date) { this.date = date; } public int getType() { return type; } public void setType(int type) { this.type = type; } public String getBody() { return body; } public void setBody(String body) { this.body = body; } }查询部分,用ContentResolver.query拿到 Cursor,投影只取需要的四列:
public List<SmsInfo> querySms(Context context) { List<SmsInfo> list = new ArrayList<>(); Uri uri = Uri.parse("content://sms/"); ContentResolver resolver = context.getContentResolver(); String[] projection = new String[]{"address", "date", "type", "body"}; Cursor cursor = null; try { cursor = resolver.query(uri, projection, null, null, "date ASC"); if (cursor != null) { int idxAddress = cursor.getColumnIndex("address"); int idxDate = cursor.getColumnIndex("date"); int idxType = cursor.getColumnIndex("type"); int idxBody = cursor.getColumnIndex("body"); while (cursor.moveToNext()) { SmsInfo info = new SmsInfo(); info.setAddress(cursor.getString(idxAddress)); info.setDate(cursor.getLong(idxDate)); info.setType(cursor.getInt(idxType)); info.setBody(cursor.getString(idxBody)); list.add(info); } } } finally { if (cursor != null) cursor.close(); } return list; }这里有个关键优化:getColumnIndex在循环外调用一次,而不是每次moveToNext都调。原示例里在循环内反复调getColumnIndex,短信一多就是明显的性能浪费。另外sortOrder传了"date ASC",让导出结果按时间排序,方便后续阅读。
序列化部分,用XmlSerializer把 List 写成 XML:
public static File backupSmsToXml(List<SmsInfo> list, Context context) { XmlSerializer serializer = Xml.newSerializer(); File file = new File(context.getExternalFilesDir(null), "sms_backup.xml"); FileOutputStream os = null; try { os = new FileOutputStream(file); serializer.setOutput(os, "UTF-8"); serializer.startDocument("UTF-8", true); serializer.startTag(null, "smss"); serializer.attribute(null, "count", String.valueOf(list.size())); serializer.attribute(null, "exportTime", String.valueOf(System.currentTimeMillis())); for (SmsInfo sms : list) { serializer.startTag(null, "sms"); serializer.attribute(null, "address", sms.getAddress() == null ? "" : sms.getAddress()); serializer.attribute(null, "date", String.valueOf(sms.getDate())); serializer.attribute(null, "type", String.valueOf(sms.getType())); serializer.text(sms.getBody() == null ? "" : sms.getBody()); serializer.endTag(null, "sms"); } serializer.endTag(null, "smss"); serializer.endDocument(); serializer.flush(); return file; } catch (Exception e) { Log.e("SmsBackup", "serialize failed", e); return null; } finally { if (os != null) { try { os.close(); } catch (IOException ignored) {} } } }几个细节值得说。setOutput的编码用"UTF-8",和startDocument保持一致,否则中文短信会乱码。startDocument第二个参数传true,表示输出独立的 XML 声明。serializer.flush()在endDocument之后调用,确保缓冲区数据全部写入文件,漏了这步可能文件不完整。address和body做了 null 判断,因为草稿短信的号码可能为空,直接attribute传 null 会抛异常。
如果你用 TaoToken 的 coding plan 来辅助生成这类模板代码,可以把上面的实体和查询逻辑贴进去让它补全序列化部分,模型 ID 选你常用的即可。接入文档在https://taotoken.net/doc,里面有完整的参数说明。
调用入口放在 Activity 里,一个按钮触发:
public void onBackupClick(View v) { new Thread(() -> { List<SmsInfo> list = querySms(this); File out = backupSmsToXml(list, this); runOnUiThread(() -> { if (out != null) { Toast.makeText(this, "导出成功: " + out.getAbsolutePath(), Toast.LENGTH_LONG).show(); } else { Toast.makeText(this, "导出失败", Toast.LENGTH_SHORT).show(); } }); }).start(); }查询和写文件都放在子线程,避免主线程 IO 导致 ANR。这是原示例里没处理好的地方,短信量大时主线程直接卡死。
4. 验证请求与成功结果:导出文件结构校验与真机读取
代码跑通不代表结果正确。导出后必须校验两件事:XML 结构是否合法、内容是否和手机里的短信对得上。这一节给出具体的校验方法。
先看导出的文件长什么样。用adb pull把文件拉到电脑:
adb pull /sdcard/Android/data/com.example.smsbackup/files/sms_backup.xml ./sms_backup.xml如果你用的是Environment.getExternalStorageDirectory(),路径换成/sdcard/sms_backup.xml。拉下来后用xmllint校验格式:
xmllint --noout sms_backup.xml && echo "XML 格式合法"合法的话会输出提示,不合法会打印具体行号和错误。常见的格式错误是标签没闭合,比如漏了endTag,xmllint会直接指出来。
再看内容结构。一个正常的导出文件应该像这样:
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?> <smss count="3" exportTime="1730000000000"> <sms address="+8613800138000" date="1729999000000" type="1">你好,这是一条测试短信</sms> <sms address="10086" date="1729999100000" type="2">话费余额查询</sms> <sms address="+8613900139000" date="1729999200000" type="1">收到,稍后回复</sms> </smss>校验要点:根标签是smss,count属性等于实际短信条数;每条sms有address、date、type三个属性,正文在标签内;type为 1 表示接收、2 表示发送,和数据库定义一致。
真机读取验证,我一般用两种方式。第一种是直接在手机上用文件管理器打开 XML,看能不能正常显示中文,乱码说明编码没对上。第二种是写个简单的解析回读,把 XML 再读回 List,和查询结果比对条数:
XmlPullParser parser = Xml.newPullParser(); parser.setInput(new FileInputStream(file), "UTF-8"); int eventType = parser.getEventType(); int parsedCount = 0; while (eventType != XmlPullParser.END_DOCUMENT) { if (eventType == XmlPullParser.START_TAG && "sms".equals(parser.getName())) { parsedCount++; } eventType = parser.next(); } Log.i("SmsBackup", "解析条数=" + parsedCount + " 原始条数=" + list.size());两个数字相等,说明序列化和解析闭环没问题。如果解析条数偏少,多半是某条短信正文里含有特殊字符导致标签提前闭合,检查serializer.text是否对内容做了转义(XmlSerializer 默认会转义&、<、>,一般不用手动处理)。
还有一种验证是拿模拟器发短信。用 DDMS 或者adb emu sms send命令:
adb emu sms send 10086 "测试短信内容"发几条后重新导出,对比条数是否增加。这个方式适合快速验证查询逻辑有没有漏掉某些类型的短信。
校验通过后,你手上就有一个结构清晰、可读可解析的备份文件了。整个过程不依赖网络,不依赖 root,纯本地完成。
5. 本篇常见错排查:401、local proxy failed、reading choices 与 OAuth 报错对照
写这类代码最容易在几个固定位置翻车。我把真实遇到过的报错和原因列出来,你对照着查。
java.lang.SecurityException: Permission Denial: reading com.android.providers.telephony.SmsProvider。这是最典型的权限问题。原因有两个:清单里没声明READ_SMS,或者声明了但没做运行时申请。Android 6.0 以上必须动态申请,只写清单不生效。检查checkSelfPermission的返回值,确认用户点了允许。
android.database.CursorIndexOutOfBoundsException: Index -1 requested。这个报错指向getColumnIndex返回了 -1。说明你投影里的字段名和实际列名对不上,比如把body写成了content,或者把address写成了number。解决方法是打印cursor.getColumnNames()看真实列名,再修正 projection。
java.io.FileNotFoundException: /storage/emulated/0/sms_backup.xml: open failed: EACCES。写文件权限问题。Android 10 以上用Environment.getExternalStorageDirectory()会失败,换成getExternalFilesDir(null)即可,那个路径不需要存储权限。如果坚持用公共目录,得走 MediaStore。
XmlPullParserException: Unexpected token或者导出的 XML 打不开。多半是漏了endTag或endDocument,标签没闭合。检查每个startTag是否都有对应的endTag,startDocument是否配了endDocument。
如果你在接入 TaoToken 辅助调试时遇到401 Unauthorized,那是 Key 没带对或者过期了。检查请求头Authorization: Bearer <key>里的 key 是否和控制台生成的一致,注意别把Bearer拼错。local proxy failed一般是本地网络配置问题,检查你的请求地址是不是https://taotoken.net/api,别多加或少加路径段。reading choices这类报错通常出现在流式响应解析时,模型返回的 chunk 结构和你预期的不一致,检查choices[0].delta.content的取值路径。OAuth 相关报错则多见于用第三方客户端登录的场景,确认回调地址和授权范围配置正确。
还有一个隐蔽的坑:Cursor 没关导致内存泄漏。查询完一定要在finally里cursor.close(),否则短信多了之后 CursorWindow 会占满内存,后续查询直接失败。原示例里在方法末尾关了,但如果中间抛异常就关不掉,所以放finally最稳。
最后提醒一句,content://sms/查的是全部短信,包括收件箱、发件箱、草稿。如果你只想导收件箱,可以在 selection 里加"type = ?",参数传"1"。这个过滤条件按需加,不影响整体链路。
6. 把备份链路接进你的工程:从查询到落盘的复用建议
代码跑通之后,怎么把它变成一个可复用的模块,而不是一次性脚本,是下一步要考虑的。我的做法是把查询和序列化拆成独立工具类,Activity 只负责权限申请和触发,这样换到别的项目里直接拷两个文件就行。
工具类SmsBackupUtil暴露两个静态方法:queryAll(Context)返回 List,exportToXml(List, File)返回是否成功。导出路径由调用方传入,不写死在工具类里,方便你根据系统版本决定用公共目录还是应用专属目录。这样职责清晰,测试也好写。
如果你要定期备份,可以配合WorkManager做周期任务,但注意后台读短信在 Android 10 以上限制更多,实际能不能跑取决于系统版本和厂商策略。练手阶段手动触发就够了。
导出文件建议加时间戳命名,比如sms_backup_20250101_120000.xml,避免多次备份互相覆盖。文件头部的exportTime属性也保留,方便追溯。
这套链路的价值在于它把 ContentProvider、Cursor、XmlSerializer 三个知识点串成了一个完整场景。你理解了短信备份,换成联系人、通话记录,套路是一样的:换 Uri、换 projection、换实体字段,序列化部分几乎不用改。所以别只盯着短信,把它当成 ContentResolver 查询加 XML 落盘的通用模板来掌握。
需要查接入文档或者生成 Key 的时候,接入文档在https://taotoken.net/doc,API Keys 在https://taotoken.net/console/api-keys。长期做编码和 Agent 相关开发的话,Coding Plan 页面有更完整的方案说明。这些是辅助工具,核心的查询和序列化逻辑还是得你自己跑一遍才算真会。