1. 从一次崩溃日志说起:android.database.StaleDataException 到底是什么
android.database.StaleDataException是 Android 里一个非常典型的运行时异常,字面意思是「尝试访问一个已经被关闭的 Cursor」。它通常出现在你拿着一个 Cursor 对象,在某个异步回调、生命周期回调或者延迟任务里继续调用moveToNext()、getString()、requery()时,而这个 Cursor 早就被close()掉了。
先看一段真实堆栈,很多人第一次见到它都是这种形态:
FATAL EXCEPTION: main android.database.StaleDataException: Attempted to access a cursor after it has been closed. at android.database.BulkCursorToCursorAdaptor.throwIfCursorIsClosed(BulkCursorToCursorAdaptor.java:64) at android.database.BulkCursorToCursorAdaptor.requery(BulkCursorToCursorAdaptor.java:133) at android.database.CursorWrapper.requery(CursorWrapper.java:186) at android.app.Activity.performRestart(Activity.java:5161) at android.app.ActivityThread.handleSleeping(ActivityThread.java:3280) at android.os.Handler.dispatchMessage(Handler.java:99) at android.os.Looper.loop(Looper.java:137) at android.app.ActivityThread.main(ActivityThread.java:5041)注意堆栈里最关键的两行:Activity.performRestart和ActivityThread.handleSleeping。这说明崩溃不是发生在你主动查询数据的时候,而是系统在 Activity 重启(比如从后台回到前台)时,框架层自己去requery()一个已经被关闭的 Cursor,结果撞上了StaleDataException。
这个异常能做什么判断?它能帮你快速定位三类问题:
第一类是生命周期错配。Activity 已经onDestroy(),但你的异步任务(线程、Handler、协程、RxJava 订阅)还在跑,回调里继续访问 Cursor。
第二类是关闭时机混乱。你在onPause()里关了 Cursor,但onResume()之后系统或你自己的代码又去requery(),时序对不上。
第三类是版本行为差异。Context.managedQuery()在低版本 Android 上由系统托管 Cursor 生命周期,Cursor.close()的语义和高版本不一样,VERSION.SDK_INT分支没处理好就会踩坑。
适合谁看?如果你正在维护一个还在用managedQuery、startManagingCursor的老项目,或者你的 App 在 Android 4.x 到 5.x 设备上偶发这个崩溃,这篇就是给你写的。下面我会给出可复制的 Cursor 封装、最小复现 Demo、关闭时机配置,以及怎么用 TaoToken 统一 Key 通道调用模型辅助定位堆栈,最后用日志和断点验证异常不再复现。
2. 复现与定位:managedQuery、Cursor.close 与 VERSION.SDK_INT 分支的坑
要修一个偶发崩溃,第一步永远是稳定复现。StaleDataException难就难在它依赖生命周期时序,手动点很难触发。我试过最有效的办法是写一个最小 Demo,把managedQuery、Cursor.close()和版本分支都放进去,然后用「后台切换 + 快速返回」的方式压。
先看问题代码。下面这段是很多老项目的典型写法:
public class MainActivity extends Activity { private Cursor cursor; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); // 老 API:由系统托管 Cursor 生命周期 cursor = managedQuery( Uri.parse("content://media/external/images/media"), null, null, null, null); } @Override protected void onPause() { super.onPause(); // 手动关闭,和系统托管冲突 if (cursor != null) { cursor.close(); } } @Override protected void onResume() { super.onResume(); // 回到前台后继续访问,此时 cursor 可能已被关闭 if (cursor != null && cursor.moveToFirst()) { do { String name = cursor.getString(cursor.getColumnIndex("_display_name")); Log.d("CursorDemo", "name=" + name); } while (cursor.moveToNext()); } } }这段代码在 Android 4.0(API 14)以下可能勉强能跑,因为managedQuery返回的 Cursor 由 Activity 托管,系统会在合适的时机帮你requery()。但从 API 14 开始,managedQuery和startManagingCursor被标记为 deprecated,系统不再保证这套托管逻辑,而你在onPause()里手动close()之后,onResume()里再访问就会直接抛StaleDataException。
更隐蔽的是堆栈里那条Activity.performRestart。当 Activity 从后台被系统重启时,框架层会尝试对托管 Cursor 执行requery(),如果这个 Cursor 已经被你手动关闭,BulkCursorToCursorAdaptor.throwIfCursorIsClosed就会抛出异常。这就是为什么崩溃堆栈看起来「不是你的代码」——其实是你的关闭时机和框架的 requery 撞车了。
复现步骤可以这样设计:
- 在
onCreate()里用managedQuery拿到 Cursor。 - 在
onPause()里调用cursor.close()。 - 按 Home 键让 App 进入后台,等 5 到 10 秒。
- 从最近任务列表重新进入 App,触发
onRestart()和onResume()。 - 观察 Logcat,大概率能看到
StaleDataException。
如果你手头没有低版本设备,可以用模拟器建一个 API 15 或 API 16 的镜像,行为差异会更明显。VERSION.SDK_INT分支的问题在于:很多人写if (VERSION.SDK_INT < 14) { cursor.close(); },但忘了 API 14 及以上系统虽然不托管,却仍然可能在performRestart里对某些 Cursor 做 requery,所以单纯按版本号判断并不够,核心还是要把「谁负责关闭」这件事理清楚。
定位阶段我建议先别急着改代码,而是把堆栈完整抓下来。如果堆栈很长、混淆过、或者涉及多个线程,人工读起来很累。这时候可以用 TaoToken 的统一 Key 通道把堆栈丢给模型,让它帮你梳理调用链和可疑的关闭点。TaoToken 是一个统一的大模型 API 接入通道,你只需要一个 Key 就能调用多种模型,不用为每个模型单独配一套鉴权和地址。官网是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api ,下面第三节我会给出具体配置。
3. 可复制配置:Cursor 封装 + TaoToken 统一 Key 通道接入
这一节分两部分:先把 Cursor 的生命周期管好,再把 TaoToken 的调用配好,用模型辅助你读堆栈。
3.1 Cursor 封装与关闭时机
核心原则只有一条:谁创建,谁关闭;关闭之后,任何地方都不再访问。对于managedQuery这种老 API,最稳妥的做法是彻底弃用,改成ContentResolver.query()自己管理。
下面是一个可复制的 Cursor 封装,用引用计数 + 关闭标记来防止「关闭后访问」:
public final class SafeCursor implements Closeable { private Cursor cursor; private boolean closed = false; public SafeCursor(Cursor cursor) { this.cursor = cursor; } public synchronized boolean moveToFirst() { if (closed || cursor == null) { Log.w("SafeCursor", "moveToFirst on closed cursor, ignored"); return false; } return cursor.moveToFirst(); } public synchronized String getString(String column) { if (closed || cursor == null) { Log.w("SafeCursor", "getString on closed cursor, ignored"); return null; } int idx = cursor.getColumnIndex(column); if (idx < 0) return null; return cursor.getString(idx); } public synchronized boolean moveToNext() { if (closed || cursor == null) return false; return cursor.moveToNext(); } @Override public synchronized void close() { if (closed) return; closed = true; if (cursor != null) { cursor.close(); cursor = null; } } public synchronized boolean isClosed() { return closed; } }配套的关闭时机配置,建议放在onDestroy()而不是onPause():
public class MainActivity extends Activity { private SafeCursor safeCursor; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Cursor raw = getContentResolver().query( Uri.parse("content://media/external/images/media"), null, null, null, null); safeCursor = new SafeCursor(raw); } @Override protected void onDestroy() { super.onDestroy(); if (safeCursor != null) { safeCursor.close(); } } }为什么放onDestroy()?因为onPause()之后 Activity 可能还会回到前台,Cursor 还有用;而onDestroy()之后 Activity 实例不会再被复用,关闭是安全的。如果你确实需要在onStop()释放资源,那就要保证onStart()里重新查询,而不是复用旧 Cursor。
关于VERSION.SDK_INT分支,正确的写法不是「低版本才关」,而是「统一自己管,不再依赖系统托管」:
// 不再使用 managedQuery / startManagingCursor // 统一用 ContentResolver.query + 自己 close Cursor cursor = getContentResolver().query(uri, null, null, null, null); try { // 使用 cursor } finally { if (cursor != null) { cursor.close(); } }如果你维护的老代码里还有managedQuery,可以加一个兼容层,但目标应该是逐步替换掉,而不是继续在版本分支里打补丁。
3.2 TaoToken 统一 Key 通道配置
TaoToken 的接入很简单,一个 Base URL 加一个 Key 就能用。下面给出三种常见配置片段,你可以按自己的工具选。
通用环境变量方式:
export TAOTOKEN_BASE_URL="https://taotoken.net/api" export TAOTOKEN_API_KEY="sk-你的Key"如果你用 Cline 或类似的编码助手,配置 JSON 大致如下:
{ "provider": "openai-compatible", "baseUrl": "https://taotoken.net/api", "apiKey": "sk-你的Key", "model": "claude-sonnet-4-20250514" }如果你用 Claude Code 这类工具,settings 片段可以这样写:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "sk-你的Key", "ANTHROPIC_MODEL": "claude-sonnet-4-20250514" } }注意三件套必须齐全:Base URL、Key、Model ID。少任何一个都会报鉴权或模型找不到的错。Key 在控制台创建,地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API Keys 管理页是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。
配好之后,你可以把StaleDataException的完整堆栈贴给模型,让它帮你标出可疑的关闭点和生命周期回调。这一步不是让模型替你改代码,而是帮你快速建立调用链的全局视图,尤其是堆栈里混了框架层方法的时候。
4. 验证请求:用日志与断点确认异常不再复现
配置好之后,先验证 TaoToken 通道能正常返回,再验证 Cursor 修复生效。
4.1 验证 TaoToken 请求
用 curl 发一个最小请求,确认 Key 和地址都对:
curl -X POST "https://taotoken.net/api/v1/chat/completions" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-你的Key" \ -d '{ "model": "claude-sonnet-4-20250514", "messages": [ {"role": "user", "content": "解释 android.database.StaleDataException 的常见触发场景"} ] }'如果返回里有choices字段和正常内容,说明通道通了。如果报 401,检查 Key 是否复制完整;如果报模型不存在,检查 Model ID 拼写。
4.2 验证 Cursor 修复
在SafeCursor里已经加了日志,复现步骤再跑一遍:
- 打开 App,进入列表页。
- 按 Home 键,等 10 秒。
- 从最近任务返回。
- 观察 Logcat。
修复前你会看到StaleDataException崩溃;修复后应该看到类似:
W/SafeCursor: moveToFirst on closed cursor, ignored或者干脆没有任何异常,因为onDestroy()之前 Cursor 一直有效。如果你在onResume()里重新查询,那就应该看到新的查询日志,而不是访问旧 Cursor。
断点验证建议打在两个位置:SafeCursor.close()和SafeCursor.moveToFirst()。观察close()之后是否还有moveToFirst()被调用。如果close()之后moveToFirst()返回 false 并打日志,说明防护生效。
再补一个Activity生命周期的日志,确认关闭时机:
@Override protected void onDestroy() { Log.d("Lifecycle", "onDestroy, closing cursor"); super.onDestroy(); if (safeCursor != null) safeCursor.close(); }跑几轮后台切换,确认onDestroy只在真正销毁时触发,onPause不再关闭 Cursor,崩溃就不再出现。
5. 常见错排查:401、local proxy failed、reading choices、OAuth
这一节把接入和排障时最容易撞的报错列出来,对照处理。
401 Unauthorized:Key 不对或没带。检查Authorization: Bearer sk-xxx是否完整,Key 是否在控制台被删除或过期。如果你用的是环境变量,确认 shell 里echo $TAOTOKEN_API_KEY有值。
local proxy failed:通常出现在本地代理工具或 IDE 插件里,说明请求没发到https://taotoken.net/api。检查 Base URL 是否被本地代理覆盖,或者插件里填的是localhost地址。把 Base URL 改回https://taotoken.net/api再试。
reading choices 报错 / choices 字段为空:一般是响应体解析失败,可能是模型名不对,或者请求体 JSON 格式有误。确认model字段是有效 Model ID,messages是数组。如果返回里没有choices,先看原始响应体,别只看解析后的错误。
OAuth 相关报错:如果你用的是 Claude Code 或类似工具,它可能默认走 OAuth 登录而不是 API Key。需要在 settings 里显式配置ANTHROPIC_BASE_URL和ANTHROPIC_API_KEY,把鉴权方式切到 Key。三件套(Base URL、Key、Model ID)缺一不可。
Cursor 侧常见错:
IllegalStateException: Cannot perform this operation because the connection pool has been closed:通常是数据库或 ContentProvider 连接被关闭后还在查询,和StaleDataException同源,检查关闭时机。
Attempted to access a cursor after it has been closed反复出现:说明还有地方在close()之后访问,用SafeCursor的日志定位调用点。
VERSION.SDK_INT分支没生效:确认你判断的是Build.VERSION.SDK_INT而不是别的常量,并且分支逻辑是「统一自管」而不是「低版本才关」。
排障时如果堆栈太长,可以把关键几行贴给模型,让它帮你判断是生命周期问题还是版本问题。模型对话入口在 https://taotoken.net/chat?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= 。
6. 长期编码与 Agent 场景:把统一 Key 通道用起来
如果你只是偶尔查一次堆栈,配个 Key 就够了。但如果你在长期维护 Android 老项目,或者用编码 Agent 帮你批量排查生命周期问题,那统一 Key 通道的价值会更明显。
Coding Plan 适合长期编码和 Agent 场景,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。它的好处是你不用为每个模型单独管理 Key 和额度,一个通道覆盖多种模型,切换模型只改 Model ID。
Claude Code 接入可以参考 https://taotoken.net/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,里面有三件套的完整配置说明。控制台在 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API Keys 在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。
回到StaleDataException本身,最后给你一个实用技巧:在项目里全局搜索managedQuery和startManagingCursor,把它们全部替换成ContentResolver.query+try/finally close。这一步做完,版本分支的坑基本就消失了。剩下的就是保证异步任务在 Activity 销毁时取消,别让回调在onDestroy()之后还去碰 Cursor。日志和断点跑通之后,这个崩溃就不会再回来了。