
1. Android 读取系统联系人为什么总在权限和 Cursor 上翻车做 Android 开发获取系统联系人信息看起来就是一句getContentResolver().query()的事但真机一跑十有八九会遇到三类问题权限申请时机不对导致查询直接抛 SecurityException、Cursor 字段索引拿错导致空指针、以及联系人数据分散在多张表里需要多次关联查询。我见过不少项目在模拟器上跑得好好的换到 Android 13/14 真机就崩核心原因就是没吃透运行时权限和 ContentProvider 的查询模型。先把结论说清楚Android 读取联系人本质是「权限 ContentResolver Cursor 字段映射」三件事。权限负责拿到入场券ContentResolver 负责向系统联系人数据库发起查询Cursor 负责把结果集按列名映射成 Java 对象。任何一环出问题都会表现为查询返回空、字段为 null 或者直接崩溃。适合谁看这篇如果你正在做通讯录导入、来电识别、社交 App 的好友匹配或者单纯想搞明白ContactsContract这套 API 怎么用这篇都能直接抄代码。我会从权限声明讲到完整查询链路再演示怎么用 TaoToken 统一 Key 通道集中管理接口调用凭证——因为很多联系人相关功能比如号码归属地、头像拉取、云端去重都要调后端接口凭证散落在各处是维护噩梦。先明确一个概念系统联系人数据不是一张表而是Contacts、RawContacts、Data三张核心表。Contacts是聚合后的联系人RawContacts是原始账户记录Data表则用 MIMETYPE 区分电话、邮箱、地址、组织、备注、昵称等所有明细。你查电话号码、邮箱、IM、地址其实都是在查Data表只是通过不同的CommonDataKinds子类来过滤 MIMETYPE。理解这一点后面所有查询代码就都通了。权限方面从 Android 6.0API 23开始READ_CONTACTS属于危险权限必须运行时申请。Android 10 之后又收紧了后台读取Android 13 对联系人相关的部分能力做了进一步限制。所以你的代码不能只在 Manifest 里声明就完事必须在合适的生命周期节点动态请求并且处理用户拒绝、永久拒绝两种分支。下面这张表先给你一个全局对照后面每一节都会展开环节关键 API常见坑权限声明AndroidManifest.xml只声明不申请真机直接崩运行时申请requestPermissions()在 onCreate 里同步查权限还没回调查询联系人ContactsContract.Contacts.CONTENT_URI排序字段写错导致空结果查电话CommonDataKinds.Phone.CONTENT_URI用 Phone.CONTACT_ID 过滤时拼串注入查邮箱CommonDataKinds.Email.CONTENT_URI列名用错取到 null查 IM/地址/组织Data.CONTENT_URI MIMETYPEMIMETYPE 拼错查不到任何行Cursor 映射getColumnIndex()索引为 -1 时 getString 抛异常我试过在一个老项目里直接照搬网上的查询代码结果在 Android 14 上getColumnIndex返回 -1getString(-1)直接抛CursorIndexOutOfBoundsException。后来才发现是查询投影projection传了 null系统返回的列集合和预期不一致。所以下面我会强调能显式指定 projection 就不要传 null这是稳定性的第一道保险。2. TaoToken 统一 Key 通道把接口凭证从代码里挪出去联系人功能做到后期几乎一定会接后端接口号码归属地查询、头像 CDN 拉取、云端联系人去重、AI 智能分组。这些接口各自有 Key散落在BuildConfig、local.properties、甚至硬编码在 Java 里一旦要换环境或者轮换密钥就得全局搜索替换非常容易漏。TaoToken 在这里的角色是「统一 Key / API 通道」你不再为每个后端服务单独维护一套凭证而是通过一个统一的入口管理调用凭证客户端只认一个 Base URL 和一个 Key。官网入口是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 根地址是 https://taotoken.net/api 。需要说清楚的是TaoToken 不是让你绕过什么限制它解决的是工程层面的凭证治理问题多个模型、多个接口的调用凭证集中在一处客户端配置收敛成一个 Base URL 一个 Key 一个 Model ID 的三件套。对于 Android 项目来说这意味着你可以在build.gradle里用buildConfigField注入而不是把密钥写死在代码里。具体到联系人场景典型用法是这样你的 App 读取本地联系人后需要调用一个「号码归属地 风险标记」的接口。传统做法是每个接口一个域名一个 Key现在统一走 TaoToken 的 API 通道客户端只需要配置一次。这样在 debug / release / 测试环境之间切换时只改一处配置即可。如果你用的是 Claude Code 这类编码工具来辅助开发 Android 项目也可以通过 Coding Plan 把模型调用统一管理起来入口在 https://taotoken.net/api 对应的控制台里。控制台地址是 https://taotoken.net/console API Keys 管理在 https://taotoken.net/api-keys 接入文档在 https://taotoken.net/doc 。模型对话调试可以用 https://taotoken.net/models 长期编码或 Agent 场景建议看 Coding Planhttps://taotoken.net/coding-plan 。这里要提醒一句客户端直连 API 通道时Key 仍然会暴露在 APK 里。生产环境更稳妥的做法是客户端调你自己的后端由后端持有 Key 再去调上游。TaoToken 的统一通道主要解决的是「凭证集中管理」和「多服务收敛」不是让你把密钥明文塞进 APK 就完事。这一点在后面的配置章节我会给出两种模式直连调试模式和后端代理模式。对于 Android 开发者来说最实际的收益是当你同时要调联系人去重接口、头像接口、AI 分组接口时不用再维护三套 Base URL 和三套鉴权逻辑统一成一套 HTTP 客户端配置即可。下面进入可复制配置环节。3. 可复制配置权限声明、查询代码与统一 Key 三件套这一节全部是可复制内容你直接贴进项目就能跑。先给权限声明再给完整查询代码最后给 TaoToken 的三件套配置。3.1 AndroidManifest.xml 权限声明manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.example.contactdemo uses-permission android:nameandroid.permission.READ_CONTACTS / uses-permission android:nameandroid.permission.INTERNET / application android:allowBackuptrue android:labelContactDemo android:themestyle/AppTheme activity android:name.ContactActivity intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity /application /manifest注意READ_CONTACTS是危险权限Manifest 声明只是「告知系统我要用」真正授权要靠运行时申请。INTERNET是普通权限声明即可用于后面调接口。3.2 运行时权限申请private static final int REQ_CONTACTS 1001; private void requestContactPermission() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) PackageManager.PERMISSION_GRANTED) { loadContacts(); } else { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_CONTACTS}, REQ_CONTACTS); } } Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode REQ_CONTACTS) { if (grantResults.length 0 grantResults[0] PackageManager.PERMISSION_GRANTED) { loadContacts(); } else { if (!ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.READ_CONTACTS)) { // 用户勾选了「不再询问」引导去设置页 Toast.makeText(this, 请到设置中开启联系人权限, Toast.LENGTH_LONG).show(); } else { Toast.makeText(this, 需要联系人权限才能导入通讯录, Toast.LENGTH_SHORT).show(); } } } }关键点不要在onCreate里申请完权限就立刻查询因为权限回调是异步的。正确做法是把查询逻辑放在onRequestPermissionsResult的授权成功分支里或者用registerForActivityResult的新 API。3.3 完整联系人查询代码private void loadContacts() { new Thread(() - { ListContactBean result new ArrayList(); ContentResolver resolver getContentResolver(); String[] projection new String[]{ ContactsContract.Contacts._ID, ContactsContract.Contacts.DISPLAY_NAME, ContactsContract.Contacts.HAS_PHONE_NUMBER }; Cursor cur resolver.query( ContactsContract.Contacts.CONTENT_URI, projection, null, null, ContactsContract.Contacts.DISPLAY_NAME COLLATE LOCALIZED ASC); if (cur null) { Log.e(ContactDemo, cursor is null); return; } try { int idIdx cur.getColumnIndexOrThrow(ContactsContract.Contacts._ID); int nameIdx cur.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME); int hasPhoneIdx cur.getColumnIndexOrThrow(ContactsContract.Contacts.HAS_PHONE_NUMBER); while (cur.moveToNext()) { String contactId cur.getString(idIdx); String name cur.getString(nameIdx); int hasPhone cur.getInt(hasPhoneIdx); ContactBean bean new ContactBean(); bean.contactId contactId; bean.name name; if (hasPhone 0) { bean.phones queryPhones(resolver, contactId); } bean.emails queryEmails(resolver, contactId); bean.ims queryIm(resolver, contactId); bean.addresses queryAddress(resolver, contactId); bean.organizations queryOrganization(resolver, contactId); bean.notes queryNote(resolver, contactId); bean.nicknames queryNickname(resolver, contactId); result.add(bean); } } finally { cur.close(); } runOnUiThread(() - { Log.i(ContactDemo, total contacts result.size()); for (ContactBean b : result) { Log.i(ContactDemo, b.toString()); } }); }).start(); }下面是各个明细查询方法注意全部使用参数化查询避免拼串注入private ListString queryPhones(ContentResolver resolver, String contactId) { ListString list new ArrayList(); Cursor c resolver.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, new String[]{ ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.TYPE }, ContactsContract.CommonDataKinds.Phone.CONTACT_ID ?, new String[]{contactId}, null); if (c ! null) { try { int numIdx c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER); int typeIdx c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.TYPE); while (c.moveToNext()) { list.add(c.getString(numIdx) |type c.getInt(typeIdx)); } } finally { c.close(); } } return list; } private ListString queryEmails(ContentResolver resolver, String contactId) { ListString list new ArrayList(); Cursor c resolver.query( ContactsContract.CommonDataKinds.Email.CONTENT_URI, new String[]{ ContactsContract.CommonDataKinds.Email.DATA, ContactsContract.CommonDataKinds.Email.TYPE }, ContactsContract.CommonDataKinds.Email.CONTACT_ID ?, new String[]{contactId}, null); if (c ! null) { try { int dataIdx c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Email.DATA); int typeIdx c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Email.TYPE); while (c.moveToNext()) { list.add(c.getString(dataIdx) |type c.getInt(typeIdx)); } } finally { c.close(); } } return list; } private ListString queryIm(ContentResolver resolver, String contactId) { ListString list new ArrayList(); Cursor c resolver.query( ContactsContract.Data.CONTENT_URI, new String[]{ ContactsContract.CommonDataKinds.Im.PROTOCOL, ContactsContract.CommonDataKinds.Im.DATA }, ContactsContract.Data.CONTACT_ID ? AND ContactsContract.Data.MIMETYPE ?, new String[]{contactId, ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE}, null); if (c ! null) { try { int protoIdx c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Im.PROTOCOL); int dataIdx c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Im.DATA); while (c.moveToNext()) { list.add(protocol c.getInt(protoIdx) | c.getString(dataIdx)); } } finally { c.close(); } } return list; } private ListString queryAddress(ContentResolver resolver, String contactId) { ListString list new ArrayList(); Cursor c resolver.query( ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_URI, new String[]{ ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS, ContactsContract.CommonDataKinds.StructuredPostal.CITY, ContactsContract.CommonDataKinds.StructuredPostal.REGION }, ContactsContract.CommonDataKinds.StructuredPostal.CONTACT_ID ?, new String[]{contactId}, null); if (c ! null) { try { int fmtIdx c.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS); int cityIdx c.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.StructuredPostal.CITY); int regionIdx c.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.StructuredPostal.REGION); while (c.moveToNext()) { list.add(c.getString(fmtIdx) | c.getString(cityIdx) | c.getString(regionIdx)); } } finally { c.close(); } } return list; } private ListString queryOrganization(ContentResolver resolver, String contactId) { ListString list new ArrayList(); Cursor c resolver.query( ContactsContract.Data.CONTENT_URI, new String[]{ ContactsContract.CommonDataKinds.Organization.COMPANY, ContactsContract.CommonDataKinds.Organization.TITLE }, ContactsContract.Data.CONTACT_ID ? AND ContactsContract.Data.MIMETYPE ?, new String[]{contactId, ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE}, null); if (c ! null) { try { int compIdx c.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.Organization.COMPANY); int titleIdx c.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.Organization.TITLE); while (c.moveToNext()) { list.add(c.getString(compIdx) | c.getString(titleIdx)); } } finally { c.close(); } } return list; } private ListString queryNote(ContentResolver resolver, String contactId) { ListString list new ArrayList(); Cursor c resolver.query( ContactsContract.Data.CONTENT_URI, new String[]{ContactsContract.CommonDataKinds.Note.NOTE}, ContactsContract.Data.CONTACT_ID ? AND ContactsContract.Data.MIMETYPE ?, new String[]{contactId, ContactsContract.CommonDataKinds.Note.CONTENT_ITEM_TYPE}, null); if (c ! null) { try { int noteIdx c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Note.NOTE); while (c.moveToNext()) { list.add(c.getString(noteIdx)); } } finally { c.close(); } } return list; } private ListString queryNickname(ContentResolver resolver, String contactId) { ListString list new ArrayList(); Cursor c resolver.query( ContactsContract.Data.CONTENT_URI, new String[]{ContactsContract.CommonDataKinds.Nickname.NAME}, ContactsContract.Data.CONTACT_ID ? AND ContactsContract.Data.MIMETYPE ?, new String[]{contactId, ContactsContract.CommonDataKinds.Nickname.CONTENT_ITEM_TYPE}, null); if (c ! null) { try { int nameIdx c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Nickname.NAME); while (c.moveToNext()) { list.add(c.getString(nameIdx)); } } finally { c.close(); } } return list; }3.4 TaoToken 三件套配置Base URL Key Model ID如果你要在联系人功能里接入后端接口比如号码归属地、AI 分组推荐把凭证收敛成三件套。下面给一个local.propertiesbuild.gradle的配置示例路径和原文一致local.properties不要提交到 GitTAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_API_KEYsk-your-key-here TAOTOKEN_MODEL_IDyour-model-idapp/build.gradleandroid { defaultConfig { buildConfigField String, TAOTOKEN_BASE_URL, \${project.findProperty(TAOTOKEN_BASE_URL) ?: }\ buildConfigField String, TAOTOKEN_API_KEY, \${project.findProperty(TAOTOKEN_API_KEY) ?: }\ buildConfigField String, TAOTOKEN_MODEL_ID, \${project.findProperty(TAOTOKEN_MODEL_ID) ?: }\ } }然后在代码里通过BuildConfig.TAOTOKEN_BASE_URL、BuildConfig.TAOTOKEN_API_KEY、BuildConfig.TAOTOKEN_MODEL_ID读取。这样 debug 和 release 可以配不同的 Key切换环境只改local.properties。如果你用 Claude Code 辅助开发配置可以写成settings.json形式把 Base URL、Key、Model ID 三件套放进去{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_API_KEY: sk-your-key-here, ANTHROPIC_MODEL: your-model-id } }注意客户端直连只适合调试。生产环境建议客户端调你自己的后端后端持有 Key 再转发避免 Key 随 APK 分发泄露。API Keys 管理入口在 https://taotoken.net/api-keys 接入文档在 https://taotoken.net/doc 。4. 验证请求与成功结果真机跑一遍看日志配置写完必须真机验证。模拟器的联系人数据库往往是空的权限行为也和真机有差异所以这一步别偷懒。4.1 真机验证步骤第一步安装 APK 后首次进入页面系统会弹出联系人权限对话框点「允许」。如果你之前拒绝过需要到「设置 → 应用 → 权限 → 联系人」里手动开启。第二步提前在手机里存几条测试联系人最好覆盖多种情况只有名字没有电话的、有多个电话的、有邮箱的、有 IM 的、有地址的、有组织的、有备注和昵称的。这样能验证所有查询分支。第三步观察 Logcat过滤 tagContactDemo。正常输出应该类似I/ContactDemo: total contacts 5 I/ContactDemo: ContactBean{name张三, phones[13800000000|type2], emails[zhangsanexample.com|type1], ...} I/ContactDemo: ContactBean{name李四, phones[13900000000|type2, 010-88886666|type3], emails[], ...}第四步对照下面这张结果表检查每个字段是否符合预期检查项预期结果异常表现联系人总数与手机通讯录条数一致为 0 说明权限或查询有问题姓名与通讯录显示名一致为 null 说明 projection 列名错电话数量与联系人实际号码数一致少于实际说明 HAS_PHONE_NUMBER 判断有误邮箱有则显示无则为空列表抛异常说明列索引为 -1IM有则显示 protocol 和账号查不到说明 MIMETYPE 拼错地址格式化地址非空全 null 说明列名用错组织公司和职位查不到说明 CONTENT_ITEM_TYPE 不对备注/昵称有则显示空说明该联系人确实没填4.2 接口调用验证如果你接了 TaoToken 通道做号码归属地查询可以用 curl 先验证通道是否通curl -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer sk-your-key-here \ -H Content-Type: application/json \ -d { model: your-model-id, messages: [ {role: user, content: 13800000000 的归属地是哪里} ] }返回 200 且 body 里有choices数组说明 Base URL、Key、Model ID 三件套配置正确。如果返回 401说明 Key 无效或没带上如果返回 404多半是 Base URL 路径写错注意是https://taotoken.net/api而不是别的路径。在 Android 端用 OkHttp 发请求时记得把超时设长一点联系人批量查询可能一次发很多条OkHttpClient client new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build(); Request request new Request.Builder() .url(BuildConfig.TAOTOKEN_BASE_URL /v1/chat/completions) .addHeader(Authorization, Bearer BuildConfig.TAOTOKEN_API_KEY) .post(RequestBody.create(mediaType, jsonBody)) .build();实测下来把三件套收敛到BuildConfig之后切换测试环境和生产环境只需要改local.properties一行比之前到处找硬编码 Key 舒服太多。5. 本篇常见错误排查401、权限拒绝、Cursor 越界逐个击破这一节按真实报错来对照你遇到哪个查哪个。5.1 权限拒绝SecurityException: Permission Denial完整报错通常长这样java.lang.SecurityException: Permission Denial: opening provider com.android.providers.contacts.ContactsProvider2 from ProcessRecord{...} requires android.permission.READ_CONTACTS or android.permission.WRITE_CONTACTS原因有三种Manifest 没声明、声明了没运行时申请、用户拒绝了。排查顺序是先看 Manifest再看checkSelfPermission返回值最后看shouldShowRequestPermissionRationale。如果是「不再询问」状态只能引导用户去设置页。5.2 401 UnauthorizedKey 或 Base URL 配错调用 TaoToken 通道时返回{error: {message: Invalid API key, type: invalid_request_error}}先检查Authorization头是不是Bearer sk-xxx格式注意 Bearer 后面有空格。再检查 Key 是否过期或被删除去 https://taotoken.net/api-keys 确认。最后检查 Base URL 是不是https://taotoken.net/api多一个斜杠或者少一段都会 404。5.3 local proxy failed本地代理配置冲突如果你在 Android Studio 里配了 HTTP Proxy或者 Gradle 走了本地代理可能报local proxy failed: Connection refused这时候检查gradle.properties里的systemProp.http.proxyHost等配置以及 Android Studio 的 Settings → HTTP Proxy。把不必要的代理关掉直连即可。5.4 reading choices响应体解析失败调用接口后解析 JSON 报com.google.gson.JsonSyntaxException: Expected BEGIN_ARRAY but was BEGIN_OBJECT或者日志里出现reading choices相关错误通常是响应结构和你的数据类不匹配。先打印原始响应体确认choices是数组还是对象再调整 Gson 的解析模型。别直接照抄别人的响应类不同接口结构不一样。5.5 CursorIndexOutOfBoundsException列索引为 -1完整报错android.database.CursorIndexOutOfBoundsException: Index -1 requested, with a size of 5原因就是getColumnIndex()返回了 -1说明你请求的列不在 Cursor 里。解决办法有两个一是用getColumnIndexOrThrow()让它在开发期就抛异常暴露问题二是显式指定 projection不要传 null。我踩过的坑就是 projection 传 null系统返回的列集合和预期不一致某些机型上少列。5.6 OAuth 相关报错如果你用 Claude Code 或类似工具接入可能遇到 OAuth 相关提示。这类问题通常是认证方式没选对检查你的配置文件里用的是 API Key 还是 OAuth token两者不要混用。接入文档 https://taotoken.net/doc 里有对应的配置说明。5.7 查询返回空但权限正常权限正常、代码没报错但联系人数量为 0。这种情况先确认手机通讯录里确实有联系人再检查查询 URI 是否正确。有些定制 ROM 的联系人 Provider 行为有差异可以尝试换用ContactsContract.Contacts.CONTENT_URI并去掉排序参数测试。另外工作资料Work Profile里的联系人和个人资料是分开的默认查询只返回个人资料。6. 把联系人读取和统一 Key 通道串起来的实践建议走到这里Android 开发获取系统联系人信息的完整链路已经跑通了权限声明与运行时申请、ContentResolver 查询、Cursor 字段映射、明细表关联查询、真机验证、错误排查。最后再给几条实践建议帮你少走弯路。第一查询一定要放到子线程。联系人数量多的时候主线程查询会卡顿甚至 ANR。上面代码里用了new Thread生产环境建议换成ExecutorService或 Kotlin 协程。第二Cursor 用完必须 close。我见过太多项目忘记关 Cursor 导致内存泄漏尤其是嵌套查询多个明细表时。用 try-finally 包起来是最稳的写法。第三projection 能显式就显式。传 null 虽然方便但不同 Android 版本返回的列集合可能不同getColumnIndex返回 -1 的坑多半来自这里。第四凭证管理要收敛。联系人功能涉及的后端接口会越来越多把 Base URL、Key、Model ID 三件套统一到local.propertiesBuildConfig比散落在代码里强太多。TaoToken 的统一通道适合做这件事控制台在 https://taotoken.net/console 模型调试在 https://taotoken.net/models 长期编码场景可以看 https://taotoken.net/coding-plan 。第五生产环境别让客户端直连。调试阶段直连方便但发布版本建议走自己的后端代理Key 留在服务端。客户端只认你自己的域名安全性和可维护性都更好。最后给一个实用技巧如果你要批量导入联系人并做去重可以先把本地联系人读成ListContactBean按号码归一化去掉空格、横线、86 前缀后再比对。这一步在客户端做比在后端做省流量但要注意号码归一化规则因地区而异别一刀切。代码都在上面直接复制到项目里改包名就能跑。遇到报错对照第 5 节排查基本覆盖了 90% 的场景。