☰
Android通讯录实战:SQLite与ListView增删查改及刷新机制
2026/10/14 16:27:53 网站建设 项目流程

简介:这是一份面向Android初学者的通讯录实战案例资源,围绕SQLite数据库与ListView组件,完整演示联系人信息的增删查改流程。案例从SQLiteOpenHelper建库建表讲起,涵盖insert、delete、update及LIKE模糊查询等核心操作,并通过自定义BaseAdapter将数据绑定到ListView,帮助理解数据模型设计、UI交互与数据绑定的配合方式。资源包共50个文件,约2.03MB,包含14个class、12个xml布局与配置、11个png图片资源、3个java源码及apk、jar、dex等编译产物,结构完整,可直接导入运行对照学习。目前已有3428人学习下载,适合刚接触Android数据存储、希望用一个完整小项目打通数据库与列表展示的开发者,也可作为课程设计或练手项目的参考模板。

1. 通讯录案例为什么总在 SQLite 和 ListView 之间翻车

很多人第一次做 Android 通讯录,代码写完一跑就懵:数据明明插进去了,ListView 就是不刷新;或者点了删除,列表空了,重新打开 App 数据又回来了。这个标题讲的就是用 SQLite 做本地持久化、用 ListView 做界面展示,把通讯录的增删查改四个动作串成一条完整链路。它适合两类人:一是刚学完 Android 四大组件、想找一个能跑通的小项目练手的新手;二是工作里需要快速搭一个本地联系人管理模块、不想引入 Room 或网络请求的开发者。核心难点不在单个 API 会不会调,而在「数据写入之后,界面怎么知道要刷新」以及「删除和修改时,怎么保证数据库和列表项对得上」。把这两个问题解决,这个案例才算真正落地。

2. 先定数据层:SQLite 建库建表和联系人实体怎么设计

2.1 联系人表结构不是随便拍脑袋

通讯录的字段看起来简单,但真写起来,姓名、电话、备注这三项就够用,别一上来就加头像路径、分组 ID、拼音索引。新手最容易犯的错是表结构设计得太复杂,结果增删查改的 SQL 写不对,调试成本翻倍。常见做法是建一张contacts表,主键用自增_id,这是 Android 里CursorAdapter默认认的列名,换成id也能跑,但后面用SimpleCursorAdapter时得多写一个参数。

CREATE TABLE contacts ( _id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, phone TEXT NOT NULL, remark TEXT );

_id用INTEGER PRIMARY KEY AUTOINCREMENT是 SQLite 的标准自增写法,name和phone设NOT NULL是为了防止插入空记录,remark允许为空,因为备注不是必填项。这里不建议给phone加UNIQUE约束,现实里一个号码可能对应多个联系人,加了唯一约束反而会在插入时报SQLiteConstraintException,新手看到这个异常往往不知道是约束冲突。

2.2 用 SQLiteOpenHelper 管理版本,别手动建库

直接调SQLiteDatabase.openOrCreateDatabase也能建库,但版本升级、onCreate 和 onUpgrade 的回调就全得自己管。标准做法是继承SQLiteOpenHelper,把建表语句放在onCreate里,版本号写在构造函数。

public class DbHelper extends SQLiteOpenHelper { private static final String DB_NAME = "contacts.db"; private static final int DB_VERSION = 1; public DbHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL("CREATE TABLE contacts (" + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + "name TEXT NOT NULL," + "phone TEXT NOT NULL," + "remark TEXT)"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL("DROP TABLE IF EXISTS contacts"); onCreate(db); } }

DB_VERSION从 1 开始,以后加字段就把它加 1,并在onUpgrade里写ALTER TABLE语句。注意onUpgrade里直接DROP TABLE再重建,会丢数据,生产环境不能这么干,但练手阶段可以接受。super(context, DB_NAME, null, DB_VERSION)第三个参数传null表示用默认的 CursorFactory,不用改。

2.3 封装一个 ContactDao,把增删查改收口

不要让 Activity 直接写 SQL,否则后面改表结构会改到崩溃。建一个ContactDao类,把四个操作包成方法。

public class ContactDao { private final DbHelper helper; public ContactDao(Context context) { helper = new DbHelper(context); } public long insert(String name, String phone, String remark) { SQLiteDatabase db = helper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put("name", name); values.put("phone", phone); values.put("remark", remark); long id = db.insert("contacts", null, values); db.close(); return id; } public int delete(long id) { SQLiteDatabase db = helper.getWritableDatabase(); int rows = db.delete("contacts", "_id = ?", new String[]{String.valueOf(id)}); db.close(); return rows; } public int update(long id, String name, String phone, String remark) { SQLiteDatabase db = helper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put("name", name); values.put("phone", phone); values.put("remark", remark); int rows = db.update("contacts", values, "_id = ?", new String[]{String.valueOf(id)}); db.close(); return rows; } public Cursor queryAll() { SQLiteDatabase db = helper.getReadableDatabase(); return db.query("contacts", null, null, null, null, null, "name ASC"); } }

insert返回新记录的_id,delete和update返回受影响行数,这两个返回值一定要检查,等于 0 说明条件没匹配上。queryAll返回Cursor,注意这里没有关闭db,因为Cursor还在用,关掉数据库连接会导致Cursor失效。正确做法是在 Activity 里用完Cursor后调cursor.close(),数据库连接由SQLiteOpenHelper内部缓存管理,不用每次手动关。

3. 再接界面层:ListView 和 CursorAdapter 怎么绑数据

3.1 布局文件只放一个 ListView 和一个按钮

主界面布局不用复杂,一个ListView占满剩余空间,底部放一个Button用来新增。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <ListView android:id="@+id/lv_contacts" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" /> <Button android:id="@+id/btn_add" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="新增联系人" /> </LinearLayout>

layout_weight="1"让 ListView 撑满按钮以外的区域,这是 LinearLayout 里最稳的写法。列表项布局单独写一个item_contact.xml,里面放两个TextView,一个显示姓名,一个显示电话。

3.2 用 SimpleCursorAdapter 把 Cursor 直接喂给 ListView

SimpleCursorAdapter是 ListView 和 SQLite 之间最省事的桥,它要求Cursor里必须包含_id列,否则会抛IllegalArgumentException。

public class MainActivity extends AppCompatActivity { private ListView lvContacts; private ContactDao dao; private SimpleCursorAdapter adapter; private Cursor cursor; @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, new String[]{"name", "phone"}, new int[]{R.id.tv_name, R.id.tv_phone}, 0); lvContacts.setAdapter(adapter); findViewById(R.id.btn_add).setOnClickListener(v -> showAddDialog()); } @Override protected void onDestroy() { super.onDestroy(); if (cursor != null && !cursor.isClosed()) { cursor.close(); } } }

SimpleCursorAdapter的构造函数里,第四个参数是Cursor的列名数组,第五个参数是布局里对应的TextView的 ID 数组,顺序要一一对应。最后一个参数0是 flags,传0就行,不用加FLAG_REGISTER_CONTENT_OBSERVER,因为后面手动调changeCursor更可控。

3.3 增删改之后必须换 Cursor,否则列表不刷新

这是整个案例里最容易翻车的地方。插入一条数据后,数据库变了,但SimpleCursorAdapter手里的Cursor还是旧的,ListView 自然不动。正确做法是每次增删改之后重新查询,然后调adapter.changeCursor(newCursor)。

private void refreshList() { Cursor newCursor = dao.queryAll(); adapter.changeCursor(newCursor); if (cursor != null && !cursor.isClosed()) { cursor.close(); } cursor = newCursor; }

changeCursor会自动关闭旧Cursor,但为了保险,还是手动关一次。注意不要调adapter.notifyDataSetChanged(),SimpleCursorAdapter不吃这一套,它只认changeCursor。这个坑我见过太多人踩,现象就是数据插进去了,列表死活不更新,一查数据库发现数据在,就是界面不动。

4. 增删查改四个动作的完整实现和参数细节

4.1 新增:用 AlertDialog 收输入,插入后立刻刷新

新增联系人用一个AlertDialog包一个自定义布局,里面放三个EditText。

private void showAddDialog() { View view = LayoutInflater.from(this) .inflate(R.layout.dialog_contact, null); EditText etName = view.findViewById(R.id.et_name); EditText etPhone = view.findViewById(R.id.et_phone); EditText etRemark = view.findViewById(R.id.et_remark); new AlertDialog.Builder(this) .setTitle("新增联系人") .setView(view) .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; } dao.insert(name, phone, remark); refreshList(); }) .setNegativeButton("取消", null) .show(); }

trim()去掉首尾空格,防止用户输入纯空格绕过非空校验。dao.insert返回的_id这里没接,因为不需要。插入后立刻refreshList(),列表就能看到新数据。注意AlertDialog的setPositiveButton默认点击后关闭对话框,如果校验不通过想阻止关闭,得在onShow里重写按钮点击事件,但这里校验失败只是提示,对话框关掉也能接受。

4.2 删除:长按列表项弹确认框,按 _id 删

给 ListView 加OnItemLongClickListener,拿到Cursor当前行的_id。

lvContacts.setOnItemLongClickListener((parent, view, position, id) -> { new AlertDialog.Builder(this) .setTitle("删除联系人") .setMessage("确定要删除这条记录吗?") .setPositiveButton("删除", (dialog, which) -> { dao.delete(id); refreshList(); }) .setNegativeButton("取消", null) .show(); return true; });

OnItemLongClickListener的第四个参数id就是Cursor里_id列的值,SimpleCursorAdapter自动帮你取好了,不用自己去cursor.moveToPosition(position)再getLong。return true表示事件已消费,不会再触发点击事件。删除后同样refreshList()。

4.3 修改:点击列表项回填数据,更新后刷新

修改的入口是点击列表项,弹出和新增类似的对话框,但输入框里预填当前值。

lvContacts.setOnItemClickListener((parent, view, position, id) -> { Cursor c = (Cursor) adapter.getItem(position); String name = c.getString(c.getColumnIndexOrThrow("name")); String phone = c.getString(c.getColumnIndexOrThrow("phone")); String remark = c.getString(c.getColumnIndexOrThrow("remark")); View dialogView = LayoutInflater.from(this) .inflate(R.layout.dialog_contact, 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); etName.setText(name); etPhone.setText(phone); etRemark.setText(remark); new AlertDialog.Builder(this) .setTitle("编辑联系人") .setView(dialogView) .setPositiveButton("保存", (dialog, which) -> { String newName = etName.getText().toString().trim(); String newPhone = etPhone.getText().toString().trim(); String newRemark = etRemark.getText().toString().trim(); if (newName.isEmpty() || newPhone.isEmpty()) { Toast.makeText(this, "姓名和电话不能为空", Toast.LENGTH_SHORT).show(); return; } dao.update(id, newName, newPhone, newRemark); refreshList(); }) .setNegativeButton("取消", null) .show(); });

adapter.getItem(position)返回的就是当前行的Cursor,已经定位到position,直接getString就行。getColumnIndexOrThrow比getColumnIndex更安全,列名写错会直接抛异常,而不是返回 -1 导致后面越界。更新完调refreshList(),列表立刻反映新值。

4.4 查询:按姓名模糊搜索的 SQL 和 Cursor 重查

查询功能可以加一个SearchView或者EditText,输入关键字后重新查数据库。

public Cursor queryByName(String keyword) { SQLiteDatabase db = helper.getReadableDatabase(); return db.query("contacts", null, "name LIKE ?", new String[]{"%" + keyword + "%"}, null, null, "name ASC"); }

LIKE ?里的?是占位符,"%"+keyword+"%"表示包含关键字的任意位置匹配。不要用字符串拼接"name LIKE '%" + keyword + "%'",那样有 SQL 注入风险,虽然本地数据库风险低,但习惯要养好。查询结果同样通过changeCursor换给 adapter。

5. 避坑排查:通讯录案例里最容易翻车的五个地方

5.1 列表不刷新,数据却在数据库里

现象:插入后Toast提示成功,数据库用adb shell查也有记录,但 ListView 就是不动。原因:SimpleCursorAdapter持有的Cursor是旧的,没有调changeCursor。解决:每次增删改后重新queryAll,然后adapter.changeCursor(newCursor),不要调notifyDataSetChanged。

5.2 删除时报 IllegalArgumentException: column '_id' does not exist

现象:一运行就崩,日志指向SimpleCursorAdapter构造函数。原因:建表时主键列名写成了id而不是_id,或者query时用了rawQuery但SELECT语句里没带_id。解决:建表语句主键统一用_id,rawQuery也要显式写SELECT _id, name, phone FROM contacts。

5.3 修改后数据错位,改 A 结果 B 变了

现象:点击第二条记录编辑,保存后第一条变了。原因:OnItemClickListener里的id参数用成了position,position是列表位置,id才是数据库主键。解决:dao.update和dao.delete一律传id,不要传position。如果列表有排序或过滤,两者更是不一致。

5.4 插入中文乱码或显示问号

现象:数据库里存的中文,ListView 显示成问号。原因:Cursor取数据时用了getBlob或者编码不对,但更常见的是TextView的字体或系统语言设置问题。解决:确认getString取的是TEXT列,数据库连接没有指定错误编码,SQLite 默认 UTF-8,一般不会乱码。如果真乱码,检查ContentValues.put时传的是不是String。

5.5 频繁操作后 Cursor 泄漏,App 越来越卡

现象:反复增删改几十次后,界面卡顿,日志里出现CursorWindow相关警告。原因:每次queryAll返回新Cursor,旧的没有关闭。解决:refreshList里changeCursor之后手动close旧Cursor,onDestroy里关闭最后一个Cursor。SimpleCursorAdapter.changeCursor虽然会关旧Cursor,但自己再关一次不冲突,养成习惯。

6. 进阶技巧:把通讯录改成可搜索、可排序、可批量删的版本

基础版跑通之后,可以往上加三个实用功能。第一个是实时搜索:在EditText上加TextWatcher,onTextChanged里调queryByName并changeCursor,注意每次输入都查库会有性能开销,可以加 300 毫秒防抖,用Handler.postDelayed实现。第二个是排序切换:在queryAll里把"name ASC"改成动态参数,按姓名或按_id倒序,排序字段不要直接拼用户输入,用白名单映射。

public Cursor queryAll(String orderBy) { String order; switch (orderBy) { case "name": order = "name ASC"; break; case "newest": order = "_id DESC"; break; default: order = "name ASC"; } SQLiteDatabase db = helper.getReadableDatabase(); return db.query("contacts", null, null, null, null, null, order); }

orderBy参数只接受预定义的几个值,不要直接把用户输入拼进 SQL。第三个是批量删除:把ListView的choiceMode设成CHOICE_MODE_MULTIPLE,长按进入多选模式,顶部显示删除按钮,遍历getCheckedItemIds()拿到所有选中的_id,在事务里批量删。

public int deleteBatch(long[] ids) { SQLiteDatabase db = helper.getWritableDatabase(); StringBuilder sb = new StringBuilder(); for (int i = 0; i < ids.length; i++) { if (i > 0) sb.append(","); sb.append(ids[i]); } int rows = db.delete("contacts", "_id IN (" + sb + ")", null); db.close(); return rows; }

ids是long数组,拼成IN (1,2,3)的形式,这里拼接是安全的,因为ids来自getCheckedItemIds(),不是用户直接输入。批量删除放在事务里更快,但delete单条语句本身就是一个事务,数据量不大时不用额外包beginTransaction。

验证方法很简单:装到真机上,连续新增 20 条,然后按姓名搜索、按最新排序、勾选 5 条批量删,再杀掉进程重开,看数据是否一致。如果重开后数据还在,说明 SQLite 持久化没问题;如果列表顺序和删除结果都对,说明_id和position没混用。我自己的习惯是每加一个功能就用adb shell进databases目录,用sqlite3 contacts.db "SELECT * FROM contacts;"看一眼原始数据,比在界面上猜靠谱得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询