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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite创建完 Key 之后,API 的基础地址是:
https://taotoken.net/api注意这个 API 地址后面不加任何 UTM 参数,保持干净。Key 的管理页面在:
https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite如果你后面要做的是长期编码、Agent 类任务,而不是单次调用,可以看下 Coding Plan:
https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite接入文档在这里,配置字段有疑问直接查:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite注意:Key 属于敏感凭证,不要硬编码进 Git 仓库。Android 端建议放在
local.properties或BuildConfig里,通过 Gradle 注入。
3. 可复制配置:清单声明 + 动态权限 + 统一 Key 骨架
3.1 AndroidManifest.xml 权限声明片段
联系人读取属于危险权限,必须在清单里声明,否则动态申请时系统直接拒绝。把下面这段放到<manifest>标签内、<application>标签之前:
<uses-permission android:name="android.permission.READ_CONTACTS" /> <uses-permission android:name="android.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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite5. 本篇常见错排查
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 请求返回 401
401 基本是 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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite如果你要做的是长期编码、Agent 自动化这类持续调用场景,建议走 Coding Plan,Key 和配额管理会更省心:
https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewriteKey 的创建和轮换在控制台完成:
https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite配置字段拿不准的时候,接入文档里都有对照说明:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite最后给个实用建议:把联系人读取和网络请求彻底解耦。读取只负责拿到List<Contact>,上传或调用模型单独封装一层,这样权限逻辑和业务逻辑互不干扰,排查问题时也能快速定位是权限没给还是通道没通。真机上多试几个系统版本,Android 13 和 14 的权限行为差异,只有实际跑过才知道。