Android SQLite实战:绿豆通讯录带你搞定本地存储与增删改查
做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重启后数据不丢。对应到数据库层面就是一张联系人表字段大概这样字段类型说明idINTEGER主键自增长nameTEXT联系人姓名必填phoneTEXT电话号码必填remarkTEXT备注信息可空这张表的设计有几点讲究。第一主键必须用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 ListContact queryAll() { SQLiteDatabase db helper.getReadableDatabase(); Cursor cursor db.query(TABLE_CONTACT, null, null, null, null, null, null); ListContact 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拿到下标再取。关游标这事我用一句话描述它的重要性你每次查询数据库都会开一个CursorCursor底层持有数据库的窗口资源不关就泄漏查的次数多了应用会变得异常卡顿甚至报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 ListContact list; private LayoutInflater inflater; public ContactAdapter(Context context, ListContact 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的layoutitem_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() { ListContact 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 ListContact 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加1onUpgrade里做迁移或重建表操作大量数据后卡顿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只负责数据不碰任何UIActivity只负责UI不写SQL。6. 实操心得与额外建议做完绿豆通讯录这个项目我个人最大的感受是SQLite的知识点其实不多但“亲手写一遍”和“看一遍教程”之间的差距非常大。很多人看教程时觉得insert、query、Cursor这些概念都懂了真到自己写的时候光是搞明白db.query()那七个参数分别填什么就得鼓捣半天。所以我的建议特别简单不要停留在收藏文章把这个项目跟着敲一遍改一改跑起来。过程中有几个可以继续深挖的扩展方向。一个是用RecyclerView替换ListViewRecyclerView的适配器机制更现代滚动性能更好现在新项目基本都用它一个是引入Room框架你会发现Room帮你把很多样板代码自动生成了写起来优雅不少但你已经明白了SQLite底层的原理用Room时遇到问题不会一脸懵还有一个是给联系人的name字段加索引体验一下“百万数据下SQLite查询依然飞快”的快感顺便理解什么是数据库索引。最后再分享一个我自己调试SQLite的土办法。如果你遇到“感觉数据有问题但界面看不出哪里不对”别急着改代码先打开Database Inspector看看原始数据长什么样。是数据本身存错了还是读取后展示在界面上出错了这两者的排查方向完全不同。数据存错了去查插入逻辑数据没问题就去看Adapter和布局。SQLite本身是非常可靠的工具绝大多数时候问题出在你自己的代码上但只要按照“数据层查数据、界面层查渲染”的思路问题很快就能定位。