Android 内容提供器读取手机联系人:TaoToken 统一 Key 配置与动态权限验证 1. 从一次真机调试说起联系人读取为什么总在权限上翻车Android 内容提供器ContentProvider读取手机联系人是很多初学者接触跨应用数据共享的第一个实战场景。它能做什么简单说就是让你的 App 通过ContentResolver去查询系统联系人数据库把姓名和号码拉出来展示。适合谁适合正在学 Android 四大组件、准备做通讯录备份、来电秀、批量导入这类功能的朋友。但真正上手你会发现代码逻辑本身不复杂翻车点几乎全集中在两件事上一是AndroidManifest.xml里权限声明漏了或者写错位置二是动态申请READ_CONTACTS的回调没处理好导致点了「允许」还是读不到数据。更隐蔽的是很多教程只讲本地读取没讲当你要把联系人数据同步到远端、或者调用大模型做智能整理时Key 和 API 通道怎么统一配置。这篇就把这条完整链路拆开从清单声明、动态权限骨架到 TaoToken 统一 Key 的 config 配置再到真机验证和报错排查一步步给你可复制的代码。我试过在 Android 13 和 Android 14 真机上跑同一套代码行为差异还挺明显后面会具体说。2. TaoToken 前置统一 Key 与 API 通道准备在动手写联系人读取之前先把「数据出去」的通道准备好。因为联系人读出来之后你大概率要做点事情——比如同步到云端、做去重、或者丢给模型做智能分组。这时候如果每个功能都单独配一套 Key维护起来会很乱。TaoToken 的思路是给你一个统一的 Key 和 API 入口Android 端只需要维护一份配置。你需要先拿到两样东西一个 API Key以及确认接入地址。控制台里创建 Key 的入口在这里https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite创建完 Key 之后API 的基础地址是https://taotoken.net/api注意这个 API 地址后面不加任何 UTM 参数保持干净。Key 的管理页面在https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite如果你后面要做的是长期编码、Agent 类任务而不是单次调用可以看下 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite接入文档在这里配置字段有疑问直接查https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite注意Key 属于敏感凭证不要硬编码进 Git 仓库。Android 端建议放在local.properties或BuildConfig里通过 Gradle 注入。3. 可复制配置清单声明 动态权限 统一 Key 骨架3.1 AndroidManifest.xml 权限声明片段联系人读取属于危险权限必须在清单里声明否则动态申请时系统直接拒绝。把下面这段放到manifest标签内、application标签之前uses-permission android:nameandroid.permission.READ_CONTACTS / uses-permission android:nameandroid.permission.WRITE_CONTACTS /WRITE_CONTACTS按需加只读的话第一个就够了。这里有个容易踩的坑有人把uses-permission写进了application里面编译能过但运行时权限永远拿不到一定要放在application外面。3.2 动态权限申请代码骨架Android 6.0 以后危险权限必须运行时申请。下面这个骨架把「检查—申请—回调」三段串起来了public class ContactActivity extends AppCompatActivity { private static final int REQ_READ_CONTACTS 1001; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_contact); ensureContactPermission(); } private void ensureContactPermission() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) PackageManager.PERMISSION_GRANTED) { readContacts(); } else { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_CONTACTS}, REQ_READ_CONTACTS); } } Override public void onRequestPermissionsResult(int requestCode, NonNull String[] permissions, NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode REQ_READ_CONTACTS) { if (grantResults.length 0 grantResults[0] PackageManager.PERMISSION_GRANTED) { readContacts(); } else { Toast.makeText(this, 未授予联系人权限, Toast.LENGTH_SHORT).show(); } } } }3.3 读取联系人的核心查询逻辑拿到权限后用ContentResolver.query()查询ContactsContract.CommonDataKinds.Phone.CONTENT_URI逐行读取private void readContacts() { Cursor cursor null; try { cursor getContentResolver().query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, null, null, null); if (cursor ! null) { while (cursor.moveToNext()) { String name cursor.getString(cursor.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)); String number cursor.getString(cursor.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.Phone.NUMBER)); Log.d(Contact, name - number); } } } catch (Exception e) { Log.e(Contact, 读取失败, e); } finally { if (cursor ! null) cursor.close(); } }3.4 TaoToken 统一 Key 的 config 配置骨架联系人读出来之后如果要走远端通道把 Key 和地址统一收口到一个配置类里。下面用BuildConfig注入的方式避免硬编码public final class ApiConfig { // 在 build.gradle 中通过 buildConfigField 注入 public static final String BASE_URL BuildConfig.TAOTOKEN_BASE_URL; public static final String API_KEY BuildConfig.TAOTOKEN_API_KEY; public static final String CHAT_ENDPOINT BASE_URL /v1/chat/completions; private ApiConfig() {} }对应的build.gradle模块级里这样注入android { buildFeatures { buildConfig true } defaultConfig { buildConfigField String, TAOTOKEN_BASE_URL, \https://taotoken.net/api\ buildConfigField String, TAOTOKEN_API_KEY, \${project.findProperty(TAOTOKEN_API_KEY) ?: }\ } }Key 的值放在项目根目录gradle.properties或本地local.properties里不要提交到版本库。4. 验证请求与成功结果4.1 真机验证联系人读取装到真机后第一次进入页面会弹出权限对话框。点「允许」后看 Logcat 过滤Contact标签应该能看到类似输出D/Contact: 张三 - 13800000000 D/Contact: 李四 - 13900000001 D/Contact: 王五 - 13700000002如果列表为空但没报错先确认手机通讯录里确实有联系人再确认查询的 URI 没写错。Phone.CONTENT_URI和Contacts.CONTENT_URI返回的字段不一样前者带号码后者只有姓名。4.2 验证统一 Key 通道联系人数据拿到后用一次最小请求验证 Key 通道是否通。下面用HttpURLConnection发一个请求确认能正常返回public void verifyChannel() throws IOException { URL url new URL(ApiConfig.CHAT_ENDPOINT); HttpURLConnection conn (HttpURLConnection) url.openConnection(); conn.setRequestMethod(POST); conn.setRequestProperty(Authorization, Bearer ApiConfig.API_KEY); conn.setRequestProperty(Content-Type, application/json); conn.setDoOutput(true); String body {\model\:\gpt-4o-mini\,\messages\: [{\role\:\user\,\content\:\ping\}]}; try (OutputStream os conn.getOutputStream()) { os.write(body.getBytes(StandardCharsets.UTF_8)); } int code conn.getResponseCode(); Log.d(ApiConfig, HTTP code); }返回HTTP 200就说明 Key 和地址配置正确。想直接在网页上验证模型对话可以用这个入口https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite5. 本篇常见错排查5.1 权限已允许但 Cursor 为 null最常见的原因是清单里权限声明位置错了或者用了READ_CONTACTS却查了需要WRITE_CONTACTS的 URI。先检查uses-permission是否在application外面再确认查询的 URI 和权限匹配。5.2 回调里 grantResults 为空数组用户点了「拒绝」或者系统直接取消了对话框grantResults会是空数组。代码里判断grantResults.length 0是必须的否则会数组越界。另外 Android 11 以后用户拒绝两次后系统不再弹窗需要引导去设置页手动开Intent intent new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); intent.setData(Uri.fromParts(package, getPackageName(), null)); startActivity(intent);5.3 Android 13 读取部分联系人字段异常Android 13 对联系人读取做了更细的权限拆分但READ_CONTACTS仍然覆盖基本读取。如果遇到SecurityException先确认 targetSdk 和真机系统版本再检查是否在query时请求了未授权的列。用getColumnIndexOrThrow而不是getColumnIndex能在字段不存在时尽早暴露问题。5.4 统一 Key 请求返回 401401 基本是 Key 没带上或者带错了。检查Authorization头是不是Bearer加空格再加 Key空格漏了也会 401。另外确认BASE_URL结尾没有多余斜杠https://taotoken.net/api后面直接拼/v1/chat/completions不要出现双斜杠。5.5 主线程查询导致 ANR联系人数据量大时query放在主线程会卡顿甚至 ANR。把readContacts()丢到子线程用ExecutorService或者Coroutine处理回调再更新 UI。这个坑在联系人上千条时特别明显。6. 继续接入与长期使用建议联系人读取跑通之后下一步通常是把数据用起来。如果你只是偶尔验证模型效果直接用模型对话页面就够了https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite如果你要做的是长期编码、Agent 自动化这类持续调用场景建议走 Coding PlanKey 和配额管理会更省心https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteKey 的创建和轮换在控制台完成https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite配置字段拿不准的时候接入文档里都有对照说明https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite最后给个实用建议把联系人读取和网络请求彻底解耦。读取只负责拿到ListContact上传或调用模型单独封装一层这样权限逻辑和业务逻辑互不干扰排查问题时也能快速定位是权限没给还是通道没通。真机上多试几个系统版本Android 13 和 14 的权限行为差异只有实际跑过才知道。