1. 从一条 StrictMode 日志说起SQLiteDatabase 未关闭异常到底在报什么如果你在 Android 项目里用过 SQLiteDatabase大概率在 Logcat 里见过这样一段红色日志E/SQLiteDatabase: close() was never explicitly called on database /data/data/com.xxx/databases/demo.db E/SQLiteDatabase: android.database.sqlite.DatabaseObjectNotClosedException: Application did not close the cursor or database object that was opened here E/SQLiteDatabase: at android.database.sqlite.SQLiteDatabase.init(SQLiteDatabase.java:1905) E/SQLiteDatabase: at android.database.sqlite.SQLiteDatabase.openDatabase(SQLiteDatabase.java:1011) E/SQLiteDatabase: at com.xxx.db.DbHelper.getWritableDatabase(DbHelper.java:42)很多人第一反应是「我明明调了 close() 啊」但日志偏偏说没关。这里要先搞清楚一件事DatabaseObjectNotClosedException不是崩溃它是一个资源泄漏告警由 Android 的SQLiteDatabase在 GC 回收时检测到对象没被显式关闭而抛出。它不会立刻让你的 App 挂掉但会带来两个真实后果一是数据库连接长期占用二是文件句柄泄漏跑久了可能出现「database is locked」或者写入失败。这条日志最有价值的地方是它自带堆栈。堆栈里SQLiteDatabase.init和openDatabase那几行指向的就是「这个数据库对象是在哪里被打开的」。也就是说异常信息本身就在告诉你泄漏源头只是很多人看到一长串at android.database...就跳过了没往下翻到自己的包名。这篇要解决的问题很具体怎么从 StrictMode 日志和堆栈线索出发把「未关闭的 cursor/database」定位到具体代码行然后用成对关闭的封装写法彻底修掉它。适合正在做 Android 本地存储、被这类日志刷屏但不知道怎么下手的开发者。下面我会先讲怎么让日志更清晰再讲怎么定位最后给出可以直接抄的封装和验证步骤。2. 让日志说人话StrictMode 配置与 TaoToken 辅助排查前置默认情况下DatabaseObjectNotClosedException的堆栈可能被系统裁剪或者因为日志级别问题只看到一半。要精准定位第一步是打开 StrictMode 的detectLeakedSqlLiteObjects让它在对象泄漏时打印完整堆栈。在 Application 的onCreate里加上这段配置注意penaltyLog()是必须的否则只检测不输出class MyApp : Application() { override fun onCreate() { super.onCreate() if (BuildConfig.DEBUG) { StrictMode.setVmPolicy( StrictMode.VmPolicy.Builder() .detectLeakedSqlLiteObjects() // 检测 SQLite 对象泄漏 .detectLeakedClosableObjects() // 检测 Closeable 未关闭 .penaltyLog() // 关键把堆栈打到 Logcat .build() ) } } }配置好之后复现一次泄漏Logcat 里会出现带StrictMode标签的完整堆栈形如StrictMode: A resource was acquired at attached stack trace but never released. StrictMode: java.lang.Throwable: Explicit termination method close not called StrictMode: at dalvik.system.CloseGuard.open(CloseGuard.java:184) StrictMode: at android.database.sqlite.SQLiteDatabase.init(SQLiteDatabase.java:1905) StrictMode: at com.example.app.data.UserDao.queryAll(UserDao.java:88)看到com.example.app.data.UserDao.queryAll(UserDao.java:88)这一行了吗这就是你要找的具体代码行。StrictMode 的堆栈是从「对象被创建」的位置开始记录的所以最上面属于你自己包名的那一帧就是打开 cursor 或 database 的地方。这里插一句关于排查效率的事。如果你在多个模块里都有数据库操作日志混在一起很难分辨我习惯把关键日志和上下文丢给模型帮忙梳理调用链。用 TaoToken 的模型对话入口https://taotoken.net/api 对应的对话能力把堆栈贴进去让它按包名分组、标出可疑的打开点比人肉翻几十行at android.快很多。它不替代你读代码但能帮你快速缩小范围。需要长期做这类代码分析的话Coding Plan 会更顺手具体入口在 https://taotoken.net/api 的 coding-plan 里。要提醒的是StrictMode 只在 Debug 构建生效别把它带到 Release。另外detectLeakedClosableObjects会带来一定性能开销排查完可以只保留detectLeakedSqlLiteObjects。3. 可复制的成对关闭写法cursor 与 database 的封装配置定位到问题行之后真正的修复是保证「谁打开谁关闭」而且要在finally里关避免异常路径漏关。下面给出三种常见场景的封装你可以直接对照自己的代码改。3.1 Cursor 的成对关闭用 use 扩展或 try-finallyKotlin 里Cursor实现了Closeable可以直接用usefun queryUsers(db: SQLiteDatabase): ListUser { val sql SELECT id, name FROM user WHERE age ? // use 会在块结束时自动 close即使抛异常 db.rawQuery(sql, arrayOf(18)).use { cursor - val list mutableListOfUser() while (cursor.moveToNext()) { list.add(User(cursor.getLong(0), cursor.getString(1))) } return list } }如果你还在用 Java就老老实实写 try-finallypublic ListUser queryUsers(SQLiteDatabase db) { Cursor cursor null; try { cursor db.rawQuery(SELECT id, name FROM user WHERE age ?, new String[]{18}); ListUser list new ArrayList(); while (cursor.moveToNext()) { list.add(new User(cursor.getLong(0), cursor.getString(1))); } return list; } finally { if (cursor ! null) { cursor.close(); // 无论是否异常都关 } } }3.2 Database 的成对关闭别在每次查询后关这里有个高频误区有人为了「不泄漏」每次查询完就db.close()。结果下次再用同一个SQLiteOpenHelper拿到的 database 已经关了反而触发「attempt to re-open an already-closed object」。正确做法是database 的生命周期跟 Helper 走不是跟单次查询走。如果你手动openDatabase就要成对管理object DbManager { private var db: SQLiteDatabase? null fun open(context: Context): SQLiteDatabase { return db ?: SQLiteDatabase.openOrCreateDatabase( context.getDatabasePath(demo.db), null ).also { db it } } fun close() { db?.takeIf { it.isOpen }?.close() db null } }在 Activity 或 Application 的合适生命周期调用DbManager.close()。用SQLiteOpenHelper的话通常不需要手动 closeHelper 内部会复用连接你只要保证 cursor 关掉即可。3.3 用 Room 的话配置里确认没有手动持有 Cursor如果你已经迁移到 Room一般不会遇到这个异常因为 Room 帮你管理了 cursor。但如果你在 Room 的Dao里返回了Cursor类型并手动操作同样要自己关。检查你的 DAODao interface UserDao { // 返回 Cursor 需要调用方自己 close容易漏 Query(SELECT * FROM user) fun queryAllAsCursor(): Cursor // 更推荐返回实体列表Room 自动管理 Query(SELECT * FROM user) fun queryAll(): ListUser }把返回Cursor的接口尽量改成返回实体列表能从源头减少泄漏。4. 验证关闭是否生效复现、请求与结果确认改完代码不能靠感觉要能复现并确认。下面是一套可跟做的验证流程。第一步构造一个会泄漏的场景确认 StrictMode 能抓到。比如故意写一个不关 cursor 的方法fun leakDemo(db: SQLiteDatabase) { val cursor db.rawQuery(SELECT * FROM user, null) cursor.moveToFirst() // 故意不 close用来验证 StrictMode 是否生效 }调用它然后手动触发 GC在 Android Studio 的 Profiler 里点 Trigger GC或者用adb shell am gcLogcat 应该出现DatabaseObjectNotClosedException。这一步是确认你的检测配置没问题。第二步把方法改成正确写法用use或 finally再次触发 GC观察日志。如果不再出现close() was never explicitly called说明关闭生效了。第三步用 Profiler 看句柄。打开 Android Studio 的 Profiler选你的进程看 Memory 里的 Native/File Descriptor 数量。反复执行查询 100 次如果文件描述符数量稳定不增长说明没有泄漏如果每次查询都涨说明还有 cursor 没关。第四步压力验证。写个循环跑 500 次查询repeat(500) { queryUsers(db) // 正确关闭的版本 }跑完看 Logcat 有没有database is locked或too many open files。没有基本就稳了。这里有个细节SQLiteDatabase的泄漏告警有时会延迟到 GC 才打印所以验证时不要只看即时日志触发一次 GC 再看更准。另外如果你在多个线程里操作数据库确保每个线程的 cursor 都在自己的作用域内关闭别跨线程传递 cursor。5. 常见报错对照排查401、local proxy failed、reading choices 与 OAuth排查过程中除了DatabaseObjectNotClosedException本身你还可能撞上一些看起来无关、实际会干扰判断的报错。下面按真实日志对照给排查方向。close() was never explicitly called on database但堆栈指向系统类说明泄漏发生在系统内部或第三方库比如某个 SDK 自己开了数据库没关。这时候看堆栈里第一个非android.的帧通常是库的入口考虑升级该库或联系维护者。attempt to re-open an already-closed object这是过度关闭的典型。检查是不是在每次查询后都调了db.close()或者同一个 Helper 被多个地方 close。解决方式是统一 database 生命周期别在查询方法里关 database。database is locked多线程并发写或者有未关闭的事务。检查是否在事务里漏了endTransaction以及是否多个线程共用一个 database 实例。用beginTransaction时务必配setTransactionSuccessful和endTransaction。local proxy failed/401这类网络报错如果你在排查时顺手用模型辅助分析日志可能会遇到接口鉴权或代理配置问题。这类报错跟 SQLite 无关但会打断排查节奏。确认你的 API Key 和 Base URL 配置正确TaoToken 的接入文档在 https://taotoken.net/api 的 doc 里有完整说明API Keys 在 https://taotoken.net/api 的 api-keys 页面获取。配置时三件套要齐全Base URL、Key、Model ID缺一个都会报鉴权失败。reading choices相关报错通常是模型返回结构解析失败跟数据库无关。如果你在用模型批量分析堆栈确认请求体格式和返回解析逻辑匹配。OAuth 相关报错如果你用的是需要 OAuth 的编码工具比如 Claude Code 类认证过期会报 OAuth 错误。重新走一遍授权流程即可别把它和数据库问题混在一起排查。排查的核心原则是先确认报错属于哪一层。数据库层的报错看堆栈里的包名网络层的报错看 HTTP 状态码别把两类问题搅在一起。6. 把关闭这件事交给结构而不是记性修完这个异常我最大的感受是靠「记得关」永远会漏靠「结构保证关」才靠谱。use、try-finally、统一的生命周期管理本质都是让关闭动作不依赖人的记忆。如果你经常需要分析这类堆栈、梳理调用链把日志丢给模型做初步归类能省不少时间模型对话入口在 https://taotoken.net/api 的模型对话里。长期做 Android 开发、需要频繁做代码分析和重构的Coding Plan 会更合适入口同样在 https://taotoken.net/api 的 coding-plan。配置过程中卡在鉴权或参数上直接翻接入文档 https://taotoken.net/api 的 doc比到处搜答案快。最后留一个我踩过的坑StrictMode 的penaltyLog在部分定制 ROM 上会被厂商日志系统过滤如果配了却看不到堆栈试试用penaltyDeath()在 Debug 下直接崩强制暴露问题定位完再换回penaltyLog。