1. CursorAdapter 列表页为什么总在切 Tab 时崩:从 Cursor 生命周期到 Base URL 的排查清单
CursorAdapter是 Android 里把数据库游标直接喂给ListView的经典适配器,它能做什么?一句话:把Cursor的一行映射成一个列表项,数据变了调notifyDataSetChanged()或swapCursor()刷新。适合谁?还在维护老项目、用SQLiteOpenHelper+ListView的 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 11(Honeycomb)之后行为变了,官方早已废弃,正确做法是自己控制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|StaleDataException"StaleDataException通常意味着你在 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_KEY=sk-你的密钥 TAOTOKEN_BASE_URL=https://taotoken.net/api/ TAOTOKEN_MODEL_ID=claude-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大于 0,old cursor closed=false(说明 swap 拿到了旧游标),列表项正常显示。如果count=0但接口有数据,问题在解析或查询条件,不在适配器。
4.3 对照表
| 现象 | 可能原因 | 验证动作 |
|---|---|---|
| requery already closed cursor | startManagingCursor 与生命周期冲突 | 改用手动 close + swapCursor |
| notifyDataSetChanged 无效 | 未换数据源或非主线程 | 检查 swapCursor 与线程 |
| 列表空但接口有数据 | Base URL / Key / Model 不匹配 | curl 验证三件套 |
| 401 Unauthorized | Key 错误或未带 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指向 Cursor,IOException、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 调试。两条线都过了,列表页基本就稳了。