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分支提前return,cursor2.close()就被跳过了。即使外层finally里有cursor2.close(),也要保证cursor2变量在finally作用域内可见且指向正确对象。很多人把 Cursor 声明在 try 内部,finally 根本拿不到引用。
注意:
db.close()不是万能的。关闭 SQLiteDatabase 不会自动关闭所有由它打开的 Cursor,Cursor 必须单独关闭。而且db.close()之后如果还有 Cursor 在用,反而会引发新的异常。
3. 前置准备:用 TaoToken 统一 Key 打通 AI 辅助分析通道
排查这类问题,光靠肉眼扫代码效率很低,尤其是堆栈里只给创建点、不给泄漏点的时候。我的做法是把 logcat 里的完整堆栈整理出来,交给 AI 做结构化分析,让它帮我列出所有可能的未关闭路径。这里用 TaoToken 的统一 Key 来接入,好处是一个 Key 可以走多个模型通道,不用为每个模型单独配环境。
TaoToken 的 API 地址是https://taotoken.net/api,控制台和文档入口如下:
- 模型对话:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=cursor_leak
- Coding Plan(长期编码/Agent 场景):https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=cursor_leak
- 控制台:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=cursor_leak
- API Keys 管理:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=cursor_leak
- 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=cursor_leak
拿到 Key 之后,你可以用任意 HTTP 客户端调用,下面给一个 curl 示例,把 logcat 堆栈作为输入,让模型帮你分析哪些路径可能漏关 Cursor:
curl -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 List<Memo> queryMemos(SQLiteDatabase db, long start, long end) { List<Memo> 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和detectLeakedClosableObjects:
@Override 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; ~duration=120 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 能保持上下文连贯,减少重复贴代码的次数。