1. 从一次崩溃日志说起Cursor 没关应用直接报警android.database.sqlite.DatabaseObjectNotClosedException: Application did not close the cursor or database object that was opened here这个报错做 Android 本地存储的同学大概率都见过。它不像空指针那样立刻崩而是像水龙头没拧紧平时看不出问题等到某个页面反复进出、数据量上来之后日志里突然刷出一大段堆栈告诉你某个 Cursor 或 SQLiteDatabase 对象在 GC 时还没被关闭。这个异常的本质是SQLite 的 Cursor 和 SQLiteDatabase 底层持有 native 资源Java 层的对象被回收时如果对应的 native 资源没释放系统就会在 finalize 阶段抛出这个异常作为警告。它通常出现在 Activity/Fragment 销毁后 Cursor 仍被引用、CursorAdapter 忘记swapCursor、或者异常分支提前return导致finally之外的关闭逻辑被跳过。适合正在写 SQLite 查询、用 CursorAdapter 展示列表、或者接手了老项目需要排查内存泄漏的 Android 开发者。我试过在一个老项目里追这个问题堆栈只给了一个创建位置但代码里同一个 helper 被好几个页面调用光看日志根本定位不到是哪条查询路径漏了关闭。后来我把 Cursor 的创建和关闭统一收口再配合 AI 辅助分析 logcat 堆栈才把几条隐藏的泄漏路径揪出来。这篇就按这个思路先讲清楚异常怎么触发再给一套可复制的 Cursor 封装骨架和 StrictMode 检测配置最后演示怎么用 TaoToken 的统一 Key 接入 AI 通道把 logcat 堆栈丢进去做辅助定位。2. 为什么 Cursor 泄漏这么难查三类典型触发场景2.1 Activity/Fragment 销毁后 Cursor 仍存活最常见的写法是在onCreate或某个点击事件里rawQuery拿到 Cursor然后把它设给 Adapter 或者存成成员变量但销毁时只记得关数据库忘了关 Cursor。Cursor 本身持有指向结果集的 native 指针页面销毁后如果还有引用链比如被 Adapter 持有、被匿名内部类捕获GC 就无法及时回收最终在 finalize 时报警。2.2 CursorAdapter 忘记 swapCursor用CursorAdapter或RecyclerView.Adapter包装 Cursor 时每次数据刷新应该调用swapCursor(newCursor)它内部会帮你关闭旧 Cursor。但很多人直接changeCursor或者干脆重新new一个 Adapter旧 Cursor 就悬在那里了。更隐蔽的是在onDestroy里只调了adapter null没有先swapCursor(null)旧 Cursor 依然没关。2.3 异常分支提前 return 绕过关闭这是 excerpt 里那段代码暴露的问题cursor2在while循环内部创建关闭语句写在循环体里。如果循环中途抛异常或者某个if分支提前returncursor2.close()就被跳过了。即使外层finally里有cursor2.close()也要保证cursor2变量在finally作用域内可见且指向正确对象。很多人把 Cursor 声明在 try 内部finally 根本拿不到引用。注意db.close()不是万能的。关闭 SQLiteDatabase 不会自动关闭所有由它打开的 CursorCursor 必须单独关闭。而且db.close()之后如果还有 Cursor 在用反而会引发新的异常。3. 前置准备用 TaoToken 统一 Key 打通 AI 辅助分析通道排查这类问题光靠肉眼扫代码效率很低尤其是堆栈里只给创建点、不给泄漏点的时候。我的做法是把 logcat 里的完整堆栈整理出来交给 AI 做结构化分析让它帮我列出所有可能的未关闭路径。这里用 TaoToken 的统一 Key 来接入好处是一个 Key 可以走多个模型通道不用为每个模型单独配环境。TaoToken 的 API 地址是https://taotoken.net/api控制台和文档入口如下模型对话https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcursor_leakCoding Plan长期编码/Agent 场景https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcursor_leak控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcursor_leakAPI Keys 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcursor_leak接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcursor_leak拿到 Key 之后你可以用任意 HTTP 客户端调用下面给一个 curl 示例把 logcat 堆栈作为输入让模型帮你分析哪些路径可能漏关 Cursorcurl -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: claude-sonnet-4-20250514, messages: [ { role: user, content: 下面是一段 Android logcat 堆栈报 DatabaseObjectNotClosedException。请帮我列出所有可能导致 Cursor 未关闭的代码路径并给出修复建议\n\n粘贴堆栈 } ] }如果你更习惯在 IDE 里直接对话可以走模型对话入口把堆栈贴进去让它按「创建点—使用点—关闭点」三段式帮你梳理。长期做 Android 排查的话Coding Plan 更适合因为它对代码上下文的理解更连贯能记住你项目里的封装习惯。4. 可复制的 Cursor 封装骨架与 StrictMode 配置4.1 一个不容易漏关的 Cursor 封装核心思路是Cursor 的创建和关闭必须成对出现在同一个作用域且关闭逻辑放在finally里变量声明在 try 之外。下面这个骨架可以直接抄public ListMemo queryMemos(SQLiteDatabase db, long start, long end) { ListMemo result new ArrayList(); Cursor cursor null; Cursor inner null; try { cursor db.rawQuery( select id, title from memos where time ? and time ? order by time desc, new String[]{String.valueOf(start), String.valueOf(end)}); while (cursor.moveToNext()) { long id cursor.getLong(0); String title cursor.getString(1); try { inner db.rawQuery( select file_path from memos where id ?, new String[]{String.valueOf(id)}); if (inner.moveToFirst()) { String path inner.getString(0); result.add(new Memo(id, title, path)); } } finally { if (inner ! null) { inner.close(); inner null; } } } } finally { if (cursor ! null) { cursor.close(); } } return result; }关键点有三个第一inner的关闭放在内层finally保证每次循环都释放第二关闭后把inner置为null避免下一轮循环误用已关闭对象第三外层cursor的关闭放在最外层finally无论是否抛异常都会执行。注意这里没有在方法里db.close()因为数据库连接通常由 Helper 统一管理方法内关闭会导致后续查询失败。4.2 StrictMode 检测配置光靠代码规范不够得让系统主动告诉你哪里漏了。在 Application 的onCreate里开启 StrictMode 的detectLeakedSqlLiteObjects和detectLeakedClosableObjectsOverride public void onCreate() { super.onCreate(); if (BuildConfig.DEBUG) { StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder() .detectLeakedSqlLiteObjects() .detectLeakedClosableObjects() .penaltyLog() .build()); } }开启后只要 Cursor 或 SQLiteDatabase 在 GC 时还没关闭logcat 里就会打出StrictMode相关的泄漏日志比DatabaseObjectNotClosedException更早暴露问题。建议只在 Debug 包开启Release 包关掉避免性能开销。4.3 CursorAdapter 的正确刷新姿势如果你用 CursorAdapter刷新数据时不要直接new新 Adapter而是public void updateData(Cursor newCursor) { Cursor old adapter.swapCursor(newCursor); if (old ! null) { old.close(); } }swapCursor返回旧 Cursor你需要手动关闭它。在onDestroy里也要记得Override protected void onDestroy() { super.onDestroy(); if (adapter ! null) { Cursor old adapter.swapCursor(null); if (old ! null) { old.close(); } } }5. 验证请求与成功结果用 AI 分析 logcat 堆栈配置好 StrictMode 后复现一次泄漏场景logcat 里会出现类似这样的日志StrictMode policy violation; ~duration120 ms: android.os.StrictMode$StrictModeViolation at android.database.sqlite.SQLiteCursor.finalize(SQLiteCursor.java:xxx) at java.lang.Daemons$FinalizerDaemon.run(Daemons.java:xxx)把这段完整堆栈包括上面的Caused by和创建点复制出来通过 TaoToken 的模型对话入口提交提示词可以这样写这是一段 Android StrictMode 泄漏日志报 SQLiteCursor 未关闭。 请按以下格式输出 1. 创建点堆栈中哪一行创建了 Cursor 2. 泄漏原因为什么没关闭 3. 修复建议具体改哪一行代码 4. 是否还有其他潜在泄漏路径 堆栈如下 粘贴完整堆栈实测下来模型能比较准确地从堆栈里定位到rawQuery的调用行并结合你贴的代码片段指出finally缺失或return提前的问题。如果堆栈里信息不够它还会提示你补充哪些上下文比如对应的 Adapter 代码或生命周期方法。验证成功的标志是修复后重新跑一遍StrictMode 不再报同一处泄漏DatabaseObjectNotClosedException也不再出现。如果还有残留把新的堆栈再丢给 AI 做二次分析通常两三轮就能清干净。6. 本篇常见错排查错误一db.close()之后又用 Cursor。关闭数据库不会关闭 Cursor但关闭数据库后 Cursor 可能失效。正确顺序是先关 Cursor再关数据库且数据库关闭要谨慎通常交给 Helper 统一管理。错误二finally里访问不到 Cursor 变量。如果 Cursor 声明在 try 内部finally 拿不到引用。必须把声明提到 try 之外初始化为null。错误三循环内 Cursor 只在外层 finally 关闭。像 excerpt 里那样cursor2在循环内创建如果只在外层 finally 关循环第二次创建时旧对象就泄漏了。必须在循环内每次用完就关。错误四swapCursor后忘记关旧 Cursor。swapCursor返回的旧 Cursor 需要手动关闭否则每次刷新都泄漏一个。错误五StrictMode 只在 Debug 开启Release 漏测。建议在测试包也开启或者用 LeakCanary 配合检测避免只在 Debug 发现。如果你在接入 AI 辅助分析时遇到 Key 配置或请求报错可以走 API Keys 管理页检查 Key 状态或查阅接入文档确认请求格式。长期做 Android 编码排查的话Coding Plan 能保持上下文连贯减少重复贴代码的次数。
