拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android 获取联系人详解:ContentResolver 查询与权限适配实战

Android 获取联系人详解:ContentResolver 查询与权限适配实战

1. Android 读取联系人为什么总踩坑:ContentResolver 查询与权限适配的真实场景

Android 获取联系人这个需求,看起来就是几行query的事,但真正落到项目里,十有八九会在三个地方翻车:权限没申请对、Cursor 没关导致内存泄漏、号码字段取出来是空。核心检索词先摆出来——Android 通过 ContentResolver 读取系统联系人,本质是跨进程访问content://com.android.contacts这个 ContentProvider,你的 App 只是"借道"系统数据库,所以权限、URI、字段映射三件事必须同时正确。

它适合谁?做通讯录备份、来电名片、企业 IM 导入好友、拨号辅助这类功能的 Android 开发者。能做什么?拿到联系人姓名、多个手机号、邮箱、头像 ID,甚至按号码反查联系人。但系统对隐私收得越来越紧,READ_CONTACTS属于危险权限(dangerous),从 Android 6.0(API 23)开始必须运行时申请,Android 10 之后部分字段还涉及分区存储和权限分级。

我见过最常见的错误写法,就是直接在onCreate里调query,然后真机一跑直接崩,日志里一行SecurityException: Permission Denial: reading com.android.providers.contacts。还有人把cursor.getColumnIndex()的返回值直接当数组下标用,字段不存在时返回 -1,getString(-1)立刻抛CursorIndexOutOfBoundsException。这些坑本篇都会给可复制的规避写法。

下面按"权限声明 → 运行时申请 → 查询遍历 → 号码映射 → 真机验证 → 报错排查"的完整链路走一遍,代码可以直接贴进项目改包名使用。查询部分我会用ContactsContract官方常量,而不是硬编码字符串,这样字段名不会因为系统版本变化而失效。

2. TaoToken 前置准备:给联系人功能加一个可调用的模型能力

联系人读取本身是纯本地逻辑,不需要联网。但很多真实项目会在拿到联系人后做智能处理,比如"根据备注自动生成分组标签""把一堆号码整理成结构化 JSON""识别名片里的公司名"。这类需求如果自己写规则会非常痛苦,用大模型做语义抽取会轻松很多。这时候就需要一个稳定的模型调用入口,我平时用的是 TaoToken。

TaoToken 是一个模型 API 聚合平台,官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,它把多家模型的调用方式统一成 OpenAI 兼容格式,你只要拿到一个 Base URL 和一个 Key,就能在 Android 端用 OkHttp 直接发请求。对联系人场景来说,典型用法是:本地用 ContentResolver 读出联系人列表,序列化成 JSON,再丢给模型做去重、分类或补全。

先说清楚它不是什么:它不是联系人数据库,也不碰你的本地数据,只是一个模型调用通道。你的联系人数据要不要上传、上传哪些字段,完全由你自己在代码里控制。涉及隐私字段时,建议只传脱敏后的昵称或哈希,别把完整号码发出去。

接入前你需要准备三样东西,这也是后面所有配置的基础:

项目说明获取位置
Base URL统一接口前缀,OpenAI 兼容https://taotoken.net/api
API Key身份凭证,形如 sk-xxx控制台 API Keys 页面
Model ID具体模型标识模型列表 / 文档

控制台入口在 https://taotoken.net/console ,API Key 在 https://taotoken.net/api-keys 生成,模型和参数说明看 https://taotoken.net/doc 。如果你只是想先验证模型能不能通,用模型对话页面 https://taotoken.net/models 直接试一句就行,不用写代码。

这里要强调一个容易混淆的点:Base URL 填https://taotoken.net/api,不要自己加/v1后缀,具体路径由 SDK 或请求体里的 endpoint 决定。很多 401 和 404 就是因为 URL 拼错。Key 只在服务端或本地调试时使用,正式 App 里不要硬编码进 APK,否则反编译就能拿到,建议走自己的后端中转。

3. 可复制配置:权限声明、运行时申请与查询代码

这一节是全文核心,所有片段都可以直接复制。先看AndroidManifest.xml的权限声明,这是第一步,漏了它后面全白搭。

<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.contactsdemo"> <!-- 读取联系人,危险权限,需运行时申请 --> <uses-permission android:name="android.permission.READ_CONTACTS" /> <!-- 如果还要写回联系人(比如备份恢复),再加这条 --> <uses-permission android:name="android.permission.WRITE_CONTACTS" /> <application android:allowBackup="true" android:label="ContactsDemo" android:theme="@style/Theme.AppCompat.Light"> <activity android:name=".MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>

注意READ_CONTACTS是危险权限,光声明不申请,在 API 23 以上会直接抛SecurityException。运行时申请用ActivityResultLauncher,这是现在官方推荐写法,比老的onRequestPermissionsResult干净。

class MainActivity : AppCompatActivity() { private lateinit var requestPermission: ActivityResultLauncher<String> override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) requestPermission = registerForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> if (granted) { loadContacts() } else { // 用户拒绝,给出解释或引导去设置页 Toast.makeText(this, "未授予联系人权限", Toast.LENGTH_SHORT).show() } } if (ContextCompat.checkSelfPermission( this, Manifest.permission.READ_CONTACTS ) == PackageManager.PERMISSION_GRANTED ) { loadContacts() } else { requestPermission.launch(Manifest.permission.READ_CONTACTS) } } }

接下来是查询主体。用ContactsContract.Contacts.CONTENT_URI而不是硬编码content://com.android.contacts/contacts,前者是官方常量,兼容性更好。遍历时先查联系人主表拿_ID和DISPLAY_NAME,再用_ID去Phone.CONTENT_URI查号码,因为一个联系人可能有多个号码。

private fun loadContacts() { val resolver = contentResolver val contacts = mutableListOf<Contact>() // 只查需要的列,减少 IO val projection = arrayOf( ContactsContract.Contacts._ID, ContactsContract.Contacts.DISPLAY_NAME_PRIMARY, ContactsContract.Contacts.HAS_PHONE_NUMBER ) resolver.query( ContactsContract.Contacts.CONTENT_URI, projection, null, null, "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} ASC" )?.use { cursor -> // use 自动关闭 Cursor,避免泄漏 val idIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID) val nameIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY) val hasPhoneIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts.HAS_PHONE_NUMBER) while (cursor.moveToNext()) { val contactId = cursor.getString(idIndex) val name = cursor.getString(nameIndex) ?: "未知" val hasPhone = cursor.getInt(hasPhoneIndex) > 0 val phones = mutableListOf<String>() if (hasPhone) { resolver.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER), "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?", arrayOf(contactId), null )?.use { phoneCursor -> val numberIndex = phoneCursor.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.Phone.NUMBER ) while (phoneCursor.moveToNext()) { phones.add(phoneCursor.getString(numberIndex)) } } } contacts.add(Contact(contactId, name, phones)) } } Log.i("ContactsDemo", "共读取 ${contacts.size} 个联系人") }

如果你要在拿到联系人后调用模型做整理,可以复用同一套 Base URL + Key + Model ID 三件套。下面是一个最小请求体示例,注意model字段填你在控制台看到的真实 Model ID:

{ "model": "your-model-id", "messages": [ { "role": "system", "content": "你是通讯录整理助手,把输入的联系人列表按公司归类,输出 JSON。" }, { "role": "user", "content": "[{\"name\":\"张三\",\"phones\":[\"13800000000\"]}]" } ], "temperature": 0.2 }

请求地址就是https://taotoken.net/api加上文档里对应的对话路径,Header 里带Authorization: Bearer sk-xxx。Android 端用 OkHttp 发 POST,Content-Type: application/json,这部分和普通 REST 请求没区别。

4. 验证请求与成功结果:真机跑通联系人读取

代码写完必须真机验证,模拟器上联系人数据往往是空的,容易误判成代码问题。先在真机上手动存两三个联系人,其中一个存两个号码,方便验证多号码逻辑。

第一步,安装运行 App,首次启动会弹出权限对话框,点"允许"。如果没弹,检查是不是之前拒绝过并且勾了"不再询问",去设置里手动开。

第二步,看 Logcat。过滤 tagContactsDemo,正常应该输出类似:

I/ContactsDemo: 共读取 3 个联系人

如果数量是 0,先确认手机里确实有联系人,再检查HAS_PHONE_NUMBER字段。有些联系人只有邮箱没有号码,hasPhone为 0,会被跳过,这是预期行为。

第三步,验证号码映射。把contacts列表打印出来,确认多号码联系人两个号都在:

contacts.forEach { c -> Log.d("ContactsDemo", "id=${c.id}, name=${c.name}, phones=${c.phones.joinToString()}") }

预期输出:

D/ContactsDemo: id=12, name=张三, phones=13800000000,13900000000 D/ContactsDemo: id=15, name=李四, phones=13700000000

第四步,如果你接了模型做整理,用模型对话页面 https://taotoken.net/models 先手动发一条测试消息,确认 Key 和 Model ID 有效,再回到 App 里发请求。这样能把"模型配置错"和"App 网络代码错"两类问题分开定位。

实测下来,真机上最容易忽略的是权限被系统"自动重置"。Android 11 之后,如果 App 长时间不用,系统会撤销危险权限,下次启动checkSelfPermission会返回未授予,所以每次进页面都要重新检查,不能只在onCreate判断一次就完事。

5. 本篇常见错排查:401、SecurityException 与 Cursor 越界

这一节按真实报错来对照,遇到问题直接搜关键字。

报错一:java.lang.SecurityException: Permission Denial: reading com.android.providers.contacts

原因:没申请READ_CONTACTS,或者申请了但用户拒绝。排查顺序:先看 Manifest 有没有声明,再看运行时有没有调requestPermission.launch,最后看用户是不是点了拒绝。如果是拒绝,checkSelfPermission会返回PERMISSION_DENIED,别硬查。

报错二:android.database.CursorIndexOutOfBoundsException: Index -1 requested

原因:getColumnIndex返回 -1,说明 projection 里没这个列,或者列名拼错。解决:改用getColumnIndexOrThrow,字段不存在时直接抛异常并告诉你哪个列名错了,比 -1 好定位。另外 projection 里写了哪些列,就只能取哪些列,别取没查的字段。

报错三:401 Unauthorized(调用模型时)

原因:API Key 错、过期,或者 Header 没带对。检查Authorization: Bearer sk-xxx格式,注意 Bearer 后面有一个空格。Key 去 https://taotoken.net/api-keys 重新生成一个再试。如果还是 401,确认 Base URL 是https://taotoken.net/api,没有多余斜杠或后缀。

报错四:local proxy failed/ 连接超时

原因:网络请求没走通,可能是设备网络问题或请求地址写错。先在模型对话页面确认服务本身可用,再检查 App 里的 URL 拼接。Android 9 以上默认禁止明文 HTTP,如果你误用了 http 开头会直接失败,确认用的是 https。

报错五:reading choices相关解析错误

原因:模型返回的 JSON 结构和你的解析代码不匹配,比如你按choices[0].message.content取,但实际返回结构不同。解决:先把原始响应体完整打日志,看清结构再写解析,别凭记忆写字段路径。

报错六:Cursor 没关导致CursorWindowAllocationException

原因:query返回的 Cursor 用完没close()。解决:全部用 Kotlin 的.use { }包裹,或者 Java 里 try-finally 手动关。嵌套查询时内外两个 Cursor 都要关。

排查时记住一个原则:权限问题看 Logcat 的SecurityException,数据问题看 Cursor 的列索引,网络问题先分离模型配置和 App 代码。把这三类分开,定位速度会快很多。

6. 继续深入:把联系人能力接到模型与长期编码工作流

联系人读取跑通之后,下一步通常是把它变成产品能力。比如做一个"通讯录智能分组",本地读出联系人,脱敏后发给模型归类;或者做"名片识别补全",把 OCR 结果和联系人字段对齐。这些场景都需要一个稳定的模型入口,TaoToken 的 API 地址是 https://taotoken.net/api ,配合 https://taotoken.net/doc 里的参数说明,基本能覆盖对话、结构化抽取这类需求。

如果你只是偶尔验证模型效果,用模型对话页面 https://taotoken.net/models 最省事,不用写代码。如果是要长期在项目里做编码辅助、Agent 编排,建议看 Coding Plan https://taotoken.net/coding-plan ,它更适合持续性的开发工作流。接入文档在 https://taotoken.net/doc ,API Key 管理在 https://taotoken.net/api-keys ,控制台总入口是 https://taotoken.net/console 。

最后给一个实用技巧:联系人查询一定要做分页或限制条数,几千个联系人的设备上一次性全查会明显卡顿。可以在 query 的 sortOrder 里加LIMIT,或者用CursorLoader做异步加载。另外号码字段里可能带空格、横线、国家码,存库前统一用正则清洗成纯数字,能省掉后面一堆匹配问题。这些细节不写进教程,但真到线上就是它们决定体验。

返回列表