1. Android Cursor 类到底解决什么问题从 SQLite 查询到遍历取值的完整链路如果你写过 Android 本地数据存储大概率绕不开android.database.Cursor。它是什么简单说Cursor 就是 SQLite 查询结果集的游标你可以把它理解成一张临时表格加一个指向当前行的指针。SQLiteDatabase.query()或rawQuery()返回的不是 List也不是数组而是一个 Cursor 对象。它适合谁适合所有需要在 Android 里读写 SQLite、封装 ContentProvider、或者维护老项目的开发者。哪怕你现在用 Room底层依然会碰到 Cursor比如RoomDatabase.query()或SupportSQLiteDatabase的原始查询。很多新手第一次拿到 Cursor 会懵为什么不能直接cursor[0]为什么取个字段还要先getColumnIndex原因在于 Cursor 是随机数据源所有数据通过列下标取得而且它内部维护一个位置指针初始位置在-1也就是第一行之前。你必须先moveToFirst()或moveToNext()把指针移到有效行才能读取数据。这个设计跟 ADO.NET 的DataReader很像但 Cursor 支持随机访问可以moveToPosition(int)跳到任意行。核心方法我按使用频率列一下moveToFirst()定位第一行返回 false 表示结果集为空moveToNext()移到下一行常配合 while 循环getCount()返回总行数getColumnIndex(String)返回列名对应的下标不存在返回 -1getColumnIndexOrThrow(String)不存在直接抛IllegalArgumentExceptiongetString(int)、getInt(int)、getLong(int)按下标取值isAfterLast()判断是否在最后一行之后close()释放资源。这些方法组合起来就构成了从查询到遍历的完整链路。实际场景里最常见的坑有三个。第一是空指针查询结果为空时直接getString会抛CursorIndexOutOfBoundsException因为指针还在 -1。第二是列名写错getColumnIndex返回 -1再用 -1 去取值同样越界。第三是忘记close()Cursor 持有底层 SQLite 的窗口资源长期不关会导致内存泄漏尤其在 Activity 或 Fragment 里查询后没释放反复进出页面就会累积。我试过在列表页每次onResume查询却不关闭跑几十次后StrictMode直接报CursorWindow泄漏警告。所以正确的使用姿势是查询后先判断moveToFirst()遍历时用getColumnIndexOrThrow提前拿好下标最后在finally块里close()。下面我会先讲清楚 TaoToken 在调试这类数据库逻辑时能帮上什么忙再给一套可直接复制的 Cursor 封装工具类和查询示例然后带你验证请求结果、排查真实报错最后给出语义一致的接入入口。2. TaoToken 前置准备用模型对话快速定位 Cursor 报错与封装思路在写 Cursor 封装之前我先说一个提效手段。Android 数据库报错往往堆栈很长CursorIndexOutOfBoundsException、StrictMode CursorWindow leak、IllegalArgumentException: column xxx does not exist这些信息夹在几十行日志里肉眼找根因很费时间。你可以把报错日志贴给 TaoToken 的模型对话让它帮你定位是哪一步指针没移动、哪个列名拼错、哪个分支漏了 close。TaoToken 的模型对话入口在 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite 打开后直接粘贴日志和你的查询代码让它给出修改建议。如果你打算长期做 Android 编码和 Agent 辅助开发可以了解 Coding Plan入口是 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite 。它适合把模型能力接进日常编码流程比如让模型帮你生成 Cursor 封装、审查 close 是否遗漏、解释getColumnIndexOrThrow和getColumnIndex的取舍。对于本篇这种数据库工具类编写模型对话足够用如果你要批量处理多个模块的数据库访问层重构Coding Plan 更顺手。接入前你需要准备三件套Base URL、API Key、Model ID。Base URL 用 https://taotoken.net/api API Key 在控制台创建入口是 https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite 。Model ID 根据你控制台里可用的模型填写比如常见的对话模型标识。这三件套在后面的配置片段里会完整出现你照着填就行。文档入口在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite 遇到参数不确定可以查。这里要强调一点TaoToken 是模型调用入口不是数据库工具也不替代 Android Studio 或 SQLite。它的作用是帮你理解报错、生成封装代码、审查资源释放逻辑。你最终运行的还是本地 SQLite 和 Cursor。把定位放在“辅助排查和代码生成”上用起来才顺。下面进入可复制配置环节我会给一套完整的 Cursor 封装工具类以及一个查询示例包含 Base URL、Key、Model ID 的配置片段方便你把模型对话接进调试流程。3. 可复制配置Cursor 封装工具类 查询示例 模型接入片段先给 Cursor 封装工具类。目标是把“移动指针、取列下标、判空、关闭”这几件事收拢避免每次查询都写重复代码。下面这个类我实测下来比较稳你可以直接放到项目里。import android.database.Cursor; import android.database.CursorIndexOutOfBoundsException; import java.io.Closeable; import java.util.ArrayList; import java.util.List; public class CursorHelper implements Closeable { private final Cursor cursor; public CursorHelper(Cursor cursor) { this.cursor cursor; } public boolean isEmpty() { return cursor null || cursor.getCount() 0 || !cursor.moveToFirst(); } public int getColumnIndexOrThrow(String columnName) { int index cursor.getColumnIndex(columnName); if (index -1) { throw new IllegalArgumentException(column columnName does not exist); } return index; } public String getString(String columnName) { return cursor.getString(getColumnIndexOrThrow(columnName)); } public int getInt(String columnName) { return cursor.getInt(getColumnIndexOrThrow(columnName)); } public long getLong(String columnName) { return cursor.getLong(getColumnIndexOrThrow(columnName)); } public boolean moveToNext() { return cursor.moveToNext(); } public boolean moveToFirst() { return cursor.moveToFirst(); } public int getCount() { return cursor.getCount(); } Override public void close() { if (cursor ! null !cursor.isClosed()) { cursor.close(); } } }这个封装的关键点isEmpty()里先判断getCount() 0再moveToFirst()避免空结果集时指针停在 -1getColumnIndexOrThrow把 -1 转成明确异常比直接越界好排查close()判断isClosed()防止重复关闭。接下来是查询示例用SQLiteOpenHelper建一张 people 表然后查询并遍历。public ListPerson queryAllPeople(SQLiteDatabase db) { ListPerson result new ArrayList(); Cursor cursor null; try { cursor db.query( people, new String[]{_id, name, phone}, null, null, null, null, name ASC ); CursorHelper helper new CursorHelper(cursor); if (helper.isEmpty()) { return result; } do { Person p new Person(); p.id helper.getLong(_id); p.name helper.getString(name); p.phone helper.getString(phone); result.add(p); } while (helper.moveToNext()); } finally { if (cursor ! null) { cursor.close(); } } return result; }注意这里用do-while而不是while因为isEmpty()里已经moveToFirst()把指针移到第一行如果再用while(moveToNext())会跳过第一行。这是很多人踩过的坑先moveToFirst()判断非空然后while(moveToNext())遍历结果第一条数据永远读不到。正确做法要么用do-while要么在moveToFirst()后先处理当前行再moveToNext()。如果你要把模型对话接进调试流程可以用下面这个 JSON 配置片段路径按你项目实际存放位置调整。Base URL、Key、Model ID 三件套都在里面。{ baseUrl: https://taotoken.net/api, apiKey: 你的_API_Key, modelId: 你的_Model_ID, timeoutMs: 60000, debug: { logCursorLeak: true, strictMode: true } }如果你用 TOML 管理配置等价写法如下[taotoken] base_url https://taotoken.net/api api_key 你的_API_Key model_id 你的_Model_ID timeout_ms 60000 [taotoken.debug] log_cursor_leak true strict_mode true如果你在 Android 项目里用settings.gradle或自定义 properties 管理也可以把这三件套写进local.properties但注意不要提交到版本库。API Key 创建入口在 https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite 创建后复制到配置里。Model ID 以控制台显示为准不要照抄别人的。配置完成后你就可以在调试 Cursor 报错时把日志和代码片段发给模型对话让它帮你确认是不是漏了 close、列名是否拼错、指针移动顺序是否正确。4. 验证请求与成功结果确认 Cursor 遍历、取值、关闭都正确配置和代码都就位后怎么验证 Cursor 逻辑真的对了我分三步走先验证查询返回行数再验证遍历取值最后验证资源关闭。第一步插入三条测试数据然后调用queryAllPeople打印result.size()。如果返回 3说明moveToFirst和do-while配合正确如果返回 2大概率是遍历时跳过了第一行如果返回 0检查isEmpty()是不是把非空结果误判了。SQLiteDatabase db helper.getWritableDatabase(); db.execSQL(INSERT INTO people (name, phone) VALUES (Alice, 1001)); db.execSQL(INSERT INTO people (name, phone) VALUES (Bob, 1002)); db.execSQL(INSERT INTO people (name, phone) VALUES (Cindy, 1003)); ListPerson list queryAllPeople(db); Log.d(CursorTest, size list.size()); for (Person p : list) { Log.d(CursorTest, id p.id , name p.name , phone p.phone); }预期日志是size3然后依次打印 Alice、Bob、Cindy 的 id、name、phone。如果name打印出 null检查getColumnIndexOrThrow是否抛了异常被吞掉或者列名大小写是否匹配。SQLite 列名默认不区分大小写但getColumnIndex在某些版本对大小写敏感建议统一用小写。第二步验证空结果集。把查询条件改成name NotExist再跑一次。预期isEmpty()返回 trueresult.size()为 0且不抛异常。如果你看到CursorIndexOutOfBoundsException说明在空结果集上直接调了getString也就是isEmpty()判断没生效。检查isEmpty()里是否先getCount() 0再moveToFirst()顺序反了在某些实现上会出问题。第三步验证关闭。开启 StrictMode 的detectLeakedSqlLiteObjects和detectLeakedClosableObjects反复进出查询页面 20 次观察 Logcat 是否有CursorWindow泄漏警告。如果没有警告说明finally里的close()生效了。你也可以在CursorHelper.close()里加一行日志确认每次查询后都走到关闭分支。StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder() .detectLeakedSqlLiteObjects() .detectLeakedClosableObjects() .penaltyLog() .build());成功结果的标准是三条数据完整打印、空查询不崩、反复查询无泄漏警告。这三步都过了说明你的 Cursor 封装和查询示例是可靠的。接下来讲常见报错排查我会对照真实错误信息给出定位路径。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth 对照定位虽然本篇主题是 Cursor但你在接入模型对话辅助调试时可能会遇到几类报错。我把它们和 Cursor 本身的报错分开讲方便你对照定位。第一类Cursor 本身的CursorIndexOutOfBoundsException: Index -1 requested, with a size of 3。这个错误说明指针还在 -1 就去取值了。根因通常是查询后没调moveToFirst()或moveToNext()直接getString。修复方式在取值前先if (!cursor.moveToFirst()) return;或者用本文的CursorHelper.isEmpty()做前置判断。注意getCount()返回 3 不代表指针有效指针初始位置始终是 -1。第二类IllegalArgumentException: column phoneNumber does not exist。这是列名拼写错误getColumnIndexOrThrow抛出来的。修复方式用cursor.getColumnNames()打印所有列名核对你的查询 projection 和实际表结构。常见坑是建表时列名是phone查询时写phoneNumber或者 projection 里没包含该列却去取值。第三类StrictMode CursorWindow leak或A resource was acquired at attached stack trace but never released。这是忘记close()的典型表现。修复方式所有 Cursor 操作放在 try-finally 里finally 中 close或者用 try-with-resourcesCursor 实现了 CloseableAPI 16 可用。注意CursorHelper实现了Closeable你也可以直接 try-with-resources。第四类模型接入侧的401 Unauthorized。这通常表示 API Key 无效或没带上。检查你的配置里apiKey是否填了控制台创建的值Base URL 是否是 https://taotoken.net/api 请求头是否带了Authorization: Bearer 你的_API_Key。如果 Key 刚创建确认没有多余空格。创建入口在 https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite 。第五类local proxy failed或连接超时。这类报错通常和本地网络环境、代理配置有关。检查你的 HTTP 客户端是否误设了代理或者超时时间太短。把timeoutMs调到 60000 再试。如果你在公司网络下确认出口是否允许访问对应域名。第六类reading choices相关报错通常出现在解析模型返回结构时。模型返回的 JSON 里choices数组为空或字段名不匹配就会在读取时抛错。检查你的解析代码是否按实际返回结构取值不要硬编码假设。可以先把原始返回打印出来确认choices[0].message.content是否存在。第七类OAuth相关报错。如果你用的是需要 OAuth 的客户端或插件报错通常表示 token 过期或回调地址不匹配。检查你的 OAuth 配置里的回调 URL 是否和平台登记一致token 是否需要刷新。这类问题优先看客户端文档确认认证流程。排查顺序建议先确认 Cursor 指针和列名再确认 close最后才看模型接入侧的网络和认证。因为数据库报错和网络报错堆栈特征完全不同混在一起看容易绕晕。把 Cursor 逻辑用本文的封装固定下来能消掉一大半低级错误。6. 语义一致 CTA按场景选择模型对话、Coding Plan 或 API Keys走到这里你已经有一套可复制的 Cursor 封装工具类、查询示例、验证步骤和排错路径。接下来按你的实际场景选入口。如果你只是偶尔遇到 Cursor 报错想把日志贴给模型快速定位用模型对话入口是 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite 。如果你要长期做 Android 编码、批量重构数据库访问层、让模型参与日常开发流程用 Coding Plan入口是 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite 。如果你还没创建 Key先去 API Keys 页面入口是 https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite 。参数和接入细节查文档入口是 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite 。API 地址统一用 https://taotoken.net/api 。最后补一个实用技巧把本文的CursorHelper和queryAllPeople放进项目后先跑一遍 StrictMode 验证确认没有 CursorWindow 泄漏再接入模型对话辅助排查。这样你的数据库层是干净的模型给出的建议也更容易聚焦在真正的逻辑问题上。Cursor 本身不难难的是指针移动顺序、列名核对和资源释放这三件事把这三件固定成工具类后面就省心了。
阅读完成 · 觉得有帮助?