--App调试内存泄露之Cursor篇:把Cursor Base URL改到TaoToken)
1. Android Cursor 内存泄露到底怎么发生的Cursor 是 Android 里访问 SQLite 查询结果集的游标对象它底层持有 native 层的 sqlite3_stmt 句柄和一块窗口缓冲区。很多人第一次听到「Cursor 泄露」会以为只是 Java 对象没被 GC其实真正麻烦的是 native 资源Java 侧的 Cursor 对象即使被回收只要没调用 close()native 的 statement 就不会释放SQLite 连接上的游标槽位会一直被占用。查询次数一多轻则 CursorWindow 内存持续上涨重则触发 CursorWindowAllocationException 或者数据库句柄耗尽。我在实际项目里遇到过最典型的一种列表页每次 onResume 都重新 query 一遍Cursor 用完就丢给 AdapterActivity 销毁时既没 swapCursor(null) 也没 close。跑十几分钟Heap 里 android.database.sqlite.SQLiteCursor 实例数一直涨MAT 里看 dominator tree一个 Cursor 后面挂着几百 KB 的 byte[]全是 CursorWindow 的窗口数据。适合谁看这篇正在用 SQLite / Room 做本地存储、列表页有查询逻辑、用 LeakCanary 或 MAT 排查过内存但没定位到 Cursor 的 Android 开发者。核心检索词就是 Android Cursor 内存泄露排查下面从原理、复现、定位到修复一步步走。先讲清楚 Cursor 的生命周期。ContentResolver.query() 或 SQLiteDatabase.query() 返回 Cursor 后它处于「已打开」状态内部 mWindow 持有数据。只有 close() 才会释放 window 并 finalize statement。Android 从 API 16 起给 Cursor 加了 finalize() 兜底但 finalize 依赖 GC 触发时机不可控而且 finalize 线程执行慢期间 native 资源一直占着。所以「等 GC 自动关」这种想法在调试阶段就会暴露成泄露。常见泄露路径有四类。第一类是 Activity/Fragment 里 query 完直接 setAdapter(cursor)销毁时没管 Cursor。第二类是 Adapter 里换了新 Cursor 但旧的没 closeswapCursor 只换引用不关旧的。第三类是 try 块里 querycatch 里 returnfinally 忘了 close。第四类是异步查询回调里持有 Cursor回调还没回来页面就销毁了。这四类在 LeakCanary 的引用链里表现不一样后面第五节会逐个对照。理解一个关键点Cursor 泄露和「内存泄露」这个词的常规理解有偏差。常规内存泄露是对象被 GC Root 强引用无法回收Cursor 泄露更多是「对象可回收但 native 资源没释放」。所以你在 Android Studio Profiler 里看 Java Heap 可能觉得还好但 native heap 或 CursorWindow 的统计会露馅。调试时两个维度都要看。2. 把 Cursor 调试环境接到 TaoToken 的准备工作这一节讲前置。你可能会问排查 Cursor 泄露为什么要接大模型服务因为现在很多团队用 AI 辅助读 MAT 报告、生成复现 Demo、解释 LeakCanary 引用链。我试过把堆转储里的引用链片段贴给模型让它帮我判断是 Adapter 持有还是匿名内部类持有效率比纯人肉翻快很多。而要把这类调试辅助跑顺得先有一个稳定的模型调用入口。TaoToken 是一个模型 API 聚合服务官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 。它能做什么用一个 Base URL 和一把 Key调用多种模型适合在 IDE 插件、命令行工具、脚本里做代码分析和报错解释。适合谁需要把 AI 辅助嵌进 Android 调试流程、又不想每个工具单独配一套鉴权的开发者。你需要准备三样东西我把它叫「三件套」Base URL、API Key、Model ID。Base URL 填 https://taotoken.net/api Key 在控制台创建Model ID 按你用的模型填。控制台入口 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite API Key 管理入口 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。这里要澄清一个容易混淆的点标题里的「Cursor Base URL」指的是 AI 编程工具 Cursor 的模型接入地址不是 Android 的数据库 Cursor。两者同名但完全无关。本文主线是 Android Cursor 泄露排查同时把 Cursor 编辑器这类 AI 工具的 Base URL 配置一并讲清楚避免你搜「Cursor Base URL」时被带偏。Cursor 编辑器的模型配置里把 OpenAI Base URL 改成 https://taotoken.net/api Key 填 TaoToken 的 KeyModel 填对应 ID就能走统一入口。如果你用的是 Claude Code 这类命令行编码工具接入文档里有对应说明入口 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。需要长期跑编码 Agent 的可以看 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。只是想先验证模型通不通用模型对话页 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 发一条消息即可。准备工作做完你就有能力在调试 Cursor 泄露时把 MAT 的引用链、LeakCanary 的 trace、logcat 的报错贴给模型让它帮你缩小范围。下面第三节给可复制的配置片段。3. 可复制的 Cursor 使用与关闭配置片段这一节给两类配置一类是 Android 侧 Cursor 的正确使用与关闭写法一类是 AI 工具侧的接入配置。先看 Android 侧这是修复泄露的根本。最稳的写法是用 try-with-resources 或 try/finally 保证 close。Android 的 Cursor 实现了 CloseableAPI 16 以上可以直接用 try-with-resourcespublic ListNote queryNotes(SQLiteDatabase db) { ListNote result new ArrayList(); String sql SELECT id, title, content FROM note ORDER BY id DESC; try (Cursor cursor db.rawQuery(sql, null)) { while (cursor.moveToNext()) { Note n new Note(); n.id cursor.getLong(cursor.getColumnIndexOrThrow(id)); n.title cursor.getString(cursor.getColumnIndexOrThrow(title)); n.content cursor.getString(cursor.getColumnIndexOrThrow(content)); result.add(n); } } return result; }注意这里把数据读进 List 后就 close 了不把 Cursor 交给 Adapter 长期持有。这是避免泄露最省心的做法。如果你非要用 CursorAdapter那必须在 Activity 销毁时 swapCursor(null)Override protected void onDestroy() { if (adapter ! null) { Cursor old adapter.swapCursor(null); if (old ! null !old.isClosed()) { old.close(); } } super.onDestroy(); }swapCursor(null) 会把 Adapter 里的 mCursor 置空并返回旧 Cursor你必须手动 close 返回的旧对象否则旧 Cursor 依然泄露。这是很多人踩的坑以为 swapCursor 会自动关。再看 AI 工具侧。Cursor 编辑器的模型配置在设置里找到 OpenAI API Key 相关项覆盖 Base URL{ openai.baseUrl: https://taotoken.net/api, openai.apiKey: 你的_TaoToken_Key, openai.model: 你的_Model_ID }如果你用的是支持 settings.json 的工具配置结构类似{ models: { provider: openai-compatible, baseUrl: https://taotoken.net/api, apiKey: 你的_TaoToken_Key, modelId: 你的_Model_ID } }Codex 的 auth.json 写法{ base_url: https://taotoken.net/api, api_key: 你的_TaoToken_Key, model: 你的_Model_ID }三件套必须齐全Base URL 是 https://taotoken.net/api Key 从控制台拿Model ID 按实际模型填。少任何一个都会在请求时报鉴权或模型不存在错误。Cline MCP 场景下同样在 MCP server 配置里填这三项。配置完保存重启对应工具让配置生效。Android 侧的代码改动则直接重新编译安装即可。下一节验证。4. 验证请求与确认 Cursor 泄露是否修复验证分两条线。先验证 AI 工具接入是否通再验证 Android Cursor 泄露是否真的修掉。AI 工具侧最直接的是发一条测试请求。用 curl 验证curl https://taotoken.net/api/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer 你的_TaoToken_Key \ -d { model: 你的_Model_ID, messages: [{role: user, content: 解释一下 Android Cursor 不关闭会导致什么}] }返回里如果有 choices 数组且 content 有内容说明 Base URL、Key、Model 三件套都对。如果返回 401是 Key 问题返回 model not found是 Model ID 问题连接超时检查 Base URL 是否写成了 https://taotoken.net/api 而不是别的路径。模型对话页 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 也能直接发消息验证。Android 侧验证 Cursor 泄露是否修复用 LeakCanary 最直观。在 Application 里初始化 LeakCanary然后反复进出列表页 20 次观察通知栏有没有泄露报告。修复前你大概率会看到 SQLiteCursor 相关的泄露修复后应该干净。再用 Android Studio Profiler 看 CursorWindow。操作路径Profiler - Memory - 触发一次 GC - 看 Native Heap 或直接 dump Java Heap搜索 SQLiteCursor 和 CursorWindow。修复前实例数随进出次数线性增长修复后应该稳定在一个小数值。还可以用 adb 看数据库连接状态adb shell dumpsys dbinfo com.example.app输出里会列出每个数据库的打开连接和活跃 Cursor 数。修复前活跃 Cursor 数不归零修复后应该回到 0。这个命令在调试阶段非常好用不用等 LeakCanary 触发。复现 Demo 的验证动作写一个按钮每次点击执行一次 query 且故意不 close点 50 次后 dump heap能看到 50 个 SQLiteCursor。然后把代码改成 try-with-resources再点 50 次dump 后 SQLiteCursor 数量应该接近 0。这个对比最能说明问题。5. 本篇常见报错与排查对照这一节把真实会遇到的报错列出来逐个给排查方向。第一个401 Unauthorized。出现在 AI 工具接入时。原因通常是 Key 没填、Key 填错、或者 Authorization 头格式不对。检查 Bearer 后面有没有空格Key 有没有多余换行。TaoToken 的 Key 在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 创建复制时注意别带上首尾空格。第二个local proxy failed 或 connection refused。出现在工具配置了本地代理但代理没起来时。检查配置里有没有多余的 proxy 字段Base URL 直接写 https://taotoken.net/api 即可不需要本地转发。第三个reading choices 相关报错比如 error reading choices field。这通常是返回体不是预期的 JSON 结构可能是 Base URL 路径不对把 /v1 漏了或多了。正确是 https://taotoken.net/api 具体路径由工具拼接。也可能是 Model ID 写错导致返回了错误结构。第四个OAuth 相关报错。某些工具默认走 OAuth 登录流程如果你要用 API Key需要在配置里显式关闭 OAuth 或选择 API Key 模式否则它会一直尝试 OAuth 而失败。第五个CursorWindowAllocationException。这是 Android 侧 Cursor 泄露的直接后果窗口内存分配失败。排查方向是找没 close 的 Cursor重点看 Adapter、异步回调、onResume 里的 query。用 dumpsys dbinfo 看活跃 Cursor 数。第六个LeakCanary 报 SQLiteCursor 泄露但引用链指向 Adapter。说明 Adapter 持有 Cursor 且 Activity 销毁时没 swapCursor(null)。按第三节的 onDestroy 写法修。第七个Cursor 已 close 但还报 attempt to re-open an already-closed object。说明有代码在 close 之后又调用了 moveToNext 之类的方法。检查是不是把 Cursor 交给了多个消费者或者异步任务在 close 后才执行。排查顺序建议先看 logcat 有没有明确异常再用 dumpsys dbinfo 看活跃 Cursor 数再用 LeakCanary 看引用链最后用 MAT 看 dominator tree。MAT 里重点看 SQLiteCursor 的 retained size以及它被谁持有。如果持有者是匿名内部类或 Handler那就是异步回调泄露。6. 后续调试与接入入口Cursor 泄露修完之后建议把「所有 query 必须 close」写进团队代码规范配合 lint 规则。Android Lint 有 Cursor 相关的检查项可以在 build.gradle 里开启。另外 Room 这类 ORM 已经帮你管理了 Cursor 生命周期新项目优先用 Room能省掉大量手动 close 的活。调试辅助方面把 MAT 引用链、LeakCanary trace 贴给模型分析时用统一的接入入口能省去反复配 Key 的麻烦。API 调用走 https://taotoken.net/api Key 在控制台管理接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里有各工具的配置示例。需要长期跑编码 Agent 的看 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 只是验证模型通不通用模型对话 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。最后留一个实用技巧在 Application 里注册 ActivityLifecycleCallbacks在 onActivityDestroyed 里打印当前未关闭的 Cursor 数量通过反射或维护一个计数器这样每次页面销毁都能看到有没有漏关。比等 LeakCanary 触发快得多适合在开发阶段常驻。