1. 手机联系人读取为什么总在字段映射上翻车
手机联系人读取这件事,听起来像是 Android 入门第一课:申请权限、拿 ContentResolver 查一把、把 id、姓名、手机号塞进列表就完事。但真到项目里,你会发现坑几乎都集中在「字段映射」和「多端一致性」上。Android 这边ContactsContract.CommonDataKinds.Phone一次查询会返回多行——同一个人有两个号码就有两条记录,CONTACT_ID相同但_ID不同;iOS 那边CNContactStore又是另一套CNContactIdentifierKey、CNPhoneNumbersKey的键值体系。两端字段名对不上,导出的 CSV 就会一会儿缺姓名、一会儿手机号串行。
更麻烦的是,很多团队在写这段逻辑时是「边查边试」:先写死列名,跑出来发现DISPLAY_NAME为空,再换成DISPLAY_NAME_PRIMARY;手机号带空格和-,又临时加replace。这种逐条手写、逐字段试错的方式,在只有一台测试机时还能忍,一旦要覆盖多机型、多系统版本,维护成本直接爆炸。
我这次想做的,是把「读取联系人 → 提取 id/姓名/手机号 → 导出 CSV」这条链路做成一次跑通、字段映射固定的骨架。同时用一个统一的 Key 管理方式,把 Android、iOS 甚至后续要接的模型辅助清洗环节串起来,避免每个端各自维护一套凭证和字段表。这里用到的统一入口是 TaoToken,官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end ,它的 API 地址是 https://taotoken.net/api ,后面配置里会具体出现。
需要先明确一点:联系人数据属于高敏感个人信息,本文所有代码只用于本地读取与本地导出,不涉及上传、不涉及第三方共享。你在真实项目里也必须先拿到用户明示授权,并且只读取业务必需字段。
2. TaoToken 前置:统一 Key 与联系人链路的定位
TaoToken 在这条链路里扮演的不是「读联系人」的角色,联系人读取永远由系统 API 完成。它解决的是另一个问题:当你需要把读出来的原始字段做规范化、去重、格式校验,或者后续要接一个模型来做「姓名与号码的异常检测」时,凭证和调用入口是分散的。Android 一个 Key、iOS 一个 Key、脚本一个 Key,字段映射表还各写各的,很容易乱。
TaoToken 提供的是统一的 API Key 与调用入口,你可以把它理解成一个「凭证与调用网关」:所有需要模型能力的环节,都走同一个 base URL 和同一套 Key 体系。这样联系人读取本身仍然是本地系统调用,但读取之后的清洗、校验、导出环节可以复用同一套配置,字段映射表也能集中维护。
具体到操作层面,你需要先拿到一个可用的 Key。进入控制台创建 API Key,地址是 https://taotoken.net/console/api-keys ,创建后复制保存。注意 Key 只在创建时完整显示一次,丢了就重新建。如果你只是想先验证模型对话是否通,可以用模型对话页面 https://taotoken.net/models 做一次最小请求;如果你是要长期跑编码或 Agent 任务,建议直接看 Coding Plan https://taotoken.net/coding-plan ,额度模型更适合持续调用。
这里要强调:联系人读取的权限声明、ContentResolver 查询、CNContactStore 请求,全部不经过 TaoToken,也不应该经过任何外部服务。TaoToken 只在你需要「读取之后的处理」时介入。把边界划清楚,后面配置才不会混。
3. 可复制配置:Android 权限声明与查询骨架
先给 Android 端的完整骨架。权限声明放在AndroidManifest.xml里,读取联系人需要READ_CONTACTS:
<uses-permission android:name="android.permission.READ_CONTACTS" />如果是 Android 6.0 以上,运行时还要动态申请。下面这段是动态权限请求的最小写法,放在 Activity 的onCreate里:
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_CONTACTS}, 1001); }权限拿到后,查询骨架如下。注意我用了Phone.CONTACT_ID作为联系人主键,而不是Phone._ID,因为同一个人多号码时_ID会变,CONTACT_ID才稳定:
public class ContactReader { public static List<ContactItem> read(Context context) { List<ContactItem> result = new ArrayList<>(); ContentResolver resolver = context.getContentResolver(); String[] projection = new String[]{ ContactsContract.CommonDataKinds.Phone.CONTACT_ID, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME_PRIMARY, ContactsContract.CommonDataKinds.Phone.NUMBER }; Cursor cursor = resolver.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, projection, null, null, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME_PRIMARY + " ASC"); if (cursor == null) return result; try { int idIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID); int nameIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME_PRIMARY); int numIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER); while (cursor.moveToNext()) { ContactItem item = new ContactItem(); item.id = cursor.getLong(idIdx); item.name = cursor.getString(nameIdx); item.number = cursor.getString(numIdx); result.add(item); } } finally { cursor.close(); } return result; } }对应的实体类保持字段名和 CSV 表头一致,后面导出就不用再映射:
public class ContactItem { public long id; public String name; public String number; }这里有个关键点:DISPLAY_NAME_PRIMARY比DISPLAY_NAME更稳定,系统会优先返回用户设置的主显示名。手机号字段NUMBER可能带空格、括号、-,建议在导出前统一清洗,但不要在读取时直接改,保留原始值便于排查。
4. iOS 端字段映射与导出 CSV 的验证动作
iOS 端用CNContactStore,字段键和 Android 完全不同。请求授权:
let store = CNContactStore() store.requestAccess(for: .contacts) { granted, error in guard granted else { return } let keys: [CNKeyDescriptor] = [ CNContactIdentifierKey as CNKeyDescriptor, CNContactGivenNameKey as CNKeyDescriptor, CNContactFamilyNameKey as CNKeyDescriptor, CNContactPhoneNumbersKey as CNKeyDescriptor ] let request = CNContactFetchRequest(keysToFetch: keys) try? store.enumerateContacts(with: request) { contact, _ in let id = contact.identifier let name = "\(contact.familyName)\(contact.givenName)" for phone in contact.phoneNumbers { let number = phone.value.stringValue // 收集 id / name / number } } }字段映射对照表如下,方便你两端对齐:
| 语义 | Android 列名 | iOS 键 |
|---|---|---|
| 联系人 id | Phone.CONTACT_ID | CNContactIdentifierKey |
| 姓名 | Phone.DISPLAY_NAME_PRIMARY | GivenName+FamilyName |
| 手机号 | Phone.NUMBER | CNPhoneNumbersKey |
导出 CSV 时,表头固定为id,name,number,每行做一次转义:姓名里如果有逗号或引号,用双引号包裹并把内部引号翻倍。下面是一个最小导出函数:
public static void exportCsv(List<ContactItem> list, File out) throws IOException { try (BufferedWriter w = new BufferedWriter(new FileWriter(out))) { w.write("id,name,number\n"); for (ContactItem item : list) { w.write(item.id + "," + escape(item.name) + "," + escape(item.number) + "\n"); } } } private static String escape(String s) { if (s == null) return ""; if (s.contains(",") || s.contains("\"")) { return "\"" + s.replace("\"", "\"\"") + "\""; } return s; }验证动作:在测试机上跑一次读取,导出 CSV,用文本编辑器打开确认三列对齐、无串行。如果姓名列出现空值,优先检查是不是用了DISPLAY_NAME而不是DISPLAY_NAME_PRIMARY;如果手机号列出现多行同 id,那是正常的,因为一个人多号码,导出时按号码展开即可。
5. 本篇常见错排查
第一个高频错误是权限申请后立刻查询,结果 cursor 为空。原因是用户还没点授权,或者授权回调还没回来。正确做法是在onRequestPermissionsResult里判断grantCode == 1001 && grantResults[0] == PERMISSION_GRANTED之后再触发查询。
第二个错误是列索引取成 -1。getColumnIndex返回 -1 说明 projection 里没有这一列,常见于把DISPLAY_NAME_PRIMARY写成了DISPLAY_NAME但系统版本不支持。解决办法是统一用DISPLAY_NAME_PRIMARY,并在取索引后加一次判断,为 -1 时回退到DISPLAY_NAME。
第三个错误是手机号带国家码或格式符,导出后无法直接用于短信。建议在导出前做一次规范化,但保留原始列,例如增加number_raw和number_clean两列,避免清洗出错后无法回溯。
第四个错误是 iOS 端enumerateContacts在后台线程调用导致 UI 卡顿或数据竞争。联系人枚举是同步阻塞的,放到DispatchQueue.global()里执行,回到主线程再更新 UI。
第五个错误是把联系人数据直接拼进模型请求做「智能清洗」,结果把敏感信息发出去了。记住边界:清洗逻辑优先本地规则实现,确实需要模型辅助时,只发送脱敏后的统计特征,不发送原始号码和姓名。
6. 语义一致 CTA:把 Key 和接入文档放在顺手的位置
联系人读取本身不需要任何外部 Key,但当你把「读取 → 清洗 → 导出」做成可复用链路,并且后续要接模型做异常检测或字段补全时,统一 Key 的价值就出来了。你可以先在 https://taotoken.net/console/api-keys 创建 Key,然后对照接入文档 https://taotoken.net/doc 把 base URL 配成 https://taotoken.net/api ,这样 Android、iOS、脚本三端用的是同一套凭证和入口,字段映射表也只需要维护一份。
如果你只是想先验证模型对话是否可用,用 https://taotoken.net/models 做一次最小请求即可;如果你是要长期跑编码或 Agent 任务,直接看 https://taotoken.net/coding-plan 。Claude Code 相关接入参考 https://taotoken.net/claudecode 。把 Key 管理和联系人读取的边界守住,这条链路就能一次跑通、长期可维护。