☰
Android开发获取系统联系人信息:TaoToken统一Key通道下的权限适配与读取实践
2026/10/7 7:46:43 网站建设 项目流程

1. Android 读取系统联系人为什么总在权限和 Cursor 上翻车

做 Android 开发获取系统联系人信息,看起来就是一句getContentResolver().query()的事,但真机一跑,十有八九会遇到三类问题:权限申请时机不对导致查询直接抛 SecurityException、Cursor 字段索引拿错导致空指针、以及联系人数据分散在多张表里需要多次关联查询。我见过不少项目在模拟器上跑得好好的,换到 Android 13/14 真机就崩,核心原因就是没吃透运行时权限和 ContentProvider 的查询模型。

先把结论说清楚:Android 读取联系人本质是「权限 + ContentResolver + Cursor 字段映射」三件事。权限负责拿到入场券,ContentResolver 负责向系统联系人数据库发起查询,Cursor 负责把结果集按列名映射成 Java 对象。任何一环出问题,都会表现为查询返回空、字段为 null 或者直接崩溃。

适合谁看这篇?如果你正在做通讯录导入、来电识别、社交 App 的好友匹配,或者单纯想搞明白ContactsContract这套 API 怎么用,这篇都能直接抄代码。我会从权限声明讲到完整查询链路,再演示怎么用 TaoToken 统一 Key 通道集中管理接口调用凭证——因为很多联系人相关功能(比如号码归属地、头像拉取、云端去重)都要调后端接口,凭证散落在各处是维护噩梦。

先明确一个概念:系统联系人数据不是一张表,而是Contacts、RawContacts、Data三张核心表。Contacts是聚合后的联系人,RawContacts是原始账户记录,Data表则用 MIMETYPE 区分电话、邮箱、地址、组织、备注、昵称等所有明细。你查电话号码、邮箱、IM、地址,其实都是在查Data表,只是通过不同的CommonDataKinds子类来过滤 MIMETYPE。理解这一点,后面所有查询代码就都通了。

权限方面,从 Android 6.0(API 23)开始,READ_CONTACTS属于危险权限,必须运行时申请。Android 10 之后又收紧了后台读取,Android 13 对联系人相关的部分能力做了进一步限制。所以你的代码不能只在 Manifest 里声明就完事,必须在合适的生命周期节点动态请求,并且处理用户拒绝、永久拒绝两种分支。

下面这张表先给你一个全局对照,后面每一节都会展开:

环节关键 API常见坑
权限声明AndroidManifest.xml只声明不申请,真机直接崩
运行时申请requestPermissions()在 onCreate 里同步查,权限还没回调
查询联系人ContactsContract.Contacts.CONTENT_URI排序字段写错导致空结果
查电话CommonDataKinds.Phone.CONTENT_URI用 Phone.CONTACT_ID 过滤时拼串注入
查邮箱CommonDataKinds.Email.CONTENT_URI列名用错,取到 null
查 IM/地址/组织Data.CONTENT_URI+ MIMETYPEMIMETYPE 拼错,查不到任何行
Cursor 映射getColumnIndex()索引为 -1 时 getString 抛异常

我试过在一个老项目里直接照搬网上的查询代码,结果在 Android 14 上getColumnIndex返回 -1,getString(-1)直接抛CursorIndexOutOfBoundsException。后来才发现是查询投影(projection)传了 null,系统返回的列集合和预期不一致。所以下面我会强调:能显式指定 projection 就不要传 null,这是稳定性的第一道保险。

2. TaoToken 统一 Key 通道:把接口凭证从代码里挪出去

联系人功能做到后期,几乎一定会接后端接口:号码归属地查询、头像 CDN 拉取、云端联系人去重、AI 智能分组。这些接口各自有 Key,散落在BuildConfig、local.properties、甚至硬编码在 Java 里,一旦要换环境或者轮换密钥,就得全局搜索替换,非常容易漏。

TaoToken 在这里的角色是「统一 Key / API 通道」:你不再为每个后端服务单独维护一套凭证,而是通过一个统一的入口管理调用凭证,客户端只认一个 Base URL 和一个 Key。官网入口是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 根地址是 https://taotoken.net/api 。

需要说清楚的是,TaoToken 不是让你绕过什么限制,它解决的是工程层面的凭证治理问题:多个模型、多个接口的调用凭证集中在一处,客户端配置收敛成一个 Base URL + 一个 Key + 一个 Model ID 的三件套。对于 Android 项目来说,这意味着你可以在build.gradle里用buildConfigField注入,而不是把密钥写死在代码里。

具体到联系人场景,典型用法是这样:你的 App 读取本地联系人后,需要调用一个「号码归属地 + 风险标记」的接口。传统做法是每个接口一个域名一个 Key,现在统一走 TaoToken 的 API 通道,客户端只需要配置一次。这样在 debug / release / 测试环境之间切换时,只改一处配置即可。

如果你用的是 Claude Code 这类编码工具来辅助开发 Android 项目,也可以通过 Coding Plan 把模型调用统一管理起来,入口在 https://taotoken.net/api 对应的控制台里。控制台地址是 https://taotoken.net/console ,API Keys 管理在 https://taotoken.net/api-keys ,接入文档在 https://taotoken.net/doc 。模型对话调试可以用 https://taotoken.net/models ,长期编码或 Agent 场景建议看 Coding Plan:https://taotoken.net/coding-plan 。

这里要提醒一句:客户端直连 API 通道时,Key 仍然会暴露在 APK 里。生产环境更稳妥的做法是客户端调你自己的后端,由后端持有 Key 再去调上游。TaoToken 的统一通道主要解决的是「凭证集中管理」和「多服务收敛」,不是让你把密钥明文塞进 APK 就完事。这一点在后面的配置章节我会给出两种模式:直连调试模式和后端代理模式。

对于 Android 开发者来说,最实际的收益是:当你同时要调联系人去重接口、头像接口、AI 分组接口时,不用再维护三套 Base URL 和三套鉴权逻辑,统一成一套 HTTP 客户端配置即可。下面进入可复制配置环节。

3. 可复制配置:权限声明、查询代码与统一 Key 三件套

这一节全部是可复制内容,你直接贴进项目就能跑。先给权限声明,再给完整查询代码,最后给 TaoToken 的三件套配置。

3.1 AndroidManifest.xml 权限声明

<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.contactdemo"> <uses-permission android:name="android.permission.READ_CONTACTS" /> <uses-permission android:name="android.permission.INTERNET" /> <application android:allowBackup="true" android:label="ContactDemo" android:theme="@style/AppTheme"> <activity android:name=".ContactActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>

注意READ_CONTACTS是危险权限,Manifest 声明只是「告知系统我要用」,真正授权要靠运行时申请。INTERNET是普通权限,声明即可,用于后面调接口。

3.2 运行时权限申请

private static final int REQ_CONTACTS = 1001; private void requestContactPermission() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) { loadContacts(); } else { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_CONTACTS}, REQ_CONTACTS); } } @Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode == REQ_CONTACTS) { if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { loadContacts(); } else { if (!ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.READ_CONTACTS)) { // 用户勾选了「不再询问」,引导去设置页 Toast.makeText(this, "请到设置中开启联系人权限", Toast.LENGTH_LONG).show(); } else { Toast.makeText(this, "需要联系人权限才能导入通讯录", Toast.LENGTH_SHORT).show(); } } } }

关键点:不要在onCreate里申请完权限就立刻查询,因为权限回调是异步的。正确做法是把查询逻辑放在onRequestPermissionsResult的授权成功分支里,或者用registerForActivityResult的新 API。

3.3 完整联系人查询代码

private void loadContacts() { new Thread(() -> { List<ContactBean> result = new ArrayList<>(); ContentResolver resolver = getContentResolver(); String[] projection = new String[]{ ContactsContract.Contacts._ID, ContactsContract.Contacts.DISPLAY_NAME, ContactsContract.Contacts.HAS_PHONE_NUMBER }; Cursor cur = resolver.query( ContactsContract.Contacts.CONTENT_URI, projection, null, null, ContactsContract.Contacts.DISPLAY_NAME + " COLLATE LOCALIZED ASC"); if (cur == null) { Log.e("ContactDemo", "cursor is null"); return; } try { int idIdx = cur.getColumnIndexOrThrow(ContactsContract.Contacts._ID); int nameIdx = cur.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME); int hasPhoneIdx = cur.getColumnIndexOrThrow(ContactsContract.Contacts.HAS_PHONE_NUMBER); while (cur.moveToNext()) { String contactId = cur.getString(idIdx); String name = cur.getString(nameIdx); int hasPhone = cur.getInt(hasPhoneIdx); ContactBean bean = new ContactBean(); bean.contactId = contactId; bean.name = name; if (hasPhone > 0) { bean.phones = queryPhones(resolver, contactId); } bean.emails = queryEmails(resolver, contactId); bean.ims = queryIm(resolver, contactId); bean.addresses = queryAddress(resolver, contactId); bean.organizations = queryOrganization(resolver, contactId); bean.notes = queryNote(resolver, contactId); bean.nicknames = queryNickname(resolver, contactId); result.add(bean); } } finally { cur.close(); } runOnUiThread(() -> { Log.i("ContactDemo", "total contacts = " + result.size()); for (ContactBean b : result) { Log.i("ContactDemo", b.toString()); } }); }).start(); }

下面是各个明细查询方法,注意全部使用参数化查询,避免拼串注入:

private List<String> queryPhones(ContentResolver resolver, String contactId) { List<String> list = new ArrayList<>(); Cursor c = resolver.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, new String[]{ ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.TYPE }, ContactsContract.CommonDataKinds.Phone.CONTACT_ID + " = ?", new String[]{contactId}, null); if (c != null) { try { int numIdx = c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER); int typeIdx = c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.TYPE); while (c.moveToNext()) { list.add(c.getString(numIdx) + "|type=" + c.getInt(typeIdx)); } } finally { c.close(); } } return list; } private List<String> queryEmails(ContentResolver resolver, String contactId) { List<String> list = new ArrayList<>(); Cursor c = resolver.query( ContactsContract.CommonDataKinds.Email.CONTENT_URI, new String[]{ ContactsContract.CommonDataKinds.Email.DATA, ContactsContract.CommonDataKinds.Email.TYPE }, ContactsContract.CommonDataKinds.Email.CONTACT_ID + " = ?", new String[]{contactId}, null); if (c != null) { try { int dataIdx = c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Email.DATA); int typeIdx = c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Email.TYPE); while (c.moveToNext()) { list.add(c.getString(dataIdx) + "|type=" + c.getInt(typeIdx)); } } finally { c.close(); } } return list; } private List<String> queryIm(ContentResolver resolver, String contactId) { List<String> list = new ArrayList<>(); Cursor c = resolver.query( ContactsContract.Data.CONTENT_URI, new String[]{ ContactsContract.CommonDataKinds.Im.PROTOCOL, ContactsContract.CommonDataKinds.Im.DATA }, ContactsContract.Data.CONTACT_ID + "=? AND " + ContactsContract.Data.MIMETYPE + "=?", new String[]{contactId, ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE}, null); if (c != null) { try { int protoIdx = c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Im.PROTOCOL); int dataIdx = c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Im.DATA); while (c.moveToNext()) { list.add("protocol=" + c.getInt(protoIdx) + "|" + c.getString(dataIdx)); } } finally { c.close(); } } return list; } private List<String> queryAddress(ContentResolver resolver, String contactId) { List<String> list = new ArrayList<>(); Cursor c = resolver.query( ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_URI, new String[]{ ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS, ContactsContract.CommonDataKinds.StructuredPostal.CITY, ContactsContract.CommonDataKinds.StructuredPostal.REGION }, ContactsContract.CommonDataKinds.StructuredPostal.CONTACT_ID + " = ?", new String[]{contactId}, null); if (c != null) { try { int fmtIdx = c.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS); int cityIdx = c.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.StructuredPostal.CITY); int regionIdx = c.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.StructuredPostal.REGION); while (c.moveToNext()) { list.add(c.getString(fmtIdx) + "|" + c.getString(cityIdx) + "|" + c.getString(regionIdx)); } } finally { c.close(); } } return list; } private List<String> queryOrganization(ContentResolver resolver, String contactId) { List<String> list = new ArrayList<>(); Cursor c = resolver.query( ContactsContract.Data.CONTENT_URI, new String[]{ ContactsContract.CommonDataKinds.Organization.COMPANY, ContactsContract.CommonDataKinds.Organization.TITLE }, ContactsContract.Data.CONTACT_ID + "=? AND " + ContactsContract.Data.MIMETYPE + "=?", new String[]{contactId, ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE}, null); if (c != null) { try { int compIdx = c.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.Organization.COMPANY); int titleIdx = c.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.Organization.TITLE); while (c.moveToNext()) { list.add(c.getString(compIdx) + "|" + c.getString(titleIdx)); } } finally { c.close(); } } return list; } private List<String> queryNote(ContentResolver resolver, String contactId) { List<String> list = new ArrayList<>(); Cursor c = resolver.query( ContactsContract.Data.CONTENT_URI, new String[]{ContactsContract.CommonDataKinds.Note.NOTE}, ContactsContract.Data.CONTACT_ID + "=? AND " + ContactsContract.Data.MIMETYPE + "=?", new String[]{contactId, ContactsContract.CommonDataKinds.Note.CONTENT_ITEM_TYPE}, null); if (c != null) { try { int noteIdx = c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Note.NOTE); while (c.moveToNext()) { list.add(c.getString(noteIdx)); } } finally { c.close(); } } return list; } private List<String> queryNickname(ContentResolver resolver, String contactId) { List<String> list = new ArrayList<>(); Cursor c = resolver.query( ContactsContract.Data.CONTENT_URI, new String[]{ContactsContract.CommonDataKinds.Nickname.NAME}, ContactsContract.Data.CONTACT_ID + "=? AND " + ContactsContract.Data.MIMETYPE + "=?", new String[]{contactId, ContactsContract.CommonDataKinds.Nickname.CONTENT_ITEM_TYPE}, null); if (c != null) { try { int nameIdx = c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Nickname.NAME); while (c.moveToNext()) { list.add(c.getString(nameIdx)); } } finally { c.close(); } } return list; }

3.4 TaoToken 三件套配置(Base URL + Key + Model ID)

如果你要在联系人功能里接入后端接口(比如号码归属地、AI 分组),推荐把凭证收敛成三件套。下面给一个local.properties+build.gradle的配置示例,路径和原文一致:

local.properties(不要提交到 Git):

TAOTOKEN_BASE_URL=https://taotoken.net/api TAOTOKEN_API_KEY=sk-your-key-here TAOTOKEN_MODEL_ID=your-model-id

app/build.gradle:

android { defaultConfig { buildConfigField "String", "TAOTOKEN_BASE_URL", "\"${project.findProperty('TAOTOKEN_BASE_URL') ?: ''}\"" buildConfigField "String", "TAOTOKEN_API_KEY", "\"${project.findProperty('TAOTOKEN_API_KEY') ?: ''}\"" buildConfigField "String", "TAOTOKEN_MODEL_ID", "\"${project.findProperty('TAOTOKEN_MODEL_ID') ?: ''}\"" } }

然后在代码里通过BuildConfig.TAOTOKEN_BASE_URL、BuildConfig.TAOTOKEN_API_KEY、BuildConfig.TAOTOKEN_MODEL_ID读取。这样 debug 和 release 可以配不同的 Key,切换环境只改local.properties。

如果你用 Claude Code 辅助开发,配置可以写成settings.json形式,把 Base URL、Key、Model ID 三件套放进去:

{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "sk-your-key-here", "ANTHROPIC_MODEL": "your-model-id" } }

注意:客户端直连只适合调试。生产环境建议客户端调你自己的后端,后端持有 Key 再转发,避免 Key 随 APK 分发泄露。API Keys 管理入口在 https://taotoken.net/api-keys ,接入文档在 https://taotoken.net/doc 。

4. 验证请求与成功结果:真机跑一遍看日志

配置写完,必须真机验证。模拟器的联系人数据库往往是空的,权限行为也和真机有差异,所以这一步别偷懒。

4.1 真机验证步骤

第一步,安装 APK 后首次进入页面,系统会弹出联系人权限对话框,点「允许」。如果你之前拒绝过,需要到「设置 → 应用 → 权限 → 联系人」里手动开启。

第二步,提前在手机里存几条测试联系人,最好覆盖多种情况:只有名字没有电话的、有多个电话的、有邮箱的、有 IM 的、有地址的、有组织的、有备注和昵称的。这样能验证所有查询分支。

第三步,观察 Logcat,过滤 tagContactDemo。正常输出应该类似:

I/ContactDemo: total contacts = 5 I/ContactDemo: ContactBean{name='张三', phones=[13800000000|type=2], emails=[zhangsan@example.com|type=1], ...} I/ContactDemo: ContactBean{name='李四', phones=[13900000000|type=2, 010-88886666|type=3], emails=[], ...}

第四步,对照下面这张结果表检查每个字段是否符合预期:

检查项预期结果异常表现
联系人总数与手机通讯录条数一致为 0 说明权限或查询有问题
姓名与通讯录显示名一致为 null 说明 projection 列名错
电话数量与联系人实际号码数一致少于实际说明 HAS_PHONE_NUMBER 判断有误
邮箱有则显示,无则为空列表抛异常说明列索引为 -1
IM有则显示 protocol 和账号查不到说明 MIMETYPE 拼错
地址格式化地址非空全 null 说明列名用错
组织公司和职位查不到说明 CONTENT_ITEM_TYPE 不对
备注/昵称有则显示空说明该联系人确实没填

4.2 接口调用验证

如果你接了 TaoToken 通道做号码归属地查询,可以用 curl 先验证通道是否通:

curl -X POST "https://taotoken.net/api/v1/chat/completions" \ -H "Authorization: Bearer sk-your-key-here" \ -H "Content-Type: application/json" \ -d '{ "model": "your-model-id", "messages": [ {"role": "user", "content": "13800000000 的归属地是哪里"} ] }'

返回 200 且 body 里有choices数组,说明 Base URL、Key、Model ID 三件套配置正确。如果返回 401,说明 Key 无效或没带上;如果返回 404,多半是 Base URL 路径写错,注意是https://taotoken.net/api而不是别的路径。

在 Android 端,用 OkHttp 发请求时记得把超时设长一点,联系人批量查询可能一次发很多条:

OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build(); Request request = new Request.Builder() .url(BuildConfig.TAOTOKEN_BASE_URL + "/v1/chat/completions") .addHeader("Authorization", "Bearer " + BuildConfig.TAOTOKEN_API_KEY) .post(RequestBody.create(mediaType, jsonBody)) .build();

实测下来,把三件套收敛到BuildConfig之后,切换测试环境和生产环境只需要改local.properties一行,比之前到处找硬编码 Key 舒服太多。

5. 本篇常见错误排查:401、权限拒绝、Cursor 越界逐个击破

这一节按真实报错来对照,你遇到哪个查哪个。

5.1 权限拒绝:SecurityException: Permission Denial

完整报错通常长这样:

java.lang.SecurityException: Permission Denial: opening provider com.android.providers.contacts.ContactsProvider2 from ProcessRecord{...} requires android.permission.READ_CONTACTS or android.permission.WRITE_CONTACTS

原因有三种:Manifest 没声明、声明了没运行时申请、用户拒绝了。排查顺序是先看 Manifest,再看checkSelfPermission返回值,最后看shouldShowRequestPermissionRationale。如果是「不再询问」状态,只能引导用户去设置页。

5.2 401 Unauthorized:Key 或 Base URL 配错

调用 TaoToken 通道时返回:

{"error": {"message": "Invalid API key", "type": "invalid_request_error"}}

先检查Authorization头是不是Bearer sk-xxx格式,注意 Bearer 后面有空格。再检查 Key 是否过期或被删除,去 https://taotoken.net/api-keys 确认。最后检查 Base URL 是不是https://taotoken.net/api,多一个斜杠或者少一段都会 404。

5.3 local proxy failed:本地代理配置冲突

如果你在 Android Studio 里配了 HTTP Proxy,或者 Gradle 走了本地代理,可能报:

local proxy failed: Connection refused

这时候检查gradle.properties里的systemProp.http.proxyHost等配置,以及 Android Studio 的 Settings → HTTP Proxy。把不必要的代理关掉,直连即可。

5.4 reading choices:响应体解析失败

调用接口后解析 JSON 报:

com.google.gson.JsonSyntaxException: Expected BEGIN_ARRAY but was BEGIN_OBJECT

或者日志里出现reading choices相关错误,通常是响应结构和你的数据类不匹配。先打印原始响应体,确认choices是数组还是对象,再调整 Gson 的解析模型。别直接照抄别人的响应类,不同接口结构不一样。

5.5 CursorIndexOutOfBoundsException:列索引为 -1

完整报错:

android.database.CursorIndexOutOfBoundsException: Index -1 requested, with a size of 5

原因就是getColumnIndex()返回了 -1,说明你请求的列不在 Cursor 里。解决办法有两个:一是用getColumnIndexOrThrow()让它在开发期就抛异常暴露问题;二是显式指定 projection,不要传 null。我踩过的坑就是 projection 传 null,系统返回的列集合和预期不一致,某些机型上少列。

5.6 OAuth 相关报错

如果你用 Claude Code 或类似工具接入,可能遇到 OAuth 相关提示。这类问题通常是认证方式没选对,检查你的配置文件里用的是 API Key 还是 OAuth token,两者不要混用。接入文档 https://taotoken.net/doc 里有对应的配置说明。

5.7 查询返回空但权限正常

权限正常、代码没报错,但联系人数量为 0。这种情况先确认手机通讯录里确实有联系人,再检查查询 URI 是否正确。有些定制 ROM 的联系人 Provider 行为有差异,可以尝试换用ContactsContract.Contacts.CONTENT_URI并去掉排序参数测试。另外,工作资料(Work Profile)里的联系人和个人资料是分开的,默认查询只返回个人资料。

6. 把联系人读取和统一 Key 通道串起来的实践建议

走到这里,Android 开发获取系统联系人信息的完整链路已经跑通了:权限声明与运行时申请、ContentResolver 查询、Cursor 字段映射、明细表关联查询、真机验证、错误排查。最后再给几条实践建议,帮你少走弯路。

第一,查询一定要放到子线程。联系人数量多的时候,主线程查询会卡顿甚至 ANR。上面代码里用了new Thread,生产环境建议换成ExecutorService或 Kotlin 协程。

第二,Cursor 用完必须 close。我见过太多项目忘记关 Cursor 导致内存泄漏,尤其是嵌套查询多个明细表时。用 try-finally 包起来是最稳的写法。

第三,projection 能显式就显式。传 null 虽然方便,但不同 Android 版本返回的列集合可能不同,getColumnIndex返回 -1 的坑多半来自这里。

第四,凭证管理要收敛。联系人功能涉及的后端接口会越来越多,把 Base URL、Key、Model ID 三件套统一到local.properties+BuildConfig,比散落在代码里强太多。TaoToken 的统一通道适合做这件事,控制台在 https://taotoken.net/console ,模型调试在 https://taotoken.net/models ,长期编码场景可以看 https://taotoken.net/coding-plan 。

第五,生产环境别让客户端直连。调试阶段直连方便,但发布版本建议走自己的后端代理,Key 留在服务端。客户端只认你自己的域名,安全性和可维护性都更好。

最后给一个实用技巧:如果你要批量导入联系人并做去重,可以先把本地联系人读成List<ContactBean>,按号码归一化(去掉空格、横线、+86 前缀)后再比对。这一步在客户端做比在后端做省流量,但要注意号码归一化规则因地区而异,别一刀切。

代码都在上面,直接复制到项目里改包名就能跑。遇到报错对照第 5 节排查,基本覆盖了 90% 的场景。

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

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

立即咨询