简介这份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 开始每次改表结构就加 1onUpgrade 才会被触发。_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; } // 更新同样用 ContentValueswhere 条件用占位符防注入 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 布局里至少要有两个 TextViewid 分别是 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 会关掉旧 CursorswapCursor 不会。如果你在别处还持有旧 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_VERSIONonUpgrade 不触发新字段不存在查询直接抛异常。血泪经验是每次动 CREATE_TABLE 就必须把 DB_VERSION 加 1并且 onUpgrade 里不要无脑 DROP生产环境要写 ALTER TABLE 迁移。演示项目可以 DROP 重建但你要清楚这只适合开发阶段。另外如果用户已经装了旧版升级后数据会丢这是 DROP 的代价。4.5 模糊查询输入特殊字符导致崩溃用户在搜索框输入 % 或 _ 时LIKE 语句会把这些当通配符返回意料之外的结果甚至因为拼接问题报错。解决是在 queryByName 里对 keyword 做转义把 % 替换成 %_ 替换成 _并在 SQL 里加 ESCAPE 。或者更简单用 instr(name, ?) 0 代替 LIKEinstr 是精确子串匹配不涉及通配符通讯录场景完全够用。5. 进阶技巧用事务批量导入与 Cursor 生命周期验证批量导入通讯录是常见需求比如从 CSV 导入几百条。如果一条条 insert每条都开一次数据库连接速度慢到怀疑人生。正确做法是用事务包起来。public void batchInsert(ListContact 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 检查确认没有泄漏才提交代码。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?