做Android开发的人应该都有过这种经历:学了各种控件、Activity跳转、ListView用法,但一到需要真正存数据的时候就卡住了。文件存吧,读写麻烦;SharedPreferences存吧,只能存点简单键值对;真要做个能增删改查的通讯录,最顺手、最不容易出幺蛾子的方案还是SQLite。今天这个“Android 绿豆通讯录”项目,就是一个特别典型的练手案例——它把SQLite的建库、建表、增删改查、Cursor读取、ListView展示全部串了一遍,你把这个项目吃透,以后凡是碰到“本地存储+列表展示”这类需求,基本就是换皮的事。
这个项目的定位很清晰:适合刚学完Android四大组件、对数据库还停留在“听说过”阶段的人。当然,如果你已经写过一些CRUD代码但总是出各种莫名其妙的问题,比如列表不刷新、数据重复、游标没用对,那这篇文章同样值得看。我会把整个通讯录从数据库设计到界面展示完整拆开讲,重点不是贴一堆代码让你抄,而是讲清楚每一步为什么这么做,以及哪些地方是容易踩坑的重灾区。
1. 项目整体设计与数据库选型思路
1.1 为什么通讯录这种场景必须用SQLite
先说结论:只要是“同一类型的数据有多条、每条数据有多个字段、需要按条件查询或修改”,SQLite就是Android本地存储里的最优解,没有之一。
绿豆通讯录里每一条联系人都有姓名、电话、备注这些字段,你可能有几百个联系人,需要在列表里展示,要根据姓名搜索,要修改某个人的号码,要删除某条记录。这种“二维表格结构的数据”,天生适合关系型数据库来管。SharedPreferences本质上是一个XML文件,适合存的是“应用配置”这种单条简单数据,硬要用它塞几百个联系人,读写效率极低,而且根本没法按条件筛选。文件存储就更不用说了,你得自己定义格式、自己处理并发,还不如直接上现成的SQL。
还有一层原因:SQLite是Android系统内置的数据库引擎,不需要额外的网络依赖,也不需要引入什么第三方库,直接用android.database.sqlite包下的API就行。数据存在应用自己的私有目录下,别的应用访问不到,安全性和隔离性也够用。对于绿豆通讯录这种纯本地demo来说,SQLite是成本最低、效果最稳的选择。
有人可能会问,现在不是有Room吗?Room确实是在SQLite之上做了一层ORM封装,查数据更方便,但它本质上还是在用SQLite,而且对于初学者来说,Room的注解和编译期代码生成反而会增加理解负担。我个人的建议是:第一步先用手写SQLiteOpenHelper把底层机制搞明白,之后再去用Room会顺手得多。这个项目就是你理解底层的最佳载体。
1.2 通讯录功能拆解与数据表设计思路
做项目最忌讳一上来就写代码。我们先想清楚,绿豆通讯录需要做什么:
- 展示全部联系人列表,每条显示姓名、电话号码;
- 添加一条新联系人(输入姓名、电话、可加备注);
- 修改一条已有联系人信息;
- 删除一条联系人;
- 数据库里的数据要能持久化保存,App重启后数据不丢。
对应到数据库层面,就是一张联系人表,字段大概这样:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 主键,自增长 |
| name | TEXT | 联系人姓名,必填 |
| phone | TEXT | 电话号码,必填 |
| remark | TEXT | 备注信息,可空 |
这张表的设计有几点讲究。第一,主键必须用id,而且最好命名为_id,这不算玄学,后面讲ListView适配器的时候你就知道为什么了——SimpleAdapter和CursorAdapter这类系统组件默认就会去找_id这个字段,你起别的名字它反而不认。第二,name和phone设为必填,这对应的是业务上的“不允许添加没有姓名的联系人”规则。第三,备注字段允许为空,你可以在插入的时候做判断:如果用户没填备注,就传空字符串,不建议直接传null,因为null在数据库里不好处理,查询和显示都容易出空指针。
2. 数据库层搭建:建库、建表、增删改查一套带走
2.1 SQLiteOpenHelper与建库建表实操
在Android里操作SQLite,第一步永远是写一个类继承SQLiteOpenHelper。这个类核心就是两个回调方法:onCreate只在数据库第一次创建时执行,适合放建表语句;onUpgrade在数据库版本号升级时执行,适合做表结构变更。
直接看代码,这是绿豆通讯录最基础的数据库帮助类:
public class MyDatabaseHelper extends SQLiteOpenHelper { private static final String DB_NAME = "green_contact.db"; private static final int DB_VERSION = 1; public static final String TABLE_CONTACT = "contact"; public static final String COL_ID = "_id"; public static final String COL_NAME = "name"; public static final String COL_PHONE = "phone"; public static final String COL_REMARK = "remark"; public MyDatabaseHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { String sql = "CREATE TABLE " + TABLE_CONTACT + " (" + COL_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + COL_NAME + " TEXT NOT NULL, " + COL_PHONE + " TEXT NOT NULL, " + COL_REMARK + " TEXT)"; db.execSQL(sql); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 简单粗暴:删掉重建。正式项目可别这么干,数据会没的 db.execSQL("DROP TABLE IF EXISTS " + TABLE_CONTACT); onCreate(db); } }很多人写到这里就直接new数据库帮助类然后调getWritableDatabase()去了,但有几个细节我要强调:
DB_VERSION要从1开始。后面你改了表结构,这个数字必须加1,不然onUpgrade永远不会触发,你会疑惑“我明明改了代码怎么数据库还是老样子”。- 字段名全部定义成常量,别在SQL语句里裸写字符串。这个项目里你可能只觉得是“多打几个字”,但到后面代码多了,你到处写
"name",一不留神拼错了,编译器不会报错,运行时就光是查不到数据就够你排查半天。 getWritableDatabase()和getReadableDatabase()的区别很多人搞不清——它不是按“读”“写”来区分的,两个方法拿到的其实都是同一个可写实例,只是当磁盘满了等极端情况下,前者抛异常、后者降级到只读。日常写操作直接getWritableDatabase就行。
2.2 增删改查方法封装:每行代码为什么这么写
数据库帮助类只负责建库建表,真正的增删改查应该单独封装到一个DAO类里,比如ContactDao。这样Activity里的代码会很干净,只调用dao.add(contact)、dao.queryAll(),不用关心SQL语句长什么样。
添加联系人,推荐用SQLiteDatabase.insert()方法:
public long add(Contact contact) { SQLiteDatabase db = helper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put(COL_NAME, contact.getName()); values.put(COL_PHONE, contact.getPhone()); values.put(COL_REMARK, contact.getRemark()); return db.insert(TABLE_CONTACT, null, values); }这里必须说说ContentValues这个东西。你把它理解成一个键值对容器,键是列名,值是要写入的数据。insert()方法内部会自动帮你把ContentValues转换成INSERT语句,本质上就是替你拼接SQL,但好处是它是参数化处理的,不会出现拼接字符串导致的SQL注入。这个习惯一定要养成:用户输入的内容,永远不要直接拼进SQL语句里,要么用insert/update/delete这种带参数的方法,要么用?占位符。
删除联系人按id删,这是最安全的删法,因为id唯一,不会误删同名的人:
public int deleteById(long id) { SQLiteDatabase db = helper.getWritableDatabase(); return db.delete(TABLE_CONTACT, COL_ID + " = ?", new String[]{String.valueOf(id)}); }delete方法的第二个参数是WHERE条件,第三个参数是条件里的?占位符对应的值。注意:条件字符串不要写成"_id = " + id,虽然功能上没错,但这是坏习惯的起点。占位符写法不仅安全,还避免了类型问题。
更新联系人类似,用update(),除了要传ContentValues,还必须传WHERE条件指定要改哪一条:
public int update(Contact contact) { SQLiteDatabase db = helper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put(COL_NAME, contact.getName()); values.put(COL_PHONE, contact.getPhone()); values.put(COL_REMARK, contact.getRemark()); return db.update(TABLE_CONTACT, values, COL_ID + " = ?", new String[]{String.valueOf(contact.getId())}); }这里有一个新手很容易踩的坑:update()不写WHERE,或者WHERE条件为空,它会把整张表的每一行都改成你传的值。所以每次写完update/delete,都要回头检查一遍WHERE参数是不是传对了。我当时第一次做通讯录就犯过这个错,本来想改一个人的备注,结果全校联系人的备注全变成“同学”了。
查询全部数据,用query()方法返回一个Cursor:
public List<Contact> queryAll() { SQLiteDatabase db = helper.getReadableDatabase(); Cursor cursor = db.query(TABLE_CONTACT, null, null, null, null, null, null); List<Contact> list = new ArrayList<>(); while (cursor.moveToNext()) { Contact contact = new Contact(); contact.setId(cursor.getLong(cursor.getColumnIndexOrThrow(COL_ID))); contact.setName(cursor.getString(cursor.getColumnIndexOrThrow(COL_NAME))); contact.setPhone(cursor.getString(cursor.getColumnIndexOrThrow(COL_PHONE))); contact.setRemark(cursor.getString(cursor.getColumnIndexOrThrow(COL_REMARK))); list.add(contact); } cursor.close(); return list; }这个方法有两点要专门说。第一,query()的七个参数分别是表名、要查询的列、条件、条件值、分组、having、排序,用null表示不限制。要按姓名排序就把第七个参数传COL_NAME + " ASC",让列表按拼音排好,比在Java代码里手动排序效率高得多。第二,cursor.getColumnIndexOrThrow(COL_NAME)比cursor.getColumnIndex(COL_NAME)更安全,后者拿不到列会返回-1,你再去getString(-1)直接抛异常,前者至少报错信息明确。
2.3 Cursor的正确使用姿势:好多人在这里栽过跟头
Cursor可以理解为数据库查询结果的“游标”,它指向结果集中的某一行数据。使用Cursor有四步走:查取、移动、取值、关闭。
前面代码里的while (cursor.moveToNext())就是移动游标,循环体里取值。这里有个非常经典的错误——用cursor.getString(0)这种下标方式取值,而不先getColumnIndex。表面上看,你查的时候列的顺序就是你在代码里写的顺序,下标0就是第一个字段,但注意,如果你没指定列而是用了null查询全部列,数据库返回的列顺序取决于表结构,谁也不敢保证永远不变。一旦你在别处加了列,或者改了表,下标就全乱了。所以永远用列名去索引,或者更硬核一点,直接用getColumnIndex拿到下标再取。
关游标这事,我用一句话描述它的重要性:你每次查询数据库都会开一个Cursor,Cursor底层持有数据库的窗口资源,不关就泄漏,查的次数多了,应用会变得异常卡顿,甚至报CursorWindowException: Could not allocate CursorWindow。所以cursor.close()一定要在循环结束之后、方法返回之前执行。有一种更保险的写法是用try-with-resources,但Android API 28以下不直接支持自动关闭Cursor,所以我现在已经习惯写一个关闭完再返回的固定范式。
另外,建议大家养成的习惯是:在Activity的onDestroy或者应用切后台时,把数据库辅助类也close掉。虽然很多人说SQLiteOpenHelper可以一直用着,但做绿豆通讯录这种练手项目,规范一点没有坏处——数据库连接长期不释放,在低内存设备上很可能会被系统判定为资源占用过高。
3. 列表展示与交互:把数据库里的数据搬到屏幕上
3.1 数据模型Contact与Adapter适配器编写
数据库里存的是行数据,界面上要展示的是一个一个的联系人条目。从行数据到View对象,中间要用到数据模型类和Adapter。
先定义一个Contact类,字段和表结构一一对应:
public class Contact { private long id; private String name; private String phone; private String remark; public Contact() {} public Contact(long id, String name, String phone, String remark) { this.id = id; this.name = name; this.phone = phone; this.remark = remark; } // getter和setter省略... }注意Contact类里我加了remark字段,但在列表item里默认不显示,只有点进详情或者编辑时才看得到。这说明什么?说明数据模型应该跟表结构保持一致,但界面展示可以只取其中一部分字段,这是“模型”和“视图”解耦的体现。为了一个列表就把备注字段删掉,后面加需求又得回来改表,这种折腾没必要。
Adapter我建议直接用自定义BaseAdapter,不要图省事用SimpleAdapter。SimpleAdapter适合的是“字段直接映射到控件”的极简场景,但通讯录列表往往要控制item样式、加点击效果、甚至动态显示不同的布局,用SimpleAdapter后期肯定要推翻重写。自定义Adapter反而思路更清晰:ViewHolder模式绑定控件,convertView复用避免重复inflate,性能比SimpleAdapter好一个档次。
public class ContactAdapter extends BaseAdapter { private List<Contact> list; private LayoutInflater inflater; public ContactAdapter(Context context, List<Contact> list) { this.list = list; this.inflater = LayoutInflater.from(context); } @Override public int getCount() { return list == null ? 0 : list.size(); } @Override public Object getItem(int position) { return list.get(position); } @Override public long getItemId(int position) { return list.get(position).getId(); } @Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView == null) { convertView = inflater.inflate(R.layout.item_contact, parent, false); holder = new ViewHolder(); holder.tvName = convertView.findViewById(R.id.tv_name); holder.tvPhone = convertView.findViewById(R.id.tv_phone); convertView.setTag(holder); } else { holder = (ViewHolder) convertView.findViewById(convertView).getTag(); } Contact contact = list.get(position); holder.tvName.setText(contact.getName()); holder.tvPhone.setText(contact.getPhone()); return convertView; } static class ViewHolder { TextView tvName; TextView tvPhone; } }getItemId(position)返回list.get(position).getId()这是有讲究的。ListView在调用notifyDataSetChanged刷新时,系统会对比item id来保持选中状态和滚动位置,如果你偷懒直接返回position,在某些交互场景下(比如删除列表中间的某一条)会有诡异的跳动问题。而且刚才说了,主键命名为_id,系统内置的ListAdapter对这个名字有默认约定,你遵守这个约定,很多系统行为就能白嫖。
3.2 添加和编辑联系人:从界面到数据库的完整链路
添加联系人的交互,我用一个自定义AlertDialog就搞定了,里面有输入姓名、电话、备注的三个EditText,点击“保存”后拿到用户输入,校验非空,构造成Contact对象,调用dao.add(),然后刷新列表。
校验这一步不能省,至少要做两层:第一层是空值判断,姓名或电话为空就提示用户,不执行的数据库操作;第二层是电话格式,简单点用正则^1[3-9]\\d{9}$校验手机号就行,也可以只判断长度不为0,因为有人会存座机号。我建议不要卡太死,毕竟是通讯录,固话、公司短号、国际号码都可能有,如果里面有备注“001”这种号码你非要校验格式,就是在折磨用户。
编辑联系人的流程略微不同,因为你要先拿到原始数据,回显到编辑界面里去。我用的做法是:点击列表里的某个item,弹出一个和添加界面结构一样的编辑对话框,但EditText里预填了当前联系人的姓名、电话、备注,保存的时候用dao.update(contact)按id更新。这里有一个容易犯的错:回显时直接给EditText.setText(contact.getName()),结果用户啥也没改就点了保存,数据当然没变化,逻辑上没错,但体验上是浪费了一次数据库操作。更合理的做法是只调update,如果更新的行数是0,就提示“没有变化”,更新行数大于0才刷新列表。
3.3 删除与刷新机制:别动不动就重新查全表
删除我用的是ListView的长按事件:用户长按某个item,弹出一个确认对话框,点击“删除”后调用dao.deleteById(contact.getId()),然后刷新列表。
这里想分享一个关于“刷新列表”的经验。新手最常见的写法是:每次操作完数据库,就重新执行一遍queryAll(),把新List整个塞给Adapter,再notifyDataSetChanged()。这个写法在数据量小的时候没毛病,绿豆通讯录也就几十个人,性能完全没压力。但你要有一个意识:频繁的全表查询是不必要的,尤其是后面数据量上去了,你会发现列表重绘很慢。更好的方式是利用getItemId和局部更新,比如只删除一条时,先从Adapter的数据源里remove掉对应position的数据,然后调用notifyDataSetChanged(),这样数据源和数据库一致,界面同步更新,但没有发生额外的数据库查询。
另外,ListView在数据变更后不调用notifyDataSetChanged(),界面是绝对不会自动变的——它不知道你的底层List已经变了。这个函数名我建议你深深地刻在脑子里,我见过太多人问“为什么我增删改了数据库但列表不变”,九成是因为忘了他。还有一个小细节:操作完数据库,要把更新后的数据重新set进Adapter里,如果你用的是同一个List对象(比如在DAO里直接返回一个新的List,然后adapter.setList(newList)),那务必记得在Adapter里写一个set方法,并在set方法里调用notifyDataSetChanged(),这样调用方只需要一行代码,不容易漏。
4. 实操全记录:从零跑通一个绿豆通讯录
4.1 准备工作与项目骨架搭建
实际动手做这个项目之前,要说一下开发环境。建议用Android Studio,创建一个Empty Activity项目,语言用Java(这个项目用Kotlin也行,但网上大部分SQLite资料是Java,为了对照方便,Java更稳妥,等SQLite玩明白了再换Kotlin不迟),包名爱叫什么叫什么,项目名可以就叫GreenContact。
绿豆通讯录的项目结构不需要太复杂:
java/你的包名/ ├── MainActivity.java // 列表展示和事件绑定 ├── db/MyDatabaseHelper.java // 数据库帮助类 ├── db/ContactDao.java // 增删改查DAO ├── model/Contact.java // 数据模型 └── adapter/ContactAdapter.java // 列表适配器资源方面,你需要准备一个列表item的layout:item_contact.xml,里面放两个TextView,一个显示姓名,一个显示电话。主布局就一个ListView加一个浮动按钮(或者底部按钮)用来添加联系人。颜色主题既然叫“绿豆”,整个主色调可以往绿色靠,像#4CAF50这种Material Design绿色就挺合适的。
4.2 核心流程的完整串联:从按钮点击到列表刷新
现在我把整个流程串起来。MainActivity的onCreate里做这几件事:
- 初始化数据库帮助类和DAO;
- 绑定ListView和Adapter,调用
queryAll()加载已有数据; - 设置添加按钮的点击事件,弹添加对话框;
- 设置ListView item的点击事件,弹编辑对话框;
- 设置ListView item的长按事件,弹删除确认。
拿“添加联系人”这一步举例,完整逻辑是这样的:
private void showAddDialog() { View dialogView = getLayoutInflater().inflate(R.layout.dialog_contact_edit, null); EditText etName = dialogView.findViewById(R.id.et_name); EditText etPhone = dialogView.findViewById(R.id.et_phone); EditText etRemark = dialogView.findViewById(R.id.et_remark); new AlertDialog.Builder(this) .setTitle("添加联系人") .setView(dialogView) .setPositiveButton("保存", (dialog, which) -> { String name = etName.getText().toString().trim(); String phone = etPhone.getText().toString().trim(); String remark = etRemark.getText().toString().trim(); if (name.isEmpty() || phone.isEmpty()) { Toast.makeText(this, "姓名和电话不能为空", Toast.LENGTH_SHORT).show(); return; } Contact contact = new Contact(0, name, phone, remark); long rowId = dao.add(contact); if (rowId > 0) { Toast.makeText(this, "添加成功", Toast.LENGTH_SHORT).show(); refreshList(); } else { Toast.makeText(this, "添加失败", Toast.LENGTH_SHORT).show(); } }) .setNegativeButton("取消", null) .show(); }这里要注意三个地方。第一,EditText.getText().toString()拿到值之后一定要.trim(),不然用户输入里混一个空格,你的“空值判断”就失效了,数据里还存了一堆看不见的空格,以后查出来显示在界面上丑得要死。第二,dao.add()返回的是插入的行id,如果返回-1,说明插入失败。第三,refreshList()方法是统一的刷新入口,代码如下:
private void refreshList() { List<Contact> list = dao.queryAll(); adapter.setList(list); }Adapter里setList的内部再调用notifyDataSetChanged。这样结构就很清晰——数据操作和界面刷新完全分离。
4.3 真机调试与实测效果:数据到底存到了哪里
跑起来之后,添加几个测试联系人,重启App,验证数据还在不在。数据持久化这件事是SQLite的天职,如果重启后数据丢了,那问题基本都出在数据库文件路径、表名不匹配或者读写用的不是同一个数据库实例上。
想知道数据到底存没存进去,最夯的方式是看数据库文件。在Android Studio里,从菜单的“View -> Tool Windows -> App Inspection”打开Database Inspector,选择正在运行的模拟器或真机进程,里面会列出应用所有的数据库文件,包括green_contact.db。你点开contact表就能很直观地看到每一行的数据,增删改查的结果实时可见。这个工具简直是SQLite调试神器,比在代码里打Log看返回值直观一百倍。
另外一个验证方法是删掉App再重装。注意,SQLite数据存在应用私有目录/data/data/你的包名/databases/下,卸载App时这个目录会被删掉。所以“重启App数据还在”验证的是持久化能力,“卸载重装数据还在”这个就不要奢望了——那是云同步才该干的事。
4.4 功能增强:加一个搜索框会怎样
演示项目做到能增删改查就够交差了,但如果你想让绿豆通讯录更像一个真正的通讯录应用,强烈建议加一个搜索功能。不加的话,列表只有十几个人还好说,几百个人的时候用户找个电话号码得翻半天,这是基本功的延伸。
搜索的思路分两种。第一种是在内存里过滤:全量查出来放进List,然后根据用户输入的文本遍历List,用contains匹配姓名或电话,把匹配的结果set给Adapter。这种做法的优点是代码简单,不需要改SQL语句;缺点是如果联系人太多,每次搜索都遍历一遍内存List,性能不佳。第二种是SQL模糊查询:
public List<Contact> queryByNameOrPhone(String keyword) { SQLiteDatabase db = helper.getReadableDatabase(); String[] columns = null; String selection = COL_NAME + " LIKE ? OR " + COL_PHONE + " LIKE ?"; String[] selectionArgs = new String[]{"%" + keyword + "%", "%" + keyword + "%"}; Cursor cursor = db.query(TABLE_CONTACT, columns, selection, selectionArgs, null, null, null); // 循环取值略... }我推荐用第二种,因为LIKE查询在数据库层就完成了筛选,你只需要把查询到的结果展示出来,逻辑上更清爽。而且LIKE ?配合%关键字%这种写法是标准的模糊查询姿势。这里要提醒一个细节:LIKE的%要拼在参数里,也就是"%" + keyword + "%",不要拼在selection字符串里,原因还是那句话——用户输入的内容永远放在参数里,不拼进SQL语句结构里,为了安全也是为了避免通配符写法混乱。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
做绿豆通讯录这个项目,我在不同人身上见到过各种各样的问题,其中有一些几乎每次都会出现。直接整理成一张速查表,你遇到什么问题来对着查就行。
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| App启动后列表空白,一条数据都没有 | 建表失败,或者查询时机早于插入时机 | 用Database Inspector看表是否存在,检查onCreate是否执行 |
| 添加联系人后列表不变 | 忘记调用notifyDataSetChanged | 统一走setList方法,内部调用notifyDataSetChanged |
| 重启App后数据消失 | 数据库文件路径不对,或每次启动都重新创建了数据库 | 确认DB_NAME一致,检查是否有手贱调用了deleteDatabase |
| 长按删除没反应 | 长按事件没绑定,或delete条件写错找不到记录 | 确认setOnItemLongClickListener已设置,delete返回行数是否>0 |
| 添加中文联系人显示乱码 | 正常情况Android SQLite默认UTF-8,乱码多半是控制台显示问题 | 先看App界面,别盯着Logcat输出判断 |
| 修改联系人不生效 | update方法没传id,或者传的id和数据库里的主键对不上 | 打日志确认contact.getId()有值,确认WHERE条件里的字段名正确 |
| 数据库操作突然抛SQLiteException: no such column | 版本升级后没有执行onUpgrade,旧表结构没变 | DB_VERSION加1,onUpgrade里做迁移或重建表 |
| 操作大量数据后卡顿 | Cursor没关闭或者频繁全表查询 | 检查所有查询路径是否close,局部刷新替代全量刷新 |
5.2 几个值得单独说的避坑经验
第一个是数据库版本升级的问题。我见过很多人在开发阶段直接把DB_VERSION改成2,然后onUpgrade里写DROP TABLE重建。这个在开发期图省事没问题,但你要清楚这个操作的代价:所有已有数据全部清空。上线后的App要是这么干,用户更新一次版本联系人全没了,这是生产事故级别的bug。正解是onUpgrade里用ALTER TABLE去加列、建新表迁移数据,至少也要在新老版本之间做数据备份。练手项目你可以偷懒,但心里得有这根弦。
第二个是ListView item复用导致的显示错乱。这个坑出现在你有多种item类型、或者item里有状态信息时,最常见的是图片加载时,ListView滚动后item显示成了之前位置的内容。原因就是convertView复用了,你只set了部分字段的值,没把之前的数据清掉。同行项目里表现就是:你点了一条联系人修改备注,返回列表发现相邻几行的备注也变了,这多半就是数据源没更新到位导致的视图错乱。解决方法是每次getView都把所有需要展示的字段set一边,或者用if (convertView == null)分支做完整初始化。
第三个是关于数据库连接管理的。SQLiteOpenHelper的实例最好用一个单例持有,如果你在Activity里每次操作数据库都new MyDatabaseHelper(this),那要保证用完都close掉。实际上更推荐的做法是写一个简单的单例:
public class DbManager { private static volatile DbManager instance; private MyDatabaseHelper helper; private DbManager(Context context) { helper = new MyDatabaseHelper(context.getApplicationContext()); } public static DbManager getInstance(Context context) { if (instance == null) { synchronized (DbManager.class) { if (instance == null) { instance = new DbManager(context); } } } return instance; } }为什么要用getApplicationContext()而不是Activity实例?因为数据库帮助类可能被多个Activity引用,如果用Activity的Context,而Activity又被销毁了,那这个数据库访问层持有的就是一份无效的上下文,容易出问题。ApplicationContext是全局的,生命周期与进程一致,适合让DAO长期持有。
第四个经验是关于Toast和刷新时机的。不要在数据库操作代码里直接弹Toast,应该在Activity层根据操作结果再弹。比如dao.add()返回-1,那是数据库层面失败了,你在Activity里提示“添加失败”才有意义。如果你在DAO里直接弹完Toast又返回,那个Toast其实是用的DAO持有的Context,大部分情况下能弹出来,但它把界面表现和业务逻辑混在了一起,代码维护起来会越来越乱。好的分层是这样的:DAO只负责数据,不碰任何UI;Activity只负责UI,不写SQL。
6. 实操心得与额外建议
做完绿豆通讯录这个项目,我个人最大的感受是:SQLite的知识点其实不多,但“亲手写一遍”和“看一遍教程”之间的差距非常大。很多人看教程时觉得insert、query、Cursor这些概念都懂了,真到自己写的时候,光是搞明白db.query()那七个参数分别填什么就得鼓捣半天。所以我的建议特别简单:不要停留在收藏文章,把这个项目跟着敲一遍,改一改,跑起来。
过程中有几个可以继续深挖的扩展方向。一个是用RecyclerView替换ListView,RecyclerView的适配器机制更现代,滚动性能更好,现在新项目基本都用它;一个是引入Room框架,你会发现Room帮你把很多样板代码自动生成了,写起来优雅不少,但你已经明白了SQLite底层的原理,用Room时遇到问题不会一脸懵;还有一个是给联系人的name字段加索引,体验一下“百万数据下SQLite查询依然飞快”的快感,顺便理解什么是数据库索引。
最后再分享一个我自己调试SQLite的土办法。如果你遇到“感觉数据有问题但界面看不出哪里不对”,别急着改代码,先打开Database Inspector,看看原始数据长什么样。是数据本身存错了,还是读取后展示在界面上出错了,这两者的排查方向完全不同。数据存错了去查插入逻辑,数据没问题就去看Adapter和布局。SQLite本身是非常可靠的工具,绝大多数时候问题出在你自己的代码上,但只要按照“数据层查数据、界面层查渲染”的思路,问题很快就能定位。