资讯详情

资讯详情

Android SQLite实战:从绿豆通讯录掌握增删改查与列表展示

把“Android 绿豆通讯录”这种项目摆在桌面上时很多人第一反应是“人人都会写”第二反应是“SQLite而已不就是一个本地缓存的数据库嘛”。可真把代码铺开看能一次把增删改查写对、把数据正确展示出来的人其实不多。SQLite作为Android系统内置的关系型数据库是本地结构化数据存储最直接的答案没有之一。这篇文章把“绿豆通讯录”从建库、设计表、跑通增删改查到列表展示这条完整链路拆开讲透适合刚学完四大组件、想用数据存储练手的同学也适合已经写过简单CRUD但总在细节上翻车的人对照排查。项目本身不复杂但涉及的知识点密度很高SQLiteOpenHelper的写法、Cursor的安全遍历、Adapter的刷新时机、数据库版本升级的约定……这些都是日常开发里真正会用到的东西。下面直接按一条从零到一的主线走每段都带能落地的代码和注释。1. 为什么偏偏拿通讯录练手SQLite在Android存储里的位置1.1 SQLite到底是什么跟SharedPreferences和文件存储差在哪很多人刚开始写Android时会想本地存数据我用SharedPreferences不就行了写个文件不就行了这问题放到通讯录场景里就站不住脚了。SharedPreferences本质是一组键值对适合存开关状态、用户配置这类零散数据。它没有表结构没有查询语法你想实现“按姓名搜索联系人”“按添加时间排序”只能把整个Map拉出来在内存里遍历数据一多就非常别扭。文件存储则是另一种思路把整个通讯录序列化成JSON或者XML写进文件里读取时整体解析到内存。问题同样是数据的“局部操作”很麻烦删一个人的联系方式就得重写整个文件改一个号码也得先把全量数据读出来改完再写回去。数据量小的时候看不出问题攒到几百个联系人之后这种全量读写的效率和代码复杂度都不可接受。SQLite解决的就是这类“结构化、需要按条件查、需要局部改”的数据场景。它是一个内嵌式关系型数据库数据库就是一个文件Android系统已经把它打包进了运行时环境不需要额外引入服务也不依赖网络。你只要通过SQL语句就能完成数据的增删改查底层还有事务机制保证操作的原子性。1.2 通讯录这种数据天生就是给数据库准备的通讯录有几个非常鲜明的数据特征每一条记录的字段结构固定姓名、电话、备注等记录数量会持续增长但不会夸张到海量最常见的操作是按姓名找人、按ID删人、修改某个人的号码。这几个特征几乎就是为SQL设计的使用场景。举个例子在一个有五百个联系人的通讯录里SharedPreferences方案要找出姓“张”的人只能遍历所有键值对然后逐个判断SQLite方案只需要一句SELECT * FROM contact WHERE name LIKE 张%;数据库自己会完成筛选你拿到的就是想要的结果集。绿豆通讯录这个项目虽然小却能把数据库这种“按条件取数”的核心价值完整地演示一遍这也是我建议用它入门SQLite的原因。而且Android开发里凡是涉及本地结构化解数据——聊天记录、待办事项、收藏列表、浏览历史——几乎都能套用同一套SQLite或ORM的写法。把通讯录做明白等于把这一整类需求的手感和套路都练到了。2. 绿豆通讯录的库表设计先想清楚“联系人”怎么拆2.1 单表还是多表决定后续所有代码的结构动手写代码之前最重要的一件事其实是设计表结构。表结构定不好后面的增删改查全要返工。通讯录里的联系人信息按照需求可以拆成几类姓名和电话是必填项邮箱、备注是选填项创建时间和更新时间用来后续排序和展示。对于入门项目单表是完全够用的。有人会纠结一个人有多个电话号码怎么办那确实是双表的场景——联系人表和电话表分离用外键关联。但绿豆通讯录作为练手项目重点在数据库操作本身我不建议一上来就把模型搞复杂。先用单表把CRUD链路跑通多号码的需求之后可以再扩展成一对多结构原理是相通的。表名叫contact字段设计如下字段类型约束说明idINTEGERPRIMARY KEY主键自增nameTEXTNOT NULL姓名必填phoneTEXTNOT NULL手机号必填emailTEXTNULL邮箱选填remarkTEXTNULL备注选填create_timeTEXTDEFAULT创建时间update_timeTEXTNULL更新时间这里有个值得说的点很多人写主键时习惯写PRIMARY KEY AUTOINCREMENT在SQLite里其实有个更轻量的写法。SQLite在底层为每张表都维护了一个隐藏的rowid你声明id INTEGER PRIMARY KEY时这个字段就直接成了rowid的别名。也就是说主键自增的机制是免费的不需要AUTOINCREMENT参与。只有一种情况需要显式加AUTOINCREMENT你要保证主键严格单调递增、绝不复用已删除的id。对通讯录场景来说这个保证没有实际意义反而每个AUTOINCREMENT都会让SQLite额外维护一个sqlite_sequence表多一层开销。2.2 建表语句与版本管理细节全在这里建表语句长这样CREATE TABLE contact ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, phone TEXT NOT NULL, email TEXT, remark TEXT, create_time TEXT DEFAULT (datetime(now, localtime)), update_time TEXT );编译期在程序里怎么写后面章节会一起贴完整代码。这里多说一句版本管理创建数据库文件、建表、升级表结构在Android里统统由SQLiteOpenHelper的版本号控制。这个版本号不是随便填的它写入了SQLiteHead文件数据库文件的头部信息是数据库结构迭代的凭证。版本1建表版本2可能要加一列头像字段版本3可能要加分组字段。每次版本号递增onUpgrade方法就会被回调你需要在里面执行对应的ALTER TABLE或者重建表的语句Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion 2) { db.execSQL(ALTER TABLE contact ADD COLUMN avatar BLOB); } }我见过很多项目包括不少商业项目图省事直接在onUpgrade里DROP TABLE再重建。通讯录这种用户数据量不大的还好如果是真实线上数据这么干等于把用户数据全删光。所以从绿豆通讯录开始就养成“增量升级”的习惯每一版结构变化对应一段迁移逻辑比什么都重要。3. 增删改查落地把每个数据库操作都写明白3.1 SQLiteOpenHelper数据库的初始化与版本升级Android里访问SQLite的标准入口是SQLiteOpenHelper子类。它负责打开数据库文件和触发建表、升级的时机。代码并不复杂关键是要理解每个方法的职责。public class MySQLiteOpenHelper extends SQLiteOpenHelper { private static final String DB_NAME green_contact.db; private static final int DB_VERSION 1; public MySQLiteOpenHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } Override public void onCreate(SQLiteDatabase db) { db.execSQL(CREATE TABLE IF NOT EXISTS contact ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, phone TEXT NOT NULL, email TEXT, remark TEXT, create_time TEXT DEFAULT (datetime(now, localtime)), update_time TEXT)); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 按版本号增量迁移不要直接删表重建 } }构造方法里的四个参数第一个是Context注意这里建议传Application级别的Context避免内存泄漏第二个是数据库文件名第三个CursorFactory基本传null第四个是版本号。有个细节值得提一下onCreate只在数据库文件第一次创建时执行。如果你已经安装过旧版App数据库文件已经在设备上存在了再去改onCreate里的建表语句是不会生效的必须把版本号加1走onUpgrade。很多初学者在这里卡很久明明改了建表语句重新运行数据表结构还是老的就是因为没搞懂这个触发机制。数据库的读和写也有区别getReadableDatabase和getWritableDatabase大多数时候返回的是同一个对象但如果磁盘满了等异常情况下getReadableDatabase会退化成只读方式打开。实际开发中业务代码很少区分这俩统一用getWritableDatabase比较省心。3.2 添加联系人ContentValues怎么用才不出错插入一条联系人记录正统做法是用ContentValues。它本质上是一个键值对容器键是列名值是插入的值内部实现了对存储类型的处理。public long insertContact(Contact contact) { SQLiteDatabase db helper.getWritableDatabase(); ContentValues values new ContentValues(); values.put(name, contact.getName()); values.put(phone, contact.getPhone()); values.put(email, contact.getEmail()); values.put(remark, contact.getRemark()); long rowId db.insert(contact, null, values); db.close(); return rowId; }insert的返回值很关键它返回新插入行的主键id。如果插入失败返回的是-1。所以判断插入是否成功不要用“返回的是不是0”来判断SQLite的约定就是返回主键失败返回负数。接触过早期MVC开发的同学可能会习惯拼SQL再execSQL执行db.execSQL(INSERT INTO contact(name, phone) VALUES(张三, 123456));这种写法在项目里尽量少用。一是容易拼出SQL注入问题二是带引号的字符串处理起来很痛苦。比如姓名里如果包含单引号拼接SQL时就需要转义ContentValues会自动帮你处理好这些。再提一个很小的点现代Android项目里数据库访问不应该散落在Activity里。最好把它集中到一个DAOData Access Object类里每个方法对应一个数据库操作。这样Activity里只关心业务逻辑数据库细节被隔离掉了。3.3 删除与修改where条件写法里的两个大坑删除联系人的核心是定义好“删谁”。按主键删是最稳妥的方式因为主键唯一确定一条记录public int deleteContactById(long id) { SQLiteDatabase db helper.getWritableDatabase(); int rows db.delete(contact, id ?, new String[]{String.valueOf(id)}); db.close(); return rows; }delete方法的签名是delete(table, whereClause, whereArgs)。第二、三个参数组合成一个条件模板。为什么一定要用?占位符而不是直接拼接字符串两个原因一个是防注入另一个是框架会帮你做类型处理和转义。这个习惯要养成别在代码里写id id这种裸拼接。修改联系人的写法跟删除非常相似只是多了一个ContentValues用来装新值public int updateContact(Contact contact) { SQLiteDatabase db helper.getWritableDatabase(); ContentValues values new ContentValues(); values.put(name, contact.getName()); values.put(phone, contact.getPhone()); values.put(email, contact.getEmail()); values.put(remark, contact.getRemark()); values.put(update_time, String.valueOf(System.currentTimeMillis())); int rows db.update(contact, values, id ?, new String[]{String.valueOf(contact.getId())}); db.close(); return rows; }这个方法返回受影响的行数。如果你拿到的id在数据库里不存在返回0这不是异常别当成错误处理。真正要警惕的是坑where条件千万不能漏。我见过不止一次更新数据时只写了db.update(contact, values, null, null)结果是把全表联系人都改成了同一个人的信息。逻辑上一旦出现“update的时候忘了带id”这种失误数据基本就救不回来了。所以建议在封装DAO层时就把where条件作为硬性参数不给漏写的余地。还有一个值得注意的细节更新后手动维护update_time。因为SQLite的DEFAULT只在插入时生效UPDATE是不会自动触发默认值的。你得在代码里自己更新这个字段除非你依赖触发器否则没有捷径。3.4 查询rawQuery之外的第二种选择查询是通讯录最核心的入口。Android提供了两套APIrawQuery直接传SQL字符串query则用一颗参数把表名、列名、条件、排序组织好。public ListContact queryAllContacts() { ListContact list new ArrayList(); SQLiteDatabase db helper.getReadableDatabase(); Cursor cursor db.query( contact, new String[]{id, name, phone, email, remark, create_time}, null, null, null, null, name ASC ); while (cursor.moveToNext()) { Contact contact new Contact(); contact.setId(cursor.getLong(cursor.getColumnIndexOrThrow(id))); contact.setName(cursor.getString(cursor.getColumnIndexOrThrow(name))); contact.setPhone(cursor.getString(cursor.getColumnIndexOrThrow(phone))); contact.setEmail(cursor.getString(cursor.getColumnIndexOrThrow(email))); contact.setRemark(cursor.getString(cursor.getColumnIndexOrThrow(remark))); list.add(contact); } cursor.close(); db.close(); return list; }query方法的七个参数按顺序是表名、要查询的列、where条件、where占位参数、groupBy、having、orderBy。每个参数都可以是null表示不限制。这个设计其实比rawQuery更安全也更结构化因为行在列和参数都被框架组织好了降低了拼串出错的风险。需要注意两个细节。第一查询列别用new String[]{*}这种全取方式。虽然能跑但会把表里所有列都捞出来如果表结构里加了大字段会影响性能和内存。按需取列是更好的习惯。第二排序时建议把“姓名排在前、添加时间排在后”这类规则写在SQL里而不是Java里。写ORDER BY name ASC让数据库排序比自己实现Comparator快也简洁。3.5 事务与批量操作通讯录同步场景下的保障绿豆通讯录如果只是单个联系人的增删改查事务可能都用不上。但只要涉及到“一次性写入多条数据”的场景比如批量导入联系人、同步云端通讯录就必须用事务。SQLite默认是自动提交模式每执行一条insert都会触发一次磁盘写入几百条数据就要写几百次速度慢是小事中途挂了数据不完整才是大事。事务的写法很固定public boolean batchInsert(ListContact contacts) { SQLiteDatabase db helper.getWritableDatabase(); db.beginTransaction(); try { for (Contact contact : contacts) { ContentValues values new ContentValues(); values.put(name, contact.getName()); values.put(phone, contact.getPhone()); values.put(email, contact.getEmail()); values.put(remark, contact.getRemark()); db.insert(contact, null, values); } db.setTransactionSuccessful(); return true; } catch (Exception e) { e.printStackTrace(); return false; } finally { db.endTransaction(); } }这段代码的核心在于setTransactionSuccessful。很多人以为beginTransaction之后会自动提交其实不会。只有在代码里主动调用了setTransactionSuccessful事务结束时才会commit否则endTransaction会执行rollback把中途所有插入全部撤销。这保证了要么全部成功要么全部不写入不会出现导入到一半留下半截数据的尴尬。注意finally里只endTransaction不需要单独处理setTransactionSuccessful的异常框架会处理好提交和回滚的选择。4. 数据展示从Cursor到界面刷新的完整链路4.1 Cursor的正确打开方式数据从数据库取出来之后展示到界面上有两种常见路线一种是把Cursor直接交给Adapter另一种是先把Cursor转换成Java对象列表再交给普通Adapter。这两种我都试过结论是入门阶段可以直接用Cursor但项目稍微复杂之后建议尽早转成对象列表。原因很简单。Cursor是数据库查询结果集的游标它指向当前读取的数据行刚查完时游标位置在“第一条记录之前”你需要调用moveToNext或moveToFirst让它落到第一条记录上。常规遍历写法是这样if (cursor.moveToFirst()) { do { String name cursor.getString(cursor.getColumnIndexOrThrow(name)); String phone cursor.getString(cursor.getColumnIndexOrThrow(phone)); // 处理每一行数据 } while (cursor.moveToNext()); } cursor.close();这里有两个高频错误。第一直接跳过moveToFirst判断上来就do-while结果空表的时候从第一条不存在的记录开始读直接越界。第二忘记关闭Cursor。Cursor一开始就是一个耗时连接资源它持有底层数据库索引和堆外的Native内存不及时关闭很容易泄漏。建议在finally块里关闭或者用try-with-resources风格处理不过Android这边流式API对try-with-resources的支持比较晚还是手动close最直白。取列值时也尽量不要写cursor.getString(cursor.getColumnIndex(name))。getColumnIndex在列名不存在时返回-1后续getString(-1)会直接抛异常排查起来不好定位。用getColumnIndexOrThrow至少报错信息会告诉你列名是什么比找-1问题快得多。4.2 列表适配位置Position与主键id的区别我在课设阶段最常踩的坑就是把ListView里的position当成数据库主键id来用。这俩是完全不同的概念。position是当前数据在列表视图中的索引位置从0开始连续递增。主键id是数据在数据库表中的唯一标识自增后不会因为删除而回补。举个例子数据库里有三条记录id分别是1、2、3显示在ListView里position就是0、1、2。这时候你把position 0对应的联系人删掉数据库里剩id 2、3刷新列表后position 0指向的成了id 2那条。如果在删除时你用的是deleteContactById(position)那删除的就是id为0的记录完全抹错人。正确的删除逻辑应该是拿到当前item对应的数据对象通过它的id字段去删除Contact contact adapter.getItem(position); dao.deleteContactById(contact.getId());ListView的老版Adapter有getItemId方法可以重写Override public long getItemId(int position) { return mList.get(position).getId(); }BaseAdapter的默认getItemId返回的是position也就是上面说的错位隐患。所以一旦数据行的key不是0到N-1连续排列就一定记得重写它。RecyclerView的Adapter设计上更严格getItemId默认返回NO_ID需要手动返回数据id才能配合notifyItemChanged等精确刷新方法这反而逼着你养成好习惯。4.3 增删改之后的界面刷新机制数据变了界面必须同步刷新。这个机制其实很朴素你先重新查数据库得到最新列表然后替换Adapter里的数据源再调用通知方法让界面重绘。private void refreshList() { ListContact latest dao.queryAllContacts(); adapter.setData(latest); adapter.notifyDataSetChanged(); }这里容易忽略的是setData里要清空旧数据。用ArrayList的clear再addAll或者直接new一个List替换引用二选一public void setData(ListContact data) { mList.clear(); mList.addAll(data); notifyDataSetChanged(); }不要只调用notifyDataSetChanged而不更新数据源那等于告诉ListView“数据变了”但ListView去Adapter里取数据拿到的还是老集合界面自然不变。还有个小技巧给ListView设置空视图。查询结果为空时直接显示一张“暂无联系人”的图片或文字避免白屏让人以为是BuglistView.setEmptyView(findViewById(R.id.tv_empty));插入、删除、修改后调用refreshList三个操作共用一套刷新逻辑。这种做法虽然粗暴但对绿豆通讯录这种体量的项目完全够用代码也更好理解。5. 实测中的那些坑数据库开发最常见的几种翻车现场5.1 游标忘了关内存悄悄涨我曾在某个模拟项目里连续执行几十次查询操作发现App内存稳定上升最后直接OOM。排查了一圈才发现是Cursor没关闭。每次查询返回的Cursor都占用了一段Native内存GC无法自动回收这种资源必须手动close。从那以后每次查询完我都在finally块里关闭Cursor同时顺手把SQLiteDatabase也close掉。有些人会说CloseHelper管理的数据库引用是线程安全的不用每个方法都close。对单线程UI操作来说确实没大问题但一旦涉及线程池操作数据库不close的话连接资源会越积越多。约定俗成的做法就是数据库在哪打开用完就在哪关别跨方法。5.2 删除后position错位误删邻居联系人这个问题上面提过但值得再说一次。我在某次演示中删除列表第一项后紧接着点“删除”按钮去买第二项结果删掉了第三个人。原因就是删完第一次后我没刷新列表ListView的position还是旧索引但底层数据已经变了。解决办法只有两条腿走路删除操作必须用主键id定位删除后必须立刻刷新列表。两者缺一不可。只刷新不用id或者用了id不刷新都有隐患。5.3 update漏写where条件全表数据全被覆盖这个坑规模更大教训也更惨烈。在一次模拟通讯录修改弹窗里我只写了db.update(contact, values, null, null)想着“反正只改一个”结果运行时所有联系人的姓名都变成了弹窗输入的那个名字。当时整个人都懵了因为代码逻辑看着没毛病。后来我给自己定了个规矩update和delete在封装时where条件作为必填参数哪怕是主动更新全表的特殊场景也显式传一个1 1表明意图。数据库操作里明确表达意图比省事更重要。5.4 同一联系人重复插入没有加唯一约束的表对重名同号这种情况是照单全收的。绿豆通讯录如果允许用户连续点“保存”很容易出现两条一模一样的数据。界面上的处理通常是保存成功后立即返回列表页同时按钮置灰禁用数据库层面则可以为phone字段加唯一索引CREATE UNIQUE INDEX idx_contact_phone ON contact(phone);这样底层就能挡住重复号码插入时catch SQLiteConstraintException 再提示用户“该号码已存在”。从源头解决问题比事后查重更可靠。5.5 数据库文件到底存在哪导出导出怎么办调试时想知道数据库里的数据对不对先得搞清文件位置。Android里数据库默认存在/data/data/包名/databases/目录下。用Android Studio自带的Device Explorer可以定位文件拿下来后用SQLite工具打开查看。真机调试没有root权限时这个路径看不到内容可以用adb exec-out run-as 包名 cat databases/xxx.db local.db的方法导出前提是App是debuggable的。这步对排查问题特别有用强烈建议学会。6. 绿豆通讯录之后这个项目还能往哪延伸6.1 分层重构把数据库代码从Activity里拆出去课设阶段很多人图省事Helper、DAO、Adapter全塞进Activity里。项目能跑但代码丑到不行。做完第一版之后我建议立刻做一次重构把代码拆成四层model包Contact实体类纯Java对象不掺任何Android依赖dao包ContactDao所有增删改查都在这里adapter包ContactAdapter负责列表展示activity包MainActivity只做界面交互和调用DAO不直接碰SQLite这样做最大的好处是“可测试性”。数据库逻辑和界面解耦后每个DAO方法可以单独验证以后换数据库实现用的来说只需要改DAO层Activity完全不用动。6.2 学习路径建议什么时候升级到Room项目做完一遍很多人会问要不要直接学Room。我的建议是如果要继续深入Android数据持久化Room确实值得学但顺序很重要。先把原生SQLiteOpenHelper这一套跑熟了解Cursor、ContentValues、事务这些底层概念再切换到Room你才能真正体会到ORM框架替你做了什么、没做什么。Room的官方SQLite之上的一个抽象层提供编译期SQL校验、LiveData响应式查询、协程支持。它最大的价值是避免你手写大量的样板代码和容易出错的游标遍历。但它也有学习成本需要理解Entity、Dao、Database三个核心注解的配合。从绿豆通讯录升级到Room相当于把知识体系平移一个台阶很多原生的概念都能对得上过渡会比较顺。我个人觉得做Android本地存储先搞定SQLite增删改查、再把Room作为工程化手段补进来是比较稳妥的一条路。绿豆通讯录这种小项目正好是走完这条路最短的载体。整个项目做完你会发现真正让你成长的往往不是那句建表SQL而是踩过的那几个坑——游标关闭、position错位、update漏条件、事务没提交。把这些都记到脑子里以后不管写什么数据存储需求都不会再在同样的地方跌倒。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →