
1. 安卓读取联系人和号码为什么一跑就崩很多人第一次写「安卓获取手机联系人和号码」代码看着没问题真机一跑要么直接闪退要么返回一堆 null。核心检索词先摆出来安卓读取系统联系人、ContentResolver 查询号码、运行时权限申请这三件事是整条链路的关键。它适合谁适合正在做通讯录备份、来电名片、企业通讯录同步、客服工单自动带出客户信息的安卓开发者尤其是刚接触 ContentProvider 的同学。我先把最容易踩的坑说清楚。安卓 6.0API 23之后READ_CONTACTS属于危险权限光在 AndroidManifest.xml 里声明是不够的必须在运行时动态申请。很多人只写了清单声明结果ContentResolver.query()直接抛SecurityExceptionApp 当场挂掉。第二个坑是号码字段ContactsContract.CommonDataKinds.Phone.NUMBER拿到的号码可能带空格、横线、括号甚至带国家码直接拿去匹配会失败。第三个坑是查询方式早期用ACTION_GET_CONTENT拉起系统选人界面返回的 Uri 结构和你直接查Phone.CONTENT_URI完全不一样混着用就会getColumnIndex返回 -1再getString(-1)又崩。这篇我会按真实项目链路走一遍清单声明、运行时权限、ContentResolver 查询、号码字段兼容处理最后把接口 endpoint 改到 TaoToken 统一 Key/API 通道做鉴权联调。你照着敲就能跑通代码都是可复制的。整条链路里本地读取是安卓系统能力上传/联调走的是网络请求这两段我会分开讲避免你把权限问题和网络问题搅在一起排查。先明确一个边界读取联系人是设备本地行为不需要联网只有当你把读到的联系人同步到后端、或者调用大模型做号码归属/去重时才涉及 API 通道。所以本文的网络部分只讲「怎么把请求 endpoint 指向统一通道并带上 Key」不涉及任何绕过系统限制的操作。2. TaoToken 统一 Key 通道前置准备2.1 为什么联系人同步场景需要统一 Key假设你做完本地读取接下来要把联系人批量同步到服务端或者调用模型做「号码格式归一化」「重复联系人合并建议」。传统做法是每个服务商一套 Key、一套 Base URL散落在代码里换环境就改一堆常量。TaoToken 的思路是给你一个统一的 API 通道Base URL 固定Key 统一管理模型用 Model ID 区分。这样你在安卓端只需要维护一份配置联调、测试、上线切换成本低很多。官网入口在这里https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。API 根地址是 https://taotoken.net/api 注意这个地址后面不加任何查询参数保持干净。2.2 拿到 Key 和确认 Model ID进入控制台创建 API Key路径是 consolehttps://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。创建完记得复制保存页面关闭后一般不再完整显示。Key 的管理页在 api-keyshttps://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 后续轮换、禁用都在这。Model ID 不要凭记忆写去文档页确认当前可用的模型标识https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。如果你只是想先在网页里验证模型通不通可以用模型对话页https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 。2.3 三件套Base URL Key Model ID不管你是用 OkHttp 手写请求还是用 Cline、Codex 这类工具配置永远是这三件套配置项值说明Base URLhttps://taotoken.net/api固定不带斜杠结尾参数API Key控制台生成放请求头 AuthorizationModel ID文档页确认请求体里的 model 字段注意Key 属于敏感凭证不要硬编码进 APK 后直接发版。安卓端建议放local.properties或服务端下发至少别提交到 Git。如果你用的是 Claude Code 这类编码工具做联调辅助接入文档在https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有 Base URL 和鉴权头的写法。长期做编码和 Agent 任务的话可以看 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。3. 可复制配置权限声明与查询代码3.1 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.INTERNET / 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 /manifestREAD_CONTACTS是读联系人INTERNET是后面联调 API 用的。如果你还要写回联系人再加WRITE_CONTACTS本文不涉及写入。3.2 运行时权限申请在 Activity 里动态申请用registerForActivityResult这套新 API别再用废弃的onRequestPermissionsResultclass MainActivity : AppCompatActivity() { private val requestPermission registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted - if (granted) { loadContacts() } else { Toast.makeText(this, 未授予联系人权限无法读取, Toast.LENGTH_SHORT).show() } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) findViewByIdButton(R.id.btnLoad).setOnClickListener { if (ContextCompat.checkSelfPermission( this, Manifest.permission.READ_CONTACTS ) PackageManager.PERMISSION_GRANTED ) { loadContacts() } else { requestPermission.launch(Manifest.permission.READ_CONTACTS) } } } }这段逻辑很直白已授权就直接读没授权就申请用户拒绝就提示。真机上第一次点按钮会弹系统对话框。3.3 ContentResolver 查询联系人和号码核心查询用Phone.CONTENT_URI一次拿到名字和号码private fun loadContacts() { val result mutableListOfString() val resolver contentResolver val projection arrayOf( ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.CONTACT_ID ) val cursor resolver.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, projection, null, null, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME ASC ) cursor?.use { c - val nameIdx c.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME) val numIdx c.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER) while (c.moveToNext()) { val name c.getString(nameIdx) ?: 未知 val rawNumber c.getString(numIdx) ?: val cleanNumber normalizeNumber(rawNumber) result.add($name - $cleanNumber) } } Log.d(Contacts, 共读取 ${result.size} 条) }注意cursor?.use {}会自动关闭游标避免内存泄漏。getColumnIndex拿到的索引如果查询失败会是 -1所以 projection 必须和读取字段严格对应。3.4 号码字段兼容处理号码清洗是重点不同厂商、不同 SIM 卡格式差异很大private fun normalizeNumber(raw: String): String { // 去掉空格、横线、括号 var n raw.replace(Regex([\\s\\-()]), ) // 处理 86 / 0086 前缀 n n.removePrefix(86).removePrefix(0086).removePrefix(86) // 只保留数字 n n.filter { it.isDigit() } return n }实测下来86 138-0000-0000会被清洗成13800000000方便后续去重和匹配。如果你的业务需要保留原始格式就把 raw 和 clean 都存下来。3.5 把 endpoint 指向 TaoToken 统一通道本地读完之后如果要调用模型做号码归一化建议用 OkHttp 发请求Base URL 指向统一通道val client OkHttpClient() val json JSONObject().apply { put(model, 你的ModelID) put(messages, JSONArray().put(JSONObject().apply { put(role, user) put(content, 帮我把这些号码去重并给出合并建议$contactList) })) } val body json.toString().toRequestBody(application/json.toMediaType()) val request Request.Builder() .url(https://taotoken.net/api/v1/chat/completions) .addHeader(Authorization, Bearer ${BuildConfig.TAOTOKEN_KEY}) .addHeader(Content-Type, application/json) .post(body) .build()Key 从BuildConfig读别写死。Model ID 用文档页确认的那个。4. 真机验证请求与成功结果4.1 本地读取验证装到真机点按钮授权后看 Logcat 过滤Contacts标签。正常输出类似D/Contacts: 共读取 128 条 D/Contacts: 张三 - 13800000000 D/Contacts: 李四 - 13911112222如果条数是 0先确认模拟器/真机通讯录里确实有联系人很多模拟器默认是空的。4.2 接口联调验证用 curl 先验证通道通不通再上安卓代码curl -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer 你的Key \ -H Content-Type: application/json \ -d { model: 你的ModelID, messages: [{role: user, content: 返回 ok}] }成功会返回 JSONchoices[0].message.content里有内容。安卓端拿到响应后解析choices数组即可。如果 curl 通、安卓不通八成是 Key 没打进请求头或者网络权限没加。4.3 完整链路结果理想状态下你点一次按钮申请权限 → 读取 128 条联系人 → 清洗号码 → 打包发给统一通道 → 返回去重建议。整个过程本地读取是毫秒级网络请求取决于模型响应速度。真机验证时建议先断网测本地读取确认权限和查询没问题再联网测接口这样出问题好定位。5. 本篇常见错误排查5.1 SecurityException: Permission Denial报错原文类似java.lang.SecurityException: Permission Denial: reading com.android.providers.contacts.ContactsProvider2 uri content://com.android.contacts/data/phones from pid... requires android.permission.READ_CONTACTS原因只声明了清单权限没做运行时申请或者用户点了拒绝。排查确认checkSelfPermission返回PERMISSION_GRANTED再查询被拒绝后引导用户去设置页手动开。5.2 getColumnIndex 返回 -1 导致崩溃报错java.lang.IllegalStateException: Couldnt read row 0, col -1 from CursorWindow原因projection 里的字段和getColumnIndex取的字段不一致或者用ACTION_GET_CONTENT返回的 Uri 去查Phone.NUMBER。排查统一用Phone.CONTENT_URI查询projection 和读取字段一一对应。5.3 401 Unauthorized接口返回{error:{message:Invalid API key,type:invalid_request_error}}原因Key 没带、带错、或者Bearer后面多了空格。排查确认请求头是Authorization: Bearer sk-xxxKey 从控制台重新复制。Key 管理页https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。5.4 local proxy failed / connection refused报错java.net.ConnectException: Failed to connect to taotoken.net/...原因设备网络不通或者你本地配了什么代理把请求拦了。排查先用浏览器或 curl 在设备同网络下测通再排查 App 网络权限。注意不要使用任何非正规网络工具保持直连即可。5.5 reading choices 解析失败报错org.json.JSONException: No value for choices原因响应结构和你解析的字段不匹配可能是请求体格式错了比如 messages 不是数组服务端返回了错误对象而不是正常响应。排查先把原始响应Log出来看确认choices存在再解析。5.6 OAuth / 鉴权头格式问题如果你用某些工具接入报 OAuth 相关错误通常是鉴权方式选错了。统一通道用的是 Bearer Token不是 OAuth 流程。接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。用 Cline MCP 或 Codex 的 auth.json 时务必写全三件套Base URL 填https://taotoken.net/apiKey 填你的凭证Model ID 填文档确认的值缺一个都会鉴权失败。6. 继续联调与工具选择本地读取跑通后下一步通常是批量同步和智能处理。如果你只是偶尔验证模型输出用模型对话页最快https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 。如果你要把联系人同步做成长期任务、或者接 Agent 自动处理Coding Plan 更合适https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。Key 的创建和轮换都在 api-keys 页https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 接入细节查文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。最后给个实用建议联系人读取这块真机测试永远比模拟器靠谱因为模拟器通讯录数据、权限弹窗行为都可能和真机不同。号码清洗函数建议单独写单元测试把86、0086、带横线、带括号这几种格式都覆盖一遍上线前能省很多事。接口联调时先把 curl 跑通再写安卓代码能快速区分是通道问题还是客户端问题。