ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Android 短信读取实战:用 CursorLoader 构建响应式短信列表

2026/9/29 14:25:25 拓冰建站 浏览量
Android 短信读取实战:用 CursorLoader 构建响应式短信列表 1. 短信列表为什么总在真机上翻车从 CursorLoader 异步加载说起Android 读取本地短信并展示成列表看起来只是「查个数据库、塞进 ListView」这么简单但真机跑起来经常出现三类问题主线程查询导致界面卡顿甚至 ANR、短信数据库更新后列表不刷新、权限没配对直接返回空 Cursor。核心原因在于短信数据源是系统提供的 ContentProvidercontent://sms它既不在你的进程里也不保证查询速度必须用异步加载机制来读。CursorLoader 就是为这种场景设计的。它继承自 AsyncTaskLoader内部帮你把 Cursor 查询放到工作线程执行查询完成后通过 LoaderManager 回调把结果送回主线程同时注册了 ContentObserver短信数据库一有变化就会自动重新查询并回调onLoadFinished。换句话说你不需要自己写 Handler、不需要手动 requery列表就能跟着短信变化自动刷新。这篇面向的是需要在 Android 应用里读取本地短信、并把它展示在列表中的开发者。我会交付可直接复制的 CursorLoader 初始化代码、LoaderManager 回调骨架、权限声明配置以及在模拟器或真机上发一条测试短信后验证列表自动刷新的完整操作步骤。短信读取属于敏感权限场景代码能跑通的前提是权限申请到位、用户明确授权这一点我会在配置章节里讲清楚。需要提前说明的是CursorLoader 属于较早期的 API现在官方更推荐 Room Flow 或 Paging 方案。但大量存量项目、教学场景、以及需要快速读取系统 ContentProvider 的小工具仍然在用 CursorLoader理解它的工作机制对排查「列表不刷新」「Cursor 泄漏」这类问题很有帮助。下面从环境准备开始一步步把可运行的实现搭出来。2. 前置准备权限声明、运行时申请与 TaoToken 辅助排查2.1 短信权限的声明与运行时申请读取短信需要READ_SMS权限它在 Android 6.0API 23之后属于危险权限必须运行时申请。先在AndroidManifest.xml里声明manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.example.smsloader uses-permission android:nameandroid.permission.READ_SMS / application android:allowBackuptrue android:labelSmsLoaderDemo android:themestyle/Theme.AppCompat.Light activity android:name.MainActivity intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity /application /manifest注意READ_SMS在部分定制系统上还会被拆分成更细的权限如果查询返回空 Cursor 但没抛异常优先怀疑权限没真正授予。运行时申请用标准写法private static final int REQ_SMS 1001; private void requestSmsPermission() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_SMS) ! PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_SMS}, REQ_SMS); } else { getLoaderManager().initLoader(1, null, this); } } Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode REQ_SMS grantResults.length 0 grantResults[0] PackageManager.PERMISSION_GRANTED) { getLoaderManager().initLoader(1, null, this); } }关键点initLoader一定要放在权限授予之后调用否则 Loader 会在无权限状态下查询拿到空结果后即使后面授权了也不会自动重查。2.2 用 TaoToken 辅助排查接口与模型调用问题短信读取本身是本地 ContentProvider 操作不涉及网络。但实际项目里短信列表往往还要配合后端接口做同步、分类或内容理解这时候调试模型调用、验证接口返回格式就会用到统一的 API 入口。我平时会用 TaoToken 来管理这类调用它的 Base URL 是https://taotoken.net/api配合 API Key 和 Model ID 三件套就能接入。如果你在项目里同时要调模型做短信内容分类可以在配置里这样写以常见的 OpenAI 兼容格式为例{ base_url: https://taotoken.net/api, api_key: sk-你的Key, model_id: claude-sonnet-4-5 }需要拿 Key 的话去控制台创建https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里面有各语言的调用示例。验证模型是否通可以直接用模型对话页面发一条测试https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。这部分和短信读取是两条线但如果你做的是「短信 AI 分类」的完整功能把 API 配置先跑通能省掉后面联调时的很多来回。3. 可复制配置CursorLoader 初始化与 LoaderManager 回调骨架3.1 布局文件先准备两个布局。主布局放 ListView 和空状态 TextView!-- res/layout/activity_main.xml -- FrameLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent ListView android:idid/lv_display_sms android:layout_widthmatch_parent android:layout_heightmatch_parent / TextView android:idid/tv_empty android:layout_widthwrap_content android:layout_heightwrap_content android:layout_gravitycenter android:text暂无短信 android:textSize16sp / /FrameLayout列表项布局!-- res/layout/activity_item.xml -- LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical android:padding12dp TextView android:idid/tv_address android:layout_widthmatch_parent android:layout_heightwrap_content android:textColor#333333 android:textSize15sp android:textStylebold / TextView android:idid/tv_content android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_marginTop4dp android:textColor#666666 android:textSize14sp / /LinearLayout3.2 Activity 完整实现下面是可直接复制的 MainActivity包含 LoaderCallbacks 的完整骨架package com.example.smsloader; import android.Manifest; import android.app.LoaderManager; import android.content.CursorLoader; import android.content.Loader; import android.content.pm.PackageManager; import android.database.Cursor; import android.net.Uri; import android.os.Bundle; import android.provider.Telephony; import android.widget.ListView; import android.widget.SimpleCursorAdapter; import android.widget.TextView; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; public class MainActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacksCursor { private static final int LOADER_ID 1; private static final int REQ_SMS 1001; private ListView listView; private TextView tvEmpty; private SimpleCursorAdapter adapter; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); listView findViewById(R.id.lv_display_sms); tvEmpty findViewById(R.id.tv_empty); adapter new SimpleCursorAdapter( this, R.layout.activity_item, null, new String[]{address, body}, new int[]{R.id.tv_address, R.id.tv_content}, SimpleCursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER); listView.setAdapter(adapter); listView.setEmptyView(tvEmpty); requestSmsPermission(); } private void requestSmsPermission() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_SMS) ! PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_SMS}, REQ_SMS); } else { getLoaderManager().initLoader(LOADER_ID, null, this); } } Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode REQ_SMS grantResults.length 0 grantResults[0] PackageManager.PERMISSION_GRANTED) { getLoaderManager().initLoader(LOADER_ID, null, this); } } Override public LoaderCursor onCreateLoader(int id, Bundle args) { return new CursorLoader( this, Telephony.Sms.CONTENT_URI, new String[]{_id, address, body, date}, null, null, date DESC); } Override public void onLoadFinished(LoaderCursor loader, Cursor data) { adapter.swapCursor(data); } Override public void onLoaderReset(LoaderCursor loader) { adapter.swapCursor(null); } }几个容易踩坑的细节Telephony.Sms.CONTENT_URI等价于Uri.parse(content://sms)用常量更规范。查询列里必须包含_id否则 SimpleCursorAdapter 会报「column _id does not exist」这是最常见的崩溃之一。SimpleCursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER这个 flag 很关键它让 adapter 注册内容观察者配合 CursorLoader 的自动重查列表才能在短信变化时刷新。如果漏了这个 flag即使 Loader 重新查询了adapter 也可能不更新。排序用date DESC让最新短信排在最上面符合直觉。3.3 用 settings 片段管理多环境配置如果你的项目要区分调试和发布环境可以把 API 相关配置抽到local.properties或build.gradle的 buildConfigField 里。以 gradle 为例android { defaultConfig { buildConfigField String, API_BASE_URL, \https://taotoken.net/api\ buildConfigField String, MODEL_ID, \claude-sonnet-4-5\ } }这样代码里用BuildConfig.API_BASE_URL引用避免硬编码。Key 不要写进代码库用环境变量或本地配置文件注入。4. 验证请求与成功结果发一条测试短信看列表自动刷新代码写完后验证分两步先确认能读到已有短信再确认新短信能触发自动刷新。4.1 模拟器发送测试短信启动模拟器后有两种方式发短信。第一种用模拟器自带的 Messaging 应用打开后新建会话随便填个号码比如 10086发送一条内容为「测试短信 001」的消息。第二种用 adb 命令更可控adb emu sms send 10086 测试短信 001这条命令会模拟一条来自 10086 的短信。执行后观察你的应用如果实现正确列表顶部应该立刻出现这条新短信不需要手动下拉刷新或重启 Activity。4.2 真机验证真机上用另一台手机给你的测试机发短信即可。注意真机需要确保应用已获得短信权限可以在「设置 - 应用 - 你的应用 - 权限」里确认。部分系统如 MIUI、EMUI对短信权限管控更严可能需要在系统设置里额外开启「读取短信」开关。4.3 预期结果与日志确认正确运行时你会看到列表按时间倒序展示短信每条显示发件号码和内容发送新短信后 1 秒内列表自动更新新短信出现在顶部没有短信时显示「暂无短信」空状态。如果想确认 Loader 确实重新查询了可以在onLoadFinished里加一行日志Override public void onLoadFinished(LoaderCursor loader, Cursor data) { android.util.Log.d(SmsLoader, onLoadFinished, count (data null ? 0 : data.getCount())); adapter.swapCursor(data); }用adb logcat -s SmsLoader过滤每发一条短信应该看到一次新的onLoadFinished回调count 递增。这就是 CursorLoader 自动刷新的直接证据。5. 本篇常见错排查401、local proxy failed、reading choices 与 OAuth 报错对照虽然短信读取是本地操作但项目里一旦接入模型或后端接口就会遇到下面这些典型报错。我把它们和排查思路列出来方便对照。5.1 401 Unauthorized现象接口返回 401提示 invalid api key 或 authentication failed。原因通常是 API Key 写错、过期或者 Base URL 和 Key 不匹配。检查三件套是否一致Base URL 用https://taotoken.net/apiKey 从控制台复制完整注意别漏掉前缀Model ID 拼写正确。如果 Key 是从环境变量读的确认变量名没写错、值没有多余空格。5.2 local proxy failed现象请求报 local proxy failed 或 connection refused。这类报错一般出现在本地代理配置上。检查你的 HTTP 客户端是否设置了代理或者系统环境变量里有没有残留的代理配置。如果用的是 OkHttp确认没有误设proxy()。把代理相关配置清掉后重试。5.3 reading choices 相关报错现象解析响应时报 reading choices 或 cannot read property choices of undefined。这说明返回的 JSON 结构和你预期的不一致通常是请求本身失败了返回的是错误对象而不是正常响应但代码直接去读choices[0]。正确做法是先判断响应状态和结构if (response.code() 200) { JSONObject json new JSONObject(response.body().string()); if (json.has(choices)) { // 正常解析 } }5.4 OAuth 相关报错现象提示 OAuth token expired 或 invalid_grant。如果你用的是需要 OAuth 的接入方式token 过期是常见原因重新走一遍授权流程即可。注意系统时间不准确也会导致 token 校验失败检查设备时间是否自动同步。5.5 短信读取本身的报错回到本篇主题短信读取最常见的两个问题一是column _id does not exist原因是查询列里没加_id二是列表不刷新原因是 SimpleCursorAdapter 没加FLAG_REGISTER_CONTENT_OBSERVER或者initLoader在权限授予前就调用了。对照这两点检查基本能解决大部分问题。6. 从短信列表到长期编码把调试链路固定下来短信列表跑通后如果你还要继续做短信分类、智能回复这类功能就会涉及模型调用和长期迭代。这时候建议把 API 配置和调试入口固定下来避免每次换环境都重新找。拿 Key 和看接入文档走这两个入口API Keys 在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。验证模型通不通用模型对话页面最快https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。如果你做的是需要长期编码、跑 Agent 任务的项目Coding Plan 会更合适https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。它适合把模型调用纳入日常开发流程而不是每次临时拼配置。最后回到 CursorLoader 本身它的价值在于把「异步查询 自动刷新」这套机制封装好了你只需要实现三个回调。理解onCreateLoader负责构造查询、onLoadFinished负责交付数据、onLoaderReset负责清理引用这条链路后面换成 Room 或 Paging 时思路是一样的。短信读取只是 ContentProvider 的一个例子联系人、通话记录、媒体库都能用同样的模式处理。