☰
【android】使用CursorAdapter注意事项:把Cursor生命周期与Base URL改到TaoToken的排查清单
2026/10/8 17:37:17 网站建设 项目流程

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 cursorstartManagingCursor 与生命周期冲突改用手动 close + swapCursor
notifyDataSetChanged 无效未换数据源或非主线程检查 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指向 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 调试。两条线都过了,列表页基本就稳了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询