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:nameandroid.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: id1, nameAlice D/ContactQuery: id2, nameBob D/ContactQuery: id3, nameCarol如果你想把结果直接显示到列表可以用 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 返回 -1projection 里没写这一列或者列名拼错。用getColumnIndexOrThrow()能在开发期直接暴露问题。配合 ListView 崩溃Cursor 缺少_ID列。SimpleCursorAdapter 强制要求结果集里有_ID查询时务必带上。主线程查询卡顿或 ANR把查询放到子线程、CursorLoader 或协程里别在主线程做大数据量遍历。SQL 注入风险永远用?加 selectionArgs不要字符串拼接用户输入。6. 接入与调试把查询能力接到实际工程如果你在本地调试时想快速验证某个提供器的返回结构或者需要对比不同模型给出的查询代码是否正确可以借助在线工具做交叉验证。我平时会把查询逻辑和报错信息整理好用模型对话快速定位是 URI 写错还是权限漏了省去反复翻文档的时间。对于需要长期写 Android 代码、频繁调试 ContentResolver 和权限逻辑的场景可以考虑用 Coding Plan 把这类重复性的代码骨架生成和排错固化下来减少手动试错。具体入口想直接对话验证查询代码和报错模型对话 https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite长期编码与 Agent 工作流Coding Plan https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite生成和管理接入密钥API Keys https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite接入文档与参数说明接入文档 https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite官网入口https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content最后留一个实用习惯每次写完查询先在 Logcat 里打印cursor.getColumnNames()确认返回的列和你 projection 里写的一致。这一步能帮你提前发现大部分列名不匹配的问题比等到界面空白再回头查要快得多。