1. 从一次真机崩溃说起:Android 读取系统联系人权限到底难在哪
很多人第一次写 Android 读取系统联系人权限,代码在模拟器上跑得挺顺,一换真机就出问题:要么列表空白,要么直接抛SecurityException,要么用户明明点了「允许」却还是查不到数据。我试过在一台双卡双待、同时登录了工作账户和个人账户的手机上调试,ContentResolver返回的游标里联系人数量比通讯录里看到的少了一大截,排查半天才发现是多账户场景没处理。
先把概念说清楚。Android 读取系统联系人权限,指的是应用通过READ_CONTACTS这个危险权限(dangerous permission),借助ContentResolver访问系统通讯录数据库ContactsContract的能力。它能做什么?可以拿到联系人姓名、电话、邮箱、头像 URI,甚至组织信息。适合谁?做通讯录备份、来电识别、社交推荐、企业通讯录同步这类功能的应用开发者。但注意,从 Android 6.0(API 23)开始,危险权限必须在运行时动态申请,光在AndroidManifest.xml里声明是不够的。
这条链路其实分四段:清单声明、运行时申请、查询投影、结果处理。任何一段出问题,表现都是「读不到」。而调试期往往还要调用后端接口做数据校验或上传,这时候如果每个环境都去申请一套 Key,管理起来很乱。我在项目里用 TaoToken 的统一 Key 通道来管理调试期的接口调用,把模型对话、接口鉴权这些杂事收敛到一个入口,主流程就能专注在权限和查询本身。下面按可跟做的顺序拆开讲。
2. TaoToken 统一 Key 通道:调试期接口调用怎么收敛
在讲权限代码之前,先把这个前置环节说清楚,因为它决定了你调试时会不会被「接口 401」打断节奏。TaoToken 是一个统一 Key/API 通道,官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api 。它的作用是让你用一套 Key 去调用多种模型和接口能力,调试 Android 应用时如果需要做联系人数据的语义处理、号码归属地补全、或者让模型帮忙生成测试数据,就不用到处注册。
具体怎么用?先到控制台创建 Key。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,进去后在 API Keys 页面生成一个 Key,地址是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。拿到 Key 之后,你可以在 Android 项目里用 OkHttp 或 Retrofit 调用,Base URL 填https://taotoken.net/api,鉴权头用Authorization: Bearer <你的Key>。
如果你只是想先验证模型能不能正常返回,可以直接用模型对话页面试一下:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。这个页面不需要写代码,输入一段联系人相关的文本,看返回是否符合预期,确认通道通了再往 Android 里接。
对于长期做编码和 Agent 开发的场景,可以考虑 Coding Plan,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,它更适合需要持续调用、批量处理的开发流程。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有完整的参数说明。如果你用 Claude Code 这类工具,Anthropic 兼容入口是 https://taotoken.net/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_content=ClaudeCodeAnthropic&utm_campaign=rewrite 。
这里要强调一点:TaoToken 只是帮你管理调试期的接口调用,它不替代 Android 系统权限机制,也不碰你的通讯录数据。联系人读取始终走ContentResolver,接口调用只是辅助。把这两件事分清楚,架构才不会乱。
3. 可复制配置:清单声明、运行时申请与查询投影
这一节是核心,给出能直接抄的代码和配置。先看AndroidManifest.xml,在<manifest>标签内、<application>之前加上权限声明:
<uses-permission android:name="android.permission.READ_CONTACTS" />如果你还需要在用户授权后写入或修改联系人,再加WRITE_CONTACTS,但本文只聚焦读取,所以一个就够。注意 Android 11(API 30)之后,如果应用需要读取联系人以提供搜索建议等场景,可能还要考虑QUERY_ALL_PACKAGES之类的策略,但读取通讯录本身READ_CONTACTS仍然有效。
接下来是布局,一个简单的 ListView:
<ListView android:id="@+id/contacts_view" android:layout_width="match_parent" android:layout_height="match_parent" />然后是 Activity 的完整逻辑。我把它拆成初始化、权限判断、查询、回调四块,方便你对照:
public class MainActivity extends AppCompatActivity { private static final int REQUEST_CODE_READ_CONTACTS = 1; private ListView contactsView; private List<String> contactsList = new ArrayList<>(); private ArrayAdapter<String> adapter; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); initView(); } private void initView() { contactsView = findViewById(R.id.contacts_view); adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, contactsList); contactsView.setAdapter(adapter); 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 { readContacts(); } } }查询部分用ContentResolver,投影(projection)建议显式指定列,不要传null。传null会返回所有列,数据量大时性能差,而且不同厂商 ROM 返回的列可能不一致,容易踩坑:
private void readContacts() { Cursor cursor = null; try { String[] projection = new String[]{ ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.CONTACT_ID }; cursor = getContentResolver().query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, projection, null, null, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC"); if (cursor != null) { int nameIndex = cursor.getColumnIndex( ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME); int numberIndex = cursor.getColumnIndex( ContactsContract.CommonDataKinds.Phone.NUMBER); while (cursor.moveToNext()) { String displayName = nameIndex >= 0 ? cursor.getString(nameIndex) : ""; String number = numberIndex >= 0 ? cursor.getString(numberIndex) : ""; if (displayName == null) displayName = "未知联系人"; if (number == null) number = "无号码"; contactsList.add(displayName + "\n" + number); } adapter.notifyDataSetChanged(); } } catch (Exception e) { Log.e("ContactsRead", "查询联系人失败", e); } finally { if (cursor != null) { cursor.close(); } } }回调方法处理授权结果:
@Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode == REQUEST_CODE_READ_CONTACTS) { if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { readContacts(); } else { Toast.makeText(this, "获取联系人权限失败", Toast.LENGTH_SHORT).show(); } } }如果你在调试期需要把联系人数据发给后端做校验,可以用 TaoToken 的通道。下面是一个 OkHttp 的配置片段,Base URL 和 Key 按前面说的填:
OkHttpClient client = new OkHttpClient(); MediaType JSON = MediaType.parse("application/json; charset=utf-8"); String body = "{\"model\":\"your-model-id\",\"messages\":[{\"role\":\"user\",\"content\":\"校验联系人格式\"}]}"; Request request = new Request.Builder() .url("https://taotoken.net/api/v1/chat/completions") .addHeader("Authorization", "Bearer " + BuildConfig.TAOTOKEN_KEY) .post(RequestBody.create(body, JSON)) .build();Key 不要硬编码在代码里,放到local.properties或BuildConfig里,避免提交到仓库。Model ID 按你实际使用的填,接入文档里有说明。
4. 真机验证:从拒绝到同意的完整请求链路
配置写完,必须真机验证。模拟器可以提前在 Contacts 应用里加几个联系人,真机则直接用你自己的通讯录。步骤是这样的:
第一步,安装并首次启动应用。系统会弹出权限申请对话框,先点「拒绝」。此时onRequestPermissionsResult会收到grantResults[0]为PERMISSION_DENIED,界面应该弹出「获取联系人权限失败」的 Toast,列表保持空白。这一步验证的是拒绝分支。
第二步,进入系统设置,找到你的应用,手动授予联系人权限,或者卸载重装再点「允许」。授权后readContacts()被调用,列表应该显示联系人姓名和号码。如果列表还是空的,先检查通讯录里是否真的有数据,再看 Logcat 有没有异常。
第三步,验证多账户场景。在系统设置里添加一个工作账户或第二个 Google 账户,往里面存几个联系人。重新查询,观察列表是否包含两个账户的联系人。ContactsContract.CommonDataKinds.Phone.CONTENT_URI默认会聚合多个账户的数据,但如果你用了自定义查询条件,可能会漏掉。
第四步,验证空值处理。故意建一个只有姓名没有号码的联系人,或者只有号码没有姓名的联系人,看代码里的null判断是否生效。我踩过的坑是某些 ROM 返回的DISPLAY_NAME为空字符串而不是null,所以判断要同时考虑null和isEmpty()。
第五步,验证接口调用。如果接了 TaoToken,在授权成功后触发一次接口请求,看 Logcat 里返回的状态码。正常应该是 200,如果返回 401,说明 Key 或鉴权头有问题,回到控制台检查。
整个过程建议用adb logcat过滤你的 TAG,实时看输出。真机验证比模拟器可靠得多,尤其是权限和账户相关的行为。
5. 常见报错排查:401、SecurityException 与游标空指针
这一节对照真实报错,给出排查路径。
报错一:java.lang.SecurityException: Permission Denial: reading com.android.providers.contacts
这是最典型的。原因通常是清单里没声明READ_CONTACTS,或者声明了但运行时没申请。检查两点:AndroidManifest.xml里是否有<uses-permission android:name="android.permission.READ_CONTACTS" />;checkSelfPermission是否在查询前调用。还有一种情况是用户在设置里手动关闭了权限,此时checkSelfPermission会返回PERMISSION_DENIED,需要引导用户去设置页。
报错二:接口返回 401 Unauthorized
如果你在调试期调用 TaoToken 接口,遇到 401,先确认Authorization头格式是不是Bearer <Key>,中间有空格。再确认 Key 有没有过期或被删除,去 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 检查。还有一种可能是 Base URL 写错了,必须是https://taotoken.net/api,不要多加斜杠或路径。
报错三:java.lang.NullPointerException在cursor.getString
这通常是投影列名写错,或者getColumnIndex返回 -1。解决办法是用getColumnIndexOrThrow在调试期暴露问题,或者像前面代码那样先判断索引是否大于等于 0。另外,cursor.moveToNext()之前要确保cursor不为null。
报错四:local proxy failed或连接超时
这类报错一般出现在网络请求环节,和权限无关。检查设备网络是否正常,接口地址是否可达。如果你在 Android 模拟器里调用,注意模拟器的网络和宿主机不同,localhost指向模拟器自身,要用宿主机 IP。TaoToken 的接口是公网地址,正常配置下不会有这个问题。
报错五:OAuth 相关错误
如果你用 Claude Code 或类似工具接入,遇到 OAuth 报错,检查 token 是否过期,重新走一遍授权流程。Anthropic 兼容入口的配置参考 https://taotoken.net/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_content=ClaudeCodeAnthropic&utm_campaign=rewrite 。
排查的核心思路是:先看 Logcat 的完整堆栈,定位是权限层、查询层还是网络层,再针对性解决。不要一上来就改代码,先读报错。
6. 把权限链路和接口通道分开管理
最后说点实操经验。Android 读取系统联系人权限这条链路,本质是系统能力调用,和你的后端接口是两回事。我建议在项目里把这两块彻底分开:权限和查询封装成一个ContactsRepository,接口调用封装成ApiClient,两者通过 ViewModel 或 UseCase 衔接。这样调试权限时不会受接口影响,调试接口时也不会动权限代码。
TaoToken 在这里的角色是统一 Key 通道,帮你把调试期的接口鉴权收敛到一个地方。需要验证模型返回时,用模型对话页面快速试;需要长期编码时,用 Coding Plan;需要查参数时,翻接入文档。把这些入口记下来,比每次到处找配置高效得多。
代码写完后,记得在真机上把「拒绝—授权—多账户—空值」四种情况都跑一遍。联系人读取看起来简单,但厂商 ROM 的差异、账户聚合的逻辑、空值的处理,每一个都可能让你多花半天。把投影写明确,把空值判断写全,把权限回调写对,这条链路就稳了。