SQLiteDatabase 增删查改实战:getWritableDatabase/getReadableDatabase 与 Cursor、execSQL、rawQuery 的配合用法 1. 为什么 Android 本地存储绕不开 SQLiteDatabase做 Android 本地数据持久化SQLite 是绕不过去的一环。它不需要额外装服务、不依赖网络App 装到手机上就能直接读写一个轻量级关系型数据库文件。而SQLiteDatabase就是 Android 封装给我们的操作入口配合SQLiteOpenHelper完成建库建表再用execSQL跑增删改、rawQuery跑查询、Cursor遍历结果集这条链路几乎是每个 Android 开发者都要走一遍的基本功。但真正写起来坑并不少。比如getWritableDatabase和getReadableDatabase到底该用哪个很多人凭感觉随便挑一个execSQL里用字符串拼接 SQL用户输入一个带单引号的昵称就直接崩Cursor用完忘记close跑久了报 CursorWindowAllocationException还有事务没提交、占位符参数类型对不上等等。这些问题在 demo 阶段看不出来一上真机、数据量一大就集中爆发。这篇就聚焦 Android 本地 SQLite 开发场景把getWritableDatabase与getReadableDatabase的选型差异讲清楚演示execSQL执行增删改、rawQuery执行查询、Cursor遍历结果集的完整链路给出可复制的建表与 CRUD 代码骨架并附上 Cursor 关闭与事务验证动作。适合刚接触 Android 数据存储、或者写过但总在细节上翻车的同学。如果你在调试过程中需要快速验证某段 SQL 或某个模型行为也可以借助 TaoToken 的模型对话能力做辅助排查后面会讲到怎么接。2. 前置准备SQLiteOpenHelper 与 TaoToken 接入2.1 建库建表DBOpenHelper 骨架先解决数据库从哪来的问题。Android 推荐继承SQLiteOpenHelper把建表语句放在onCreate版本升级逻辑放在onUpgrade。下面这份骨架可以直接复制import android.content.Context; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import androidx.annotation.Nullable; public class DBOpenHelper extends SQLiteOpenHelper { public DBOpenHelper(Nullable Context context) { // context 上下文name 数据库文件名factory 游标工厂传 null 用系统默认version 版本号 super(context, person.db, null, 2); } Override public void onCreate(SQLiteDatabase db) { // 数据库第一次被创建时调用 db.execSQL(create table person( personid Integer primary key autoincrement, name varchar(20), phone varchar(20))); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 版本号变大时调用这里做字段追加 db.execSQL(ALTER TABLE person ADD phone varchar(20)); } }这里有个容易被忽略的点onCreate只在数据库文件第一次生成时执行一次。如果你改了建表语句但没升version新表结构根本不会生效App 会一直用旧表。调试阶段最省事的做法是把 version 加一或者直接卸载重装。2.2 为什么这里会提到 TaoToken写 SQLite 代码时经常遇到一些看起来对但就是报错的情况比如占位符数量和参数数组长度不一致、Cursor列索引取错、事务里抛异常没回滚。这类问题靠肉眼盯代码效率很低我习惯把报错栈和 SQL 片段丢给模型对话让它帮我定位。TaoToken 提供统一的 API 入口兼容常见的对话与编码模型调用方式不用在多个平台之间来回切。它的接入地址是https://taotoken.net/api控制台里可以创建 API Key文档里有各语言的调用示例。对于 Android 开发者来说最实用的场景是把Logcat里的异常信息贴进去让它帮你判断是 SQL 语法问题还是 Cursor 使用问题。下面给一段可复制的调用示例用 curl 验证连通性curl https://taotoken.net/api/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -d { model: claude-sonnet-4-20250514, messages: [ {role: user, content: Android rawQuery 报错 CursorIndexOutOfBoundsException怎么排查} ] }API Key 在控制台的 API Keys 页面创建地址是https://taotoken.net/console/api-keys。拿到 Key 之后建议用环境变量管理别硬编码进代码仓库。如果你更想直接在网页里试模型效果可以用模型对话页面地址是https://taotoken.net/models适合快速验证一段 SQL 或一个报错解释。3. 可复制配置getWritableDatabase 与 getReadableDatabase 选型3.1 两个方法的真实差异很多人以为getReadableDatabase就是只读其实不是。看源码逻辑getWritableDatabase以读写方式打开数据库。如果磁盘空间满了会直接抛SQLiteException。getReadableDatabase内部先尝试调用getWritableDatabase。如果因为磁盘满等原因失败它会退化成以只读方式打开数据库保证你至少还能读。所以在磁盘空间正常的情况下getReadableDatabase返回的其实就是getWritableDatabase返回的那个实例两者是同一个对象。这也解释了为什么有人用getReadableDatabase居然也能插入成功——因为底层拿到的就是可写实例。选型建议很明确只读查询用getReadableDatabase任何写操作insert/update/delete用getWritableDatabase。查询场景用 readable 的好处是万一设备存储告急你的列表页至少还能展示缓存数据而不是直接崩。3.2 实例缓存别重复打开SQLiteOpenHelper对数据库实例做了缓存。同一个 helper 对象连续调用两次getWritableDatabase拿到的是同一个SQLiteDatabaseSQLiteDatabase db dbOpenHelper.getWritableDatabase(); SQLiteDatabase db1 dbOpenHelper.getWritableDatabase(); // db db1返回 true这意味着你不需要自己维护单例只要保证 helper 是同一个对象即可。反过来说如果你 new 了多个 helper就会打开多个连接容易引发锁竞争。正确做法是在 Application 或 Activity 级别持有一个 helper 实例。3.3 用占位符替代字符串拼接原始写法里常见这种db.execSQL(insert into person(name,phone) values( p.getName() , p.getPhone() ));只要用户名字里带一个单引号比如OBrienSQL 就断了直接抛语法异常。更危险的是这给了 SQL 注入可乘之机。正确姿势是用?占位符把参数放进第二个数组db.execSQL(insert into person(personid,name,phone) values(?,?,?), new Object[]{p.getId(), p.getName(), p.getPhone()});execSQL和rawQuery的第二个参数都是给占位符填值的框架会自动做转义。记住一条只要 SQL 里有用户输入就必须用占位符。4. 完整 CRUD 链路execSQL 增删改 rawQuery 查 Cursor 遍历4.1 实体类与 Service先定义数据对象public class Person { private Integer id; private String name; private String phone; public Person(Integer id, String name, String phone) { this.id id; this.name name; this.phone phone; } public Integer getId() { return id; } public String getName() { return name; } public String getPhone() { return phone; } }Service 层封装增删改查注意每个方法里数据库实例的获取方式public class PersonService { private DBOpenHelper dbOpenHelper; public PersonService(DBOpenHelper helper) { this.dbOpenHelper helper; } // 增 public void save(Person p) { SQLiteDatabase db dbOpenHelper.getWritableDatabase(); db.execSQL(insert into person(personid,name,phone) values(?,?,?), new Object[]{p.getId(), p.getName(), p.getPhone()}); } // 删 public void delete(Integer id) { SQLiteDatabase db dbOpenHelper.getWritableDatabase(); db.execSQL(delete from person where personid?, new Object[]{id}); } // 改 public void update(Person p) { SQLiteDatabase db dbOpenHelper.getWritableDatabase(); db.execSQL(update person set name?,phone? where personid?, new Object[]{p.getName(), p.getPhone(), p.getId()}); } // 查 public Person find(Integer id) { SQLiteDatabase db dbOpenHelper.getReadableDatabase(); Cursor cursor db.rawQuery(select * from person where personid?, new String[]{id.toString()}); try { if (cursor.moveToFirst()) { int personid cursor.getInt(cursor.getColumnIndexOrThrow(personid)); String name cursor.getString(cursor.getColumnIndexOrThrow(name)); String phone cursor.getString(cursor.getColumnIndexOrThrow(phone)); return new Person(personid, name, phone); } } finally { cursor.close(); } return null; } }4.2 Cursor 遍历与关闭Cursor是一个指向结果集的游标初始位置在第一行之前。常用移动方法方法作用返回值moveToFirst()移到第一行结果集为空返回 falsemoveToNext()移到下一行已过最后一行返回 falsemoveToPrevious()移到上一行已过第一行返回 falsemoveToLast()移到最后一行结果集为空返回 false遍历全部记录的标准写法Cursor cursor db.rawQuery(select * from person, null); try { while (cursor.moveToNext()) { int id cursor.getInt(cursor.getColumnIndexOrThrow(personid)); String name cursor.getString(cursor.getColumnIndexOrThrow(name)); // 处理每条记录 } } finally { cursor.close(); }这里有两个关键动作。第一用getColumnIndexOrThrow而不是硬编码索引getInt(0)因为一旦你调整了 select 的字段顺序硬编码索引会静默取错列非常难查。第二Cursor必须close放在finally里保证异常时也能释放。Cursor 底层持有 CursorWindow 内存不关会持续占用数据量大时直接 OOM。4.3 事务验证批量写入时逐条 execSQL 会触发多次磁盘同步慢且容易中途失败留下脏数据。用事务包起来SQLiteDatabase db dbOpenHelper.getWritableDatabase(); db.beginTransaction(); try { for (Person p : list) { db.execSQL(insert into person(personid,name,phone) values(?,?,?), new Object[]{p.getId(), p.getName(), p.getPhone()}); } db.setTransactionSuccessful(); // 标记成功不调用则回滚 } finally { db.endTransaction(); }验证事务是否生效故意在循环中间抛一个异常然后查询表里记录数。如果事务正确一条都不会插入如果没包事务异常前的记录会残留。这个动作建议在真机上跑一次比看文档印象深得多。5. 本篇常见错排查5.1 CursorIndexOutOfBoundsException报错信息通常是 Index -1 requested, with a size of N。原因是你在调用moveToFirst或moveToNext之前就去getString了此时游标位置是 -1。解决任何读取列值之前先确认移动方法返回了 true。5.2 占位符数量与参数不匹配execSQL的 SQL 里有 3 个?但参数数组只给了 2 个会抛 bind or column index out of range。反过来参数多了也会报错。排查时数一遍问号再数一遍数组长度。另外注意rawQuery的第二个参数是String[]而execSQL是Object[]类型别传错。5.3 数据库版本没升导致表结构不生效改了onCreate的建表语句但version没变onCreate不会重新执行。表现是新增的字段查询时报 no such column。解决把 version 加一并在onUpgrade里写好迁移语句调试期也可以直接卸载 App 重装。5.4 忘记 close 导致内存泄漏Cursor和SQLiteDatabase都是需要释放的资源。Cursor 用 try-finally 关闭数据库实例由 helper 缓存管理通常不需要手动 close但如果你在某个页面临时 new 了 helper记得在onDestroy里调用helper.close()。5.5 主线程操作数据库卡顿SQLite 读写是磁盘 IO数据量大时放主线程会掉帧甚至 ANR。简单查询影响不大但批量插入、复杂查询建议放到子线程或使用协程。真机测试时打开 StrictMode 能帮你发现主线程 IO。6. 语义一致 CTA把调试效率提上去上面这套 CRUD 骨架跑通之后你会发现真正耗时间的往往不是写代码而是排查那些边界情况——特殊字符、并发写入、Cursor 越界。我的做法是遇到报错先自己定位一轮定位不到就把关键信息整理好丢给模型对话做二次分析比盲目搜索快很多。如果你也想把这条链路接进自己的开发流程可以这样走需要创建和管理 Key去 API Keys 页面https://taotoken.net/console/api-keys想先验证模型对某段 SQL 或报错的解释是否靠谱用模型对话https://taotoken.net/models长期做 Android 编码、想让 Agent 帮你持续改代码看 Coding Planhttps://taotoken.net/coding-plan接入细节和参数说明在文档里https://taotoken.net/doc最后留一个我踩过的坑rawQuery返回的 Cursor 在moveToFirst返回 false 时不要以为可以跳过 close空结果集同样要关。养成 try-finally 的习惯比事后查内存泄漏省事得多。