ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

android.database.StaleDataException 排查:Cursor 关闭后仍被访问,TaoToken 统一 Key 通道下的复现与修复

android.database.StaleDataException 排查:Cursor 关闭后仍被访问,TaoToken 统一 Key 通道下的复现与修复 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.0API 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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_URLhttps://taotoken.net/api export TAOTOKEN_API_KEYsk-你的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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API Keys 管理页是 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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 UnauthorizedKey 不对或没带。检查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 IDmessages是数组。如果返回里没有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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。6. 长期编码与 Agent 场景把统一 Key 通道用起来如果你只是偶尔查一次堆栈配个 Key 就够了。但如果你在长期维护 Android 老项目或者用编码 Agent 帮你批量排查生命周期问题那统一 Key 通道的价值会更明显。Coding Plan 适合长期编码和 Agent 场景地址是 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。它的好处是你不用为每个模型单独管理 Key 和额度一个通道覆盖多种模型切换模型只改 Model ID。Claude Code 接入可以参考 https://taotoken.net/ClaudeCodeAnthropic?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里面有三件套的完整配置说明。控制台在 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API Keys 在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。回到StaleDataException本身最后给你一个实用技巧在项目里全局搜索managedQuery和startManagingCursor把它们全部替换成ContentResolver.querytry/finally close。这一步做完版本分支的坑基本就消失了。剩下的就是保证异步任务在 Activity 销毁时取消别让回调在onDestroy()之后还去碰 Cursor。日志和断点跑通之后这个崩溃就不会再回来了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进