
1. 为什么 SimpleCursorAdapter 读联系人总翻车从 Cursor 到列表的完整实现SimpleCursorAdapter是 Android 里把数据库游标直接映射到列表控件的老牌适配器它能做什么一句话你给它一个Cursor、一组列名、一组控件 ID它就把每一行数据塞进对应的TextView里。适合谁适合需要快速实现通讯录、通话记录、短信列表这类「查表即显示」功能的开发者尤其是还在维护传统 View 体系项目、不想引入 RecyclerView 的场景。但我在实际项目里发现很多人第一次写联系人列表时代码看着没错跑起来要么列表空白要么直接崩在SimpleCursorAdapter构造那一行。核心原因有三个第一Contacts.People这套 API 早就废弃了新系统上查出来是空 Cursor第二SimpleCursorAdapter有个硬性要求——Cursor 里必须包含一个叫_id的列否则绑定就报IllegalArgumentException第三查询联系人号码时如果只查ContactsContract.ContactsNUMBER列根本不存在得去Phone这张表拿。这篇就按「能直接复制去跑」的标准把权限声明、Cursor 查询、Adapter 绑定、真机验证、报错排查整条链路走一遍。你跟着做完模拟器和真机都能看到联系人列表正常渲染。中间涉及网络请求或模型调用的部分我会顺带说下怎么用 TaoToken 的 API 做联调方便你把通讯录数据和后端服务串起来。先明确一个前提本文基于ContactsContract这套现代 API不再使用android.provider.Contacts.People。如果你手里的老代码还在用People.CONTENT_URI建议直接替换否则在 Android 6.0 以上基本查不到数据。2. 前置准备TaoToken 接入与联系人权限配置在写联系人查询之前先把两件事准备好一是运行环境的权限二是如果你后续要把联系人数据发给模型做处理比如智能去重、号码归属地补全需要有一个稳定的 API 入口。这里我用 TaoToken 来做模型调用侧的准备它的官网是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 注意 API 地址后面不加任何参数。先说权限。读联系人必须声明READ_CONTACTS而且它要放在application标签前面这是很多新手踩的坑——放错位置不会编译报错但运行时不生效。完整声明如下manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.example.contactsdemo uses-permission android:nameandroid.permission.READ_CONTACTS / application android:allowBackuptrue android:labelContactsDemo android:themestyle/AppTheme activity android:name.MainActivity intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity /application /manifest注意 Android 6.0API 23之后READ_CONTACTS属于危险权限光在 Manifest 里声明不够还得在运行时动态申请。很多人只写了 Manifest 就跑去真机测试结果 Cursor 返回空还以为是查询语句写错了。动态申请的代码我放在下一节和查询逻辑放一起方便你对照。再说 TaoToken 侧的准备。如果你只是本地读联系人显示列表其实用不到网络但实际业务里经常要把联系人上传做匹配、或者调用模型做姓名规范化。这时候你需要一个 API Key。获取路径是登录后进入控制台在 API Keys 页面创建密钥。控制台地址是 https://taotoken.net/console 创建 Key 的页面是 https://taotoken.net/api-keys 。拿到 Key 之后模型调用的 Base URL 填https://taotoken.net/apiModel ID 按你实际要用的模型填比如做文本处理可以选对应的对话模型。这里给一个最小可用的请求配置方便你验证 Key 是否生效。用 curl 就能测curl https://taotoken.net/api/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer 你的API_KEY \ -d { model: 你的模型ID, messages: [ {role: user, content: 把这三个姓名规范化张三、李四、王五} ] }返回里能看到choices数组就说明通了。这一步不是必须的但如果你后面要做「联系人 模型」的联动先把这条链路跑通省得后面排查问题时分不清是权限问题还是网络问题。3. 可复制配置Cursor 查询与 SimpleCursorAdapter 绑定这一节是核心我把完整的 Activity 代码拆成几块讲每块都能直接复制。先看整体结构动态申请权限 → 查询Phone.CONTENT_URI→ 构造SimpleCursorAdapter→ 绑定到 ListView。先解决权限申请。在onCreate里判断并请求private static final int REQ_CONTACTS 1001; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ! PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_CONTACTS}, REQ_CONTACTS); } else { loadContacts(); } } Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode REQ_CONTACTS grantResults.length 0 grantResults[0] PackageManager.PERMISSION_GRANTED) { loadContacts(); } else { Toast.makeText(this, 没有联系人权限列表无法显示, Toast.LENGTH_SHORT).show(); } }然后是查询逻辑。关键点查询ContactsContract.CommonDataKinds.Phone.CONTENT_URI投影里必须包含_id、DISPLAY_NAME、NUMBER三列。_id是SimpleCursorAdapter的硬性要求缺了它直接抛异常。private void loadContacts() { ContentResolver resolver getContentResolver(); String[] projection new String[]{ ContactsContract.CommonDataKinds.Phone._ID, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.NUMBER }; Cursor cursor resolver.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, projection, null, null, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME ASC ); if (cursor null) { Toast.makeText(this, 查询返回空 Cursor, Toast.LENGTH_SHORT).show(); return; } String[] fromColumns new String[]{ ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.NUMBER }; int[] toViews new int[]{ android.R.id.text1, android.R.id.text2 }; SimpleCursorAdapter adapter new SimpleCursorAdapter( this, android.R.layout.simple_list_item_2, cursor, fromColumns, toViews, 0 ); ListView listView findViewById(R.id.contact_list); listView.setAdapter(adapter); }布局文件activity_main.xml很简单一个 ListView 就够?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical ListView android:idid/contact_list android:layout_widthmatch_parent android:layout_heightmatch_parent / /LinearLayout这里有个细节要提醒SimpleCursorAdapter构造函数的最后一个参数是 flags传0表示不使用自动重查。如果你传了CursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER记得在onDestroy里调用adapter.changeCursor(null)释放否则会内存泄漏。我一般传0手动管理 Cursor 生命周期配合startManagingCursor或者自己在onDestroy里cursor.close()。如果你要把联系人数据发给 TaoToken 做处理可以在拿到 Cursor 后遍历组装 JSON再走 API。比如ListString names new ArrayList(); if (cursor.moveToFirst()) { do { names.add(cursor.getString(cursor.getColumnIndexOrThrow( ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME))); } while (cursor.moveToNext()); } // 再把 names 拼成请求体发给 https://taotoken.net/api注意遍历完如果要重新给 Adapter 用得cursor.moveToFirst()复位否则列表会空。4. 验证请求与成功结果模拟器与真机实测代码写完后怎么确认列表真的渲染出来了分两步走。第一步模拟器验证。打开 Android Studio 的 AVD启动一个带 Google APIs 的镜像注意不带 Google APIs 的镜像里联系人数据库是空的你查出来也是空列表别以为是代码问题。启动后打开模拟器自带的 Contacts 应用手动添加两三个联系人比如「张三 13800000001」「李四 13800000002」。然后运行你的 App首次会弹出权限对话框点允许。正常情况下ListView 会显示两行每行上面是姓名、下面是号码。第二步真机验证。真机联系人通常比较多正好用来测滚动和性能。安装 APK 后同样授权观察列表是否完整。如果真机上列表为空但模拟器正常八成是权限被拒了——去设置里手动打开「联系人」权限再试。成功的结果长这样android.R.layout.simple_list_item_2这个系统布局里text1是大字号姓名text2是小字号号码两行对齐显示。如果你看到的是空白行或者只有姓名没有号码说明toViews和fromColumns的对应关系错了检查一下数组顺序是否一一对应。再给一个验证 API 链路是否通的方法。如果你在 App 里集成了 TaoToken 调用可以在拿到联系人后发一条测试请求看返回的choices里有没有内容。模型对话的入口是 https://taotoken.net/models 你可以在那里先手动试一条确认模型 ID 和 Key 都对再写进代码。实测下来先手动验证再写代码能省掉一半的联调时间。5. 常见报错排查401、Cursor 空、_id 缺失与 OAuth 问题这一节按真实报错来对。我把踩过的坑列成表你对照着查。报错/现象原因解决java.lang.IllegalArgumentException: column _id does not exist投影里没加_id在 projection 数组第一项加Phone._ID列表空白Cursor 不为 null 但 count 为 0模拟器无联系人数据或权限被拒用带 Google APIs 的镜像检查运行时权限401 Unauthorized调 TaoToken 时API Key 错误或没带 Bearer 前缀检查Authorization: Bearer sk-xxx格式local proxy failed本地网络配置问题请求没发出去检查 Base URL 是否为https://taotoken.net/api不要加多余路径reading choices报错返回体不是预期 JSON可能被拦截打印完整响应体确认choices字段存在OAuth 相关报错用了需要 OAuth 的旧接口改用 API Key 方式走标准 Bearer 认证真机上列表滚动卡顿Cursor 未关闭或主线程查询查询放子线程onDestroy里cursor.close()重点说两个。第一个是_id缺失这个报错信息很明确但新手容易忽略因为投影里明明有DISPLAY_NAME和NUMBER看起来「数据够了」。SimpleCursorAdapter内部要用_id做稳定 ID所以必须给。第二个是local proxy failed。这个通常出现在你本地配了网络工具、或者 Base URL 写错的情况下。TaoToken 的 API 地址就是https://taotoken.net/api不要写成https://taotoken.net/api/v1之外的多余路径也不要在后面拼 query 参数。如果你在代码里用 Retrofit 或 OkHttp检查 baseUrl 是否以/结尾以及拼接路径时有没有重复斜杠。还有一个隐蔽的坑startManagingCursor在Activity里用没问题但如果你在Fragment或ViewModel里用会报生命周期相关错误。现代写法建议直接用LoaderManager或CursorLoader或者手动管理。我现在的习惯是手动管理查询完在onDestroy里cursor.close()简单可控。6. 从列表到可用通讯录接入文档与后续扩展列表能显示只是第一步。实际项目里你还会遇到联系人去重、号码格式化、按拼音排序、搜索过滤。这些都可以在 Cursor 查询阶段用selection和sortOrder解决比如按号码去重可以用DISTINCT按拼音排序需要额外查SORT_KEY列。如果你要把通讯录和模型能力结合比如做「智能分组」或「姓名纠错」接入文档在 https://taotoken.net/doc 里面有完整的请求示例和参数说明。长期做编码类任务、需要 Agent 辅助的可以看 Coding Planhttps://taotoken.net/coding-plan 。Claude Code 相关的接入配置在 https://taotoken.net/claude-code 。最后给一个实用技巧调试联系人查询时先把 Cursor 的列名全部打印出来cursor.getColumnNames()这样你就知道实际有哪些列可用不用猜。我试过在真机上打印发现不同厂商 ROM 返回的列略有差异打印一次比查文档快得多。列表渲染出来后记得在onDestroy里关闭 Cursor这是最容易被忽略但最影响稳定性的收尾动作。