)
1. 从一次真机同步失败说起Android 通讯录读取到底难在哪Android 通讯录读取这件事看起来就是查个 Cursor 拿数据但真到真机上跑问题会一个接一个冒出来。我最近在做一个把联系人同步进自有 App 的需求第一版代码在模拟器上跑得好好的换到真机就翻车有的手机读出来一堆重复号码有的联系人名字是空的还有的机器直接返回 0 条记录。排查了半天才发现问题不在查询语句本身而在于对 ContactsContract 这套数据模型的理解不够深。ContactsContract 是 Android 官方提供的通讯录访问框架它把联系人数据拆成了三张核心表RawContacts原始联系人、Data数据项、Contacts聚合联系人。很多人第一次写通讯录读取会直接查 Data 表然后按 mimetype 去区分姓名和电话这个思路没错但忽略了多账户聚合和空值兜底结果就是数据对不上。这篇文章面向的是需要把联系人同步进自有 App 的 Android 开发者。我会把从权限申请、Cursor 查询、字段映射到多账户去重、空值兜底、真机验证的完整链路讲清楚最后给出一份可以直接复用的查询代码和字段对照表。如果你正在做通讯录同步、通讯录备份、或者需要读取联系人做业务匹配这篇应该能帮你少踩几个坑。另外提一句如果你在开发过程中需要调用大模型来做联系人信息的智能分类、去重合并或者自然语言查询TaoToken 的统一 Key 通道可以省掉你分别对接多家模型的麻烦后面我会在配置章节给出具体接入方式。先说清楚一个前提通讯录读取涉及用户隐私Google Play 和国内应用市场对 READ_CONTACTS 权限的审核都很严格。你的 App 必须有明确的业务场景比如通讯录备份、好友推荐、来电识别不能为了读而读。这一点在写代码之前就要想清楚否则上架会被打回。2. 权限申请与 ContactsContract 数据模型READ_CONTACTS 到底怎么用2.1 READ_CONTACTS 权限的申请时机READ_CONTACTS 属于危险权限Android 6.0 以后必须运行时申请。很多人习惯在 Activity 的 onCreate 里直接申请但更好的做法是在真正需要读取通讯录的那一刻再申请这样用户能理解你为什么需要这个权限。// 在需要读取通讯录的地方调用 private val requestPermissionLauncher registerForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted - if (isGranted) { readContacts() } else { // 用户拒绝给出解释或引导去设置页 showPermissionDeniedTip() } } fun checkAndRequestPermission() { when { ContextCompat.checkSelfPermission( this, Manifest.permission.READ_CONTACTS ) PackageManager.PERMISSION_GRANTED - { readContacts() } shouldShowRequestPermissionRationale(Manifest.permission.READ_CONTACTS) - { // 用户之前拒绝过解释为什么需要 showRationaleDialog() } else - { requestPermissionLauncher.launch(Manifest.permission.READ_CONTACTS) } } }这里有个细节shouldShowRequestPermissionRationale 返回 true 说明用户拒绝过一次但没勾选不再询问这时候你应该先解释再申请。如果返回 false 且权限没授予可能是用户勾选了不再询问这时候只能引导去系统设置页手动开启。2.2 ContactsContract 三张核心表的关系理解这三张表是写好查询的关键RawContacts 表存储的是原始联系人每一条对应一个账户下的一条联系人记录。比如你手机里登录了 Google 账户和本地账户同一个人可能在两个账户下各有一条 RawContact。Data 表存储的是具体的数据项每一条对应一个字段比如一个电话号码、一个姓名、一个邮箱。每条 Data 记录通过 raw_contact_id 关联到 RawContacts。Contacts 表是聚合后的联系人系统会把多个 RawContact 聚合成一个 Contact。聚合规则由系统根据姓名、电话等相似度自动判断也可以通过 AggregationExceptions 手动干预。所以正确的查询路径是先查 RawContacts 拿到 contact_id再查 Data 表按 contact_id 过滤最后按 mimetype 区分字段类型。这也是 excerpt 里那段代码的思路但它有几个问题没有处理多号码、没有去重、空值兜底不完整。2.3 字段映射对照表Data 表里的 mimetype 决定了 data1 字段的含义下面这张表是实战中最常用的映射关系mimetype 常量字符串值data1 含义常用字段Phone.CONTENT_ITEM_TYPEvnd.android.cursor.item/phone_v2电话号码data1号码, data2类型StructuredName.CONTENT_ITEM_TYPEvnd.android.cursor.item/name姓名data1全名, data2名, data3姓Email.CONTENT_ITEM_TYPEvnd.android.cursor.item/email_v2邮箱data1邮箱地址Organization.CONTENT_ITEM_TYPEvnd.android.cursor.item/organization组织data1公司, data4职位Note.CONTENT_ITEM_TYPEvnd.android.cursor.item/note备注data1备注内容电话号码的类型data2对应的是 Phone.TYPE_MOBILE、Phone.TYPE_HOME、Phone.TYPE_WORK 等常量你可以用 Phone.getTypeLabel() 拿到本地化的标签。3. 可复用的查询代码从 Cursor 到 JSON 的完整链路3.1 查询 RawContacts 拿到 contact_id先查 RawContacts 表拿到所有有效的 contact_id。注意 DELETED 字段被删除的联系人记录可能还在表里但 contact_id 为 null需要过滤掉。val rawContactsCursor contentResolver.query( ContactsContract.RawContacts.CONTENT_URI, arrayOf( ContactsContract.RawContacts.CONTACT_ID, ContactsContract.RawContacts.ACCOUNT_TYPE, ContactsContract.RawContacts.ACCOUNT_NAME ), ${ContactsContract.RawContacts.DELETED} 0, null, ${ContactsContract.RawContacts.SORT_KEY_PRIMARY} ASC )这里加了 DELETED 0 的过滤条件比在代码里判断 contact_id 是否为 null 更高效。ACCOUNT_TYPE 和 ACCOUNT_NAME 可以用来区分联系人来自哪个账户后面去重会用到。3.2 按 contact_id 查 Data 表并映射字段拿到 contact_id 后逐个查 Data 表。这里要注意一个联系人可能有多个电话号码所以不能简单地用 map.put 覆盖要用列表收集。data class ContactInfo( val contactId: String, val displayName: String, val phones: MutableListString mutableListOf(), val emails: MutableListString mutableListOf(), val accountType: String? null ) fun readContacts(): ListContactInfo { val result mutableListOfContactInfo() val rawContactsCursor contentResolver.query( ContactsContract.RawContacts.CONTENT_URI, arrayOf( ContactsContract.RawContacts.CONTACT_ID, ContactsContract.RawContacts.ACCOUNT_TYPE ), ${ContactsContract.RawContacts.DELETED} 0, null, null ) rawContactsCursor?.use { cursor - val idIndex cursor.getColumnIndex(ContactsContract.RawContacts.CONTACT_ID) val accountIndex cursor.getColumnIndex(ContactsContract.RawContacts.ACCOUNT_TYPE) while (cursor.moveToNext()) { val contactId cursor.getString(idIndex) ?: continue val accountType cursor.getString(accountIndex) val contact ContactInfo(contactId, , accountType accountType) val dataCursor contentResolver.query( ContactsContract.Data.CONTENT_URI, arrayOf( ContactsContract.Data.DATA1, ContactsContract.Data.MIMETYPE ), ${ContactsContract.Data.CONTACT_ID} ?, arrayOf(contactId), null ) dataCursor?.use { dc - val dataIndex dc.getColumnIndex(ContactsContract.Data.DATA1) val mimeIndex dc.getColumnIndex(ContactsContract.Data.MIMETYPE) while (dc.moveToNext()) { val data1 dc.getString(dataIndex) ?: val mimeType dc.getString(mimeIndex) ?: when (mimeType) { ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE - { if (data1.isNotBlank()) contact.phones.add(data1) } ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE - { if (data1.isNotBlank()) contact.displayName data1 } ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE - { if (data1.isNotBlank()) contact.emails.add(data1) } } } } if (contact.displayName.isNotBlank() || contact.phones.isNotEmpty()) { result.add(contact) } } } return result }3.3 多账户去重与空值兜底上面的代码已经能拿到数据了但多账户场景下会出现同一个人的多条记录。去重策略有两种按 contact_id 去重系统已经聚合过的或者按姓名号码去重自己聚合。系统聚合后的 contact_id 是唯一的但不同 RawContact 可能对应同一个 contact_id。所以更稳妥的做法是先按 contact_id 分组把同一个 contact_id 下的所有 RawContact 数据合并。fun deduplicateByContactId(list: ListContactInfo): ListContactInfo { return list.groupBy { it.contactId }.map { (_, group) - val merged ContactInfo( contactId group.first().contactId, displayName group.firstOrNull { it.displayName.isNotBlank() }?.displayName ?: , accountType group.first().accountType ) group.forEach { c - c.phones.forEach { if (!merged.phones.contains(it)) merged.phones.add(it) } c.emails.forEach { if (!merged.emails.contains(it)) merged.emails.add(it) } } merged } }空值兜底的原则是姓名可能为空有些联系人只存了号码号码可能为空有些联系人只存了名字邮箱基本都为空。在转 JSON 的时候所有字段都要给默认值避免服务端解析报错。3.4 接入 TaoToken 统一 Key 通道做智能处理如果你拿到联系人后想做智能分类、去重合并、或者自然语言查询可以通过 TaoToken 的统一 Key 通道调用大模型。配置方式很简单在项目的 local.properties 或者 BuildConfig 里配置{ baseUrl: https://taotoken.net/api, apiKey: sk-你的TaoToken密钥, modelId: claude-sonnet-4-20250514 }然后在代码里用 OkHttp 发起请求val client OkHttpClient() val json JSONObject().apply { put(model, claude-sonnet-4-20250514) put(messages, JSONArray().put(JSONObject().apply { put(role, user) put(content, 请把以下联系人按公司分类${contactsJson}) })) } val request Request.Builder() .url(https://taotoken.net/api/v1/chat/completions) .addHeader(Authorization, Bearer sk-你的TaoToken密钥) .addHeader(Content-Type, application/json) .post(json.toString().toRequestBody(application/json.toMediaType())) .build()这样你就不用分别对接多家模型的 API一个 Key 走通所有模型。密钥可以在 TaoToken 的 API Keys 页面生成接入文档里有完整的参数说明。4. 真机验证三种边界场景的实测步骤4.1 权限拒绝场景在真机上测试权限拒绝最直接的方式是去系统设置里手动关闭通讯录权限然后回到 App 触发读取。预期结果是App 不崩溃弹出解释提示引导用户去设置页。private fun showPermissionDeniedTip() { AlertDialog.Builder(this) .setTitle(需要通讯录权限) .setMessage(读取通讯录用于好友推荐请在设置中开启权限) .setPositiveButton(去设置) { _, _ - val intent Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data Uri.fromParts(package, packageName, null) } startActivity(intent) } .setNegativeButton(取消, null) .show() }实测下来部分国产 ROM 在权限拒绝后不会回调 onRequestPermissionsResult而是直接返回空数据。所以你的代码里要同时处理权限未授予和查询返回空两种情况。4.2 无联系人场景在模拟器或者新手机上通讯录可能是空的。这时候 rawContactsCursor 返回的 Cursor 不为 null但 moveToNext 直接返回 false。你的代码要能正常返回空列表而不是抛异常。验证方法新建一个模拟器不导入任何联系人直接跑读取逻辑。预期结果是返回空列表UI 显示暂无联系人。4.3 多账户场景在真机上登录两个账户比如一个 Google 账户和一个本地账户分别添加联系人其中故意让两个账户下有同名但不同号码的联系人。预期结果是系统聚合后可能合并成一条也可能保持两条取决于聚合规则。你的去重逻辑要能正确处理这两种情况。验证步骤账户 A 添加联系人张三号码 13800000001账户 B 添加联系人张三号码 13800000002跑读取逻辑打印 contact_id 和号码列表检查是否出现重复的张三记录号码是否都拿到了如果系统聚合了你会拿到一个 contact_id 对应两个号码如果没聚合你会拿到两个 contact_id 各对应一个号码。两种结果都是正常的你的代码要都能处理。5. 常见报错排查401、local proxy failed、reading choices 怎么解5.1 401 Unauthorized如果你在接入 TaoToken 时遇到 401通常是 Key 没配对或者请求头格式不对。检查两点Authorization 头是不是 Bearer sk-xxx 格式Key 是不是在 TaoToken 控制台生成的。注意不要有多余的空格或者换行。# 用 curl 快速验证 Key 是否有效 curl -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer sk-你的密钥 \ -H Content-Type: application/json \ -d {model:claude-sonnet-4-20250514,messages:[{role:user,content:hi}]}如果返回 401去 TaoToken 的 API Keys 页面重新生成一个 Key。如果返回 200说明 Key 没问题问题在客户端代码。5.2 local proxy failed这个报错通常出现在你配置了本地代理但代理没启动或者代理地址写错了。如果你没有用代理检查一下 OkHttp 的 proxy 配置是不是被全局设置了。在 Android 里有些网络库会读取系统代理设置导致请求被转发到不存在的本地代理。// 显式禁用代理 val client OkHttpClient.Builder() .proxy(Proxy.NO_PROXY) .build()5.3 reading choices 报错这个报错一般出现在解析大模型返回的 JSON 时choices 数组为空或者结构不对。检查你的请求体里 model 参数是不是写对了有些模型 ID 拼写错误会导致返回空 choices。另外如果用了流式输出streamtrue返回的是 SSE 格式不能用普通的 JSON 解析。// 非流式请求的返回结构 { choices: [ { message: { role: assistant, content: 返回内容 } } ] }5.4 OAuth 相关报错如果你用的是 Claude Code 或者 Codex 这类工具可能会遇到 OAuth 报错。这类工具通常需要配置 auth.json 或者 settings.json。以 Codex 为例配置文件在 ~/.codex/auth.json{ base_url: https://taotoken.net/api, api_key: sk-你的TaoToken密钥, model: claude-sonnet-4-20250514 }Claude Code 的配置在 ~/.claude/settings.json{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_API_KEY: sk-你的TaoToken密钥, ANTHROPIC_MODEL: claude-sonnet-4-20250514 } }三件套Base URL Key Model ID缺一不可少一个都会报错。如果你用 CC Switch 或者 Cline MCP配置方式类似都是在设置里填这三个值。6. 把联系人同步做稳从查询到上云的完整建议通讯录读取这件事代码写对只是第一步真正难的是处理各种边界情况。我在实际项目里总结了几个经验第一永远不要假设 Cursor 不为 null。Android 的 ContentResolver.query 在某些 ROM 上会返回 null尤其是权限被限制的时候。所有 Cursor 操作都要用 ?.use 包裹。第二字段映射要用常量而不是硬编码字符串。ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE 比 vnd.android.cursor.item/phone_v2 更安全官方改字符串的概率虽然低但用常量可读性更好。第三去重逻辑要放在服务端也做一遍。客户端去重只能处理当前设备的数据如果用户换手机或者多设备同步服务端不去重还是会出问题。第四同步频率要控制。通讯录变化不频繁没必要每次启动都全量读取。可以用 ContentObserver 监听变化或者用 SharedPreferences 记录上次同步时间增量同步。如果你需要把联系人数据传给大模型做智能处理TaoToken 的统一 Key 通道可以帮你省掉多模型对接的麻烦。密钥在 API Keys 页面生成接入文档里有完整的请求示例。需要长期跑编码任务或者 Agent 的话Coding Plan 会更划算。想先验证模型效果可以直接在模型对话页面测试。最后提醒一句通讯录数据敏感传输和存储都要加密。别为了省事直接明文传出了事不是小事。