☰
友录联系人板块数据同步:用ContentResolver与Intent打通TaoToken统一通道
2026/10/8 17:58:56 网站建设 项目流程

1. 友录联系人板块的数据链路:ContentResolver 查询与 Intent 跳转到底难在哪

友录联系人板块这类功能,说白了就是两件事:把系统通讯录里的联系人读出来展示,以及让用户能跳到系统联系人界面去新增或编辑。听起来简单,但真正动手写的时候,坑集中在三个地方。

第一是权限。Android 6.0 之后 READ_CONTACTS 和 WRITE_CONTACTS 都是危险权限,不动态申请直接 query 会抛 SecurityException,而且不同厂商 ROM 对权限弹窗的处理还不一样。第二是 ContentResolver 的查询结构。ContactsContract 这套 API 分 Contacts、RawContacts、Data 三张逻辑表,很多人只查 Contacts 表拿到 _ID 和 DISPLAY_NAME 就以为完事了,结果发现拿不到手机号和头像,因为详细信息在 Data 表里,得用 _ID 做二次查询。第三是 Intent 跳转的回传处理。用 SHOW_OR_CREATE_CONTACT 跳过去新增联系人,用户保存完回来,你的列表还是旧的,因为没监听数据变化。

再往上一层,如果友录这类应用还要把联系人数据同步到自己的服务端,或者做跨设备的联系人备份,就绕不开鉴权和请求转发。这时候一个统一的 Key/API 通道就很有必要——不用在每个模块里散落硬编码的 token,也不用自己维护一套签名逻辑。TaoToken 在这里扮演的就是这个统一通道的角色,把鉴权和转发收敛到一处。

这篇会按「读联系人 → 跳转传参 → 统一通道接入 → 验证 → 排错」的顺序拆,代码都是可以直接复制进项目的。适合正在做通讯录模块、或者想把本地数据同步到远端但不想自己造鉴权轮子的 Android 开发者。

2. 接入前的准备:TaoToken 统一 Key/API 通道配置

在写联系人代码之前,先把统一通道配好,后面同步联系人数据时直接调用就行,不用中途再回来折腾。

TaoToken 的定位是一个统一的模型与 API 接入通道,你在这里拿到一个 Key,就能通过统一的 Base URL 去请求后端能力,省掉每个模块单独对接鉴权的麻烦。对友录这种既有本地联系人读取、又要把数据往远端同步的场景来说,把鉴权收敛到一处,代码会干净很多。

先到官网注册并进入控制台。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,登录后在 API Keys 页面创建一个新的 Key。创建时建议按用途命名,比如youlu-contacts-sync,方便后面排查是哪个模块在用。

创建完 Key 之后,你需要记下三个东西,后面配置里会反复用到:

配置项值说明
Base URLhttps://taotoken.net/api所有请求的统一入口,不加 UTM 参数
API Keysk-xxxxxxxx控制台生成,只显示一次,务必保存
Model ID按需选择联系人场景一般用轻量模型做字段补全或去重

如果你用的是 Claude Code 这类编码工具来辅助开发友录模块,可以在工具里配置 Anthropic 兼容的接入方式,Base URL 同样填https://taotoken.net/api,Key 填刚创建的。具体配置文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,里面有各客户端的完整参数。

这里有个容易踩的坑:Base URL 后面不要自己加/v1或者/chat/completions,TaoToken 的通道会自己处理路径拼接,你手动加了反而会 404。Key 也不要写进 git 仓库,建议放在local.properties或者用 BuildConfig 注入。

配好之后,你可以在终端里先做一次最小验证,确认 Key 是通的:

curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer sk-你的Key" \ -H "Content-Type: application/json" \ -d '{ "model": "你的ModelID", "messages": [{"role": "user", "content": "ping"}] }'

返回里有choices字段就说明通道是通的。这一步过了,再往下写联系人同步逻辑就不会卡在鉴权上。

3. 可复制配置:ContentResolver 查询、Intent 跳转与统一通道参数

这一节是核心,把联系人读取、跳转传参、以及同步到 TaoToken 通道的配置全部给全。

3.1 权限声明与动态申请

先在AndroidManifest.xml里声明权限:

<uses-permission android:name="android.permission.READ_CONTACTS" /> <uses-permission android:name="android.permission.WRITE_CONTACTS" /> <uses-permission android:name="android.permission.INTERNET" />

动态申请用 ActivityResultLauncher,别再用旧的onRequestPermissionsResult:

private final ActivityResultLauncher<String[]> permLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), result -> { Boolean read = result.get(Manifest.permission.READ_CONTACTS); if (Boolean.TRUE.equals(read)) { loadContacts(); } else { Toast.makeText(this, "需要联系人权限才能加载友录列表", Toast.LENGTH_SHORT).show(); } }); private void ensurePermission() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) { permLauncher.launch(new String[]{ Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS }); } else { loadContacts(); } }

3.2 ContentResolver 查询联系人主表

先查 Contacts 表拿到 _ID 和显示名,这是列表的基础数据:

public List<Contact> queryContacts(Context context) { List<Contact> list = new ArrayList<>(); ContentResolver resolver = context.getContentResolver(); String[] projection = { ContactsContract.Contacts._ID, ContactsContract.Contacts.DISPLAY_NAME_PRIMARY, ContactsContract.Contacts.PHOTO_ID, ContactsContract.Contacts.HAS_PHONE_NUMBER }; Cursor cursor = resolver.query( ContactsContract.Contacts.CONTENT_URI, projection, null, null, ContactsContract.Contacts.DISPLAY_NAME_PRIMARY + " ASC" ); if (cursor == null) return list; try { int idIdx = cursor.getColumnIndex(ContactsContract.Contacts._ID); int nameIdx = cursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY); int photoIdx = cursor.getColumnIndex(ContactsContract.Contacts.PHOTO_ID); int phoneFlagIdx = cursor.getColumnIndex(ContactsContract.Contacts.HAS_PHONE_NUMBER); while (cursor.moveToNext()) { Contact c = new Contact(); c.setId(cursor.getString(idIdx)); c.setName(cursor.getString(nameIdx)); c.setPhotoId(cursor.getString(photoIdx)); c.setHasPhone(cursor.getInt(phoneFlagIdx) > 0); list.add(c); } } finally { cursor.close(); } return list; }

注意DISPLAY_NAME_PRIMARY比DISPLAY_NAME更推荐,前者在 API 21+ 上排序和展示更稳定。

3.3 二次查询 Data 表拿手机号和邮箱

主表只告诉你「有没有手机号」,具体号码在 Data 表里。用 _ID 做条件查:

public void fillDetail(Context context, Contact contact) { ContentResolver resolver = context.getContentResolver(); Cursor cursor = resolver.query( ContactsContract.Data.CONTENT_URI, new String[]{ ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Email.ADDRESS }, ContactsContract.Data.CONTACT_ID + " = ?", new String[]{contact.getId()}, null ); if (cursor == null) return; try { while (cursor.moveToNext()) { String mime = cursor.getString(0); if (ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE.equals(mime)) { contact.setPhone(cursor.getString(1)); } else if (ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE.equals(mime)) { contact.setEmail(cursor.getString(2)); } } } finally { cursor.close(); } }

3.4 Intent 跳转新增与编辑联系人

新增联系人用SHOW_OR_CREATE_CONTACT,把姓名和电话预填进去:

public void addContact(Context context, String name, String phone) { Intent intent = new Intent(ContactsContract.Intents.SHOW_OR_CREATE_CONTACT); intent.setData(Uri.parse("tel:" + phone)); intent.putExtra(ContactsContract.Intents.Insert.NAME, name); intent.putExtra(ContactsContract.Intents.Insert.PHONE, phone); context.startActivity(intent); }

编辑已有联系人用ACTION_EDIT,注意finishActivityOnSaveCompleted这个 extra,设成 true 后用户保存完系统界面会自动关闭,回到你的列表:

public void editContact(Context context, Contact contact) { Intent intent = new Intent(Intent.ACTION_EDIT); intent.setData(Uri.parse("content://contacts/people/" + contact.getId())); intent.putExtra("finishActivityOnSaveCompleted", true); context.startActivity(intent); }

3.5 统一通道参数配置

把 TaoToken 的接入参数集中放在一个配置类里,别散落在各处:

public final class TaoTokenConfig { public static final String BASE_URL = "https://taotoken.net/api"; public static final String API_KEY = BuildConfig.TAO_TOKEN_KEY; public static final String MODEL_ID = "你的ModelID"; public static final String SYNC_ENDPOINT = BASE_URL + "/v1/chat/completions"; }

TAO_TOKEN_KEY通过local.properties注入到 BuildConfig,避免硬编码。同步联系人时,把联系人列表序列化成 JSON 作为请求体,走统一通道转发。

4. 验证请求与成功结果:联系人加载与跳转回传怎么确认

代码写完不算完,得一步步验证每个环节都通了。

4.1 验证联系人列表加载

在loadContacts()里加日志,确认 Cursor 有数据:

private void loadContacts() { List<Contact> list = queryContacts(this); Log.d("YouluContacts", "查询到联系人数量: " + list.size()); for (Contact c : list) { fillDetail(this, c); Log.d("YouluContacts", "姓名: " + c.getName() + " 电话: " + c.getPhone()); } adapter.submitList(list); }

跑起来后看 Logcat,如果数量是 0,先检查模拟器里有没有导入联系人。Android Studio 的模拟器可以在 Contacts 应用里手动加几个,或者用adb导入 vcf 文件。

4.2 验证 Intent 跳转与回传

点新增按钮,系统联系人界面应该带着预填的姓名和电话打开。保存后返回,列表不会自动刷新,因为 ContentObserver 还没注册。加上监听:

private final ContentObserver contactsObserver = new ContentObserver(new Handler(Looper.getMainLooper())) { @Override public void onChange(boolean selfChange) { loadContacts(); } }; @Override protected void onResume() { super.onResume(); getContentResolver().registerContentObserver( ContactsContract.Contacts.CONTENT_URI, true, contactsObserver); } @Override protected void onPause() { super.onPause(); getContentResolver().unregisterContentObserver(contactsObserver); }

这样用户从系统界面保存回来,列表会自动刷新,体验就顺了。

4.3 验证统一通道请求

用 curl 或者 Postman 先单独测通道,确认 Key 和 Base URL 没问题:

curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer sk-你的Key" \ -H "Content-Type: application/json" \ -d '{ "model": "你的ModelID", "messages": [ {"role": "system", "content": "你是联系人数据清洗助手"}, {"role": "user", "content": "把以下联系人去重:张三,张三,李四"} ] }'

返回的 JSON 里有choices[0].message.content就说明通道完全通了。然后在 App 里用 OkHttp 发同样的请求,把联系人数据传过去做去重或补全。

4.4 成功结果的判断标准

联系人模块验证通过的标准是:列表能正确显示姓名和电话;点新增能跳到系统界面且预填生效;保存返回后列表自动刷新;统一通道请求返回 200 且 choices 字段有内容。这四条都过了,友录联系人板块的主链路就算打通了。

5. 本篇常见错误排查:401、local proxy failed、reading choices 与 OAuth

这一节把实际开发中最容易撞上的报错列出来,对照着查。

5.1 401 Unauthorized

最常见的就是 Key 没带对。检查三处:请求头是不是Authorization: Bearer sk-xxx,Bearer 后面有没有多余空格,Key 是不是复制时漏了字符。还有一种情况是 Key 被禁用或额度用完,去控制台 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 看下 Key 状态。

5.2 local proxy failed

这个报错通常出现在你本地配了代理但代理没起来,或者 Base URL 被错误地指向了 localhost。TaoToken 的 Base URL 必须是https://taotoken.net/api,不要改成http://127.0.0.1:xxxx。如果你在 Android 模拟器里请求,模拟器访问外网是通的,不需要额外配代理。

5.3 reading choices 相关报错

报错信息里出现reading 'choices'或者Cannot read property 'choices' of undefined,说明返回体不是预期的 JSON 结构。原因一般是:请求路径写成了/api/v1/chat/completions之外的形式,或者请求体里 model 字段填了不存在的 Model ID。先确认 Model ID 和控制台里列出的完全一致,再确认路径没多加后缀。

5.4 OAuth 相关报错

如果你在 Claude Code 或类似工具里配置时遇到 OAuth 报错,多半是认证方式选错了。TaoToken 走的是 API Key 认证,不是 OAuth 流程。在工具的配置里应该选 API Key 模式,把 Base URL 和 Key 填进去,不要走浏览器授权那套。Claude Code 的接入配置在文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 里有完整示例。

5.5 联系人查询返回空 Cursor

不是报错但很常见。检查权限是否真的授予了(有些 ROM 会默认拒绝),检查ContactsContract.Contacts.CONTENT_URI有没有写错,检查模拟器里是否真的有联系人数据。还有一个隐蔽的坑:查询时用了DISPLAY_NAME排序但设备上该字段为 null,导致排序异常,换成DISPLAY_NAME_PRIMARY就好。

5.6 Intent 跳转后列表不刷新

前面提过,加 ContentObserver 就行。但要注意注册和反注册的时机,在onResume注册、onPause反注册,避免内存泄漏。如果还是不刷新,检查registerContentObserver的第二个参数notifyForDescendants是不是 true。

6. 把联系人同步接到统一通道:后续可以怎么扩展

联系人模块跑通之后,如果你要做跨设备同步或者云端备份,统一通道的价值就体现出来了。你不需要在每个同步点单独处理鉴权,只要把数据序列化后 POST 到https://taotoken.net/api就行。

长期做编码和 Agent 类任务的,可以看下 Coding Plan,把日常开发里的模型调用也收敛到同一个通道:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。想先验证模型效果的,直接去模型对话页面试:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。

联系人数据同步这块,我自己的做法是:本地用 ContentResolver 读,变化用 ContentObserver 监听,同步请求走统一通道,Key 通过 BuildConfig 注入。这样模块之间解耦,后面换后端或者加字段都不用动联系人读取的代码。

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

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

立即咨询