尧图精选

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

🕒 发布时间:2026/10/2 11:51:54 📁 来源:尧图网络
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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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_KEYBase URL 同样填https://taotoken.net/api。这样你在终端里就能直接对话验证 Key 是否有效不用先写 Android 代码。验证通过后再把同样的 Key 搬到 App 里。对于长期做编码和 Agent 的场景可以考虑 Coding Plan它把额度、模型、并发这些参数打包管理省得每次手动配。入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。但记住本篇主线还是 Contacts 列表TaoToken 只是为后续扩展留的口子。一个实操建议先在控制台把 Key 建好用模型对话页面发一条测试消息确认通道通了。模型对话入口 https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。这一步花两分钟能省掉后面在 Android 里排查 401 的时间。Key 管理页面在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。配置片段我习惯写成 JSON放在app/src/main/assets/taotoken.json运行时读取。这样换环境只改这一个文件不用动代码。下面第 3 节会给出完整可复制的内容。3. 可复制的权限声明与 Loader 回调骨架这一节是核心全部是可复制代码。先给权限和布局再给 Fragment 骨架最后给 TaoToken 的 config 片段。权限声明放在AndroidManifest.xml的manifest下uses-permission android:nameandroid.permission.READ_CONTACTS /注意 Android 6.0 以后这是危险权限运行时还要动态申请。动态申请用ActivityCompat.requestPermissions回调里判断PackageManager.PERMISSION_GRANTED再initLoader。很多人漏了这一步结果 Loader 查出来是空 Cursor还以为是查询写错了。主布局res/layout/contacts_list_view.xml?xml version1.0 encodingutf-8? ListView xmlns:androidhttp://schemas.android.com/apk/res/android android:idid/contacts_list android:layout_widthmatch_parent android:layout_heightmatch_parent /行布局res/layout/contacts_list_item.xml?xml version1.0 encodingutf-8? TextView xmlns:androidhttp://schemas.android.com/apk/res/android android:idandroid:id/text1 android:layout_widthmatch_parent android:layout_heightwrap_content android:padding16dp android:textSize16sp /注意行布局里 id 用的是android:id/text1这是系统预定义的SimpleCursorAdapter 的TO_IDS直接引用android.R.id.text1就能对上不用自己定义。Fragment 骨架实现LoaderManager.LoaderCallbacksCursor和AdapterView.OnItemClickListenerpublic class ContactsFragment extends Fragment implements LoaderManager.LoaderCallbacksCursor, 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 LoaderCursor onCreateLoader(int id, Bundle args) { return new CursorLoader( getActivity(), Contacts.CONTENT_URI, PROJECTION, null, null, Contacts.DISPLAY_NAME_PRIMARY ASC); } Override public void onLoadFinished(LoaderCursor loader, Cursor cursor) { mCursorAdapter.swapCursor(cursor); } Override public void onLoaderReset(LoaderCursor 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/apiKey 填控制台生成的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()应该是 0ListView 显示空白但不应崩溃。这里有个细节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-keyOpenAI 风格是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。控制台里可以随时轮换 KeyApp 端只读 config 文件。对于长期做这类功能的团队Coding Plan 能把额度管理也一起解决。最后给一个实用技巧CursorLoader 的initLoader如果传同一个 id系统会复用已有的 Loader不会重复查询。所以搜索时不要每次都initLoader而是用restartLoader强制重新查询。restartLoader会丢弃旧 Cursor 并触发onLoaderReset然后重新走onCreateLoader。这个区别在搜索场景里很关键用错了会出现「输入新关键词但列表还是旧结果」。代码写完真机跑一遍有数据和无数据两种状态都验证过再提交。联系人权限这块国产 ROM 的坑不少多测几台设备比看文档管用。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →