☰
Android 通讯录读取实战:用 ContactsContract 把联系人信息拿全(TaoToken 统一 Key 通道)
2026/10/3 7:04:00 网站建设 项目流程

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: MutableList<String> = mutableListOf(), val emails: MutableList<String> = mutableListOf(), val accountType: String? = null ) fun readContacts(): List<ContactInfo> { val result = mutableListOf<ContactInfo>() 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: List<ContactInfo>): List<ContactInfo> { 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 账户和一个本地账户),分别添加联系人,其中故意让两个账户下有同名但不同号码的联系人。预期结果是:系统聚合后可能合并成一条,也可能保持两条,取决于聚合规则。你的去重逻辑要能正确处理这两种情况。

验证步骤:

  1. 账户 A 添加联系人"张三",号码 13800000001
  2. 账户 B 添加联系人"张三",号码 13800000002
  3. 跑读取逻辑,打印 contact_id 和号码列表
  4. 检查是否出现重复的"张三"记录,号码是否都拿到了

如果系统聚合了,你会拿到一个 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。另外,如果用了流式输出(stream=true),返回的是 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 会更划算。想先验证模型效果,可以直接在模型对话页面测试。

最后提醒一句:通讯录数据敏感,传输和存储都要加密。别为了省事直接明文传,出了事不是小事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询