
1. Android SQLite query 实战从 rawQuery 到参数化查询的完整配置与验证Android 本地数据库开发里SQLiteDatabase.query和rawQuery是最常被拿来对比的两个方法。query是 Android 封装好的结构化查询接口把表名、列名、条件、排序、分页拆成独立参数内部帮你拼 SQLrawQuery则允许你直接写完整 SQL 语句自由度更高但更容易踩注入和拼接的坑。这篇文章面向正在写本地存储、缓存、离线数据的 Android 开发者尤其是刚接触selectionArgs参数化写法、搞不清Cursor什么时候该关的人。我会把两种写法的选型逻辑、可直接复制的查询封装、注入风险对照表以及用 adb 和单元测试验证结果的具体动作全部走一遍。你跟着敲完至少能搞清楚三件事什么时候用query、selectionArgs里的?到底怎么填、Cursor和SQLiteDatabase谁该在finally里关。先说结论方向绝大多数业务查询用query就够了只有涉及多表 JOIN、子查询、GROUP BY 复杂聚合时才轮到rawQuery上场。而无论用哪个参数化都是底线字符串拼接WHERE nameinput这种写法在本地库同样危险别以为数据不出手机就没事。2. TaoToken 前置给查询结果加一层模型校验与语义检查写数据库查询本身不需要联网但当你需要验证「查出来的这批数据语义对不对」「字段映射有没有错位」「这条 SQL 的业务含义是否符合预期」时接一个大模型做辅助检查会省很多事。我这边用的是 TaoToken它的官网是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 兼容 OpenAI 风格的调用方式Android 端用 OkHttp 或 Retrofit 都能直接发请求。为什么在 SQLite 查询场景里要提它因为实际开发中Cursor遍历出来的Map经常出现字段名和值对不上、getColumnIndex返回 -1、null被吞成空串这类问题。你可以把查询结果 JSON 丢给模型让它帮你核对字段结构或者把一段rawQuery的 SQL 交给它做注入风险审查。这不是必须步骤但对排查「数据看着对、逻辑就是不对」的情况很有用。接入前你需要准备三样东西Base URL、API Key、Model ID。Base URL 填https://taotoken.net/apiAPI Key 在控制台生成地址是 https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 。Model ID 根据你选的模型填比如对话类模型直接写模型名即可。想先试对话效果可以打开 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 看看可用列表。如果你打算长期在 Android 项目里做编码辅助、Agent 调用可以考虑 Coding Plan入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有完整的请求示例。Claude Code 相关的接入说明在 https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaude_codeutm_campaignrewrite 。需要强调的是TaoToken 在这里的角色是「查询结果的语义校验助手」不是数据库本身也不替代你的 SQLite 逻辑。数据库该在本地跑还是在本地跑模型只是帮你多一双眼睛看数据。3. 可复制配置query 与 rawQuery 的封装代码与参数化写法这一节是核心直接给能跑的代码。先看query的标准签名它有多个重载最全的那个长这样public Cursor query(boolean distinct, String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit)参数含义逐个说清楚distinct是否去重table表名columns要返回的列传null表示所有列selection是 WHERE 条件用?占位selectionArgs是占位符对应的值数组后面四个是分组、过滤、排序、分页。注意selection里写_id?selectionArgs就传new String[]{2}顺序必须一一对应。下面是我实际项目里用的查询封装返回ListMapString,String比原 excerpt 里返回单条Map更通用public ListMapString, String queryPersons(String selection, String[] selectionArgs) { ListMapString, String result new ArrayList(); SQLiteDatabase db null; Cursor cursor null; try { db helper.getReadableDatabase(); cursor db.query( true, // distinct person, // table null, // columnsnull 表示全部 selection, // 例如 _id? selectionArgs, // 例如 new String[]{2} null, null, // groupBy, having _id ASC, // orderBy 100 // limit ); int colsLen cursor.getColumnCount(); while (cursor.moveToNext()) { MapString, String row new HashMap(); for (int i 0; i colsLen; i) { String colName cursor.getColumnName(i); String colValue cursor.getString(i); row.put(colName, colValue null ? : colValue); } result.add(row); } } catch (Exception e) { Log.e(PersonDao, query failed, e); } finally { if (cursor ! null) cursor.close(); if (db ! null) db.close(); } return result; }这里有个细节cursor.getString(i)直接用列索引比getColumnIndex(colName)再查一次更快也避免列名拼错返回 -1 的坑。原 excerpt 里用getColumnIndex是能跑但多一次查找。再看rawQuery的参数化写法复杂查询用它public ListMapString, String rawQueryPersons(String minId) { ListMapString, String result new ArrayList(); SQLiteDatabase db null; Cursor cursor null; try { db helper.getReadableDatabase(); String sql SELECT p._id, p.name, p.age FROM person p WHERE p._id ? ORDER BY p.age DESC LIMIT 50; cursor db.rawQuery(sql, new String[]{minId}); while (cursor.moveToNext()) { MapString, String row new HashMap(); row.put(_id, cursor.getString(0)); row.put(name, cursor.getString(1)); row.put(age, cursor.getString(2)); result.add(row); } } finally { if (cursor ! null) cursor.close(); if (db ! null) db.close(); } return result; }rawQuery的第二个参数同样是selectionArgsSQL 里的?按顺序被替换。千万别写成WHERE _id minId这就是注入入口。如果你在 Android 项目里用 Gradle 管理依赖SQLite 本身是系统内置的不需要额外依赖。但如果你用 Room配置会不一样这里聚焦原生 API。下面是一个settings.gradle片段确认你的项目用的是标准 Android 配置// settings.gradle pluginManagement { repositories { google() mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() } } rootProject.name SqliteQueryDemo include :app注入风险对照表直接看写法是否安全说明selection_id?selectionArgs安全参数化推荐selection_id id危险拼接id 来自外部即可注入rawQuery(... WHERE namename, null)危险引号拼接经典注入rawQuery(... WHERE name?, new String[]{name})安全参数化execSQL(DELETE FROM person WHERE _idid)危险删除场景同样要参数化4. 验证请求与成功结果adb 与单元测试双验证代码写完不能只看编译通过得验证查询结果真的对。两种方式adb 命令行直接查库和单元测试断言。先说 adb。Android 的 SQLite 库文件在应用私有目录需要 root 或run-as才能访问 debug 包。命令如下# 进入应用私有目录debug 包可用 run-as adb shell run-as com.example.sqlitequerydemo ls databases/ # 打开数据库 adb shell run-as com.example.sqlitequerydemo sqlite3 databases/person.db # 在 sqlite3 交互里执行 sqlite .tables sqlite SELECT * FROM person; sqlite SELECT _id, name FROM person WHERE _id 2 ORDER BY age DESC LIMIT 5;如果设备没有sqlite3命令可以先把库文件 pull 出来adb exec-out run-as com.example.sqlitequerydemo cat databases/person.db /tmp/person.db sqlite3 /tmp/person.db SELECT * FROM person;成功结果应该看到类似1|张三|25 2|李四|30 3|王五|28再说单元测试。用 AndroidJUnit4 加 Robolectric 可以在 JVM 上跑 SQLite 查询测试不用真机RunWith(AndroidJUnit4.class) public class PersonDaoTest { private PersonDao dao; private SQLiteDatabase db; Before public void setUp() { Context ctx ApplicationProvider.getApplicationContext(); db SQLiteDatabase.create(null); db.execSQL(CREATE TABLE person(_id INTEGER PRIMARY KEY, name TEXT, age INTEGER)); db.execSQL(INSERT INTO person VALUES(1,张三,25)); db.execSQL(INSERT INTO person VALUES(2,李四,30)); dao new PersonDao(db); } Test public void queryById_returnsCorrectRow() { ListMapString, String rows dao.queryPersons(_id?, new String[]{2}); assertEquals(1, rows.size()); assertEquals(李四, rows.get(0).get(name)); assertEquals(30, rows.get(0).get(age)); } Test public void rawQuery_minId_returnsFiltered() { ListMapString, String rows dao.rawQueryPersons(1); assertEquals(1, rows.size()); assertEquals(李四, rows.get(0).get(name)); } }跑./gradlew test两个用例都绿说明参数化查询和 rawQuery 都按预期工作。如果断言失败先看Cursor是不是没moveToNext就取值或者selectionArgs顺序错了。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth这一节把实际会撞到的报错列出来对照解决。401 Unauthorized如果你在 Android 端调 TaoToken 做结果校验返回 401 通常是 API Key 没带或带错。检查请求头Authorization: Bearer 你的KeyKey 从 https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 生成。注意 Key 不要硬编码进 APK用 BuildConfig 或本地配置注入。local proxy failed这个报错一般出现在你本地配了代理但代理没起来或者 Android 模拟器的网络代理设置和宿主机不一致。检查模拟器设置里的代理或者干脆清掉代理直连。注意这里说的是开发环境的网络配置问题不是让你去搞什么特殊网络工具正常公司网络或家庭网络直连即可。reading choices 相关报错调用模型接口时如果返回体解析失败报reading choices或类似字段缺失先打印原始响应体看结构。常见原因是请求的 Model ID 写错或者接口返回了错误对象而不是正常结构。确认 Model ID 和文档一致文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。OAuth 相关报错如果你用 Claude Code 或某些 CLI 工具接入报 OAuth 失败检查三件套是否齐全Base URL 填https://taotoken.net/apiKey 填对Model ID 填对。Claude Code 的接入说明单独在 https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaude_codeutm_campaignrewrite 。三件套缺一个都会认证失败。Cursor 相关坑getColumnIndex返回 -1 导致getString(-1)抛异常改用列索引Cursor忘记close导致内存泄漏务必在finally关SQLiteDatabase每次getReadableDatabase后都close其实不推荐因为它是连接池频繁开关反而低效通常让 helper 管理生命周期只在明确不用时关。selectionArgs 顺序错selection_id? AND name?selectionArgs必须new String[]{id, name}顺序反了查不到数据还不报错最难查。6. 语义一致 CTA把查询校验接进你的开发流数据库查询写对只是第一步把结果校验和 SQL 审查接进日常开发流能少踩很多坑。你可以把rawQuery的 SQL 和查询结果 JSON 一起发给模型做语义核对接口地址用 https://taotoken.net/api Key 在 https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 生成。想先试模型对话效果打开 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。长期做 Android 编码辅助或 Agent 集成看 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。完整接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。最后留一个我常用的实用技巧在PersonDao里加一个debugQuery方法把最终执行的 SQL 和selectionArgs拼成可读字符串打日志出问题时一眼看出参数有没有对上。参数化查询的?在日志里显示不出来手动替换成实际值再打印排查效率翻倍。