1. CursorAdapter 列表页为什么总在切 Tab 时崩从 Cursor 生命周期到 Base URL 的排查清单CursorAdapter是 Android 里把数据库游标直接喂给ListView的经典适配器它能做什么一句话把Cursor的一行映射成一个列表项数据变了调notifyDataSetChanged()或swapCursor()刷新。适合谁还在维护老项目、用SQLiteOpenHelperListView的 Android 开发者尤其是列表页嵌在TabHost、ViewPager、Fragment里反复切换的场景。我最近在一个列表页里就踩了坑点击 Tab 停顿一下直接抛java.lang.IllegalStateException: trying to requery an already closed cursor。这个报错的本质是——Cursor已经被关闭了但CursorAdapter或系统还在尝试 requery 它。它和网络层看起来八竿子打不着但排查时你会发现列表刷新异常往往有两类根因一类是 Cursor 生命周期没管好另一类是数据源包括远端接口的 Base URL 配错导致请求失败、列表空刷。这篇就把这两条线合成一份可跟做的排查清单前半段讲 Cursor 管理后半段讲把网络层 Base URL 收敛到 TaoToken 时的配置与验证。先说结论方向startManagingCursor()在 API 11Honeycomb之后行为变了官方早已废弃正确做法是自己控制Cursor的close()配合swapCursor()而不是changeCursor()。下面按步骤拆。2. CursorAdapter 生命周期管理startManagingCursor 废弃后的正确写法与内存泄漏排查2.1 为什么 startManagingCursor 会 requery 已关闭的 cursor老代码常见写法是startManagingCursor(cursor)让 Activity 帮你管理游标生命周期。它在低版本能用是因为 Activity 在onStop时不会立刻关游标onResume时再 requery。但 API 11 之后Activity 的生命周期回调顺序和 Fragment 复用逻辑变了Tab 切换时 Activity 可能先onDestroy关掉游标随后适配器又触发一次 requery于是报trying to requery an already closed cursor。我试过在startManagingCursor里加版本判断绕过能压住一部分崩溃但这是治标。真正的问题是谁创建 Cursor谁负责关闭别交给框架。2.2 手动管理 Cursor 的可复制代码核心原则三条查询得到的 Cursor 由自己持有刷新用swapCursor()返回旧游标并关闭onDestroy里兜底关闭。public class NoteListActivity extends Activity { private CursorAdapter mAdapter; private Cursor mCursor; private SQLiteDatabase mDb; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_note_list); ListView listView findViewById(R.id.list_view); mDb new NoteDbHelper(this).getReadableDatabase(); mCursor queryNotes(); // 注意不要调用 startManagingCursor(mCursor) mAdapter new NoteCursorAdapter(this, mCursor, CursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER); listView.setAdapter(mAdapter); } private Cursor queryNotes() { return mDb.query(note, null, null, null, null, null, created_at DESC); } private void reload() { Cursor newCursor queryNotes(); // swapCursor 返回旧游标由我们负责关闭 Cursor old mAdapter.swapCursor(newCursor); if (old ! null !old.isClosed()) { old.close(); } mCursor newCursor; } Override protected void onDestroy() { if (mAdapter ! null) { Cursor c mAdapter.swapCursor(null); if (c ! null !c.isClosed()) { c.close(); } } if (mCursor ! null !mCursor.isClosed()) { mCursor.close(); mCursor null; } if (mDb ! null) { mDb.close(); } super.onDestroy(); } }关键点swapCursor()和changeCursor()的区别。changeCursor()会直接关掉旧游标如果你在别处还引用着旧游标就会崩swapCursor()把旧游标还给你让你自己决定何时关。列表页刷新一律用swapCursor()。2.3 notifyDataSetChanged 失效的三种原因很多人调了notifyDataSetChanged()列表却不刷新原因通常有三第一数据源换了新 Cursor但适配器还指着旧 Cursor。notifyDataSetChanged()只通知视图重绘不会帮你换数据源必须swapCursor()。第二FLAG_REGISTER_CONTENT_OBSERVER没加或者 Cursor 没有正确注册观察者底层数据变化不会触发刷新。构造适配器时带上这个 flag。第三在非 UI 线程改了数据。notifyDataSetChanged()必须在主线程调用子线程查询完用runOnUiThread或 Handler 切回来。2.4 用 Logcat 定位游标问题排查时先过滤关键字把崩溃栈和游标状态打出来adb logcat -c adb logcat | grep -iE CursorAdapter|already closed|requery|StaleDataExceptionStaleDataException通常意味着你在 Cursor 关闭后还访问它配合Cursor.finalize()的警告日志能定位到具体是哪个游标没关。内存泄漏则看LeakCanary报告里有没有Cursor被 Activity 强引用。3. 把网络层 Base URL 收敛到 TaoToken可复制的配置片段与路径列表页的数据如果来自远端接口Base URL 配错会直接导致请求失败、列表空刷表现和 Cursor 问题很像。这里给出把 Base URL 统一指向 TaoToken 的配置方式。TaoToken 的 API 入口是https://taotoken.net/api官网是https://taotoken.net/控制台和密钥在 console 与 api-keys 页面管理。3.1 Android 项目里的 Base URL 常量如果你用 Retrofit把 Base URL 抽成常量避免散落各处public final class ApiConfig { // 注意结尾斜杠Retrofit 要求 baseUrl 以 / 结尾 public static final String BASE_URL https://taotoken.net/api/; public static final String MODEL_ID claude-sonnet-4-20250514; }Retrofit 初始化Retrofit retrofit new Retrofit.Builder() .baseUrl(ApiConfig.BASE_URL) .addConverterFactory(GsonConverterFactory.create()) .build();3.2 用 settings 风格片段管理密钥与模型密钥不要硬编码进 APK。用local.properties或gradle.properties注入构建时写进BuildConfig# gradle.properties不要提交到公开仓库 TAOTOKEN_API_KEYsk-你的密钥 TAOTOKEN_BASE_URLhttps://taotoken.net/api/ TAOTOKEN_MODEL_IDclaude-sonnet-4-20250514// app/build.gradle android { buildTypes { debug { buildConfigField String, API_KEY, \${TAOTOKEN_API_KEY}\ buildConfigField String, BASE_URL, \${TAOTOKEN_BASE_URL}\ buildConfigField String, MODEL_ID, \${TAOTOKEN_MODEL_ID}\ } } }三件套对齐Base URL 用https://taotoken.net/api/Key 从 api-keys 页面获取Model ID 按你实际调用的模型填。三者缺一请求就会 401 或 404。3.3 请求头写法OkHttpClient client new OkHttpClient.Builder() .addInterceptor(chain - { Request original chain.request(); Request request original.newBuilder() .header(Authorization, Bearer BuildConfig.API_KEY) .header(Content-Type, application/json) .build(); return chain.proceed(request); }) .build();配置完成后列表页拉数据失败时先确认 Base URL 和 Key再回头查 Cursor能省很多时间。4. 验证请求与列表刷新curl 命令、Logcat 过滤与成功结果对照4.1 先用 curl 验证接口通不通在写 Android 代码前先用命令行确认 Base URL 和 Key 有效curl -X POST https://taotoken.net/api/v1/messages \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: claude-sonnet-4-20250514, max_tokens: 128, messages: [{role: user, content: ping}] }返回里能看到content数组和usage字段说明链路通了。如果返回 401检查 Key返回 404检查 Base URL 是否多了或少了/v1。4.2 Android 侧验证列表刷新在reload()里加日志确认 Cursor 换成功private void reload() { Cursor newCursor queryNotes(); Log.d(CursorCheck, newCursor count (newCursor null ? -1 : newCursor.getCount())); Cursor old mAdapter.swapCursor(newCursor); Log.d(CursorCheck, old cursor closed (old null || old.isClosed())); if (old ! null !old.isClosed()) { old.close(); } mCursor newCursor; }过滤命令adb logcat -s CursorCheck:D OkHttp:D成功结果应该是newCursor count大于 0old cursor closedfalse说明 swap 拿到了旧游标列表项正常显示。如果count0但接口有数据问题在解析或查询条件不在适配器。4.3 对照表现象可能原因验证动作requery already closed cursorstartManagingCursor 与生命周期冲突改用手动 close swapCursornotifyDataSetChanged 无效未换数据源或非主线程检查 swapCursor 与线程列表空但接口有数据Base URL / Key / Model 不匹配curl 验证三件套401 UnauthorizedKey 错误或未带 Authorization检查请求头内存泄漏Cursor 未关闭LeakCanary onDestroy 兜底5. 本篇常见错排查401、local proxy failed、reading choices、OAuth 逐条对照5.1 401 Unauthorized最常见。原因Key 没带、Key 过期、或者把 Key 写成了别的字段。检查Authorization: Bearer sk-xxx格式确认 Key 来自 api-keys 页面。Android 里如果用了BuildConfig.API_KEY确认 gradle 注入成功别是空字符串。5.2 local proxy failed这个报错通常出现在你本地配了代理但代理没起来。排查确认设备网络能直连taotoken.net关掉本地代理设置用 curl 在电脑上先验证。如果 curl 通、App 不通检查 App 是否走了系统代理或 OkHttp 里手动设了 proxy。5.3 reading choices 相关解析错误返回体里没有choices字段通常是请求体格式不对或者 Model ID 写错导致服务端返回了错误结构。检查 JSON 里model、messages字段拼写确认 Model ID 和 Base URL 匹配。解析时先判空再取字段避免NullPointerException。5.4 OAuth 相关报错如果你用的是需要 OAuth 的客户端比如某些 CLI 工具报 OAuth 错误说明 token 刷新失败或 scope 不对。这类工具通常有独立的配置文件比如 Codex 的auth.json、Claude Code 的 settings。以 Codex 为例auth.json里要写全三件套{ base_url: https://taotoken.net/api/, api_key: sk-你的密钥, model: claude-sonnet-4-20250514 }Claude Code 的 settings 同理Base URL、Key、Model ID 三项对齐缺一项就会在启动时报鉴权失败。Cline 的 MCP 配置也是这个逻辑把 Base URL 指向https://taotoken.net/api/Key 填对Model ID 选对。5.5 Cursor 与网络问题混在一起怎么分先看崩溃栈IllegalStateException指向 CursorIOException、HttpException指向网络。列表空刷但无崩溃优先查网络三件套有崩溃栈优先查 Cursor 生命周期。两条线分开验证别一起改。6. 接入与排障的下一步从 API Keys 到 Coding Plan 的分流排查完 Cursor 和 Base URL如果你要把这套接入固化到项目里按场景选入口需要拿 Key、配 Base URL、看接入文档的直接去 API Keys 页面和接入文档把三件套对齐后再写代码。想先验证模型返回是否符合预期用模型对话页面快速试一条请求确认返回结构再落到 Android 解析层。如果是长期做编码、Agent 类项目需要稳定的调用额度和配置管理走 Coding Plan 更合适。回到列表页本身最后给你一个实用习惯每次改完 Cursor 相关代码先跑一遍 Tab 切换 旋转屏幕 后台返回这三组操作再跑adb logcat | grep -iE cursor|StaleData。这三组操作能覆盖 90% 的游标生命周期问题。网络层则固定用 curl 先验证再进 App 调试。两条线都过了列表页基本就稳了。
阅读完成 · 觉得有帮助?