简介:这份Android通讯录案例资源面向具备Java与Android基础的开发者,围绕SQLite数据库与ListView组件,完整演示联系人信息的增删查改实现。内容涵盖SQLiteOpenHelper子类设计、Contact表结构定义、insert/delete/update/query等数据库操作,以及自定义BaseAdapter完成数据绑定与列表项点击交互,并涉及LoaderManager异步加载与搜索过滤等性能优化思路,适合作为Android数据存储与UI组件综合练习的参考项目。资源包共50个文件,包含14个class、12个xml布局与配置、11个png图片、3个java源码及jar、apk、dex等编译产物,压缩包约2.03MB,目录结构清晰,便于直接导入运行与对照学习。目前已有3428人学习下载,可帮助开发者快速理解数据模型设计、数据库操作与UI交互的完整链路,为后续复杂应用开发打下基础。
1. 从一次数据丢失说起:这个通讯录案例到底能解决什么
去年帮一个做外包的朋友救火,他接了个小工具单子,需求简单到不能再简单——本地通讯录,能存名字、电话、备注,能搜能改能删。他图省事,用内存 List 存数据,结果用户切后台再回来,数据全没了,被投诉到差点退款。这种场景其实特别典型:数据量不大、不需要联网、但要持久化,SQLite 就是为这种需求量身定做的。这份 Android 案例把 SQLite 数据库和 ListView 绑在一起,做了一套完整的通讯录增删查改,覆盖了从建库建表、封装 DAO、到列表刷新、条目点击编辑的全链路。它适合两类人:一是刚学完 Android 四大组件、想找一个能跑通的小项目练手的;二是工作里偶尔要写本地存储模块、但每次都要翻旧代码的。核心价值不在于界面多花哨,而在于它把「数据怎么落盘、列表怎么跟着变」这条线讲透了,这是很多人做本地工具时最容易翻车的地方。
2. 建库建表与 DAO 封装:SQLiteOpenHelper 的三个关键参数
2.1 为什么不用 Room 而用原生 SQLite
现在官方主推 Room,但这份案例坚持用原生 SQLiteOpenHelper,原因很实际:通讯录这种单表、字段少、查询简单的场景,引入 Room 的注解处理器和依赖反而增加编译负担,而且很多老项目、教学环境、嵌入式设备上跑的还是原生 API。更关键的是,理解 SQLiteOpenHelper 的 onCreate 和 onUpgrade 回调,是搞懂 Android 数据持久化的基本功。Room 底层也是这套东西,只是包了一层。所以这个案例的选型逻辑是:用最少的依赖,把 CRUD 的每一步都暴露出来,方便你调试和改字段。
2.2 建表语句与字段设计
先看数据库帮助类的核心代码。表名用 contacts,字段包括自增主键 _id、姓名 name、电话 phone、备注 note,外加一个创建时间 create_time 方便排序。
public class DBHelper extends SQLiteOpenHelper { private static final String DB_NAME = "contacts.db"; private static final int DB_VERSION = 1; public static final String TABLE_NAME = "contacts"; // 建表语句:_id 必须用这个名,否则 SimpleCursorAdapter 不认 private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + "_id INTEGER PRIMARY KEY AUTOINCREMENT, " + "name TEXT NOT NULL, " + "phone TEXT NOT NULL, " + "note TEXT, " + "create_time INTEGER)"; public DBHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL(CREATE_TABLE); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 生产环境别直接 DROP,这里为了演示简单处理 db.execSQL("DROP TABLE IF EXISTS " + TABLE_NAME); onCreate(db); } }这里有几个参数值得说清楚。DB_VERSION 从 1 开始,每次改表结构就加 1,onUpgrade 才会被触发。_id 这个列名不是随便起的,后面用 SimpleCursorAdapter 绑定时,它默认找的就是 _id,你写成 id 会直接报错,这是新手最常见的翻车点之一。create_time 存的是 System.currentTimeMillis(),用 INTEGER 存长整型,比存字符串省空间也好排序。
2.3 DAO 层的增删查改封装
把数据库操作单独抽一个 DAO 类,是让代码能维护的关键。下面这套方法覆盖了插入、删除、更新、按姓名模糊查询、查全部。
public class ContactDao { private final DBHelper helper; public ContactDao(Context context) { helper = new DBHelper(context); } // 插入:用 ContentValues 比拼 SQL 安全,避免引号转义问题 public long insert(String name, String phone, String note) { SQLiteDatabase db = helper.getWritableDatabase(); ContentValues cv = new ContentValues(); cv.put("name", name); cv.put("phone", phone); cv.put("note", note); cv.put("create_time", System.currentTimeMillis()); long id = db.insert(DBHelper.TABLE_NAME, null, cv); db.close(); return id; // 返回 -1 表示插入失败 } // 删除:按 _id 删,返回值是受影响行数 public int delete(long id) { SQLiteDatabase db = helper.getWritableDatabase(); int rows = db.delete(DBHelper.TABLE_NAME, "_id = ?", new String[]{String.valueOf(id)}); db.close(); return rows; } // 更新:同样用 ContentValues,where 条件用占位符防注入 public int update(long id, String name, String phone, String note) { SQLiteDatabase db = helper.getWritableDatabase(); ContentValues cv = new ContentValues(); cv.put("name", name); cv.put("phone", phone); cv.put("note", note); int rows = db.update(DBHelper.TABLE_NAME, cv, "_id = ?", new String[]{String.valueOf(id)}); db.close(); return rows; } // 查询全部:按创建时间倒序,最新的在最上面 public Cursor queryAll() { SQLiteDatabase db = helper.getReadableDatabase(); return db.query(DBHelper.TABLE_NAME, null, null, null, null, null, "create_time DESC"); } // 模糊查询:name LIKE %keyword% public Cursor queryByName(String keyword) { SQLiteDatabase db = helper.getReadableDatabase(); return db.query(DBHelper.TABLE_NAME, null, "name LIKE ?", new String[]{"%" + keyword + "%"}, null, null, "create_time DESC"); } }insert 返回的 long 是行号,-1 代表失败,这个返回值一定要判断,不然用户点了保存没反应你都不知道哪错了。delete 和 update 返回受影响行数,可以用来判断是否真的改到了数据。query 方法返回 Cursor,注意这里没有 close db,因为 Cursor 还在用,关早了会崩。常见做法是在 Activity 的 onDestroy 里统一关,或者用 try-finally 包住。queryByName 里的 LIKE 占位符写法是 "%" + keyword + "%",不要直接拼进 SQL 字符串,否则用户输入单引号就炸了。
3. ListView 绑定 CursorAdapter:让列表跟着数据变
3.1 SimpleCursorAdapter 的构造与字段映射
ListView 显示数据库数据,最省事的方案是 SimpleCursorAdapter,它直接把 Cursor 的列映射到布局里的控件。构造它需要四个东西:上下文、item 布局、Cursor、从哪列取数据、填到哪个控件。
public class MainActivity extends AppCompatActivity { private ListView lvContacts; private ContactDao dao; private SimpleCursorAdapter adapter; private Cursor cursor; // from 是 Cursor 的列名,to 是 item 布局里的控件 id private static final String[] FROM = {"name", "phone"}; private static final int[] TO = {R.id.tv_name, R.id.tv_phone}; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); lvContacts = findViewById(R.id.lv_contacts); dao = new ContactDao(this); cursor = dao.queryAll(); adapter = new SimpleCursorAdapter( this, R.layout.item_contact, cursor, FROM, TO, CursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER); lvContacts.setAdapter(adapter); } }FROM 数组里的 "name" 和 "phone" 必须和建表时的列名完全一致,大小写敏感。TO 数组的顺序要和 FROM 一一对应,写反了就会出现姓名栏显示电话的尴尬。最后一个参数 FLAG_REGISTER_CONTENT_OBSERVER 很关键,它让适配器自动监听 Cursor 变化,配合后面说的 changeCursor 才能实现刷新。item 布局里至少要有两个 TextView,id 分别是 tv_name 和 tv_phone,这个自己建一个 XML 就行。
3.2 增删改之后如何刷新列表
很多人写完插入发现列表没变,以为代码错了,其实是 Cursor 没重新查。正确做法是每次数据变动后重新 query,然后调 adapter.changeCursor()。
private void refreshList() { Cursor newCursor = dao.queryAll(); // changeCursor 会接管新 Cursor 并释放旧的,不用手动 close 旧 cursor adapter.changeCursor(newCursor); cursor = newCursor; } // 插入示例 private void onAddConfirm(String name, String phone, String note) { long id = dao.insert(name, phone, note); if (id != -1) { refreshList(); Toast.makeText(this, "已保存", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(this, "保存失败", Toast.LENGTH_SHORT).show(); } }changeCursor 和 swapCursor 的区别要记牢:changeCursor 会关掉旧 Cursor,swapCursor 不会。如果你在别处还持有旧 Cursor 的引用,就用 swapCursor,否则用 changeCursor 更省心。这里每次 refresh 都重新 queryAll,数据量大时会有性能问题,但通讯录几百条数据完全无感。如果真要优化,可以在 query 里加 limit,或者用 Loader 机制,但那是另一个话题了。
3.3 条目点击进入编辑页的数据传递
点某一条要跳到编辑页,得把 _id 传过去,编辑页再根据 id 查详情。取 id 的方式是从 adapter 的 Cursor 里读。
lvContacts.setOnItemClickListener((parent, view, position, id) -> { // 这里的 id 就是 Cursor 里 _id 列的值,SimpleCursorAdapter 自动处理了 Intent intent = new Intent(MainActivity.this, EditActivity.class); intent.putExtra("contact_id", id); startActivity(intent); });注意 OnItemClickListener 回调里的第四个参数 id,当适配器是 CursorAdapter 时,它直接就是 _id 的值,不用自己去 getItemId。编辑页拿到 id 后,用 dao 查单条记录填充输入框,保存时再调 update。这里有个细节:如果编辑页修改后返回,MainActivity 要在 onResume 里调 refreshList,否则列表还是旧数据。我一般会在 onResume 里统一刷新,比用 startActivityForResult 简单。
4. 避坑与排查:五个让通讯录跑不起来的真实问题
4.1 列表一片空白,日志没有报错
现象是应用能启动,ListView 区域空的,但数据库里明明有数据。原因通常是 Cursor 查出来是空的,或者 FROM 里的列名和建表语句对不上。先打日志看 cursor.getCount(),如果是 0,检查 insert 是否真的成功、有没有调 db.close 之前就返回了。如果 count 大于 0 但界面不显示,检查 item 布局的根布局高度是不是写成了 wrap_content 导致每个 item 高度为 0,改成固定高度或 match_parent 试试。
4.2 插入成功但列表不刷新
这个前面提过,根因是 Cursor 没重新查询。SimpleCursorAdapter 不会自动感知数据库变化,必须手动 changeCursor。另一个隐蔽原因是 refreshList 里用了 swapCursor 但旧 Cursor 没关,导致内存泄漏,时间长了列表卡顿。解决就是统一用 changeCursor,并且不要在别处持有 Cursor 引用。
4.3 中文姓名排序乱掉
现象是按姓名排序时,中文顺序完全不对。原因是 SQLite 默认的 ORDER BY 对中文按 Unicode 码点排,不是拼音。常见做法是查询时不排序,在 Java 层用 Collator 排,或者存一个拼音字段。如果数据量小,直接在 queryAll 里去掉 orderBy,拿到 Cursor 后转成 List 用 Collections.sort 配合 Collator.getInstance(Locale.CHINA) 处理。这个坑在英文环境测试时发现不了,一上中文数据就暴露。
4.4 数据库版本升级后应用崩溃
改了表结构但忘了改 DB_VERSION,onUpgrade 不触发,新字段不存在,查询直接抛异常。血泪经验是每次动 CREATE_TABLE 就必须把 DB_VERSION 加 1,并且 onUpgrade 里不要无脑 DROP,生产环境要写 ALTER TABLE 迁移。演示项目可以 DROP 重建,但你要清楚这只适合开发阶段。另外,如果用户已经装了旧版,升级后数据会丢,这是 DROP 的代价。
4.5 模糊查询输入特殊字符导致崩溃
用户在搜索框输入 % 或 _ 时,LIKE 语句会把这些当通配符,返回意料之外的结果,甚至因为拼接问题报错。解决是在 queryByName 里对 keyword 做转义,把 % 替换成 %,_ 替换成 _,并在 SQL 里加 ESCAPE ''。或者更简单,用 instr(name, ?) > 0 代替 LIKE,instr 是精确子串匹配,不涉及通配符,通讯录场景完全够用。
5. 进阶技巧:用事务批量导入与 Cursor 生命周期验证
批量导入通讯录是常见需求,比如从 CSV 导入几百条。如果一条条 insert,每条都开一次数据库连接,速度慢到怀疑人生。正确做法是用事务包起来。
public void batchInsert(List<Contact> list) { SQLiteDatabase db = helper.getWritableDatabase(); db.beginTransaction(); try { for (Contact c : list) { ContentValues cv = new ContentValues(); cv.put("name", c.name); cv.put("phone", c.phone); cv.put("note", c.note); cv.put("create_time", System.currentTimeMillis()); db.insert(DBHelper.TABLE_NAME, null, cv); } db.setTransactionSuccessful(); // 不调这句,事务不会提交 } finally { db.endTransaction(); db.close(); } }beginTransaction 到 endTransaction 之间的操作要么全成功要么全回滚,setTransactionSuccessful 是提交标记,忘了写就等于白干。实测 500 条数据,逐条插入要 3 秒以上,事务包裹后 200 毫秒以内,差距就是这么直接。
验证 Cursor 有没有泄漏,我一般用 StrictMode。在 Application 的 onCreate 里开启:
StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder() .detectLeakedSqlLiteObjects() .detectLeakedClosableObjects() .penaltyLog() .build());跑一遍增删查改,如果 Logcat 里出现 "A SQLiteConnection object for database was leaked",说明有 Cursor 或 db 没关。通讯录这种小项目最容易忽略 close,但养成习惯后,换到复杂项目能省掉大量排查时间。从那以后我每次写完 DAO 方法,都强制走一遍 StrictMode 检查,确认没有泄漏才提交代码。希望帮到你。
本文还有配套的精品资源,点击获取