1. 从一次真机崩溃说起:读取手机通讯录到底难在哪
很多人第一次写「读取手机通讯录」这个功能,都是被产品经理一句「把联系人导进来」给带进坑的。代码写起来好像就几行:申请权限、查 Cursor、循环读字段、塞进集合。但真机一跑,问题全冒出来了——Android 13 以上READ_CONTACTS弹窗不出现、Cursor 忘记关导致内存泄漏、联系人头像读出来是 null、同一个号码重复出现好几条、华为和小米上字段名对不上。
我试过在一个项目里把通讯录导入做成「一键同步」,结果测试机跑得好好的,用户机上直接 ANR。后来拆开看,问题出在三件事上:权限申请没做版本分支、Cursor 遍历时在主线程做了 Bitmap 解码、异常兜底只写了个空 catch。这三件事恰好就是「读取手机通讯录」这条链路上最容易翻车的地方。
所以这篇不讲虚的,就围绕一条完整链路来写:运行时权限申请 → 联系人字段解析 → 异常兜底 → 把模型调用 endpoint 改到 TaoToken 做字段清洗与结构化输出。适合谁看?正在做通讯录导入、联系人去重、CRM 同步、或者想把本地联系人喂给大模型做整理的移动端开发者。读完你能直接抄走权限声明、查询代码、字段映射表,还有一份常见报错对照表。
核心检索词先摆出来:读取手机通讯录、运行时权限申请、联系人字段解析、Cursor 遍历、TaoToken 统一 Key。这几个词会贯穿全文,你搜到的和这里写的应该是同一件事。
先说清楚一个前提:通讯录属于敏感个人数据,读取前必须拿到用户明确授权,且只能用于用户知情同意的用途。本文所有代码都建立在「用户主动点击导入按钮」这个场景上,不做后台静默读取,这一点在合规上是底线。
2. 权限申请与字段解析:Android 读取手机通讯录的完整链路
2.1 权限声明:Manifest 里到底要写几个
先看AndroidManifest.xml。读取通讯录最基础的是READ_CONTACTS,如果你还要写入(比如合并重复联系人),再加WRITE_CONTACTS。注意 Android 6.0(API 23)之后这两个都是危险权限,必须运行时申请,光在 Manifest 里声明没用。
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.contactimport"> <uses-permission android:name="android.permission.READ_CONTACTS" /> <uses-permission android:name="android.permission.WRITE_CONTACTS" /> <application android:allowBackup="true" android:label="联系人导入"> <!-- 你的 Activity / Service --> </application> </manifest>这里有个坑:Android 13(API 33)开始,部分权限被拆得更细,但READ_CONTACTS本身没有被拆分,所以不用像读媒体文件那样去处理READ_MEDIA_IMAGES。真正需要版本分支的是「申请方式」和「用户拒绝后的引导」。
2.2 运行时权限申请:用 ActivityResultLauncher 替代旧 API
老代码里常见onRequestPermissionsResult+requestPermissions,这套在 AndroidX 里已经过时。现在推荐registerForActivityResult,代码更短,回调更清晰。
class ContactImportActivity : AppCompatActivity() { private val requestPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (granted) { loadContacts() } else { // 用户拒绝,给出解释并引导去设置页 showPermissionDeniedDialog() } } private fun ensurePermissionAndLoad() { when { ContextCompat.checkSelfPermission( this, Manifest.permission.READ_CONTACTS ) == PackageManager.PERMISSION_GRANTED -> { loadContacts() } shouldShowRequestPermissionRationale(Manifest.permission.READ_CONTACTS) -> { // 用户之前拒绝过,先解释为什么需要 showRationaleDialog { requestPermission.launch(Manifest.permission.READ_CONTACTS) } } else -> { requestPermission.launch(Manifest.permission.READ_CONTACTS) } } } }shouldShowRequestPermissionRationale返回 true,说明用户拒绝过一次但没勾「不再询问」,这时候先弹一个说明框,通过率会高很多。如果返回 false 且权限没给,多半是勾了「不再询问」,只能引导去系统设置页手动开。
2.3 字段解析:Cursor 遍历的正确姿势
通讯录数据在ContactsContract里,联系人、电话、邮箱、头像分散在不同表。最省事的做法是用ContactsContract.Contacts.CONTENT_URI查主表,再用_ID去查子表。但这样会有 N+1 查询问题,联系人一多就慢。更好的方式是直接查Phone.CONTENT_URI或Data.CONTENT_URI,一次拿到需要的字段。
下面这段是查「有电话号码的联系人」,包含姓名、电话、头像 ID:
private fun loadContacts(): List<Contact> { val contacts = mutableListOf<Contact>() val projection = arrayOf( ContactsContract.CommonDataKinds.Phone.CONTACT_ID, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.PHOTO_ID ) val selection = "${ContactsContract.CommonDataKinds.Phone.NUMBER} IS NOT NULL" val sortOrder = "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} ASC" contentResolver.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, projection, selection, null, sortOrder )?.use { cursor -> val idIdx = cursor.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.Phone.CONTACT_ID) val nameIdx = cursor.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME) val numberIdx = cursor.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.Phone.NUMBER) val photoIdx = cursor.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.Phone.PHOTO_ID) while (cursor.moveToNext()) { val contact = Contact( contactId = cursor.getLong(idIdx), name = cursor.getString(nameIdx) ?: "", phone = cursor.getString(numberIdx) ?: "", photoId = cursor.getLong(photoIdx) ) contacts.add(contact) } } return contacts }几个关键点:
use {}是 Kotlin 的扩展函数,等价于 try-finally,保证 Cursor 一定关闭。老代码里手动cursor.close()一旦中间抛异常就漏了,这是内存泄漏的常见来源。
getColumnIndexOrThrow比getColumnIndex好,字段不存在时直接抛异常,而不是返回 -1 让你在后面越界。字段名对不上时能第一时间发现。
PHOTO_ID拿到的是头像的 ID,不是二进制。要真正拿到图片,得用这个 ID 去查Data表,再解码。这一步千万别在主线程做。
2.4 头像解码:放到子线程,别在主线程碰 Bitmap
头像二进制存在ContactsContract.Data表里,字段是PHOTO。解码用BitmapFactory.decodeStream:
suspend fun loadAvatar(photoId: Long): Bitmap? = withContext(Dispatchers.IO) { if (photoId <= 0L) return@withContext null val uri = ContentUris.withAppendedId( ContactsContract.Data.CONTENT_URI, photoId) contentResolver.openInputStream(uri)?.use { input -> BitmapFactory.decodeStream(input) } }withContext(Dispatchers.IO)把它切到 IO 线程。如果你用协程,记得在 Activity 销毁时取消,否则回调回来 View 已经没了。
2.5 异常兜底:别写空 catch
通讯录读取的异常主要有三类:权限被撤销(SecurityException)、Cursor 字段缺失(IllegalArgumentException)、IO 异常。兜底策略是:权限异常重新走申请流程,字段异常降级用getColumnIndex加判空,IO 异常跳过当前条目继续。
try { val list = loadContacts() renderList(list) } catch (e: SecurityException) { // 权限在读取过程中被撤销 ensurePermissionAndLoad() } catch (e: IllegalArgumentException) { // 字段不匹配,降级处理 Log.e(TAG, "字段解析失败", e) renderList(emptyList()) } catch (e: Exception) { Log.e(TAG, "读取通讯录未知异常", e) toast("读取失败,请重试") }到这里,本地链路的骨架就搭完了。但真实业务里,联系人数据往往是脏的:号码带空格、带 +86、姓名里混着表情、同一个人存了三条。这时候就该把模型调用接进来做清洗和结构化。
3. 把模型调用 endpoint 改到 TaoToken:统一 Key 的配置方法
3.1 为什么要在通讯录场景里接模型
本地解析出来的联系人,字段是「能用」但「不干净」。比如号码+86 138-0000-0000、13800000000、013800000000混在一起,姓名有「张三」「张三(同事)」「张 三」。如果直接入库,去重和匹配会很难做。用模型做一轮字段清洗和结构化输出,能把号码归一化成 E.164、姓名去掉备注、还能顺便判断哪些是公司联系人。
关键问题是:模型调用要配 Key、配 endpoint。如果每个项目都单独申请、单独管理,Key 一多就乱。TaoToken 的思路是统一 Key,一个 Key 走多个模型,endpoint 改一处就行。
3.2 配置三件套:Base URL + Key + Model ID
不管你用哪种客户端,接入任何 OpenAI 兼容接口都离不开三件套:Base URL、API Key、Model ID。TaoToken 的 API 地址是https://taotoken.net/api,注意这个地址不带任何查询参数,直接作为 base。
先看最通用的 JSON 配置(很多工具用这种格式):
{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的TaoTokenKey", "model": "claude-sonnet-4-20250514", "timeout": 60 }如果你用的是 Cline 这类 VS Code 插件,配置写在settings.json里,字段名可能是baseUrl和apiKey,注意大小写:
{ "cline.apiProvider": "openai", "cline.openAiBaseUrl": "https://taotoken.net/api", "cline.openAiApiKey": "sk-你的TaoTokenKey", "cline.openAiModelId": "claude-sonnet-4-20250514" }如果你用 Codex 这类工具,认证信息写在auth.json里,路径通常在用户目录下的.codex/auth.json:
{ "OPENAI_API_KEY": "sk-你的TaoTokenKey", "OPENAI_BASE_URL": "https://taotoken.net/api" }三件套里最容易错的是 Base URL 结尾。有的工具要求带/v1,有的要求不带。TaoToken 的https://taotoken.net/api作为 base,具体路径由 SDK 拼接。如果你在 curl 里手写,完整地址是https://taotoken.net/api/v1/chat/completions。
3.3 在通讯录清洗里怎么调用
假设你已经把联系人列表序列化成 JSON,接下来发一个请求让模型做清洗。用 curl 演示最直观:
curl https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-你的TaoTokenKey" \ -d '{ "model": "claude-sonnet-4-20250514", "messages": [ { "role": "system", "content": "你是联系人数据清洗助手。输入是联系人数组,请输出结构化 JSON:phone 归一化为 E.164 格式,name 去掉括号备注,并标记 isCompany。只输出 JSON,不要解释。" }, { "role": "user", "content": "[{\"name\":\"张三(同事)\",\"phone\":\"+86 138-0000-0000\"},{\"name\":\"李四\",\"phone\":\"013900000000\"}]" } ], "temperature": 0 }'temperature设 0,让输出稳定,避免每次清洗结果不一样。返回的choices[0].message.content就是结构化后的 JSON,直接解析入库。
如果你在 Android 里调用,用 OkHttp 或 Retrofit 都行,把 base URL 设成https://taotoken.net/api/,注意 Retrofit 要求 base URL 以/结尾。Key 不要硬编码在代码里,放local.properties或 BuildConfig,避免打包泄露。
3.4 统一 Key 的好处
一个 Key 管多个模型,切换模型只改model字段,endpoint 和 Key 都不动。对通讯录这种「清洗用便宜模型、复杂判断用强模型」的场景很实用。你可以在配置里放两个 model ID,简单清洗走快的,遇到歧义再走强的。
需要拿 Key 的话,去控制台创建:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。创建后记得复制保存,页面关了就看不到了。
4. 验证请求:确认链路真的通了
4.1 先用最小请求验证 Key 和 endpoint
别一上来就跑完整通讯录。先发一个最小请求,确认三件套没问题:
curl https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-你的TaoTokenKey" \ -d '{ "model": "claude-sonnet-4-20250514", "messages": [{"role": "user", "content": "回复 ok"}], "max_tokens": 10 }'期望返回里choices[0].message.content包含ok。如果这一步就失败,先看第 5 节的报错对照表,别往下走。
4.2 再验证通讯录读取
在 App 里点导入按钮,观察日志。正常流程应该是:权限弹窗出现 → 授权 → 日志打印「读取到 N 条联系人」→ 列表渲染。如果 N 是 0,先确认模拟器里有没有存联系人,模拟器默认是空的。
4.3 最后验证清洗结果
把本地读到的联系人 JSON 发给模型,检查返回:
val cleaned = contactCleanService.clean(rawContactsJson) // 期望:phone 变成 +8613800000000 这种格式 // name 里没有括号备注 // 每条都有 isCompany 字段验证清单可以做成一个 checklist:
| 检查项 | 期望结果 | 失败时看 |
|---|---|---|
| 权限弹窗 | 首次点击出现 | 第 5 节 401 / 权限被拒 |
| 读取条数 | 与系统通讯录一致 | Cursor 字段名 |
| 号码格式 | E.164 | 模型 prompt |
| 头像 | 非空或占位图 | PHOTO_ID 解码 |
| 模型返回 | 合法 JSON | 第 5 节 reading choices |
4.4 用模型对话页快速试 prompt
如果你不想每次都写代码试 prompt,可以直接在模型对话页里粘贴联系人 JSON,调好 system prompt 再搬回代码。地址:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite 。这样调 prompt 效率高很多,尤其是清洗规则复杂的时候。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
这一节按真实报错来对照。每个报错我都写清楚现象、原因、解决。
5.1 401 Unauthorized
现象:请求返回{"error":{"message":"Invalid API key"}}或状态码 401。
原因通常三个:Key 复制时带了空格、Key 已经失效、Header 格式不对。检查Authorization: Bearer sk-xxx,Bearer 和 Key 之间一个空格,Key 前后不能有空格。如果确认 Key 没问题还是 401,去控制台重新生成一个:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。
5.2 local proxy failed
现象:客户端报local proxy failed或connection refused。
这个报错多半是本地代理配置残留。检查你的工具里有没有设http_proxy/https_proxy环境变量,或者客户端设置里开了「使用系统代理」。把代理关掉,直连https://taotoken.net/api。如果你在公司网络里,确认防火墙没拦 443 端口。
5.3 reading choices 相关报错
现象:Cannot read properties of undefined (reading 'choices')或reading '0'。
这是解析返回时choices不存在。原因一般是:请求根本没成功(返回的是错误对象),但代码直接去读response.choices[0]。正确做法是先判断状态码和error字段:
val body = response.body?.string() val json = JSONObject(body) if (json.has("error")) { Log.e(TAG, "API 错误: ${json.getJSONObject("error").getString("message")}") return } val choices = json.optJSONArray("choices") if (choices == null || choices.length() == 0) { Log.e(TAG, "返回为空") return } val content = choices.getJSONObject(0) .getJSONObject("message").getString("content")5.4 OAuth 相关报错
现象:OAuth token expired或invalid_grant。
如果你用的是 Claude Code 这类走 OAuth 的工具,报这个说明登录态过期。重新走一次登录流程即可。注意 OAuth 和 API Key 是两套体系,别混用。如果你在 Claude Code 里想改用统一 Key,把配置里的认证方式从 OAuth 切到 API Key,Base URL 填https://taotoken.net/api,Model ID 填你要用的模型。
5.5 报错对照表
| 报错关键词 | 大概率原因 | 处理 |
|---|---|---|
| 401 / Invalid API key | Key 错或失效 | 重新复制或生成 |
| local proxy failed | 本地代理残留 | 关代理直连 |
| reading 'choices' | 未判空直接解析 | 先判 error 再取 choices |
| OAuth token expired | 登录态过期 | 重新登录或切 API Key |
| SecurityException | 权限被撤销 | 重新申请权限 |
| IllegalArgumentException | Cursor 字段名错 | 用 getColumnIndexOrThrow 定位 |
| ANR | 主线程解码头像 | 切到 IO 线程 |
排查顺序建议:先 curl 验证 Key 和 endpoint,再验证 App 权限,最后验证模型返回解析。一层一层来,别跳。
6. 长期做通讯录清洗与 Agent 编排,怎么选接入方式
通讯录清洗这种任务,如果只是偶尔跑一次,用模型对话页手动粘贴就够了。但如果你要做成产品里的常驻功能,比如「每次打开 App 自动同步并清洗」,那就需要一个稳定的接入方式。
短期验证 prompt,用模型对话页最快:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite 。调好规则再搬进代码。
如果你在写代码阶段需要频繁调 API、试不同模型,用 API Key 直连:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。配合接入文档看参数:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
如果你要做的是长期编码、Agent 编排,比如让模型自动处理联系人去重、自动分类、自动生成标签,那更适合用 Coding Plan,把模型能力嵌进你的开发流:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。
最后说个我踩过的坑:通讯录读取一定要在用户点击后触发,别在onCreate里自动申请权限。用户没心理准备时弹权限框,拒绝率极高,而且一旦勾了「不再询问」,后面很难挽回。把导入做成一个明确的按钮,文案写清楚「导入后仅用于本地整理」,通过率会好很多。模型清洗那一步,记得给返回加超时和重试,网络抖动时别让整个导入流程卡死。