简介:一份基于Java的SQLite数据库安卓应用数据存储管理设计源码,面向安卓开发初学者和需要实现本地数据持久化功能的技术人员。项目围绕SQLite数据库的集成与使用展开,提供高效便捷的数据存储管理方案,覆盖应用配置、界面资源与核心业务代码,适合学习数据库操作、数据表设计以及安卓工程结构组织。资源包共46个文件,大小9.54MB,以XML配置文件、Java源代码、WebP图片资源为主,辅以Gradle构建脚本、Gradle Wrapper、字体文件、属性文件及ProGuard混淆规则等,可帮助读者理解Android项目从构建到运行的完整配置过程。内容包含完整工程目录与辅助说明文件,便于对照源码梳理数据存储模块的调用逻辑,也可直接复用其中的基础架构进行二次开发或课程设计。已有365人浏览学习,适合需要快速上手安卓SQLite存储开发的人群。
1. 基于Java的SQLite数据库安卓应用数据存储管理设计源码,解决的是什么问题
一个安卓应用只要需要“记住用户的东西”,就绕不开本地数据存储。SharedPreferences只能存键值对,文件存储要自己管格式和并发,而真正有结构、会增长、需要查询更新的数据,最稳的底座仍然是SQLite。这个标题说的“基于Java的SQLite数据库安卓应用数据存储管理设计源码”,就是把建库、建表、增删改查、事务、升级全部收进一个分层清晰的存储管理模块,而不是让SQL散落在Activity里。适合谁?适合本地数据结构复杂、数据量会涨、需要维护和迁移的应用,也适合那些想把手里的CRUD代码从“能用”整理成“能维护”的开发者。下面从建库开始,一步步把数据存储管理这张桌子摆开。
2. 安卓端SQLite的选型边界与建库落地:先想清楚,再动手
2.1 SQLite、SharedPreferences、文件存储各管哪一摊
很多项目一开始用SharedPreferences存集合数据,存到后面发现JSON越来越长,每次读写整个文件,而且任何一次try/catch没写好,解析失败就是一片空白。SharedPreferences适合的是配置项、登录态、少量用户偏好,它的问题是没有查询能力,没有事务能力,数据量过百条基本就该考虑换存储介质了。
文件存储适合的是图片、日志、导出文件这类“大对象”,它的问题是没有结构化查询,并发写同一个文件需要自己加锁,而且文件损坏很难发现。SQLite恰好补上这两块空白:单文件数据库,一套完整的SQL查询能力,事务保证原子性,索引加速查询,不需要跑到服务端去部署,SDK原生支持。它的边界是:不适合存大文件,不适合做多进程并发写,不适合做服务端数据库那种高并发写入。
数据存储管理的设计顺序应该是反着来的:先梳理业务实体有哪些、实体之间什么关系、哪些字段需要查询和排序,再决定要不要上SQLite,而不是先把数据库建了再想表结构。这个切入点也是这个标题的源码和普通Demo拉开差距的地方——存储管理的设计源码注重的是“分层”和“边界”。
2.2 用SQLiteOpenHelper建库建表:一个可以直接抄进项目的工具类
SQLiteOpenHelper是系统给的数据库生命周期管理类,它把数据库的打开、版本判断、首次建表、升级迁移都封装好了。它的三个核心回调是:onCreate在数据库首次创建时执行,onUpgrade在检测到版本号变高时执行,onOpen在每次打开成功后执行。我一般会用一个单独的类管理建库建表,不让Activity直接接触这些回调。
public class AppDatabaseHelper extends SQLiteOpenHelper { // 数据库版本号:每次升级表结构时 +1 private static final int DB_VERSION = 1; private static final String DB_NAME = "app_store.db"; private static volatile AppDatabaseHelper instance; public static AppDatabaseHelper getInstance(Context context) { if (instance == null) { synchronized (AppDatabaseHelper.class) { if (instance == null) { instance = new AppDatabaseHelper(context.getApplicationContext()); } } } return instance; } private AppDatabaseHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { // 用户表:主键自增,username 做唯一约束避免重复注册 db.execSQL("CREATE TABLE IF NOT EXISTS user (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "username TEXT NOT NULL UNIQUE," + "avatar_path TEXT," + "created_at INTEGER NOT NULL" + ")"); // 记录表:外键指向 user.id,删除用户时级联删除记录 db.execSQL("CREATE TABLE IF NOT EXISTS record (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "user_id INTEGER NOT NULL," + "title TEXT NOT NULL," + "content TEXT," + "updated_at INTEGER NOT NULL," + "FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE" + ")"); db.execSQL("CREATE INDEX IF NOT EXISTS idx_record_user_id ON record(user_id)"); db.execSQL("CREATE INDEX IF NOT EXISTS idx_record_updated_at ON record(updated_at)"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 这里先演示最粗暴做法:删除重建。第 6 章会讲正确的增量迁移。 db.execSQL("DROP TABLE IF EXISTS record"); db.execSQL("DROP TABLE IF EXISTS user"); onCreate(db); } }这段代码有几个关键设计点。数据库helper用了单例,避免多个线程同时拿到不同实例去打开同一个库文件。onCreate里面先建两张表再建索引,索引的字段就是查询条件里最常出现的user_id和updated_at,这个顺序是有意的:先有表结构,索引才能挂上去。created_at和updated_at都存成INTEGER而不是TEXT,存Unix毫秒时间戳,排序和区间查询都比字符串快得多。
有人问为什么用AUTOINCREMENT。SQLite的INTEGER PRIMARY KEY本身就会自增,加不加AUTOINCREMENT的区别在于:不加的时候,删掉最后几条记录后,新插入的行可能复用被删除的id;加了之后,id只会单调递增,不会复用。如果业务的id会暴露给外部接口或参与同步逻辑,建议加上AUTOINCREMENT,否则可以不加,性能上两者没有明显差别。
2.3 建表设计的三个习惯:主键策略、索引选择、类型映射
第一个习惯是主键优先用无业务含义的INTEGER自增id。很多新手把username或者手机号设成主键,短时间没问题,但一旦业务允许用户改名,或者手机号被回收再分配,主键变化会牵连大量关联表。用自增id做主键,业务唯一字段单独加UNIQUE约束,改业务字段不影响关联关系。
第二个习惯是索引不要见一个加一个。SQLite的索引是B-tree实现的,每次写操作都要同步维护索引,索引越多写入越慢。常见的做法是:只有where条件里频繁出现的字段、join用到的外键字段、排序字段才加索引。像record表的user_id是外键查询的主入口,updated_at是列表排序的常用字段,这两个加了,其他的先不加,等真的出现慢查询再补。
第三个习惯是TEXT和INTEGER的选择。日期、时间不要用TEXT存字符串,直接用INTEGER存时间戳,排序快、比较快、存储小。布尔值存成INTEGER的0和1,SQLite没有专门的布尔类型。金额如果有精度要求,用INTEGER存分而不是REAL存元,避免浮点误差。这些类型映射看起来是小事,但等表里几万条数据的时候,写错类型会导致索引完全失效。
3. 把CRUD收进数据管理层:标准增删改查与DAO分层设计
3.1 ContentValues与SQLiteDatabase:增删改的标准姿势
SQLiteDatabase直接暴露了insert、update、delete、query四个原生方法,它们都支持SQL语句之外的参数化写法。参数化写法的好处是避免SQL注入,而且不用手动拼字符串。ContentValues是一个Map-like结构,key是列名,value是写入的值。
public class UserDao { private final AppDatabaseHelper helper; public UserDao(AppDatabaseHelper helper) { this.helper = helper; } // 插入一条用户记录,返回新插入行的 id,失败返回 -1 public long insertUser(String username, String avatarPath) { ContentValues values = new ContentValues(); values.put("username", username); values.put("avatar_path", avatarPath); values.put("created_at", System.currentTimeMillis()); SQLiteDatabase db = helper.getWritableDatabase(); return db.insert("user", null, values); } // 更新用户头像,返回受影响的行数 public int updateAvatar(long userId, String newAvatarPath) { ContentValues values = new ContentValues(); values.put("avatar_path", newAvatarPath); values.put("updated_at", System.currentTimeMillis()); SQLiteDatabase db = helper.getWritableDatabase(); return db.update("user", values, "id = ?", new String[]{String.valueOf(userId)}); } // 按 id 删除用户,关联记录通过外键级联删除 public int deleteUser(long userId) { SQLiteDatabase db = helper.getWritableDatabase(); return db.delete("user", "id = ?", new String[]{String.valueOf(userId)}); } }insert、update、delete三个方法中,插入返回的是新行的row id,更新和删除返回的是受影响行数。注意update和delete的where语句一定要用?占位符,配合selectionArgs传参。SQLiteDatabase不会自动拼接这些参数,是它内部把参数绑定到预编译语句上,这样即使用户输入了特殊字符也不会被当成SQL执行,这是防注入的标准做法。
有一个细节很多人忽视:db.update返回0不代表出错了,可能是where条件没匹配到记录。所以判断“是否更新成功”不能只看返回行数,还要看业务上是否需要关心“本来就不存在”的情况。insert返回-1才是真的失败,失败原因通常是对应表有约束冲突,或者数据库仅可读。
3.2 查询与Cursor:为什么必须在受控范围里消费结果集
查询是CRUD里最容易被写坏的环节。Cursor是查询结果的游标,它是数据库资源的持有者,Java层面看到的是一个对象,底层确实占用着SQLite的B-tree遍历和文件句柄。如果不关闭Cursor,反复查询几次之后就会报“Could not close the cursor”之类的错误,而且这种错误不一定是立刻崩溃,很可能在用户切了几次页面之后才爆出来。
public List<UserRecord> queryUsersByKeyword(String keyword) { List<UserRecord> list = new ArrayList<>(); SQLiteDatabase db = helper.getReadableDatabase(); String sql = "SELECT id, username, avatar_path, created_at FROM user WHERE username LIKE ? ORDER BY created_at DESC"; String[] args = new String[]{"%" + keyword + "%"}; Cursor cursor = db.rawQuery(sql, args); try { while (cursor.moveToNext()) { UserRecord record = new UserRecord( cursor.getLong(cursor.getColumnIndexOrThrow("id")), cursor.getString(cursor.getColumnIndexOrThrow("username")), cursor.getString(cursor.getColumnIndexOrThrow("avatar_path")), cursor.getLong(cursor.getColumnIndexOrThrow("created_at")) ); list.add(record); } } finally { cursor.close(); } return list; }这个查询方法里有三个关键点。一是查询列只写需要的字段:user表里如果以后加了备注、个性签名之类的大字段,SELECT后面只挑id、username、avatar_path、created_at,不把整行数据全拖出来,内存和IO都会省很多。二是LIKE查询:keyword前后拼了%,这就是模糊匹配的核心写法,如果keyword往里传时忘记拼%,就变成精确匹配,功能直接不对。三是Cursor的关闭时机:我习惯用try...finally包住遍历逻辑,finally里close,确保遍历过程中抛异常也会关掉游标。
关于getColumnIndexOrThrow,它和getColumnIndex的区别是:前者在列名不存在时直接抛异常,后者返回-1。列名写错是查库最常见的低级错误,用getColumnIndexOrThrow能让问题在出bug的第一时间暴露,而不是取数据时得到null或者报错,排查起来快得多。
3.3 数据管理层的分层:DAO、Repository与Activity的边界
很多项目的SQLite代码最终变乱,根源在于没有分层边界。Activity直接拿helper建表,直接写Cursor解析,这个列表页写一遍,那个设置页再写一遍,等业务逻辑变了要改字段名,得翻五六个页面。这个标题里的“数据存储管理设计”其实就是指这种分层:Activity不碰SQLite,只和Repository打交道。
DAO层负责单表的基本增删改查,一个表一个DAO,方法粒度很细,比如insertUser、queryUserById。Repository层负责跨表和业务组装,比如“获取用户详情及最近十条记录”这样一个查询会同时用到UserDao和RecordDao,就在Repository里编排,返回一个封装好的业务对象。最上层才是Activity,它只需要调repository.getUserDetail(userId),拿到一个已经组装好的对象,不用关心底层怎么查的。
这样分层的定价是:第一,SQL语句只存在于DAO里,改动表结构时只改一个文件;第二,缓存、加密、数据源切换这些后加的增强都藏在Repository背后,对上层无感;第三,单元测试可以直接针对DAO写,不用起一个Activity。如果项目规模不大,至少也要做到Activity不写SQL这一步,这是数据存储管理源码设计里见过的避免后期重构痛苦的最务实做法。
4. 事务、并发与性能红线:数据量上来后的三个必然关卡
4.1 事务的正确打开方式:beginTransaction与setTransactionSuccessful
事务是数据库的原子性保证。没有事务的批量写操作,每条SQL各自提交,中途任何一条失败,前面成功的也没法回滚,数据会停留在半截状态。比如批量导入记录,导入到第50条时遇到一个格式错误,前49条已经落库了,这个现场就是典型的脏数据。
事务的标准写法是beginTransaction()开始事务,setTransactionSuccessful()标记成功,endTransaction()结束事务。三个方法配合起来才能保证:只有标记成功的事务才会提交,没标记的事务在endTransaction时自动回滚。
public boolean batchInsertRecords(long userId, List<RecordItem> items) { SQLiteDatabase db = helper.getWritableDatabase(); boolean success = false; db.beginTransaction(); try { for (RecordItem item : items) { ContentValues values = new ContentValues(); values.put("user_id", userId); values.put("title", item.getTitle()); values.put("content", item.getContent()); values.put("updated_at", System.currentTimeMillis()); long rowId = db.insert("record", null, values); if (rowId == -1) { // 这一条插入失败,直接抛异常中断整个事务 throw new SQLiteException("insert record failed at item: " + item.getTitle()); } } db.setTransactionSuccessful(); success = true; } catch (Exception e) { // 日志记录异常,事务会自动回滚,不会留下半截数据 android.util.Log.e("RecordRepository", "batch insert failed", e); } finally { db.endTransaction(); } return success; }重点说一下setTransactionSuccessful这句话的位置。它必须在事务内所有操作都执行成功之后调用,而且只能调用一次。如果还没执行完就调用,事务照样提交,后面再插入几条,出了错也回滚不了。我的习惯是把它放在try块的最后一行,前面任何一步失败都会抛异常跳过它。endTransaction在finally里执行,保证beginTransaction和endTransaction永远成对出现,不会出现事务开了却没结束的情况。
事务还有一个隐藏的性能优势:每条SQL单独提交的时候,SQLite会把每条写入都触发作一次磁盘同步,而包在事务里的批量写入只在提交时同步一次。所以批量插入几百条数据时,用事务和不用的耗能差距是数量级的。
4.2 并发读写模型:SQLite的锁机制与WAL模式取舍
SQLite默认的锁机制是读写互斥:同一时刻只允许一个写操作,读操作和写操作也不能同时进行。在安卓上,绝大多数应用的数据库写入频率很低,这个机制完全够用。但如果你在做的是日志记录、埋点上报、本地缓存同步这种写入频度较高的场景,默认模式会出现“database is locked”错误,尤其是多个线程同时开写的时候。
打开WAL模式是解决这个问题的常见做法。WAL全称是Write-Ahead Logging,写入操作先追加到wal日志文件,checkpoint时再合并进主库文件。它的直接效果是:读操作不阻塞写操作,写操作也不阻塞读操作,并发能力明显提升。
public class AppDatabaseHelper extends SQLiteOpenHelper { @Override public void onConfigure(SQLiteDatabase db) { super.onConfigure(db); // 启用 WAL 模式:并发读写下减少 database is locked 错误 db.enableWriteAheadLogging(); } }onConfigure是在数据库配置阶段回调的,比onCreate更早,适合在这里设置连接参数。enableWriteAheadLogging本身在API 16+可用,对老机器反而需要做一次API判断。开WAL之后有两个需要注意的地方:一是在数据库目录里会多出.wal和.shm两个文件,备份数据库时这三个文件要一起处理,只拷主库文件会丢数据;二是WAL模式下读取到的总是事务提交后的数据,不开启时读事务里能看到未提交的修改,这个语义变化在涉及跨线程共享连接时要留意。
如果只是列表页读、用户偶尔点个按钮写一条,不开WAL也绰绰有余。开WAL的意义在于给“写入别阻塞我页面加载”留了余地,但是别把它当成高并发神器,SQLite本质还是面向单写者设计的,真正的高并发写入应该交给后端数据库。
4.3 性能红线:批量插入、索引命中、查询列裁剪
性能问题是SQLite在安卓上最容易被忽略的一环。数据量到几千条的时候,很多实现还跑得动,到几万条时,慢查询就会在列表滑动、搜索输入的瞬间卡住UI。性能设计不是等卡了再优化,而是在写第一版数据管理代码时就该守住几条红线。
第一条是批量插入必须走事务,不做逐条提交。前面已经演示过事务里的批量插入,直接在循环里db.insert()、不套事务的写法,一万条数据可能要卡十几秒,套上事务后通常在几百毫秒级别。
第二条是查询要让索引命中。索引在where条件和order by字段上才有效。比如record表建了idx_record_user_id,你在WHERE user_id = ?时能命中,但你在WHERE user_id + 0 = ?或者WHERE user_id LIKE '123%'时,索引就废了。给索引字段包一层函数,是让索引失效的最常见原因。
// 反例:对索引字段做函数运算,索引失效 String badSql = "SELECT * FROM record WHERE user_id + 0 = ?"; // 正例:直接比较原始列值,索引生效 String goodSql = "SELECT * FROM record WHERE user_id = ?";第三条是查询列只取需要的,并且用limit限制返回条数。列表页如果只要第一屏的20条数据,就写LIMIT 20 OFFSET 0,不要一次性把几千行都塞进List里,这会占用大量内存,而且绝大多数情况用户根本不会往下翻那么深。
第四条是避免在SELECT语句里返回大文本字段。record表的content如果是用户写的长文,列表查询时不要把它查出来,等用户点进详情再单独查一次。这个优化立竿见影,尤其当列表滑动卡顿的时候。
5. 五条血泪踩坑记录:SQLite数据管理翻车现场与排查方法
5.1 改了表结构但onUpgrade一直不执行
现象:开发环境里改了建表SQL,新增了一个字段,杀掉应用重新运行,Android Studio的数据库检查器里表结构还是老的,怎么都不生效。
原因:SQLiteOpenHelper只在数据库的版本号变高时触发onUpgrade。如果你只是改了onCreate里的建表语句,数据库文件已经存在,onCreate不会再跑,onUpgrade也没被触发,因为版本号没变。
解决:把AppDatabaseHelper里的DB_VERSION从1改成2,然后onUpgrade里写ALTER TABLE语句补字段。如果是没发版之前的开发阶段,另一个偷懒办法是卸载应用重装,让数据库文件删掉重建,但一旦上过测试机,这个办法就不适用了,必须走版本号递增的正规路线。
5.2 自增主键做关联,但业务字段频繁变更导致数据错乱
现象:一套阅读记录功能,一开始用“书籍ID+章节号”做主键,后来业务调整,章节号允许在编辑后改变,结果关联历史记录全错乱了,查出来的数据张冠李戴。
原因:用业务字段做主键,业务一变主键跟着变,所有引用这个主键的记录都得跟着改,一旦有漏网之鱼就出现关联断裂。
解决:主键改成自增id,业务上的“书籍ID+章节号”改成普通列,单独加联合UNIQUE约束来保证不重复。关联表存的是自增id,业务字段怎么改都不影响关联关系。
5.3 Cursor用完不关,列表页反复刷新后内存持续上涨
现象:用户反复下拉刷新列表,页面切换几次之后,内存占用明显升高,一段时间后列表加载变慢,最后直接抛异常崩溃。
原因:每次查询拿到Cursor后没用close,Cursor底层持有数据库文件句柄和查询结果集。Java层面失去了对Cursor的引用后,它得等finalize被调用才释放资源,但时间不可控,大量累积下来文件句柄就耗尽了。
解决:所有Cursor的查询遍历都用try...finally包裹,finally里close。虽然新版SDK里Cursor实现了AutoCloseable,可以写在try-with-resources里,但底层的释放时机仍然取决于close是否被显式调用,把close写在finally是任何版本下都不会错的写法。
5.4 数据库查询直接跑在UI线程,列表一大数据就ANR
现象:搜索用户的时候,输入关键词点击搜索,界面卡住不动,等两三秒才响应,数据再多一点直接弹出“应用无响应”的对话框。
原因:getWritableDatabase和Cursor遍历都是磁盘IO操作,在主线程执行会阻塞UI渲染。Android要求主线程在5秒内处理完一个输入事件,数据库查询一旦超过这个阈值就触发ANR。
解决:把数据库操作全部移到后台线程。常用做法是拿一个单线程的ExecutorService跑数据库任务,或者项目里已经用协程、线程池就把DAO调用包进异步任务里。UI线程只负责接收结果并更新控件。
5.5 事务里抛出异常,数据还是留下了一半
现象:批量导入数据时,程序在第50条抛了异常,日志里明明打出了异常信息,App重启后发现前50条数据都进去了,预期的回滚没有发生。
原因:事务代码里调用了setTransactionSuccessful,但它被放在了循环之前,或者放在了try块的外面,也可能try块里用了两个嵌套事务只提交了外层,导致部分数据被当成成功事务提交了。
解决:setTransactionSuccessful必须放在事务内所有操作之后、endTransaction之前,并且保证它前面的任何异常都会绕过它。检查代码里是否在beginTransaction之后过早调用,或者异常被catch掉之后流程继续走到了提交逻辑。事务里如果有catch块,一定要在catch中把异常标记为失败状态,然后继续往外抛,不能吞掉异常。
6. 从Demo到能长期维护的工程:迁移、调试与数据安全边界
6.1 用onUpgrade做增量迁移,而不是删库重建
第5章提到开发阶段可以删库重建,但应用上线后绝对不行。用户手机里的数据比代码金贵,一次粗暴的DROP TABLE就能把全部用户记录清零。增量迁移的正确写法是按oldVersion逐级升级:从1升到2执行一段,从2升到3再执行一段,中间跨版本的时候循环补齐。
@Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 2) { db.execSQL("ALTER TABLE user ADD COLUMN nickname TEXT"); } if (oldVersion < 3) { db.execSQL("CREATE TABLE IF NOT EXISTS setting (key TEXT PRIMARY KEY, value TEXT)"); } }这种逐级判断的写法保证了用户无论从哪个老版本升级上来,都能按顺序把缺的补上。反例是只写最后一个版本的改动,然后判断oldVersion == 1才执行,用户从2升到3时就漏掉了1到2的变更。
6.2 用新的数据库调试工具验证写入结果
真机上跑通了功能不等于数据存对了。出现重复记录、字段错位、时间变成负数这类问题,最好在电脑上打开数据库文件实际看一眼。调试阶段可以直接用开发工具的数据库检查器,但要知道wal日志和主库文件不是实时合并的,真机上考虑先调用一次checkpoint再拉库文件。自己电脑上习惯装一个可视化工具单独打开db文件,直接看表结构和数据内容,排查效率比在代码里打日志快得多。
6.3 数据安全边界:什么时候才需要引入加密
SQLite默认不加密,数据库文件拷走就能用工具打开。如果应用存的是通讯录、聊天记录、离线缓存的身份令牌,放明文等于把用户的隐私裸奔在磁盘上。SQLCipher是SQLite的加密扩展,加密后整个库文件变成密文,能防住直接拷库文件的泄露路径。
但我一般不建议第一版就上加密。SQLCipher引入会让库大小增加、读写性能下降,而且改造成本不小。正确顺序是:先做功能,上线后确认确实存敏感数据,配合安全审计,再评估加密方案。如果项目从一开始就明确要存储敏感字段,直接选用支持加密的数据库框架会更省事,别等着数据量大了再回头加。
对于“基于Java的SQLite数据库安卓应用数据存储管理设计源码”这个方向,最后想提醒的是:SQLite的存储管理功夫不在SQL本身,而在设计边界和应对变化。我自己的习惯是每建一个表,都在项目里留一份表结构变更记录,版本号、改动字段、原因都写清楚,等应用发布两年后再回头看,这份记录比任何注释都管用。希望这些从建库到迁移的坑和经验,能帮你把数据存储这块做扎实。
本文还有配套的精品资源,点击获取