☰
Android SQLite本地存储实践:从增删改查到性能优化与并发防坑
2026/10/12 4:31:08 网站建设 项目流程

先把结论放在最前面:在Android里做本地数据存储,SQLite数据库到今天依然是最实在的方案之一。你可能听过“别直接用SQLite,都用Room了”这种说法,但如果你真正看过Room的源码,就会发现底层还是SQLite,只是帮你省掉了一堆模板代码。尤其是项目里需要自己控制SQL、做复杂报表查询、批量同步数据的时候,直接操作SQLite反而更灵活。

这篇文章不打算写成官方文档的复读版,而是从我实际踩坑的角度,把Android本地SQLite存储的完整链路梳理一遍:怎么初始化、怎么写增删改查、性能怎么调、数据库版本怎么升级、多线程并发怎么写、数据怎么加密和调试,以及最后聊一聊什么时候别用SQLite。你要是刚上手,建议从头看;已经写过一些增删改查,可以直接跳到自己关心的章节。

1. 为什么Android系统偏偏内置了SQLite

1.1 嵌入式场景下的“零配置”优势

SQLite是一个嵌入式的、关系型的、轻量级数据库,整套数据库引擎就是一个C语言库,不到几百KB级别,整个数据库存储在App私有目录下的单个文件里。它在嵌入式场景里最大的好处是零配置:不需要独立的数据库服务器进程,不需要配置端口、账号、密码,不需要网络连接,你的App进程直接调用C库函数读写文件。

这和你在服务器上用的MySQL/PostgreSQL完全是两回事。那些是C/S架构,数据库是一个独立进程,App通过网络协议连上去。而SQLite是进程内数据库,它把整个引擎“嵌入”到了应用里,说白了就是一组API加上一个文件。

Android在系统层就内置了SQLite的实现,并通过android.database.sqlite包向上层提供Java/Kotlin接口。所以开发者在Android里用SQLite,不需要额外引入任何依赖,不需要做JNI适配,直接用系统API就行。这一点对APK体积和启动速度都很友好。

轻量之外,SQLite还保证了关系型数据库的核心诉求:支持SQL语法、支持事务(ACID)、支持索引、支持主键外键等约束。也就是说,你不需要因为“它很轻”就放弃规范化设计,结构化的数据放进去依然可以用标准SQL姿势去管理。

1.2 单文件存储与App私有目录的契合

SQLite默认把整个数据库保存成一个文件,比如/data/data/你的包名/databases/xxx.db。在Android里,这个路径属于应用私有目录,不需要申请存储权限,其他普通应用默认访问不到。所以它天然适合存“不想被随便看到”的业务数据:用户信息、离线缓存、操作记录、草稿数据等。

单文件还有一个附带好处:备份和迁移方便。比如你需要从旧版本升级数据库结构,本质上就是在管理这个文件里的schema和元数据;你要导出数据库到电脑上分析,直接拿到这个.db文件就能用数据库工具打开。

很多新手会拿SharedPreferences和SQLite做对比,其实两者的定位完全不同。SharedPreferences适合存键值对,比如开关状态、用户ID、缓存的小配置;SQLite适合存有结构、有关联、需要查询统计的数据。比如一个任务列表,你要“按完成状态筛选”,要“按时间排序取最近一周”,要“统计每天完成数量”,这种需求用SharedPreferences硬做非常别扭,而用SQLite就是几张表的常规操作。

2. SQLiteOpenHelper:应用生命周期里的数据库看门人

2.1 自定义Helper的基本骨架

Android官方推荐的做法是继承SQLiteOpenHelper来管理数据库的创建和版本升级。这个类最核心的是四个方法:构造方法、onCreate、onUpgrade、onConfigure。

onCreate只在数据库文件第一次创建时调用。你要在这里建表、建索引、写入初始数据。onUpgrade在数据库版本号变大时调用,通常在这里做表结构变更。onConfigure每次打开数据库连接时调用,适合设置WAL模式、外键约束等连接级参数。

下面是我在实际项目里经常使用的Helper骨架,Kotlin版,可以直接照着改:

class TaskDbHelper(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) { override fun onConfigure(db: SQLiteDatabase) { super.onConfigure(db) // 开启外键约束,前提是表设计里确实用了外键 db.setForeignKeyConstraintsEnabled(true) // 开启WAL,读写的并发友好度会好很多,后面详聊 db.enableWriteAheadLogging() } override fun onCreate(db: SQLiteDatabase) { db.execSQL( """ CREATE TABLE IF NOT EXISTS task ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, status INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL ) """.trimIndent() ) db.execSQL( "CREATE INDEX IF NOT EXISTS idx_task_status ON task(status)" ) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { // 见“数据库版本升级”章节,这里不做粗暴的删表重建 } companion object { const val DB_NAME = "app_local.db" const val DB_VERSION = 1 } }

这段代码里有个细节需要注意:setForeignKeyConstraintsEnabled(true)这一行,如果你并没有在表里使用外键,可以不开,避免每次连接都多一层约束开销。而enableWriteAheadLogging()开启WAL之后,并发读写体验会有明显改善,但也要注意WAL模式下数据库文件旁边会出现-wal和-shm两个附加文件,备份或拷贝数据库时容易漏掉,后面会提到。

2.2 为什么几乎必须写成单例

SQLiteOpenHelper内部并不建议你反复new出来去拿数据库实例。原因有两个:一是每次构造都会做一遍版本检查和文件路径解析,属于无效开销;二是数据库对象本身是有状态的,同一时间多个Helper实例各管各的连接,容易出现连接管理混乱。

更关键的是,getWritableDatabase()和getReadableDatabase()本身已经有连接复用的设计:同一个SQLiteOpenHelper实例内部会维护一个连接池,重复调用会复用已经打开的连接。所以最佳实践很简单:一个数据库对应一个Helper单例。

下面这种单例写法在项目里非常常见,懒加载模式,避免在Application初始化时就去创建数据库对象:

class DatabaseManager private constructor(context: Context) { private val helper = TaskDbHelper(context.applicationContext) protected fun getWritableDatabase(): SQLiteDatabase { return helper.writableDatabase } companion object { @Volatile private var instance: DatabaseManager? = null fun getInstance(context: Context): DatabaseManager { return instance ?: synchronized(this) { instance ?: DatabaseManager(context.applicationContext).also { instance = it } } } } }

注意这里传入的一定是applicationContext,不要传Activity或Service的上下文。因为Helper持有Context引用,如果是Activity,会阻止Activity被回收,造成内存泄漏。这是个很典型的低级错误,但我在很多项目里都见过。

2.3 getReadableDatabase和getWritableDatabase不是两个数据库

getReadableDatabase()和getWritableDatabase()这两个方法,很多初学者以为一个返回只读库、一个返回读写库,甚至有人会特地在查询时用readableDatabase,写入时用writableDatabase,然后在同一个线程里换来换去。

实际源码里,getReadableDatabase()调用的是openOrCreateDatabase的逻辑:它首先尝试以读写模式打开,只有在磁盘满或权限受限等极少数情况下才会降级为只读模式。换句话说,绝大多数情况下,getReadableDatabase()和getWritableDatabase()返回的是同一个可写实例。

关于连接内部逻辑,我建议你这么理解就够了:同一个Helper拿到的数据库对象是带状态的,只管去调用,不用纠结该用哪个。真正需要小心的是,这两次调用都会触发数据库的版本检查。如果数据库还没有创建,或者版本号比Helper里声明的低,会同步执行onCreate或onUpgrade。这些操作都是磁盘IO,如果放在主线程,用户会明显感觉到卡顿,甚至触发ANR。

3. 增删改查四件套:写起来容易,踩坑也最容易

3.1 insert、update、delete、query的正确打开方式

先定义一个最简单的任务表结构,后面所有示例都围绕它:

字段类型说明
idINTEGER PRIMARY KEY AUTOINCREMENT自增主键
titleTEXT NOT NULL任务标题
statusINTEGER NOT NULL DEFAULT 00未完成,1已完成
created_atINTEGER NOT NULL创建时间戳,毫秒

插入一条数据,官方推荐用ContentValues,避免直接拼SQL:

fun insertTask(title: String): Long { val db = helper.writableDatabase val values = ContentValues().apply { put("title", title) put("status", 0) put("created_at", System.currentTimeMillis()) } return db.insert("task", null, values) }

insert方法返回的是新插入行的row ID,如果失败返回-1。注意不要忽略这个返回值,我见过太多人插入完不检查,出问题了才发现数据根本没进去。

更新操作要用selection和selectionArgs参数化写法:

fun markTaskDone(id: Long) { val db = helper.writableDatabase val values = ContentValues().apply { put("status", 1) } db.update( "task", values, "id = ?", arrayOf(id.toString()) ) }

删除同理:

fun deleteTask(id: Long) { val db = helper.writableDatabase db.delete("task", "id = ?", arrayOf(id.toString())) }

查询是重头戏,很多人觉得query方法参数一堆很难记,于是直接rawQuery拼SQL。其实query的参数结构很固定:表名、要查的列、where条件、条件参数、groupBy、having、orderBy、limit。你用熟了会发现,写query比拼字符串更安全也更好维护:

fun queryTasks(status: Int?): List<Task> { val db = helper.readableDatabase val selection = if (status != null) "status = ?" else null val selectionArgs = if (status != null) arrayOf(status.toString()) else null val cursor = db.query( "task", arrayOf("id", "title", "status", "created_at"), selection, selectionArgs, null, null, "created_at DESC", "50" ) val tasks = mutableListOf<Task>() while (cursor.moveToNext()) { val id = cursor.getLong(cursor.getColumnIndexOrThrow("id")) val title = cursor.getString(cursor.getColumnIndexOrThrow("title")) val taskStatus = cursor.getInt(cursor.getColumnIndexOrThrow("status")) val createdAt = cursor.getLong(cursor.getColumnIndexOrThrow("created_at")) tasks.add(Task(id, title, taskStatus, createdAt)) } cursor.close() return tasks }

这里强烈建议用getColumnIndexOrThrow而不是getColumnIndex。前者在列不存在时会直接抛异常,能第一时间暴露问题;后者返回-1,然后你拿-1去取值,又是一堆看不懂的崩溃。

3.2 为什么用selectionArgs而不是拼字符串

先看反面教材,很多人刚学的时候这么写:

db.execSQL("DELETE FROM task WHERE id = " + id) db.rawQuery("SELECT * FROM task WHERE title = '" + title + "'", null)

这种做法在数据量小、纯本地的场景下看起来没问题,但只要title里包含单引号,SQL语句就废了。比如用户输入don't do this,拼接出来变成WHERE title = 'don't do this',要么语法报错,要么结果异常。更严重的是,如果某个字段来源不可控,恶意构造参数可以变成“删库”这样的危险操作。

参数化写法selectionArgs会把参数当作纯数据传给SQLite引擎,从根上避免SQL注入。这跟服务端防注入的原理完全一致。所以只要你手里有外部传入的值,一律走参数化,这是原则问题,没有任何例外。

3.3 Cursor的遍历、关闭与内存释放

Cursor是SQLite查询结果集在Java层的表现,内部有行位置指针。常见错误有两个:一是忘了close(),二是循环里动不动moveToPosition然后不确认是否有效。

正确姿势是:在finally块里关闭Cursor,或者用use扩展。Kotlin可以这么写:

val cursor = db.query(...) try { while (cursor.moveToNext()) { // 处理每一行 } } finally { cursor.close() }

另一个细节是,如果你在循环里调用了cursor.moveToPosition(position),记得检查返回值,因为如果position越界,返回false,但指针已经移动到了一个无效位置,再调getString就会抛CursorIndexOutOfBoundsException。

关于managedQuery和startManagingCursor,那是旧时代的东西,现在已经废弃了,不要用。

3.4 nullColumnHack到底是干什么的

很多人在insert("task", null, values)时,第二个参数传null,根本不理解这个参数的用途。这个参数叫nullColumnHack,只在values为空时才有意义。因为SQL标准里INSERT INTO table VALUES()在某些场景下无法正确插入一行空数据,所以当你需要插入一条除了主键外所有列都为默认值的记录时,需要传一个允许为空的列名,比如"title",SQLite会把它显式设为NULL来保证插入能成功。

实际开发中,我们很少会真的插入一条完全空的数据,所以这个参数绝大多数情况传null就行。但你要知道它的存在,不然看到源码或老项目里传了其他值还会一头雾水。

4. 数据量一涨就卡?SQLite性能从入门到能用

4.1 索引不是越多越好,复合索引看查询条件

很多人做性能优化,第一反应就是“加索引”。但索引不是灵丹妙药,它本质是空间换时间。对一张频繁写操作的表来说,每多一个索引,写操作就要多维护一份B+树,写入性能会下降。

加索引之前,你要先分析查询语句的WHERE条件。比如我经常要“按状态过滤任务并按创建时间倒序”,那在status上建单列索引收益就很明显。但如果查询条件是“状态加分类”,可能就需要考虑复合索引(status, category_id)。

这里还要注意索引失效问题。在WHERE条件里对索引列做函数运算,比如WHERE strftime('%Y-%m-%d', created_at / 1000, 'unixepoch') = '2024-01-01',那索引基本废掉了,SQLite会对全表做一次运算再过滤。真要按日期统计,更合理的做法是准备一个单独的日期字段,写入时就存好,查询时直接走普通索引。

4.2 批量写用事务,实测差异非常大

SQLite的每次写操作默认都处于自动提交事务中,也就是说,每执行一条insert,都要同步一次磁盘。批量插入1000条数据,就是1000次磁盘同步,慢到你怀疑人生。

解决办法是显式启用事务,把1000条写入合并到一次磁盘同步里:

fun insertTasksInBatch(titles: List<String>) { val db = helper.writableDatabase db.beginTransaction() try { val values = ContentValues() for (title in titles) { values.clear() values.put("title", title) values.put("status", 0) values.put("created_at", System.currentTimeMillis()) db.insert("task", null, values) } db.setTransactionSuccessful() } finally { db.endTransaction() } }

划重点:setTransactionSuccessful()必须在endTransaction()之前调用,否则事务会被判定为回滚。很多新手写完忘掉这一行,数据一条都插不进去,还找不到原因。

我在一个模拟项目里做过对比:逐条插入1000条数据,普通执行大概要2到4秒,包一层事务之后,耗时可以降到200到500毫秒,提升一个数量级完全是正常的。所以只要是批量写,一律事务,没有任何理由去裸写。

4.3 预编译SQLiteStatement,性能和防注入双赢

insert方法虽然方便,但底层每次都要解析SQL、绑定参数,仍然有一定开销。如果是一批结构完全相同的写入,用SQLiteStatement预编译性能更好:

val sql = "INSERT INTO task(title, status, created_at) VALUES(?, ?, ?)" val statement = db.compileStatement(sql) db.beginTransaction() try { for (title in titleList) { statement.bindString(1, title) statement.bindLong(2, 0) statement.bindLong(3, System.currentTimeMillis()) statement.executeInsert() } db.setTransactionSuccessful() } finally { db.endTransaction() statement.close() }

第一次compileStatement会把SQL解析成执行计划,之后每次只需绑定新参数执行,省去了重复解析的开销。配合事务后,批量写入的提速非常明显。另外注意,bind的索引从1开始,不是0,写错下标就等着崩溃吧。

4.4 WAL模式,以及对备份的隐性影响

默认的日志模式是DELETE模式,每次写事务结束都要把回滚日志删除。WAL(Write-Ahead Logging)模式的思路是:写操作先追加到-wal文件,读操作只需要读主数据库文件加WAL尾部;写不阻塞读,读不阻塞写。Android的enableWriteAheadLogging()就是做这件事。

需要注意的是,开启WAL后,数据库目录下会出现xxx.db-wal和xxx.db-shm两个文件。如果哪天你要直接拷贝数据库文件出来分析,只拷xxx.db而不带-wal文件,会丢失WAL里还没来得及合并的数据。正确做法是先把连接关掉或者执行一次PRAGMA wal_checkpoint,让它把数据合并回主库,再拷贝文件。

另外,WAL模式下主数据库文件的读取并发对象有上限,如果项目里有特别多的线程同时开连接,你要注意连接池的大小配置。普通应用不用操心,后台定时任务多的时候再排查也不迟。

5. 版本升级不翻车:数据库迁移的完整思路

5.1 onUpgrade不是让你删表重建

新手最常见的升级姿势是:

override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { db.execSQL("DROP TABLE IF EXISTS task") onCreate(db) }

但这种做法的代价是:用户数据全没了。升级数据库版本,前提是保留原有业务数据,所以绝对不允许删表重建。正确做法是:把数据库当前的数据迁移到新表结构里。

onUpgrade接收的oldVersion和newVersion分别是数据库文件当前版本和Helper声明的目标版本。经典写法是:

override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { when (oldVersion) { 1 -> migrateV1ToV2(db) 2 -> migrateV2ToV3(db) } }

比起一堆if (oldVersion < 2) ... if (oldVersion < 3) ...,用when按版本逐级迁移的可读性更好,而且天然支持跨版本升级。因为用户在老版本直接升级到新版本时,oldVersion可能直接是1,newVersion是3,when分支会把1到2、2到3的迁移依次执行,保证链路完整。

5.2 ALTER TABLE的局限:加列简单,改结构难

加一列很简单,SQLite支持ALTER TABLE ADD COLUMN。但要注意,新加的列如果有NOT NULL约束,必须提供默认值,否则已有数据的改表会失败:

ALTER TABLE task ADD COLUMN priority INTEGER NOT NULL DEFAULT 0

如果要做复杂的迁移,比如拆分表、合并表、修改字段类型,SQLite官网没有提供“ALTER COLUMN”这种操作。标准流程是反范式重建:

  1. 创建一张新表task_new,结构是目标结构。
  2. 从旧表把需要的数据通过INSERT INTO task_new SELECT ...迁移过去。
  3. 删除旧表DROP TABLE task。
  4. 把新表重命名为旧表名ALTER TABLE task_new RENAME TO task。
  5. 重建索引。

整个过程要包在事务里,避免中途失败导致半迁移状态:

private fun migrateV1ToV2(db: SQLiteDatabase) { db.beginTransaction() try { db.execSQL("CREATE TABLE task_new (...)") db.execSQL("INSERT INTO task_new(id, title, status, created_at) SELECT id, title, status, created_at FROM task") db.execSQL("DROP TABLE task") db.execSQL("ALTER TABLE task_new RENAME TO task") db.execSQL("CREATE INDEX ...") db.setTransactionSuccessful() } finally { db.endTransaction() } }

这里有个我自己踩过很多次的坑:如果旧表有外键引用了它,删旧表前要确认引用关系。SQLite默认不强制外键,但开启了外键约束的话,DROP TABLE task可能导致子表数据受影响,所以迁移脚本里最好把涉及外键的调整也一并处理。

5.3 升级失败兜底与降级问题

onUpgrade如果抛出异常,数据库连接会进入异常状态。一个稳妥的做法是,在升级前先备份原始数据库文件。Android的Context.getDatabasePath可以拿到数据库文件路径,升级前用FileInputStream拷贝一份出去。

还有低版本App打开高版本数据库文件的情况,这个其实比较少见,但网上有个老梗“Unable to open database file: file is encrypted or is not a database”,多半就是版本或加密状态不匹配。处理思路是:如果数据库版本高于当前代码支持的最高版本,提示用户清除数据或引导升级,而不是傻乎乎地去执行什么“降级迁移”。SQLite本身没有实现完备的降级机制,别指望有官方方案。

6. 并发与锁:多线程写入的隐形炸弹

6.1 SQLite的锁机制,以及你为什么会撞上它

SQLite的锁机制基于文件系统,有SHARED和EXCLUSIVE等状态。简单说:多个线程可以同时读;写操作开始时需要获得排他锁;在事务提交之前,别的写操作都得等。

在Android里,最常见的崩溃是android.database.sqlite.SQLiteDatabaseLockedException: database is locked。出现这个异常,通常不是因为数据库真的被“恶意锁死”了,而是你的App里存在多个数据库连接或同一连接被并发事务持有导致写锁冲突。

常见场景:你在一个后台线程里执行长事务,同时另一个线程也去写同一个库,后者的写入尝试等不到锁就抛异常。尤其在旧设备或SQLite版本较老时,锁等待超时时间很短,更容易触发。

6.2 排查链路:从日志到锁等待

遇到database is locked,我一般按以下顺序排查:

  1. 日志里搜SQLiteException,看有没有跨线程共用了同一个SQLiteDatabase对象,没有加同步。
  2. 检查是否在多个地方各自创建了Helper,导致连接互相不认识。
  3. 检查是否有事务长时间未提交,尤其是在beginTransaction之后忘了endTransaction。
  4. 检查是否打开了WAL但连接池太小,导致并发读多时写线程排队过久。

建议你在代码里做一个统一的数据库访问入口,所有线程都通过同一个Helper拿连接,并且写操作尽量收敛到串行队列。Android里的ExecutorService配合HandlerThread都能做到。

6.3 事务嵌套和提交时机的经验

另一个隐蔽坑是事务提交时机。beginTransaction是支持嵌套的,但SQLite的嵌套事务并不真正独立,最外层提交或回滚决定整个事务结果。所以你要确保:

fun outerMethod() { val db = helper.writableDatabase db.beginTransaction() try { innerMethod() db.setTransactionSuccessful() } finally { db.endTransaction() } } fun innerMethod() { val db = helper.writableDatabase db.beginTransaction() // 嵌套事务 try { // 业务逻辑 db.setTransactionSuccessful() } finally { db.endTransaction() } }

在这种嵌套场景里,如果内层事务没出问题,代码看起来一切正常。但如果内层事务要回滚,实际回滚的效果可能会被外层事务覆盖,导致数据不符合预期。我的建议是:如果事务粒度复杂,不要搞嵌套,用一个明确的TransactionManager统一管理,或者往里传一个事务标志,避免隐式嵌套。

7. 数据安全与调试手段:给本地数据库上个门锁

7.1 数据库存在哪,以及它是否安全

默认情况下,数据库文件位于/data/data/包名/databases/。App的私有目录,第三方应用无权限访问。但在用户开启了USB调试并授权adb backup、或者设备已经root的场景下,明文数据库很容易被拷走。所以,如果你的数据库里存在登录Token、身份证号、支付相关的敏感字段,我建议不要存明文;必须存的时候,至少要做字段级加密或整体库加密。

Android提供的原生数据库API没有内置加密功能。开源方案里最主流的是SQLCipher,它是对SQLite的加密增强,社区版可以免费使用,API基本兼容,替换Helper类后,原来的SQL操作还是那套写法,数据库文件整体加密,拷走也没法直接打开。

我参与过一个离线数据同步项目,数据库里需要缓存服务端下发的用户报告和密钥材料。当时选型就是SQLCipher,因为整体透明加密,改动成本比逐个字段做AES要小得多,也不用担心字段加密导致无法正常索引和统计。

7.2 SQLCipher接入的核心注意点

接入SQLCipher并不复杂,主要改动是:

  • 替换依赖为net.zetetic:android-database-sqlcipher
  • 让自定义Helper继承net.sqlcipher.database.SQLiteOpenHelper和SQLiteDatabase
  • 在getWritableDatabase时传入密码,比如getWritableDatabase(passphrase)。

要注意几个细节:

  1. 密码不能硬编码在代码里,至少要从KeyStore或服务端下发,本地也要做混淆。
  2. 从明文库迁移到加密库需要单独的工具脚本,不是改个Helper就能自动加密历史数据。
  3. 加密后性能会下降,尤其在大量写入场景,因为每次磁盘IO都要加解密。
  4. SQLCipher的版本升级可能带来格式兼容问题,升级依赖前先确认加密库格式是否向前兼容。

7.3 Android Studio的Database Inspector和导出db文件

排查SQLite问题,最实用的工具是Android Studio自带的Database Inspector。选中有调试的进程后,能看到App里的数据库、表结构和数据,可以实时执行SQL查询,还能直接查看表内容。

它的应用场景很直接:你在手机上千辛万苦操作出某个数据状态,然后在Database Inspector里一查,到底有没有写进去、字段对不对,一目了然。比你在代码里加一百行Log还高效。

如果Inspector显示的数据和老API对不上,记得先看是不是WAL文件里的数据还没合并。另一个土办法是把数据库文件导出到电脑上用数据库工具查看:

adb exec-out run-as 你的包名 cat /data/data/你的包名/databases/app_local.db > app_local.db

run-as要求应用是debuggable的,release包不一定能跑通。如果跑不通,可以临时在代码里做一个“导出数据库到公共目录”的调试入口,用完立刻删掉,避免把这个调试功能带到线上版本。

8. 什么时候别用SQLite,以及我现在的选型思路

8.1 与SharedPreferences/DataStore的边界

虽然SQLite很强大,但它不是所有本地数据的答案。如果你要保存的只是几个键值配置,比如“主题模式”“上次启动时间”“引导页是否看过”,用SQLite去建表完全是在杀鸡用牛刀,还要写一堆字段映射逻辑。

Android官方的DataStore就是用来替代SharedPreferences的新方案,支持Kotlin协程,支持类型安全的Preferences和Proto DataStore。键值型配置优先用DataStore,而不是SQLite。

那SQLite适合什么?核心特征是:数据有固定结构,存在关系,需要条件查询、排序、分页、聚合统计,或者批量同步。比如聊天记录、订单列表、商品目录、离线下载任务,这些数据用SQLite存,语义最清晰,查询能力最完整。

8.2 上不上Room,三条判断标准

Room是Android官方推荐的上层ORM封装,它会帮你在编译期校验SQL正确性,自动处理许多模板代码和LiveData/Flow的联动。但Room的底层仍然是SQLite,而且它要求你写Entity、Dao、Database三个组件,引入了一整套代码生成机制。

我自己判断是否上Room的标准有三条:

  1. 团队是否对SQL足够熟悉。如果大家本来就会写SQLite,直接裸写反而更灵活;如果团队新人多,Room的类型安全校验能挡掉不少低级错误。
  2. 是否需要数据库和UI层做响应式绑定。Room和LiveData/Flow集成得很好,数据变化能直接驱动界面刷新;裸SQLite要做到这一点,得自己维护观察者逻辑。
  3. 项目复杂度是否值得引入注解处理器和生成代码。依赖和构建链路的复杂化是实打实的成本,一个小工具模块用Room确实有点重。

8.3 我的个人选择思路

先把话说清楚:我不是那种“永远只用SQLite”的老派开发者。新项目如果是纯绿色的独立功能,我通常会优先评估Room;如果项目已经用SQLite跑了两三年,表结构稳定,也没遇到什么痛点,我不会为了“升级”而升级。

但是有一点很明确:无论你用不用SQLite作为主力存储,底层原理必须懂。因为即便你用Room,升级数据库、解决并发锁冲突、排查性能瓶颈的时候,最终你面对的还是SQLite这个文件、SQL语句和它的一套行为规则。把这篇文章里的东西吃透,再去用Room,你会觉得它只是一个帮你生成代码的顺手的工具,而不是一个黑洞。

最后再分享一点实际体会

我在刚开始接手一个老项目时,整个仓库里布满了execSQL拼字符串的写法,一打开新界面就各种缓存数据错乱,三天两头报database is locked。后来花了一周时间把所有访问点收敛到一个Manager里,统一了Helper单例、事务和查询模型,问题立刻少了一大半。回头总结下来,SQLite本身并不难,难的是围绕它的工程纪律:统一入口、参数化查询、事务边界明确、升级脚本可追溯。

如果你现在正打算在项目里用SQLite,我的建议是不要急着写业务代码,先把Helper、Dao、事务管理这几层地基打稳。给表结构设计留一点余地,字段能加默认值就加默认值,索引建在真正高频的查询条件上,版本号从第一天就好好维护。等你的应用开始面临数据升级、多线程写入、性能优化这些“成长的烦恼”,手上有这套基本功,处理起来会从容很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询