
1. Android 读取联系人为什么总踩坑ContentResolver 查询与权限适配的真实场景Android 获取联系人这个需求看起来就是几行query的事但真正落到项目里十有八九会在三个地方翻车权限没申请对、Cursor 没关导致内存泄漏、号码字段取出来是空。核心检索词先摆出来——Android 通过 ContentResolver 读取系统联系人本质是跨进程访问content://com.android.contacts这个 ContentProvider你的 App 只是借道系统数据库所以权限、URI、字段映射三件事必须同时正确。它适合谁做通讯录备份、来电名片、企业 IM 导入好友、拨号辅助这类功能的 Android 开发者。能做什么拿到联系人姓名、多个手机号、邮箱、头像 ID甚至按号码反查联系人。但系统对隐私收得越来越紧READ_CONTACTS属于危险权限dangerous从 Android 6.0API 23开始必须运行时申请Android 10 之后部分字段还涉及分区存储和权限分级。我见过最常见的错误写法就是直接在onCreate里调query然后真机一跑直接崩日志里一行SecurityException: Permission Denial: reading com.android.providers.contacts。还有人把cursor.getColumnIndex()的返回值直接当数组下标用字段不存在时返回 -1getString(-1)立刻抛CursorIndexOutOfBoundsException。这些坑本篇都会给可复制的规避写法。下面按权限声明 → 运行时申请 → 查询遍历 → 号码映射 → 真机验证 → 报错排查的完整链路走一遍代码可以直接贴进项目改包名使用。查询部分我会用ContactsContract官方常量而不是硬编码字符串这样字段名不会因为系统版本变化而失效。2. TaoToken 前置准备给联系人功能加一个可调用的模型能力联系人读取本身是纯本地逻辑不需要联网。但很多真实项目会在拿到联系人后做智能处理比如根据备注自动生成分组标签把一堆号码整理成结构化 JSON识别名片里的公司名。这类需求如果自己写规则会非常痛苦用大模型做语义抽取会轻松很多。这时候就需要一个稳定的模型调用入口我平时用的是 TaoToken。TaoToken 是一个模型 API 聚合平台官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 它把多家模型的调用方式统一成 OpenAI 兼容格式你只要拿到一个 Base URL 和一个 Key就能在 Android 端用 OkHttp 直接发请求。对联系人场景来说典型用法是本地用 ContentResolver 读出联系人列表序列化成 JSON再丢给模型做去重、分类或补全。先说清楚它不是什么它不是联系人数据库也不碰你的本地数据只是一个模型调用通道。你的联系人数据要不要上传、上传哪些字段完全由你自己在代码里控制。涉及隐私字段时建议只传脱敏后的昵称或哈希别把完整号码发出去。接入前你需要准备三样东西这也是后面所有配置的基础项目说明获取位置Base URL统一接口前缀OpenAI 兼容https://taotoken.net/apiAPI Key身份凭证形如 sk-xxx控制台 API Keys 页面Model ID具体模型标识模型列表 / 文档控制台入口在 https://taotoken.net/console API Key 在 https://taotoken.net/api-keys 生成模型和参数说明看 https://taotoken.net/doc 。如果你只是想先验证模型能不能通用模型对话页面 https://taotoken.net/models 直接试一句就行不用写代码。这里要强调一个容易混淆的点Base URL 填https://taotoken.net/api不要自己加/v1后缀具体路径由 SDK 或请求体里的 endpoint 决定。很多 401 和 404 就是因为 URL 拼错。Key 只在服务端或本地调试时使用正式 App 里不要硬编码进 APK否则反编译就能拿到建议走自己的后端中转。3. 可复制配置权限声明、运行时申请与查询代码这一节是全文核心所有片段都可以直接复制。先看AndroidManifest.xml的权限声明这是第一步漏了它后面全白搭。manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.example.contactsdemo !-- 读取联系人危险权限需运行时申请 -- uses-permission android:nameandroid.permission.READ_CONTACTS / !-- 如果还要写回联系人比如备份恢复再加这条 -- uses-permission android:nameandroid.permission.WRITE_CONTACTS / application android:allowBackuptrue android:labelContactsDemo android:themestyle/Theme.AppCompat.Light activity android:name.MainActivity intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity /application /manifest注意READ_CONTACTS是危险权限光声明不申请在 API 23 以上会直接抛SecurityException。运行时申请用ActivityResultLauncher这是现在官方推荐写法比老的onRequestPermissionsResult干净。class MainActivity : AppCompatActivity() { private lateinit var requestPermission: ActivityResultLauncherString override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) requestPermission registerForActivityResult( ActivityResultContracts.RequestPermission() ) { granted - if (granted) { loadContacts() } else { // 用户拒绝给出解释或引导去设置页 Toast.makeText(this, 未授予联系人权限, Toast.LENGTH_SHORT).show() } } if (ContextCompat.checkSelfPermission( this, Manifest.permission.READ_CONTACTS ) PackageManager.PERMISSION_GRANTED ) { loadContacts() } else { requestPermission.launch(Manifest.permission.READ_CONTACTS) } } }接下来是查询主体。用ContactsContract.Contacts.CONTENT_URI而不是硬编码content://com.android.contacts/contacts前者是官方常量兼容性更好。遍历时先查联系人主表拿_ID和DISPLAY_NAME再用_ID去Phone.CONTENT_URI查号码因为一个联系人可能有多个号码。private fun loadContacts() { val resolver contentResolver val contacts mutableListOfContact() // 只查需要的列减少 IO val projection arrayOf( ContactsContract.Contacts._ID, ContactsContract.Contacts.DISPLAY_NAME_PRIMARY, ContactsContract.Contacts.HAS_PHONE_NUMBER ) resolver.query( ContactsContract.Contacts.CONTENT_URI, projection, null, null, ${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} ASC )?.use { cursor - // use 自动关闭 Cursor避免泄漏 val idIndex cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID) val nameIndex cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY) val hasPhoneIndex cursor.getColumnIndexOrThrow(ContactsContract.Contacts.HAS_PHONE_NUMBER) while (cursor.moveToNext()) { val contactId cursor.getString(idIndex) val name cursor.getString(nameIndex) ?: 未知 val hasPhone cursor.getInt(hasPhoneIndex) 0 val phones mutableListOfString() if (hasPhone) { resolver.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER), ${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} ?, arrayOf(contactId), null )?.use { phoneCursor - val numberIndex phoneCursor.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.Phone.NUMBER ) while (phoneCursor.moveToNext()) { phones.add(phoneCursor.getString(numberIndex)) } } } contacts.add(Contact(contactId, name, phones)) } } Log.i(ContactsDemo, 共读取 ${contacts.size} 个联系人) }如果你要在拿到联系人后调用模型做整理可以复用同一套 Base URL Key Model ID 三件套。下面是一个最小请求体示例注意model字段填你在控制台看到的真实 Model ID{ model: your-model-id, messages: [ { role: system, content: 你是通讯录整理助手把输入的联系人列表按公司归类输出 JSON。 }, { role: user, content: [{\name\:\张三\,\phones\:[\13800000000\]}] } ], temperature: 0.2 }请求地址就是https://taotoken.net/api加上文档里对应的对话路径Header 里带Authorization: Bearer sk-xxx。Android 端用 OkHttp 发 POSTContent-Type: application/json这部分和普通 REST 请求没区别。4. 验证请求与成功结果真机跑通联系人读取代码写完必须真机验证模拟器上联系人数据往往是空的容易误判成代码问题。先在真机上手动存两三个联系人其中一个存两个号码方便验证多号码逻辑。第一步安装运行 App首次启动会弹出权限对话框点允许。如果没弹检查是不是之前拒绝过并且勾了不再询问去设置里手动开。第二步看 Logcat。过滤 tagContactsDemo正常应该输出类似I/ContactsDemo: 共读取 3 个联系人如果数量是 0先确认手机里确实有联系人再检查HAS_PHONE_NUMBER字段。有些联系人只有邮箱没有号码hasPhone为 0会被跳过这是预期行为。第三步验证号码映射。把contacts列表打印出来确认多号码联系人两个号都在contacts.forEach { c - Log.d(ContactsDemo, id${c.id}, name${c.name}, phones${c.phones.joinToString()}) }预期输出D/ContactsDemo: id12, name张三, phones13800000000,13900000000 D/ContactsDemo: id15, name李四, phones13700000000第四步如果你接了模型做整理用模型对话页面 https://taotoken.net/models 先手动发一条测试消息确认 Key 和 Model ID 有效再回到 App 里发请求。这样能把模型配置错和App 网络代码错两类问题分开定位。实测下来真机上最容易忽略的是权限被系统自动重置。Android 11 之后如果 App 长时间不用系统会撤销危险权限下次启动checkSelfPermission会返回未授予所以每次进页面都要重新检查不能只在onCreate判断一次就完事。5. 本篇常见错排查401、SecurityException 与 Cursor 越界这一节按真实报错来对照遇到问题直接搜关键字。报错一java.lang.SecurityException: Permission Denial: reading com.android.providers.contacts原因没申请READ_CONTACTS或者申请了但用户拒绝。排查顺序先看 Manifest 有没有声明再看运行时有没有调requestPermission.launch最后看用户是不是点了拒绝。如果是拒绝checkSelfPermission会返回PERMISSION_DENIED别硬查。报错二android.database.CursorIndexOutOfBoundsException: Index -1 requested原因getColumnIndex返回 -1说明 projection 里没这个列或者列名拼错。解决改用getColumnIndexOrThrow字段不存在时直接抛异常并告诉你哪个列名错了比 -1 好定位。另外 projection 里写了哪些列就只能取哪些列别取没查的字段。报错三401 Unauthorized调用模型时原因API Key 错、过期或者 Header 没带对。检查Authorization: Bearer sk-xxx格式注意 Bearer 后面有一个空格。Key 去 https://taotoken.net/api-keys 重新生成一个再试。如果还是 401确认 Base URL 是https://taotoken.net/api没有多余斜杠或后缀。报错四local proxy failed/ 连接超时原因网络请求没走通可能是设备网络问题或请求地址写错。先在模型对话页面确认服务本身可用再检查 App 里的 URL 拼接。Android 9 以上默认禁止明文 HTTP如果你误用了 http 开头会直接失败确认用的是 https。报错五reading choices相关解析错误原因模型返回的 JSON 结构和你的解析代码不匹配比如你按choices[0].message.content取但实际返回结构不同。解决先把原始响应体完整打日志看清结构再写解析别凭记忆写字段路径。报错六Cursor 没关导致CursorWindowAllocationException原因query返回的 Cursor 用完没close()。解决全部用 Kotlin 的.use { }包裹或者 Java 里 try-finally 手动关。嵌套查询时内外两个 Cursor 都要关。排查时记住一个原则权限问题看 Logcat 的SecurityException数据问题看 Cursor 的列索引网络问题先分离模型配置和 App 代码。把这三类分开定位速度会快很多。6. 继续深入把联系人能力接到模型与长期编码工作流联系人读取跑通之后下一步通常是把它变成产品能力。比如做一个通讯录智能分组本地读出联系人脱敏后发给模型归类或者做名片识别补全把 OCR 结果和联系人字段对齐。这些场景都需要一个稳定的模型入口TaoToken 的 API 地址是 https://taotoken.net/api 配合 https://taotoken.net/doc 里的参数说明基本能覆盖对话、结构化抽取这类需求。如果你只是偶尔验证模型效果用模型对话页面 https://taotoken.net/models 最省事不用写代码。如果是要长期在项目里做编码辅助、Agent 编排建议看 Coding Plan https://taotoken.net/coding-plan 它更适合持续性的开发工作流。接入文档在 https://taotoken.net/doc API Key 管理在 https://taotoken.net/api-keys 控制台总入口是 https://taotoken.net/console 。最后给一个实用技巧联系人查询一定要做分页或限制条数几千个联系人的设备上一次性全查会明显卡顿。可以在 query 的 sortOrder 里加LIMIT或者用CursorLoader做异步加载。另外号码字段里可能带空格、横线、国家码存库前统一用正则清洗成纯数字能省掉后面一堆匹配问题。这些细节不写进教程但真到线上就是它们决定体验。