news 2026/10/2 11:51:53

用 CursorLoader + Fragment 构建 Contacts ListView:TaoToken 统一 Key 配置与验证

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用 CursorLoader + Fragment 构建 Contacts ListView:TaoToken 统一 Key 配置与验证

1. 为什么 Fragment + CursorLoader 查联系人总翻车

Contacts 列表这个需求看起来简单:读通讯录、塞进 ListView、点一下跳详情。但真动手写,问题一个接一个。我见过太多项目卡在这三处:权限申请了却拿不到数据、Cursor 在 UI 线程查询导致列表卡顿、Fragment 重建后 Loader 重复初始化把结果刷成空。

先说清楚这套方案是什么、能做什么、适合谁。ContactsContract 是 Android 系统提供的联系人数据契约层,它把联系人拆成三张核心表:Contacts(联系人聚合)、RawContacts(原始账户记录)、Data(具体数据行,比如电话、邮箱、姓名)。CursorLoader 是 LoaderManager 体系里的异步查询组件,它在独立于 UI 线程的进程里跑查询,查完通过回调把 Cursor 交回来。Fragment 负责承载列表 UI 和生命周期。三者组合起来,就是官方推荐的「后台查、主线程绑」模式,适合做通讯录、拨号盘联想、消息选人这类场景。

适合谁看:已经会写 Activity 和基本 Adapter、但被 Loader 生命周期和权限回调绕晕的 Android 开发者。如果你还在用managedQuery或者自己开 Thread 查 Cursor,这篇能帮你把架构理顺。

我试过最坑的一次:在onCreateView里直接getActivity().getContentResolver().query(...),模拟器上联系人少没感觉,真机导入两千条后列表滑动直接掉帧。换成 CursorLoader 后,查询在后台完成,UI 只负责swapCursor,滑动立刻顺了。这就是为什么官方文档反复强调「用 CursorLoader 而不是手动 query」。

还有一个容易被忽略的点:Loader 的 id 和生命周期绑定。Fragment 被系统回收重建时,initLoader如果传的 id 不一致,或者onLoadFinished里没做空判断,就会出现「列表闪一下变空」的诡异现象。后面第 5 节我会把这类报错逐条拆开。

另外,现在很多团队会把模型调用、Key 管理这类能力抽到统一网关,避免每个模块各写一套鉴权。联系人列表本身不涉及网络,但如果你后续要接「智能补全联系人」「按语义搜索通讯录」这类能力,就需要一个统一的 API 通道。TaoToken 在这里扮演的就是这个角色:一个 Base URL、一个 Key、一个 Model ID,三件套配好,Android 端用 OkHttp 就能调。本篇会先把本地 Contacts 查询跑通,再给出 TaoToken 的 config 片段,方便你后面扩展。

2. TaoToken 统一 Key 与 API 通道前置配置

这一节解决「统一入口」的问题。很多项目里,模型调用散落在各个模块,Key 硬编码在 BuildConfig,换环境要重新打包。TaoToken 的思路是把模型访问收敛到一个网关:官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api 。你只需要在控制台生成一个 Key,所有模型请求都走这个 Base URL。

先说清楚它不是什么:它不是联系人数据的来源,Contacts 数据永远来自系统 ContactsContract。TaoToken 是给「联系人相关的智能能力」用的通道,比如你想在列表顶部加一个「按描述找联系人」的搜索框,把自然语言转成查询条件,这时候才需要它。把这两层分清楚,架构才不会乱。

配置三件套:Base URL、API Key、Model ID。Base URL 固定为https://taotoken.net/api,注意不要加 UTM 参数,那是给网页跳转用的。API Key 在控制台生成,形如sk-开头的一串。Model ID 按你实际开通的模型填,比如claude-sonnet-4-5或gpt-4o这类标识。这三个值建议放在local.properties或 CI 的环境变量里,不要提交到 Git。

如果你用 Claude Code 做客户端联调,配置方式略有不同。Claude Code 读取的是环境变量ANTHROPIC_BASE_URL和ANTHROPIC_API_KEY,Base URL 同样填https://taotoken.net/api。这样你在终端里就能直接对话,验证 Key 是否有效,不用先写 Android 代码。验证通过后再把同样的 Key 搬到 App 里。

对于长期做编码和 Agent 的场景,可以考虑 Coding Plan,它把额度、模型、并发这些参数打包管理,省得每次手动配。入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。但记住,本篇主线还是 Contacts 列表,TaoToken 只是为后续扩展留的口子。

一个实操建议:先在控制台把 Key 建好,用模型对话页面发一条测试消息,确认通道通了。模型对话入口 https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。这一步花两分钟,能省掉后面在 Android 里排查 401 的时间。Key 管理页面在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。

配置片段我习惯写成 JSON,放在app/src/main/assets/taotoken.json,运行时读取。这样换环境只改这一个文件,不用动代码。下面第 3 节会给出完整可复制的内容。

3. 可复制的权限声明与 Loader 回调骨架

这一节是核心,全部是可复制代码。先给权限和布局,再给 Fragment 骨架,最后给 TaoToken 的 config 片段。

权限声明放在AndroidManifest.xml的<manifest>下:

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

注意 Android 6.0 以后这是危险权限,运行时还要动态申请。动态申请用ActivityCompat.requestPermissions,回调里判断PackageManager.PERMISSION_GRANTED再initLoader。很多人漏了这一步,结果 Loader 查出来是空 Cursor,还以为是查询写错了。

主布局res/layout/contacts_list_view.xml:

<?xml version="1.0" encoding="utf-8"?> <ListView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/contacts_list" android:layout_width="match_parent" android:layout_height="match_parent" />

行布局res/layout/contacts_list_item.xml:

<?xml version="1.0" encoding="utf-8"?> <TextView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@android:id/text1" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="16dp" android:textSize="16sp" />

注意行布局里 id 用的是@android:id/text1,这是系统预定义的,SimpleCursorAdapter 的TO_IDS直接引用android.R.id.text1就能对上,不用自己定义。

Fragment 骨架,实现LoaderManager.LoaderCallbacks<Cursor>和AdapterView.OnItemClickListener:

public class ContactsFragment extends Fragment implements LoaderManager.LoaderCallbacks<Cursor>, AdapterView.OnItemClickListener { private static final int CONTACT_ID_INDEX = 0; private static final int LOOKUP_KEY_INDEX = 1; @SuppressLint("InlinedApi") private static final String[] PROJECTION = { Contacts._ID, Contacts.LOOKUP_KEY, Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB ? Contacts.DISPLAY_NAME_PRIMARY : Contacts.DISPLAY_NAME }; @SuppressLint("InlinedApi") private static final String[] FROM_COLUMNS = { Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB ? Contacts.DISPLAY_NAME_PRIMARY : Contacts.DISPLAY_NAME }; private static final int[] TO_IDS = { android.R.id.text1 }; private ListView mContactsList; private SimpleCursorAdapter mCursorAdapter; private long mContactId; private String mContactKey; private Uri mContactUri; public ContactsFragment() {} @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { return inflater.inflate(R.layout.contacts_list_view, container, false); } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); mContactsList = getActivity().findViewById(R.id.contacts_list); mCursorAdapter = new SimpleCursorAdapter( getActivity(), R.layout.contacts_list_item, null, FROM_COLUMNS, TO_IDS, 0); mContactsList.setAdapter(mCursorAdapter); mContactsList.setOnItemClickListener(this); getLoaderManager().initLoader(0, null, this); } @Override public Loader<Cursor> onCreateLoader(int id, Bundle args) { return new CursorLoader( getActivity(), Contacts.CONTENT_URI, PROJECTION, null, null, Contacts.DISPLAY_NAME_PRIMARY + " ASC"); } @Override public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) { mCursorAdapter.swapCursor(cursor); } @Override public void onLoaderReset(Loader<Cursor> loader) { mCursorAdapter.swapCursor(null); } @Override public void onItemClick(AdapterView<?> parent, View view, int position, long rowId) { Cursor cursor = ((SimpleCursorAdapter) parent.getAdapter()).getCursor(); if (cursor == null || !cursor.moveToPosition(position)) return; mContactId = cursor.getLong(CONTACT_ID_INDEX); mContactKey = cursor.getString(LOOKUP_KEY_INDEX); mContactUri = Contacts.getLookupUri(mContactId, mContactKey); } }

TaoToken 的 config 片段,放在app/src/main/assets/taotoken.json:

{ "base_url": "https://taotoken.net/api", "api_key": "sk-替换成你在控制台生成的Key", "model_id": "claude-sonnet-4-5", "timeout_seconds": 30 }

读取这个 JSON 用AssetManager,解析后把base_url、api_key、model_id三件套传给 OkHttp 的拦截器。注意base_url结尾不要带斜杠,拼接路径时统一处理。Key 不要写死在代码里,也不要打进 APK 的明文资源,生产环境建议走服务端下发或 Android Keystore 加密存储。

如果你用 Cline 或 CC Switch 这类工具做联调,配置项名称可能不同,但三件套不变:Base URL 填https://taotoken.net/api,Key 填控制台生成的,Model ID 填你开通的。Codex 的auth.json里对应字段是base_url和api_key,填法一致。

4. 验证请求:一次查询结果与空列表两种动作

写完代码必须验证,而且要验证两种状态:有数据、没数据。很多人只测有数据的情况,上线后用户通讯录为空,列表直接崩或者白屏。

第一种动作:有数据。在模拟器里先手动加三个联系人,名字分别叫 Alice、Bob、Carol。运行 App,进入 Fragment,你应该看到三行文字按字母序排列。如果没看到,先检查权限是否授予,再检查initLoader是否在权限回调之后调用。可以在onLoadFinished里打一行日志:

Log.d("ContactsFragment", "cursor count = " + (cursor == null ? -1 : cursor.getCount()));

正常应该输出cursor count = 3。如果输出 0,说明查询条件或权限有问题;如果输出 -1,说明 cursor 为 null,通常是 Loader 没初始化成功。

第二种动作:空列表。把模拟器里的联系人全部删掉,或者用一个全新未导入联系人的设备。重新进入 Fragment,此时cursor.getCount()应该是 0,ListView 显示空白,但不应崩溃。这里有个细节:swapCursor(null)和swapCursor(空 Cursor)是两回事。onLoaderReset里传 null 是释放引用,onLoadFinished里传空 Cursor 是正常结果。如果你在onLoadFinished里判断if (cursor != null && cursor.getCount() > 0)才 swap,那空列表时 ListView 会保留旧数据,这是错的。正确做法是无条件swapCursor(cursor),让 Adapter 自己处理空 Cursor。

验证 TaoToken 通道是否通,可以在同一个 Fragment 里加一个按钮,点击后发一条测试请求。用 OkHttp 构造:

Request request = new Request.Builder() .url(config.baseUrl + "/v1/messages") .addHeader("x-api-key", config.apiKey) .addHeader("anthropic-version", "2023-06-01") .addHeader("content-type", "application/json") .post(RequestBody.create( "{\"model\":\"" + config.modelId + "\",\"max_tokens\":64," + "\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]}", MediaType.parse("application/json"))) .build();

返回 200 且 body 里有内容,说明 Key 和通道都正常。返回 401 说明 Key 无效或没带上;返回 404 说明路径拼错了,检查base_url后面接的路径。这一步验证通过,你后面做「智能搜索联系人」就有底了。

两种验证动作都要在真机上跑一遍。模拟器的联系人数据库和真机行为有差异,尤其是国产 ROM 对 READ_CONTACTS 的权限弹窗做了定制,模拟器上直接授予,真机上可能弹两次。真机验证能提前暴露这类问题。

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

这一节把真实会撞上的报错逐条拆开。先说 Contacts 侧的,再说 TaoToken 侧的。

报错一:java.lang.SecurityException: Permission Denial: reading com.android.providers.contacts。这是没申请 READ_CONTACTS 或者用户拒绝了。检查三处:Manifest 里有没有声明、运行时有没有requestPermissions、回调里有没有判断grantResult == PackageManager.PERMISSION_GRANTED。三处缺一不可。如果用户勾了「不再询问」,要引导去设置页手动开。

报错二:CursorIndexOutOfBoundsException: Index 2 requested, size 2。这是 PROJECTION 和索引常量对不上。比如你 PROJECTION 里只放了_ID和LOOKUP_KEY两列,却去取DISPLAY_NAME的索引。解决办法是让索引常量和 PROJECTION 顺序严格对应,CONTACT_ID_INDEX = 0、LOOKUP_KEY_INDEX = 1,取名字用cursor.getColumnIndex(Contacts.DISPLAY_NAME_PRIMARY)动态拿,别写死。

报错三:IllegalStateException: Fragment not attached to Activity。这是 Loader 回调在 Fragment 已经 detach 之后才触发。onLoadFinished里访问getActivity()前先判断isAdded()。更稳的做法是用getActivity().getApplicationContext()构造 CursorLoader,避免持有 Activity 引用。

报错四:TaoToken 侧401 Unauthorized。三种可能:Key 没带、Key 写错、Key 被禁用。检查请求头字段名是否正确,Anthropic 风格是x-api-key,OpenAI 风格是Authorization: Bearer。用模型对话页面先验证 Key 本身有效,再排查代码。

报错五:local proxy failed或连接超时。这通常是网络层问题,检查设备网络是否正常、base_url是否写成了https://taotoken.net/api/(多了斜杠导致路径拼接成//v1/messages)。把base_url统一去掉结尾斜杠,拼接时手动加/。

报错六:reading choices或响应体解析失败。这是返回的 JSON 结构和你的解析代码不匹配。不同模型的响应字段不同,Anthropic 是content数组,OpenAI 是choices数组。先打印原始 body,确认结构再写解析。别凭记忆写字段名。

报错七:OAuth 相关报错。如果你用 Claude Code 或某些客户端,它们可能走 OAuth 流程而不是 API Key。这时候要确认客户端配置的是 API Key 模式,Base URL 填https://taotoken.net/api。OAuth 和 API Key 是两套鉴权,别混用。

排查顺序建议:先看 Logcat 的完整堆栈,定位是 Contacts 侧还是网络侧;再用最小复现,把 Fragment 单独抽出来跑;最后对照本文的配置片段逐行核对。90% 的问题出在权限、索引、Base URL 这三处。

6. 从本地列表到智能检索的下一步

本地 Contacts 列表跑通后,下一步通常是加搜索。传统做法是用Contacts.CONTENT_FILTER_URI做前缀匹配,但用户输入「上次开会那个人」这种自然语言,前缀匹配就无能为力了。这时候可以把用户输入发给模型,让它转成结构化查询条件,再回填到 CursorLoader 的 selection 里。

具体做法:在列表顶部加一个 EditText,用户输入后防抖 300ms,把文本发给 TaoToken 通道,让模型输出 JSON 格式的查询条件,比如{"name_like": "张", "has_phone": true}。拿到结果后重建 Loader,用新的 selection 查询。这样既保留了 CursorLoader 的异步优势,又加上了语义理解。

TaoToken 在这个链路里就是那个统一的模型入口。你不需要为每个模型单独配 Key,也不用改 Base URL。控制台里可以随时轮换 Key,App 端只读 config 文件。对于长期做这类功能的团队,Coding Plan 能把额度管理也一起解决。

最后给一个实用技巧:CursorLoader 的initLoader如果传同一个 id,系统会复用已有的 Loader,不会重复查询。所以搜索时不要每次都initLoader,而是用restartLoader强制重新查询。restartLoader会丢弃旧 Cursor 并触发onLoaderReset,然后重新走onCreateLoader。这个区别在搜索场景里很关键,用错了会出现「输入新关键词但列表还是旧结果」。

代码写完,真机跑一遍,有数据和无数据两种状态都验证过,再提交。联系人权限这块,国产 ROM 的坑不少,多测几台设备比看文档管用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 11:50:47

TaoToken 之外,.vimrc 里 guifont/filetype/autocmd 怎么配才不踩坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 11:48:30

压缩 PDF 免费的工具有哪些?电脑手机多场景工具整理

每次遇到 PDF 文件过大&#xff0c;微信、邮箱上传被限制&#xff0c;很多人第一反应就是找 PDF 压缩工具。网上工具五花八门&#xff0c;有的打着免费旗号&#xff0c;下载时却要付费、加水印&#xff0c;挑选起来很费时间。今天整理一批真实可用的免费 PDF 压缩工具&#xff…

作者头像 李华
网站建设 2026/10/2 11:48:28

昆明中国名酒折扣店(云南总部运营中心)口碑好的酒水批发公司有哪些

昆明口碑好的酒水批发公司有哪些?中国名酒折扣店(云南总部运营中心)值得一看在昆明想找一家靠谱的酒水批发机构&#xff0c;很多人都会先搜索酒水批发公司排名比较好的酒水批发机构这类问题。名酒市场鱼龙混杂&#xff0c;真伪难辨、价格虚高、品类单一&#xff0c;是消费者和…

作者头像 李华
网站建设 2026/10/2 11:48:20

昆明中国名酒折扣店酒水批发拿货门槛低吗,服务怎么样

昆明中国名酒折扣店酒水批发拿货门槛低吗&#xff0c;服务怎么样 买酒这件事&#xff0c;为什么越来越让人纠结在昆明&#xff0c;开一家烟酒店的老板不少&#xff0c;想拿便宜好酒的消费者更多。但真正去对接过货源的人都清楚&#xff0c;这条路上坑太多了。一位在曲靖做了多年…

作者头像 李华
网站建设 2026/10/2 11:47:53

Gazebo自建模型调试总报错?用TaoToken统一Key打通AI辅助排错链路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 11:47:48

吴江区工业废料回收价格与厂家选择,捷盛发环保科技废品回收技术解析

吴江区工业废料回收行业基础科普工业废料是工业生产活动中产生的各类边角料、残次品、报废设备、淘汰物料的统称&#xff0c;在制造业集中的吴江区&#xff0c;各类生产加工企业、车间改造项目、设备更新工程每天都会产生大量工业废料。按照废料的属性可大致分为&#xff1a; 金…

作者头像 李华