1. 从一次联系人列表闪退说起CursorLoader 到底解决什么问题Android 联系人模块开发里最容易踩的坑不是「读不到数据」而是「读到了但界面卡死」。我最早写联系人列表时用的是managedQuery在低端机上滑动列表会明显掉帧后来换成CursorLoader才把主线程解放出来。这篇就围绕CursorLoader 获取联系人并使用选项菜单添加联系人这条完整链路展开把查询和新增两条路都跑通。先说清楚它是什么。CursorLoader是 Android 3.0 引入的异步查询封装它继承自AsyncTaskLoaderCursor内部帮你做了三件事在后台线程执行ContentResolver.query()、监听数据源变化自动重查、把结果通过回调交回主线程。你不需要自己开线程也不需要手动requery()。它适合谁适合所有需要展示系统数据联系人、短信、媒体库的列表场景尤其是数据量可能上千条、用户还会在别的 App 里改数据的场合。联系人就是典型用户在系统通讯录里加了一个人你的列表应该自动刷新而不是等用户杀进程重进。这篇的目标很明确用LoaderManager初始化一个CursorLoader查询ContactsContract.Contacts.CONTENT_URI用CursorAdapter渲染列表再通过选项菜单弹出一个输入框把新联系人写回系统数据库。同时我会把接口鉴权这一层用 TaoToken 统一 Key 串起来演示请求验证怎么做避免你在联调阶段卡在 401 上。需要提前说明的是联系人读写属于危险权限从 Android 6.0 开始必须运行时申请光在 Manifest 里声明是不够的。这一点后面会单独讲。2. TaoToken 统一 Key 前置准备把鉴权通道先打通在写 Loader 之前先把鉴权这层理清楚。很多同学写联系人 Demo 时不需要网络但一旦你要把联系人同步到自己的后端、或者调用一个模型接口做「智能补全联系人备注」就会立刻遇到 Key 管理问题每个服务一个 Key散落在BuildConfig、local.properties、strings.xml里改一次要翻五个文件。TaoToken 的思路是给你一个统一入口把模型调用、编码辅助、接口鉴权收敛到同一套 Key 上。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 根地址是 https://taotoken.net/api 注意 API 地址不带 UTM 参数直接写进代码里就行。你需要先拿到 Key。进入控制台创建https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 然后在 API Keys 页面生成一个https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。生成后立刻复制页面刷新就看不到了。拿到 Key 之后Android 项目里不要硬编码。推荐放在local.properties然后在build.gradle里读出来注入BuildConfig// app/build.gradle android { defaultConfig { buildConfigField String, TAOTOKEN_KEY, \${localProperties.getProperty(taotoken.key)}\ buildConfigField String, TAOTOKEN_BASE, \https://taotoken.net/api\ } }local.properties里加一行taotoken.keysk-你的Key这个文件默认在.gitignore里不会误提交。这样你的联系人同步请求就能带上统一鉴权头不用为每个后端单独配一套。如果你后面要做长期编码或 Agent 类任务可以看 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。想先验证模型返回格式用模型对话页面试https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 。接入细节查文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。3. 可复制配置权限声明、Loader 初始化与菜单回调这一节是核心所有代码都能直接抄。先看权限AndroidManifest.xml里必须声明读写联系人uses-permission android:nameandroid.permission.READ_CONTACTS/ uses-permission android:nameandroid.permission.WRITE_CONTACTS/注意targetSdkVersion如果大于等于 23还要在运行时申请。下面这段是运行时申请的最小实现if (checkSelfPermission(Manifest.permission.READ_CONTACTS) ! PackageManager.PERMISSION_GRANTED) { requestPermissions(new String[]{ Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS}, 1001); }接下来是 Activity 实现LoaderCallbacksCursor。关键点initLoader的 id 用常量避免和别的 Loader 撞车onCreateLoader里返回CursorLoader投影列至少包含_id和display_name。public class MainActivity extends Activity implements LoaderManager.LoaderCallbacksCursor { private static final int LOADER_ID 1; private static final Uri CONTACT_URI ContactsContract.Contacts.CONTENT_URI; private ListView lv; private TextView tvEmpty; private ContactAdapter adapter; private ContentResolver resolver; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); lv findViewById(R.id.lv); tvEmpty findViewById(R.id.tv); resolver getContentResolver(); adapter new ContactAdapter(this, null, CursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER); lv.setAdapter(adapter); lv.setEmptyView(tvEmpty); getLoaderManager().initLoader(LOADER_ID, null, this); } Override public LoaderCursor onCreateLoader(int id, Bundle args) { String[] projection { ContactsContract.Contacts._ID, ContactsContract.Contacts.DISPLAY_NAME }; return new CursorLoader(this, CONTACT_URI, projection, null, null, ContactsContract.Contacts.DISPLAY_NAME ASC); } Override public void onLoadFinished(LoaderCursor loader, Cursor data) { adapter.swapCursor(data); } Override public void onLoaderReset(LoaderCursor loader) { adapter.swapCursor(null); } }FLAG_REGISTER_CONTENT_OBSERVER这个标志很重要它让CursorAdapter注册内容观察者配合CursorLoader实现数据变化自动刷新。少了它你在系统通讯录里加人列表不会动。适配器部分bindView里按列名取值public class ContactAdapter extends CursorAdapter { public ContactAdapter(Context c, Cursor cur, int flags) { super(c, cur, flags); } Override public View newView(Context ctx, Cursor cur, ViewGroup parent) { return LayoutInflater.from(ctx) .inflate(R.layout.item_contact, parent, false); } Override public void bindView(View view, Context ctx, Cursor cur) { TextView tvName view.findViewById(R.id.tv1); tvName.setText(cur.getString( cur.getColumnIndexOrThrow( ContactsContract.Contacts.DISPLAY_NAME))); } }菜单回调是新增联系人的入口。onCreateOptionsMenu加载菜单资源onOptionsItemSelected里弹对话框Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.main, menu); return true; } Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() R.id.action_settings) { showAddDialog(); return true; } return super.onOptionsItemSelected(item); }showAddDialog里用AlertDialog加一个EditText确定时执行两段插入——先插RawContacts拿 id再插Data写名字private void showAddDialog() { View view LayoutInflater.from(this) .inflate(R.layout.dialog_add, null); final EditText et view.findViewById(R.id.et); new AlertDialog.Builder(this) .setTitle(添加联系人) .setView(view) .setPositiveButton(确定, (d, w) - { String name et.getText().toString().trim(); if (name.isEmpty()) return; ContentValues raw new ContentValues(); Uri rawUri resolver.insert( ContactsContract.RawContacts.CONTENT_URI, raw); long rawId ContentUris.parseId(rawUri); ContentValues data new ContentValues(); data.put(ContactsContract.Data.RAW_CONTACT_ID, rawId); data.put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds .StructuredName.CONTENT_ITEM_TYPE); data.put(ContactsContract.CommonDataKinds .StructuredName.DISPLAY_NAME, name); resolver.insert(ContactsContract.Data.CONTENT_URI, data); }) .setNegativeButton(取消, null) .show(); }菜单资源res/menu/main.xmlmenu xmlns:androidhttp://schemas.android.com/apk/res/android item android:idid/action_settings android:orderInCategory100 android:showAsActionalways android:title添加联系人/ /menu到这里查询和新增两条链路都齐了。插入完成后因为CursorLoader在监听Contacts.CONTENT_URI列表会自动刷新出新联系人不需要你手动调adapter.notifyDataSetChanged()。4. 验证请求与成功结果从空列表到自动刷新配置写完跑一遍看结果。第一次启动如果系统通讯录是空的你会看到tvEmpty显示的「没有更多数据了」。点右上角菜单「添加联系人」输入「张三」确定。预期现象对话框关闭列表立刻出现「张三」。这个「立刻」就是CursorLoader的功劳——resolver.insert触发了内容观察者CursorLoader收到通知后重新查询onLoadFinished被调用swapCursor更新界面。如果你想确认数据真的写进去了可以用adb查adb shell content query --uri content://com.android.contacts/contacts \ --projection display_name输出里应该能看到display_name张三。这一步能帮你区分「界面没刷新」和「数据没写进去」两类问题。再验证一下自动刷新保持 App 在前台用另一台设备或系统通讯录加一个联系人回到你的 App列表应该自动多出一行。如果没动八成是FLAG_REGISTER_CONTENT_OBSERVER没加或者swapCursor传了 null。关于鉴权验证如果你在新增联系人后要同步到后端请求大概长这样curl -X POST https://taotoken.net/api/v1/contacts/sync \ -H Authorization: Bearer sk-你的Key \ -H Content-Type: application/json \ -d {name:张三,source:android}返回 200 且 body 里有{code:0}就说明 Key 和通道都通了。如果返回 401先检查 Key 有没有多余空格再确认Authorization头格式是Bearer加 Key中间一个空格。实测下来联系人插入最容易出问题的是RawContacts和Data的两次插入顺序。必须先拿到rawId再写Data顺序反了会插入一条没有名字的空联系人列表里显示为空白行。5. 本篇常见错排查401、CursorIndexOutOfBounds 与空列表这一节按真实报错来对。第一个高频错误是java.lang.SecurityException: Permission Denial: reading com.android.providers.contacts。原因就一个运行时权限没申请或者用户在弹窗里点了拒绝。解决方式是申请前先checkSelfPermission拒绝后给个引导别直接崩。第二个是android.database.CursorIndexOutOfBoundsException: Index -1 requested。这通常出现在你手动调cursor.moveToFirst()之前就getString。用CursorAdapter时不用管框架帮你移动游标但如果你在onLoadFinished里自己遍历记得先判断data ! null data.moveToFirst()。第三个是列表一直空但adb查得到数据。检查onCreateLoader的投影列如果你只查了_id却在bindView里取display_namegetColumnIndex会返回 -1getString(-1)抛异常或返回空。投影列和取值列必须一致。第四个是接口鉴权相关的401 Unauthorized或local proxy failed。401 前面说了检查 Key 和头格式。local proxy failed一般出现在你本地配了抓包工具但证书没装或者请求地址写成了http而不是https。TaoToken 的 API 根地址是https://taotoken.net/api别漏了https。第五个是OAuth相关报错如果你用的是 Claude Code 或 Codex 这类工具接入报OAuth token expired时去控制台重新生成 Key 即可。涉及 Claude Code 接入时三件套要写全Base URL 填https://taotoken.net/apiKey 填你的sk-开头字符串Model ID 按文档填对应模型名缺一个都会鉴权失败。第六个是新增联系人后列表不刷新。除了前面说的观察者标志还有一种情况是你插入时用了Contacts.CONTENT_URI而不是RawContacts.CONTENT_URI导致观察者监听的 URI 和写入的 URI 不一致。写入统一走RawContacts加Data两段式。6. 把两条链路收进一个可复用模块联系人模块写完之后建议把 Loader 和插入逻辑抽成一个ContactRepositoryActivity 只负责 UI 回调。这样你换 Fragment 或者加搜索过滤时不用动查询代码。CursorLoader的selection参数可以直接接搜索关键词比如DISPLAY_NAME LIKE ?传new String[]{% keyword %}配合restartLoader就能实现实时搜索。鉴权这层也一样把 Base URL 和 Key 收进一个ApiClient单例所有请求走同一个出口。TaoToken 的统一 Key 在这里的价值就是你新增一个后端接口时不用再申请一套凭证改个 path 就行。需要看完整接入示例的话文档页有各语言的片段https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。最后留一个实用技巧调试联系人插入时别每次都卸载重装。用adb shell pm clear 你的包名清数据比卸载快得多而且不会丢你刚配好的 Key。