1. 从一次真机崩溃说起:Android 读取手机联系人到底卡在哪
很多人第一次写 Android 读取手机联系人,代码照着示例敲完,在模拟器上跑得好好的,一装到真机就出问题:要么列表空空如也,要么直接抛SecurityException闪退,要么查询返回的 Cursor 是 null。我试过最典型的一次,是同事把READ_CONTACTS只写进了AndroidManifest.xml,忘了 Android 6.0 之后必须运行时动态申请,结果用户点“读取联系人”按钮,App 当场挂掉。
这个场景的核心链路其实就四步:清单里声明READ_CONTACTS权限、运行时动态申请、用ContentResolver查询ContactsContract、解析返回的Uri和 Cursor 拿到姓名与号码。听起来简单,但每一步都有坑。比如ContactsContract.CommonDataKinds.Phone.CONTENT_URI和ContactsContract.Contacts.CONTENT_URI查出来的字段不一样,前者直接带号码,后者还得再查一次;再比如同一个联系人存了三个号码,用 Phone 表查就会返回三行,姓名重复出现。
这篇文章面向的是刚接触 Android 数据读取的开发者,或者被权限和 Cursor 折磨过的同学。我会把可复制的权限配置、查询代码骨架、真机验证步骤都写清楚,最后再对照几个真实报错讲排查思路。你跟着做,基本能一次跑通。如果你在本地调试时想顺手对比一下模型对这类代码的解释,可以用 TaoToken 的模型对话功能把报错贴进去问,地址是 https://taotoken.net/api ,配合接入文档看会更顺。
先说清楚一个概念:ContentResolver是 Android 里跨应用访问数据的“中介”,联系人数据存在系统的 Contacts Provider 里,你的 App 不能直接读数据库文件,只能通过ContentResolver.query()这个统一入口去查。ContactsContract则是一组契约类,定义了联系人相关的表名、列名和 Uri。理解这两者的分工,后面写代码就不会迷路。
2. 前置准备:TaoToken 接入与 Android 工程环境确认
在动手写联系人读取之前,先把两件事准备好:一个是 Android 工程本身的环境,另一个是调试过程中用来辅助排查的 TaoToken 接入。这里不是让你把 TaoToken 塞进 App 里,而是当你在写代码、看报错、查字段含义时,有个能快速问答和验证的通道。
Android 侧你需要确认:compileSdk建议 34 及以上,minSdk至少 23(因为运行时权限是 23 引入的),Android Studio 用较新的稳定版即可。真机调试比模拟器靠谱,因为模拟器默认联系人库是空的,你得手动加几个联系人才能验证。真机上一般自带通讯录,验证更真实。
TaoToken 这边,如果你要在本地用命令行工具或者编辑器插件辅助写代码,需要拿到 API Key。进入控制台创建密钥:https://taotoken.net/api-keys ,然后按接入文档配置 Base URL 和 Model ID:https://taotoken.net/doc 。Base URL 填https://taotoken.net/api,注意不要带多余的路径。Model ID 按你选的模型填,比如做代码解释和报错分析,选一个擅长代码的就行。
如果你打算长期用 AI 辅助 Android 开发,比如让它帮你生成 Cursor 解析样板、解释ContactsContract字段,可以考虑 Coding Plan:https://taotoken.net/coding-plan ,适合持续编码场景。Claude Code 用户走 Anthropic 兼容入口:https://taotoken.net/claude-code-anthropic 。这些配置和联系人读取本身是解耦的,只是让你在踩坑时有个能问的地方。
工程侧还要注意一点:AndroidManifest.xml里的权限声明只是“声明”,不代表“授予”。从 Android 6.0 开始,危险权限必须运行时申请,用户点了允许才算真正拿到。READ_CONTACTS就属于危险权限,所以清单声明和动态申请缺一不可。很多人只做了一半,这就是闪退的根源。
3. 可复制配置:AndroidManifest 权限声明与运行时申请代码
这一节给你可以直接抄的配置。先看清单文件,在<manifest>标签下、<application>之前加权限声明:
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.contactsdemo"> <uses-permission android:name="android.permission.READ_CONTACTS" /> <application android:allowBackup="true" android:label="ContactsDemo" android:theme="@style/Theme.AppCompat.Light"> <activity android:name=".MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>注意READ_CONTACTS是危险权限,光写这一行不够。接下来是运行时申请。推荐用registerForActivityResult这套新 API,比老的onRequestPermissionsResult更清晰:
public class MainActivity extends AppCompatActivity { private static final int REQ_CONTACTS = 1001; private final ActivityResultLauncher<String> requestPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestPermission(), granted -> { if (granted) { readContacts(); } else { Toast.makeText(this, "未授予联系人权限", Toast.LENGTH_SHORT).show(); } }); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); findViewById(R.id.btn_read).setOnClickListener(v -> checkAndRequest()); } private void checkAndRequest() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) { readContacts(); } else { requestPermissionLauncher.launch(Manifest.permission.READ_CONTACTS); } } }如果你用的是 Kotlin,逻辑一样,只是语法更短。这里的关键点是:先checkSelfPermission判断,已授权就直接读,没授权才launch申请。不要一上来就申请,用户体验差,而且部分定制系统会拦截。
再给一个settings.gradle和build.gradle里需要确认的片段,避免依赖缺失:
// app/build.gradle android { compileSdk 34 defaultConfig { minSdk 23 targetSdk 34 } } dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.core:core:1.12.0' }androidx.core提供了ContextCompat和ActivityResultContracts,别漏了。配置到这一步,权限链路就完整了:清单声明 + 运行时申请 + 结果回调。
4. 查询 ContactsContract:ContentResolver 代码骨架与真机验证
权限拿到后,核心就是查询。先定义 Uri,再调ContentResolver.query(),然后遍历 Cursor。下面这段是可以直接跑的骨架:
private void readContacts() { List<String> result = new ArrayList<>(); Uri uri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI; String[] projection = { ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.NUMBER }; ContentResolver resolver = getContentResolver(); Cursor cursor = null; try { cursor = resolver.query(uri, projection, null, null, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC"); if (cursor != null) { int nameIdx = cursor.getColumnIndex( ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME); int numberIdx = cursor.getColumnIndex( ContactsContract.CommonDataKinds.Phone.NUMBER); while (cursor.moveToNext()) { String name = cursor.getString(nameIdx); String number = cursor.getString(numberIdx); result.add("姓名:" + name + ";电话:" + number); } } } catch (SecurityException e) { Log.e("Contacts", "权限异常", e); } finally { if (cursor != null) { cursor.close(); } } Toast.makeText(this, "联系人条数:" + result.size(), Toast.LENGTH_LONG).show(); for (String s : result) { Log.d("Contacts", s); } }几个要点。第一,projection明确指定要查的列,比传 null 更高效,也避免拿到一堆用不上的字段。第二,getColumnIndex要在循环外取一次,循环内反复取会拖慢速度。第三,Cursor 用完必须close(),放在finally里最稳,否则容易内存泄漏。第四,排序用DISPLAY_NAME ASC,让结果按姓名排,方便核对。
真机验证步骤:先在手机通讯录里手动加三个联系人,其中一个存两个号码。然后安装 App,点按钮,第一次会弹权限框,选“允许”。看 Logcat 里Contacts标签的输出,应该能看到姓名和号码。注意那个存了两个号码的联系人会输出两行,姓名相同、号码不同,这是 Phone 表的正常行为,因为它是“每个号码一行”。
如果你想只拿每个联系人一行,可以改用ContactsContract.Contacts.CONTENT_URI查_ID和DISPLAY_NAME,再根据_ID去 Phone 表查号码。但那样代码更长,初学阶段先用 Phone 表跑通更直观。验证时如果 Logcat 里一条都没有,先确认手机通讯录里真的有联系人,再看权限是否真的授予了。
5. 常见报错排查:SecurityException、Cursor 为 null 与字段读不到
这一节对照几个真实报错讲。第一个,java.lang.SecurityException: Permission Denial: reading com.android.providers.contacts。这个几乎都是权限没到位。检查三处:清单里有没有READ_CONTACTS、运行时有没有申请、用户是不是点了拒绝。如果用户勾了“不再询问”,下次launch会直接回调 false,你得引导用户去系统设置里手动开。可以用shouldShowRequestPermissionRationale判断,但别过度设计,先保证基础流程对。
第二个,cursor为 null。query()返回 null 通常意味着 Uri 写错了,或者 Provider 不可用。确认你用的是ContactsContract.CommonDataKinds.Phone.CONTENT_URI,而不是自己拼的字符串。另外,如果projection里的列名写错,某些设备会抛IllegalArgumentException,而不是返回 null,所以列名要从ContactsContract常量里取,别手写字符串。
第三个,getColumnIndex返回 -1,然后getString(-1)抛CursorIndexOutOfBoundsException。这说明你查的列不在 projection 里。比如你 projection 只放了DISPLAY_NAME和NUMBER,却去取_ID,就会 -1。解决办法是 projection 和取值列一一对应,或者用getColumnIndexOrThrow让错误更早暴露。
第四个,OAuth 或本地代理相关报错,比如local proxy failed、401 Unauthorized。这类一般出现在你用 AI 工具辅助调试、配置 Base URL 或 Key 时。检查https://taotoken.net/api是否写对,Key 是否过期,Model ID 是否匹配。如果是 Claude Code 场景,确认走的是 Anthropic 兼容入口。这些和联系人读取本身无关,但调试时容易混在一起,分开看就行。
还有一个隐蔽的坑:部分国产 ROM 对联系人权限做了额外限制,即使你申请了READ_CONTACTS,也可能需要用户在系统“权限管理”里单独开“读取联系人”。遇到查不到数据但权限显示已授予,去系统设置里翻一下。
6. 继续深入:把联系人读取接进你的实际项目
跑通基础链路后,你可以按需扩展。比如把结果做成 RecyclerView 列表,加搜索框过滤,或者把号码做格式化。再进一步,如果要做批量导入、去重、和云端同步,逻辑会复杂不少,这时候用 AI 辅助梳理字段和边界条件会省时间。需要验证模型对某段 Cursor 代码的解释,可以用模型对话:https://taotoken.net/api ,把代码和报错一起贴进去。
实际项目里我建议把联系人读取封装成一个 Repository 类,权限申请留在 Activity 或 Fragment,查询逻辑独立出来,方便测试。Cursor 的关闭、异常捕获、空判断都收在 Repository 里,上层只拿List<Contact>。这样后面换数据源或者加缓存,改动面小。
最后提醒一句:读取联系人属于敏感数据,Google Play 上架时需要在隐私政策里说明用途,并且最好做“最小必要”读取,别一次性把全部联系人上传。技术上跑通是一回事,合规使用是另一回事。你先把这篇的代码在真机上跑一遍,遇到报错按第 5 节对照排查,基本就稳了。