1. 为什么你查联系人总是返回空 Cursor
很多人第一次写 Android 内容提供器相关代码时,都会遇到一个很迷惑的现象:代码没报错,ContentResolver.query()也正常返回了一个 Cursor 对象,但getCount()就是 0,界面上什么都显示不出来。我试过在模拟器上反复检查 URI、列名、排序参数,最后发现真正的问题出在权限上——清单里少了一行<uses-permission>,或者运行时权限没申请,系统直接把查询结果过滤成了空集。
内容提供器(ContentProvider)是 Android 四大组件之一,它把数据封装成类似"数据库表"的接口,让不同应用之间可以安全地共享数据。而 ContentResolver 就是你作为客户端去访问这些数据的入口。它适合谁?适合所有需要在应用之间读写共享数据(联系人、媒体库、日历、用户字典等)的 Android 开发者,尤其是刚接触跨进程数据访问、对 URI 和 Cursor 还不熟的同学。
这篇内容我会带你跑通一个最小闭环:从清单权限声明,到 URI 构造,到 ContentResolver 查询,再到 Cursor 读取和运行时权限申请,最后用一个读取联系人的完整例子验证结果。每一步都给可复制的代码骨架,你照着改包名就能用。
2. 前置准备:权限、URI 和查询四要素
在动手写查询之前,先把三件事理清楚,否则后面报错你会不知道从哪查。
第一是权限。内容提供器的读权限分两种:一种是普通权限,在清单里声明后安装时系统自动授予;另一种是危险权限(比如读取联系人READ_CONTACTS),除了清单声明,还必须在运行时动态申请。你查的是哪个提供器,就要去它的官方文档里找确切的权限名,不能猜。
第二是 URI。每个提供器都暴露一个或多个 Content URI,格式通常是content://authority/path。比如联系人提供器的ContactsContract.Contacts.CONTENT_URI,用户字典的UserDictionary.Words.CONTENT_URI。URI 决定了你访问哪张"表"。
第三是查询四要素,对应query()的五个参数(URI 之外):projection(要返回哪些列)、selection(筛选条件)、selectionArgs(条件里的占位参数)、sortOrder(排序)。这四样和 SQL 的 SELECT 子句一一对应,理解了 SQL 就理解了它。
注意:查询一定要放在子线程或异步任务里执行,主线程查询大数据量会触发 ANR。本文为了演示清晰用同步写法,实际项目请用 CursorLoader 或协程。
3. 可复制配置:清单权限与查询代码骨架
3.1 清单文件声明权限
以读取联系人为例,在AndroidManifest.xml的<manifest>下、<application>外添加:
<uses-permission android:name="android.permission.READ_CONTACTS" />如果你还要写数据,再加WRITE_CONTACTS。声明之后,普通权限安装即授予;危险权限还需要下一步的运行时申请。
3.2 构造查询并读取 Cursor
下面是一个查询联系人姓名和 ID 的骨架,注意 projection 里必须包含_ID列,否则后面配合 SimpleCursorAdapter 会直接崩:
// 1. 定义要返回的列 String[] projection = { ContactsContract.Contacts._ID, ContactsContract.Contacts.DISPLAY_NAME }; // 2. 筛选条件:只取有电话号码的联系人 String selection = ContactsContract.Contacts.HAS_PHONE_NUMBER + " = ?"; String[] selectionArgs = { "1" }; // 3. 排序 String sortOrder = ContactsContract.Contacts.DISPLAY_NAME + " ASC"; // 4. 执行查询 Cursor cursor = getContentResolver().query( ContactsContract.Contacts.CONTENT_URI, projection, selection, selectionArgs, sortOrder ); // 5. 读取结果 if (cursor != null) { int idIndex = cursor.getColumnIndex(ContactsContract.Contacts._ID); int nameIndex = cursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME); while (cursor.moveToNext()) { String id = cursor.getString(idIndex); String name = cursor.getString(nameIndex); Log.d("ContactQuery", "id=" + id + ", name=" + name); } cursor.close(); // 必须关闭,否则泄漏 } else { Log.e("ContactQuery", "cursor is null, 查询失败"); }这里 selection 用了?占位符加 selectionArgs,而不是把用户输入拼进字符串。这是防 SQL 注入的关键:用户输入被当作参数绑定,不会被解释成 SQL 语句。如果你写成"name = " + userInput,恶意输入就可能拼出破坏性语句。
3.3 运行时权限申请
Android 6.0 起,危险权限必须动态申请。在查询前检查并请求:
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions( this, new String[]{ Manifest.permission.READ_CONTACTS }, REQUEST_CODE_READ_CONTACTS); } else { queryContacts(); // 已授权,直接查 }然后在onRequestPermissionsResult里根据授权结果决定是否继续查询。授权通过再调用查询方法,否则提示用户。
4. 验证请求:跑通一次完整的联系人读取
把上面的代码串起来,完整流程是这样的:
先在 Activity 的onCreate里调用权限检查逻辑。用户点"允许"后,onRequestPermissionsResult收到PERMISSION_GRANTED,触发queryContacts()。查询返回 Cursor 后,遍历打印日志。
实测下来,在模拟器里预置几个联系人,运行后 Logcat 会输出类似:
D/ContactQuery: id=1, name=Alice D/ContactQuery: id=2, name=Bob D/ContactQuery: id=3, name=Carol如果你想把结果直接显示到列表,可以用 SimpleCursorAdapter 绑定 ListView,但记得 Cursor 必须含_ID列:
String[] fromColumns = { ContactsContract.Contacts.DISPLAY_NAME }; int[] toViews = { android.R.id.text1 }; SimpleCursorAdapter adapter = new SimpleCursorAdapter( this, android.R.layout.simple_list_item_1, cursor, fromColumns, toViews, 0); listView.setAdapter(adapter);看到列表里出现联系人名字,就说明从提供器获取数据的最小闭环跑通了。整个过程的核心就是:权限到位、URI 正确、projection 含_ID、Cursor 及时关闭。
5. 本篇常见错排查
查询返回 null:部分提供器内部出错时返回 null,另一些直接抛异常。先判空再使用,别直接cursor.getCount()。
getCount() 为 0 但数据明明存在:九成是权限问题。检查清单权限名是否拼错、运行时权限是否真的授予。可以在设置里手动看应用权限状态。
列名找不到,getColumnIndex 返回 -1:projection 里没写这一列,或者列名拼错。用getColumnIndexOrThrow()能在开发期直接暴露问题。
配合 ListView 崩溃:Cursor 缺少_ID列。SimpleCursorAdapter 强制要求结果集里有_ID,查询时务必带上。
主线程查询卡顿或 ANR:把查询放到子线程、CursorLoader 或协程里,别在主线程做大数据量遍历。
SQL 注入风险:永远用?加 selectionArgs,不要字符串拼接用户输入。
6. 接入与调试:把查询能力接到实际工程
如果你在本地调试时想快速验证某个提供器的返回结构,或者需要对比不同模型给出的查询代码是否正确,可以借助在线工具做交叉验证。我平时会把查询逻辑和报错信息整理好,用模型对话快速定位是 URI 写错还是权限漏了,省去反复翻文档的时间。
对于需要长期写 Android 代码、频繁调试 ContentResolver 和权限逻辑的场景,可以考虑用 Coding Plan 把这类重复性的代码骨架生成和排错固化下来,减少手动试错。
具体入口:
- 想直接对话验证查询代码和报错:模型对话 https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
- 长期编码与 Agent 工作流:Coding Plan https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
- 生成和管理接入密钥:API Keys https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
- 接入文档与参数说明:接入文档 https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
官网入口:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
最后留一个实用习惯:每次写完查询,先在 Logcat 里打印cursor.getColumnNames(),确认返回的列和你 projection 里写的一致。这一步能帮你提前发现大部分列名不匹配的问题,比等到界面空白再回头查要快得多。