摘要:本文以通讯录管理应用为实战案例,手把手带读者从零搭建一个完整的 Android 应用。内容涵盖开发环境配置、主界面与详情页 UI 设计、SQLite 数据库建表、数据模型封装、增删改查工具类编写、ListView 列表展示、选项菜单绑定、界面状态控制以及常见报错排查与性能优化,帮助初学者打通从界面到数据流转的完整链路。
关键词:Android 开发;SQLite;ListView;Adapter;通讯录应用;数据持久化;增删改查
在移动应用开发的学习路径中,通讯录管理往往是一个极具代表性的实战项目。它看似功能简单,却完整涵盖了从界面布局、数据持久化到交互逻辑的核心流程。很多初学者在接触 Android 开发时,容易陷入"只懂控件用法,不懂数据流转"的困境,导致做出的应用要么界面僵硬,要么数据一关闭就丢失。实际上,构建一个健壮的通讯录应用,关键在于如何将 UI 组件与底层数据存储无缝衔接,以及如何优雅地处理用户的多步操作状态。
如果你正在寻找一个能够串联起 SQLite 数据库、自定义 Adapter、菜单事件处理以及生命周期管理的综合案例,那么本文的内容将非常契合你的需求。我们将不再局限于零散的知识点讲解,而是通过从零搭建一个完整的通讯录应用,一步步拆解其中的技术难点。无论你是希望巩固基础的学生开发者,还是想要快速回顾核心流程的资深工程师,这个过程都能帮助你理清思路,掌握构建数据驱动型应用的通用方法论。
接下来,我们将深入代码细节,从环境配置开始,逐步实现联系人列表的展示、详情查看、数据增删改查等核心功能,并重点探讨在实际开发中如何规避常见陷阱,提升应用的稳定性和用户体验。
在动手编码之前,我们先从整体上把握这个通讯录应用的数据流转路径。下图清晰地展示了从界面层到数据库层的完整调用链:

各层职责如下:
- Activity 界面层:负责展示列表、详情页等 UI,接收用户点击与输入事件,是数据流转的起点与终点。
- Adapter 适配层 :作为界面与数据之间的桥梁,将
Contact对象渲染为列表项,并把用户操作转发给数据层。 - DatabaseManager 数据操作层 :集中封装
insert、update、delete、queryAll等增删改查方法,屏蔽底层 SQL 细节。 - SQLite 数据库 :负责数据的持久化存储,返回
Cursor结果集供上层解析。
① 开发环境准备与项目初始化配置
工欲善其事,必先利其器。在开始编写任何代码之前,确保拥有一个稳定且配置正确的开发环境是至关重要的。下面我们按步骤拆解,从安装工具到创建项目,一步步带你跑通环境。
1. 安装 Android Studio
首先,你需要安装最新版本的 Android Studio,这是官方推荐的集成开发环境,内置了完善的模拟器管理和代码提示功能。前往 Android 开发者官网 下载对应你操作系统的安装包(Windows / macOS / Linux 均可)。安装过程中,建议勾选以下组件:
- Android SDK:编译和运行应用所必需的基础工具包。
- Android SDK Platform-Tools :包含
adb等调试工具,后续排查设备连接问题会用到。 - Android Emulator:用于在电脑上运行虚拟设备,方便快速预览效果。
安装完成后,首次启动 Android Studio 会自动下载缺失的 SDK 组件,请耐心等待,并确保网络畅通。
2. 创建新项目
打开 Android Studio,点击 New Project 。在模板选择界面中,选择 "Empty Views Activity" 模板即可,避免引入不必要的依赖复杂度。这个模板会为你生成一个最精简的 Activity 骨架,非常适合从零开始的教学场景。
在项目创建向导中,需要配置以下关键参数:
- Name :项目名称,例如
ContactsApp。 - Package name :应用包名,例如
com.example.contacts,它是应用在系统中的唯一标识,后续所有类的包路径都基于此。 - Language :语言建议选择 Kotlin 或 Java,这取决于你的技术栈偏好。本文将以 Java 为例进行演示,因为其语法结构在理解底层逻辑上更为直观。
- Minimum SDK :最低 SDK 版本建议设置为 API 21 (Android 5.0),这样可以覆盖绝大多数现存设备,同时也能使用较为现代的 API 特性。
3. 检查并配置 build.gradle
完成创建后,第一件事是检查项目根目录下的 build.gradle 文件,确保没有冗余的插件依赖。重点确认以下几点:
- SDK 版本一致性 :
compileSdk与targetSdk建议保持一致,minSdk与向导中设置的一致。 - 依赖精简 :删除模板中自动生成但用不到的依赖,例如默认的
ConstraintLayout我们会在后续章节用到,但一些示例性的测试依赖可以移除。 - Java 版本 :在
android块中配置compileOptions,将sourceCompatibility和targetCompatibility设置为JavaVersion.VERSION_1_8,避免因版本不匹配导致的编译报错。
4. 配置 AndroidManifest.xml
为了后续方便调试数据库,建议在 AndroidManifest.xml 中适当配置日志权限。虽然现代 Android 版本对权限管理日益严格,但基础的读写操作在沙盒内部通常是默认允许的,无需额外申请外部存储权限,除非涉及文件导出功能。
此外,请确认 <application> 标签中已正确声明主 Activity,并设置了 android:exported="true"(API 31 及以上要求显式声明),否则应用可能无法正常启动。
5. 验证环境
完成上述配置后,点击工具栏上的 Run 按钮(绿色三角形),选择一个模拟器或连接真机,运行一次空项目。如果应用能正常启动并显示空白页面,说明你的开发环境已经就绪,可以进入下一章节开始编写通讯录的界面布局了。
② 通讯录主界面布局设计与组件规划
主界面是用户进入应用后的第一触点,其设计原则应当是简洁高效。我们需要一个能够容纳大量数据的列表容器,以及一个用于触发新增操作的浮动按钮。在 res/layout/activity_main.xml 中,核心组件是 ListView 或更推荐的 RecyclerView。考虑到教学清晰性,这里我们先用 ListView 来阐述基本原理,它的适配器模式非常经典。
1. 布局整体结构
布局结构可以采用 ConstraintLayout 作为根节点,将 ListView 设置为占据除底部或浮动按钮外的所有空间。为了方便后续扩展,我们可以给 ListView 定义一个明确的 ID,例如 @+id/contact_list_view。在屏幕右下角放置一个 FloatingActionButton (FAB),用于唤起新建联系人的对话框或跳转页面。这种布局不仅符合 Material Design 规范,也能让用户直观地找到核心操作入口。
下面是 activity_main.xml 的完整布局代码:
xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ListView
android:id="@+id/contact_list_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:divider="#CCCCCC"
android:dividerHeight="1dp"
android:padding="8dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_add"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:contentDescription="新增联系人"
android:src="@android:drawable/ic_input_add"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
2. 关键组件说明
ListView:作为联系人列表的容器,通过android:divider和android:dividerHeight设置列表项之间的分隔线,让界面更清晰易读。0dp的宽高配合ConstraintLayout的约束,使其自动填满父容器剩余空间。FloatingActionButton:右下角的悬浮按钮,是新增联系人的核心入口。android:contentDescription用于无障碍描述,方便屏幕阅读器朗读,这也是 Material Design 规范中强调的可访问性细节。
3. 列表项布局规划
列表项是列表界面的最小展示单元,我们将其定义在 res/layout/item_contact.xml 中。每个列表项展示联系人的姓名和电话号码,采用垂直排列的 LinearLayout:
xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_phone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="#666666" />
</LinearLayout>
4. 布局设计要点
- 单位规范 :切记,不要在 XML 中硬编码具体的像素值,尽量使用
dp和sp单位,以保证在不同密度屏幕上的显示效果一致。其中dp用于控件尺寸和间距,sp用于文字大小,这样文字会跟随系统字体缩放设置。 - ID 命名 :为每个控件定义语义清晰的 ID,如
contact_list_view、fab_add、tv_name、tv_phone,便于在 Java 代码中通过findViewById准确引用,也方便团队协作时快速理解。 - 可访问性 :为图标类控件(如 FAB)添加
contentDescription,为纯装饰性控件设置importantForAccessibility="no",让应用对无障碍用户更友好。 - 空状态提示 :当联系人列表为空时,可以给
ListView设置一个空视图(如android:empty指向一个提示 TextView),避免用户面对一片空白不知所措。这一步我们会在后续章节结合代码实现。
③ 联系人详情查看页面的 UI 构建方法
当用户点击列表中的某一项时,需要跳转到详情页查看完整信息,或者进行编辑。详情页的 UI 构建重点在于表单的合理排布。通常包含姓名、电话号码、邮箱地址等字段。在 res/layout/activity_detail.xml 中,我们可以使用 LinearLayout 垂直排列多个 TextInputLayout 包裹的 EditText。
1. 表单字段设计
TextInputLayout 的优势在于它能自动处理错误提示信息的显示动画,提升用户体验。每个输入框都应设置合适的 inputType,例如电话号码字段设置为 phone,这样弹出的键盘会直接显示数字键区;邮箱字段设置为 textEmailAddress。姓名、电话、邮箱三个字段分别对应 Contact 模型中的 name、phone、email 属性,与数据库表结构一一对应。
下面是 activity_detail.xml 的完整布局代码:
xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="姓名">
<EditText
android:id="@+id/et_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="电话">
<EditText
android:id="@+id/et_phone"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="phone" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="邮箱">
<EditText
android:id="@+id/et_email"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="16dp">
<Button
android:id="@+id/btn_save"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="保存" />
<Button
android:id="@+id/btn_delete"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="删除" />
</LinearLayout>
</LinearLayout>
2. 关键组件说明
TextInputLayout:作为EditText的容器,自动处理错误提示信息的显示动画,提升用户体验。android:hint属性在输入框为空时显示提示文字,聚焦或输入后自动上浮为标签,交互更友好。EditText:实际的输入控件,通过android:inputType控制键盘类型。textPersonName弹出普通字母键盘,phone弹出数字键盘,textEmailAddress弹出带@和.的邮箱专用键盘。Button:底部两个按钮分别对应保存和删除操作。使用android:layout_weight="1"让两个按钮在水平方向上均分宽度,视觉上更整齐。
3. 布局设计要点
- 标题栏 :页面顶部需要一个清晰的标题栏,显示"查看详情"或"编辑联系人"。可以在
AndroidManifest.xml中为DetailActivity设置android:label,或使用Toolbar动态设置标题,让用户明确当前所处模式。 - 查看/编辑状态切换 :为了保持界面整洁,如果是在查看模式下,可以将
EditText设置为不可编辑状态(setEnabled(false)),仅在点击"编辑"按钮后切换为可输入状态。这种状态切换逻辑将在后续章节详细讨论。 - 输入校验 :在保存前对输入内容做基础校验,例如姓名不能为空、电话格式是否合法。
TextInputLayout提供了setError()方法,可以在输入框下方显示红色错误提示,引导用户修正。 - 软键盘处理 :在
AndroidManifest.xml中为DetailActivity设置android:windowSoftInputMode="adjustResize",确保软键盘弹出时不会遮挡底部按钮,提升输入体验。
④ 本地数据库创建与数据表结构定义
数据持久化是通讯录应用的生命线。Android 原生提供的 SQLite 数据库轻量且高效,非常适合存储此类结构化数据。我们需要创建一个继承自 SQLiteOpenHelper 的帮助类。在构造函数中指定数据库名称(如 contacts.db)和版本号。
1. 数据表结构设计
核心的数据表结构设计如下:我们需要一张名为 contacts 的表。字段包括 _id(主键,自增整数)、name(文本类型,非空)、phone(文本类型)、email(文本类型)以及 create_time(长整型,记录时间戳)。各字段的职责如下:
_id:主键,使用INTEGER PRIMARY KEY AUTOINCREMENT声明,系统会自动为每条记录生成唯一的自增整数,作为数据的唯一标识。后续的更新、删除操作都依赖这个字段定位记录。name:联系人姓名,使用TEXT NOT NULL声明,保证每条记录都必须有姓名,避免出现空数据。phone:电话号码,使用TEXT类型。这里不选用INTEGER,是因为电话号码可能包含+、-等符号,且首位可能为0,用文本存储更稳妥。email:邮箱地址,使用TEXT类型,允许为空。create_time:创建时间,使用LONG类型存储毫秒级时间戳,便于后续按时间排序或做时间范围查询。
2. 编写 ContactDbHelper.java
下面给出 ContactDbHelper.java 的完整实现。为了让代码更易维护,我们把表名、字段名都定义为常量,建表语句也集中管理:
java
package com.example.contacts;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
public class ContactDbHelper extends SQLiteOpenHelper {
private static final String DB_NAME = "contacts.db";
private static final int DB_VERSION = 1;
public static final String TABLE_NAME = "contacts";
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_EMAIL = "email";
public static final String COL_CREATE_TIME = "create_time";
private static final String SQL_CREATE_TABLE =
"CREATE TABLE " + TABLE_NAME + " (" +
COL_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
COL_NAME + " TEXT NOT NULL, " +
COL_PHONE + " TEXT, " +
COL_EMAIL + " TEXT, " +
COL_CREATE_TIME + " LONG)";
public ContactDbHelper(Context context) {
super(context, DB_NAME, null, DB_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL(SQL_CREATE_TABLE);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
db.execSQL("DROP TABLE IF EXISTS " + TABLE_NAME);
onCreate(db);
}
}
3. 关键方法说明
onCreate:在数据库首次创建 时被系统调用,只执行一次。这里执行建表语句,创建contacts表。如果后续要新增表,也在这个方法里追加对应的建表语句。onUpgrade:当数据库版本号升高 时被调用,用于处理表结构升级。对于初版应用,直接DROP TABLE IF EXISTS然后重新创建是最简单的策略,但会清空已有数据。在生产环境中需谨慎处理,建议使用ALTER TABLE增量迁移或备份数据,避免用户数据丢失。- 常量集中管理 :把表名、字段名定义为
public static final常量,后续在DatabaseManager、Contact等类中直接引用,避免手写字符串拼错导致运行时崩溃。
4. 设计要点
- 版本号递增 :每次修改表结构(新增字段、改类型等),都必须把
DB_VERSION加 1,并同步更新onUpgrade中的迁移逻辑,否则系统不会触发升级。 - 主键命名 :Android 的
CursorAdapter等组件默认要求表包含名为_id的整数主键,因此这里沿用_id命名,避免后续适配器绑定出问题。 - 时间戳存储 :
create_time用LONG存储System.currentTimeMillis()的毫秒值,排序和比较都方便,展示时再格式化为可读日期。
⑤ 封装 ContactColumn 数据模型类
为了让代码更具面向对象特性,避免在业务逻辑中直接操作原始的 Cursor 对象,我们需要定义一个 JavaBean 类,通常命名为 Contact 或 ContactModel。这个类应当包含与数据库字段一一对应的成员变量:id, name, phone, email, createTime。
1. 成员变量设计
数据模型类的成员变量与 contacts 表的字段一一对应,命名上采用 Java 的驼峰命名法,与数据库的下划线命名法形成映射:
id:对应数据库的_id主键,使用long类型,用于唯一标识一条联系人记录。name:对应name字段,使用String类型,存储联系人姓名。phone:对应phone字段,使用String类型,存储电话号码。email:对应email字段,使用String类型,存储邮箱地址。createTime:对应create_time字段,使用long类型,存储创建时间的毫秒级时间戳。
2. 编写 Contact.java
下面给出 Contact.java 的完整实现。除了标准的构造方法和 Getter/Setter,我们还提供了一个静态工厂方法 getContactFromCursor,专门用于从 Cursor 对象中提取数据并实例化模型:
java
package com.example.contacts;
import android.database.Cursor;
public class Contact {
private long id;
private String name;
private String phone;
private String email;
private long createTime;
public Contact() {
}
public Contact(long id, String name, String phone, String email, long createTime) {
this.id = id;
this.name = name;
this.phone = phone;
this.email = email;
this.createTime = createTime;
}
public static Contact getContactFromCursor(Cursor cursor) {
Contact contact = new Contact();
contact.setId(cursor.getLong(cursor.getColumnIndex(ContactDbHelper.COL_ID)));
contact.setName(cursor.getString(cursor.getColumnIndex(ContactDbHelper.COL_NAME)));
contact.setPhone(cursor.getString(cursor.getColumnIndex(ContactDbHelper.COL_PHONE)));
contact.setEmail(cursor.getString(cursor.getColumnIndex(ContactDbHelper.COL_EMAIL)));
contact.setCreateTime(cursor.getLong(cursor.getColumnIndex(ContactDbHelper.COL_CREATE_TIME)));
return contact;
}
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public long getCreateTime() {
return createTime;
}
public void setCreateTime(long createTime) {
this.createTime = createTime;
}
}
3. 关键方法说明
- 无参构造 :用于创建空对象,在新增联系人时先
new Contact()再逐个set属性,使用灵活。 - 全参构造:用于一次性初始化所有字段,适合从已有数据快速构建对象。
getContactFromCursor:静态工厂方法,接收一个Cursor对象,通过cursor.getColumnIndex()获取列索引,再用cursor.getLong()/cursor.getString()读取对应值并封装为Contact对象。这样上层业务代码无需关心Cursor的解析细节。
4. 设计要点
- 列名引用常量 :在
getContactFromCursor中,列名直接引用ContactDbHelper中定义的常量(如ContactDbHelper.COL_ID),而不是手写字符串。这样一旦数据库字段改名,只需修改ContactDbHelper一处,避免因拼写错误导致运行时崩溃。 - 类型匹配 :
_id和create_time是整数类型,用getLong()读取;name、phone、email是文本类型,用getString()读取。类型不匹配会抛出CursorIndexOutOfBoundsException或类型转换异常。 - 可维护性 :把
Cursor到对象的转换逻辑集中在这个模型类中,后续数据库结构微调时,只需修改这一处解析逻辑,而不需要遍历整个项目去修改所有查询代码,极大地提高了代码的可维护性。
⑥ 编写数据库操作工具类实现增删改查
将所有数据库操作集中管理是良好的编程习惯。我们可以创建一个单例模式的 DatabaseManager 类。在这个类中,封装四个核心方法:insert, update, delete, queryAll。
1. 单例模式设计
DatabaseManager 采用单例模式 ,确保整个应用只存在一个数据库操作实例,避免重复创建 ContactDbHelper 导致资源浪费。构造函数设为 private,通过静态方法 getInstance(Context context) 获取唯一实例:
- 私有构造 :在构造方法中初始化
ContactDbHelper,并传入context.getApplicationContext(),避免持有 Activity 引用造成内存泄漏。 - 线程安全 :
getInstance方法使用synchronized关键字修饰,保证多线程环境下也只会创建一个实例。 - 全局访问 :任何地方通过
DatabaseManager.getInstance(this)即可获得操作入口,无需重复 new 对象。
2. 编写 DatabaseManager.java
下面给出 DatabaseManager.java 的完整实现。四个核心方法分别对应增、删、改、查,所有 SQL 细节都被封装在方法内部,上层业务代码只需传入 Contact 对象或 ID 即可:
java
package com.example.contacts;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import java.util.ArrayList;
import java.util.List;
public class DatabaseManager {
private static DatabaseManager instance;
private final ContactDbHelper dbHelper;
private DatabaseManager(Context context) {
dbHelper = new ContactDbHelper(context.getApplicationContext());
}
public static synchronized DatabaseManager getInstance(Context context) {
if (instance == null) {
instance = new DatabaseManager(context);
}
return instance;
}
public long insert(Contact contact) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
ContentValues values = new ContentValues();
values.put(ContactDbHelper.COL_NAME, contact.getName());
values.put(ContactDbHelper.COL_PHONE, contact.getPhone());
values.put(ContactDbHelper.COL_EMAIL, contact.getEmail());
values.put(ContactDbHelper.COL_CREATE_TIME, System.currentTimeMillis());
long id = db.insert(ContactDbHelper.TABLE_NAME, null, values);
db.close();
return id;
}
public int update(Contact contact) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
ContentValues values = new ContentValues();
values.put(ContactDbHelper.COL_NAME, contact.getName());
values.put(ContactDbHelper.COL_PHONE, contact.getPhone());
values.put(ContactDbHelper.COL_EMAIL, contact.getEmail());
int rows = db.update(ContactDbHelper.TABLE_NAME, values,
ContactDbHelper.COL_ID + "=?",
new String[]{String.valueOf(contact.getId())});
db.close();
return rows;
}
public int delete(long id) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
int rows = db.delete(ContactDbHelper.TABLE_NAME,
ContactDbHelper.COL_ID + "=?",
new String[]{String.valueOf(id)});
db.close();
return rows;
}
public List<Contact> queryAll() {
List<Contact> list = new ArrayList<>();
SQLiteDatabase db = dbHelper.getReadableDatabase();
Cursor cursor = db.query(ContactDbHelper.TABLE_NAME,
null, null, null, null, null,
ContactDbHelper.COL_CREATE_TIME + " DESC");
while (cursor.moveToNext()) {
list.add(Contact.getContactFromCursor(cursor));
}
cursor.close();
db.close();
return list;
}
}
3. 核心方法详解
insert(Contact contact):接收一个Contact对象,将其name、phone、email填充到ContentValues中,create_time自动取当前时间戳System.currentTimeMillis()。调用db.insert()返回新行的自增 ID,上层可用它判断插入是否成功(返回 -1 表示失败)。update(Contact contact):同样用ContentValues承载更新字段,以_id作为 WHERE 条件(COL_ID + "=?"),确保只修改指定记录。返回受影响的行数,0 表示没有匹配到记录。delete(long id):依据主键 ID 删除对应行,返回被删除的行数。删除是不可逆操作,调用前应做好二次确认。queryAll():执行全表查询,按create_time DESC倒序返回,保证最新添加的联系人排在列表最前面。遍历Cursor时用while (cursor.moveToNext())逐行读取,每行通过Contact.getContactFromCursor(cursor)转换为Contact对象并加入List。
4. 设计要点
- 资源管理 :每次操作完成后调用
db.close()关闭数据库连接,Cursor使用完毕后也要cursor.close(),防止内存泄漏。虽然SQLiteOpenHelper内部会管理连接,但显式关闭是更稳妥的习惯。 - 列名引用常量 :所有表名、字段名都引用
ContactDbHelper中定义的常量(如ContactDbHelper.COL_NAME),避免手写字符串拼错导致运行时崩溃。 - 参数化查询 :
update和delete的 WHERE 条件使用?占位符配合String[]传参,而不是直接拼接字符串,既防止 SQL 注入,又避免特殊字符导致语法错误。 - 单例持有 ApplicationContext :构造时传入
context.getApplicationContext()而非 Activity 本身,避免单例长期持有 Activity 引用造成内存泄漏。 - 查询排序 :
queryAll默认按create_time DESC排序,让新添加的联系人显示在最上方,符合用户直觉。如需按姓名排序,可改为COL_NAME + " ASC"。
⑦ 使用 ListView 展示联系人列表数据
有了数据源和模型,接下来的任务是将它们渲染到界面上。这需要自定义一个 BaseAdapter 或其子类。在 Adapter 的 getView 方法中,我们需要加载列表项的布局文件(例如 item_contact.xml),该布局通常包含两个 TextView,分别显示姓名和电话。
1. 编写 ContactAdapter.java
下面给出 ContactAdapter.java 的完整实现。它继承自 BaseAdapter,将 List<Contact> 数据源与 item_contact.xml 布局绑定起来:
java
package com.example.contacts;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.TextView;
import java.util.List;
public class ContactAdapter extends BaseAdapter {
private final Context context;
private final List<Contact> contactList;
public ContactAdapter(Context context, List<Contact> contactList) {
this.context = context;
this.contactList = contactList;
}
@Override
public int getCount() {
return contactList.size();
}
@Override
public Object getItem(int position) {
return contactList.get(position);
}
@Override
public long getItemId(int position) {
return contactList.get(position).getId();
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
ViewHolder holder;
if (convertView == null) {
// 首次创建:加载布局并缓存控件引用
convertView = LayoutInflater.from(context)
.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.getTag();
}
// 填充当前行的数据
Contact contact = contactList.get(position);
holder.tvName.setText(contact.getName());
holder.tvPhone.setText(contact.getPhone());
return convertView;
}
static class ViewHolder {
TextView tvName;
TextView tvPhone;
}
}
2. 视图复用机制详解
ListView 在滚动时会频繁创建和销毁列表项视图,如果每次都重新 inflate 布局并调用 findViewById,会造成严重的性能损耗。因此必须使用视图复用机制:
convertView判空 :getView方法每次被调用时,系统会传入一个convertView参数。如果它为null,说明当前没有可复用的旧视图,需要inflate新布局;如果非null,则直接复用这个旧视图,避免重复加载布局文件。ViewHolder模式 :在首次创建视图时,把tv_name、tv_phone等控件引用缓存到ViewHolder对象中,并通过convertView.setTag(holder)挂载到视图上。下次复用时,通过convertView.getTag()直接取出缓存,无需再调用findViewById,大幅减少控件查找开销。- 数据填充 :无论视图是新建还是复用,最后都要根据
position从contactList中取出对应的Contact对象,把姓名和电话设置到TextView上。
3. 在 Activity 中绑定 Adapter
在 MainActivity 中,实例化 ContactAdapter,将 DatabaseManager 查询到的列表数据传给它,最后通过 listView.setAdapter(adapter) 完成绑定:
java
private void loadContacts() {
List<Contact> contacts = dbManager.queryAll();
adapter = new ContactAdapter(this, contacts);
listView.setAdapter(adapter);
}
关键点说明:
- 数据源 :
dbManager.queryAll()返回按create_time DESC排序的List<Contact>,作为 Adapter 的数据源。 - 刷新时机 :每当数据发生变化(如新增、删除、修改),需要重新查询数据库并调用
adapter.notifyDataSetChanged()刷新界面,这样用户才能实时看到最新的变化。在MainActivity的onResume中调用loadContacts(),可以保证从详情页返回时列表自动刷新。 - 点击事件 :通过
listView.setOnItemClickListener监听列表项点击,在回调中取出当前Contact对象,携带其 ID 跳转到详情页。
4. 设计要点
- 空状态处理 :当
contactList为空时,getCount()返回 0,ListView会显示空白。可以在布局中为ListView设置android:empty指向一个提示 TextView,引导用户点击浮动按钮添加第一条联系人。 - 数据更新 :
notifyDataSetChanged()会触发getView重新执行,但不会重建整个列表,性能开销较小。如果数据源对象被整体替换(如重新new了一个List),记得重新setAdapter或调用notifyDataSetChanged。 - ID 绑定 :
getItemId返回联系人的数据库主键_id,这样ListView在选中状态、滚动定位等场景下能准确关联到具体记录,避免因列表顺序变化导致错位。 - 布局复用 :
item_contact.xml中两个TextView的 ID(tv_name、tv_phone)必须与ViewHolder中引用的 ID 一致,否则会抛出NullPointerException。
⑧ 选项菜单 Menu 的创建与功能绑定
除了主界面的浮动按钮,Android 还提供了选项菜单(Options Menu)来承载更多全局功能,比如"搜索"、"排序"或"关于"。下面我们以"排序"功能为例,完整演示菜单资源的创建、菜单的加载以及点击事件的绑定。
1. 创建菜单资源文件 menu_main.xml
在 res/menu 目录下新建 menu_main.xml,定义菜单项。这里我们添加两个菜单项:一个用于按姓名排序,一个用于按时间排序:
xml
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_sort_name"
android:title="按姓名排序"
app:showAsAction="never" />
<item
android:id="@+id/action_sort_time"
android:title="按时间排序"
app:showAsAction="never" />
</menu>
关键属性说明:
android:id:每个菜单项的唯一标识,后续在onOptionsItemSelected中通过item.getItemId()判断用户点击了哪个菜单项。android:title:菜单项显示的文字,直接面向用户,应使用简洁明确的中文描述。app:showAsAction:控制菜单项是否显示在工具栏上。设为never表示折叠进右上角的"更多"菜单(三个点图标),适合功能较多或次要的操作;设为always或ifRoom则直接显示在工具栏上。
2. 在 Activity 中加载菜单
在 MainActivity 中重写 onCreateOptionsMenu 方法,使用 MenuInflater 将 XML 菜单加载到界面上:
java
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.menu_main, menu);
return true;
}
要点说明:
getMenuInflater():获取MenuInflater实例,用于把 XML 资源解析为Menu对象。inflate(R.menu.menu_main, menu):将menu_main.xml中定义的菜单项填充到传入的menu中。- 返回值
true:表示菜单已成功创建并显示。如果返回false,则菜单不会显示。
3. 绑定菜单点击事件
重写 onOptionsItemSelected 方法,通过判断 item.getItemId() 的值执行相应逻辑。这里我们实现"按姓名排序"和"按时间排序"两个功能:
java
@Override
public boolean onOptionsItemSelected(MenuItem item) {
int id = item.getItemId();
if (id == R.id.action_sort_name) {
// 按姓名排序:重新查询并刷新列表
List<Contact> contacts = dbManager.queryAllSorted(ContactDbHelper.COL_NAME + " ASC");
adapter = new ContactAdapter(this, contacts);
listView.setAdapter(adapter);
Toast.makeText(this, "已按姓名排序", Toast.LENGTH_SHORT).show();
return true;
} else if (id == R.id.action_sort_time) {
// 按时间排序:默认按创建时间倒序
List<Contact> contacts = dbManager.queryAll();
adapter = new ContactAdapter(this, contacts);
listView.setAdapter(adapter);
Toast.makeText(this, "已按时间排序", Toast.LENGTH_SHORT).show();
return true;
}
return super.onOptionsItemSelected(item);
}
要点说明:
item.getItemId():返回被点击菜单项的 ID,与 XML 中定义的android:id一一对应。return true:表示该事件已被处理,不再向上传递。return super.onOptionsItemSelected(item):对于未识别的菜单项,交给父类处理,保证默认行为(如返回键)不受影响。
4. 扩展 DatabaseManager 支持自定义排序
为了让排序功能真正生效,我们需要在 DatabaseManager 中增加一个支持自定义排序字段的查询方法。在原有 queryAll() 基础上,新增 queryAllSorted(String orderBy):
java
public List<Contact> queryAllSorted(String orderBy) {
List<Contact> list = new ArrayList<>();
SQLiteDatabase db = dbHelper.getReadableDatabase();
Cursor cursor = db.query(ContactDbHelper.TABLE_NAME,
null, null, null, null, null, orderBy);
while (cursor.moveToNext()) {
list.add(Contact.getContactFromCursor(cursor));
}
cursor.close();
db.close();
return list;
}
这样,queryAll() 可以简化为对 queryAllSorted 的调用:
java
public List<Contact> queryAll() {
return queryAllSorted(ContactDbHelper.COL_CREATE_TIME + " DESC");
}
5. 设计要点
- 菜单项分组 :当菜单项较多时,可以使用
<group>标签对菜单项进行逻辑分组,配合android:checkableBehavior实现单选或多选效果,让菜单结构更清晰。 - 动态菜单 :
onCreateOptionsMenu只在菜单首次创建时调用一次。如果需要在运行时动态增删菜单项,可以重写onPrepareOptionsMenu,在每次菜单显示前更新菜单内容。 - 图标与文字 :菜单项可以同时设置
android:icon和android:title。在showAsAction="never"时通常只显示文字;若显示在工具栏上,建议同时提供图标以提升辨识度。 - 事件分发 :
onOptionsItemSelected是菜单点击的统一入口,务必在方法末尾调用super.onOptionsItemSelected(item),避免遗漏系统默认行为。 - 与数据层解耦 :排序逻辑应放在
DatabaseManager中,通过传入排序字段实现,而不是在 Activity 里手写 SQL。这样既保持了数据操作的集中管理,也便于后续扩展更多排序维度。
⑨ 界面交互逻辑与标志变量状态控制
在实际开发中,界面状态往往不是单一的。例如,用户可能处于"纯查看模式",也可能处于"编辑模式"。我们需要引入标志变量(Flag)来控制这些状态。下面我们以 DetailActivity 为例,完整演示如何用标志变量管理"新建 / 查看 / 编辑"三种模式,并让保存、删除逻辑与状态机保持一致。
1. 标志变量设计
在 DetailActivity 中,我们定义两个核心标志变量来区分页面所处的模式:
java
private long contactId = -1; // 当前联系人的数据库主键,-1 表示尚未确定
private boolean isNew = false; // 是否为新建模式
private boolean isEditMode = false; // 是否为编辑模式(查看模式下为 false)
各标志变量的职责如下:
contactId:记录当前正在操作的联系人主键。从Intent中读取,若为-1说明是新建场景,否则是查看或编辑已有联系人。isNew:标记当前是否为新建模式。新建模式下保存时调用insert,否则调用update。isEditMode:标记当前是否处于可编辑状态。查看模式下为false,输入框禁用、保存按钮隐藏;点击"编辑"后置为true,输入框启用、保存按钮显示。
2. 在 onCreate 中初始化状态
在 onCreate 中,根据 Intent 传入的参数确定初始模式,并调用 setViewMode 统一刷新界面:
java
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_detail);
dbManager = DatabaseManager.getInstance(this);
etName = findViewById(R.id.et_name);
etPhone = findViewById(R.id.et_phone);
etEmail = findViewById(R.id.et_email);
btnSave = findViewById(R.id.btn_save);
btnDelete = findViewById(R.id.btn_delete);
contactId = getIntent().getLongExtra("contact_id", -1);
isNew = getIntent().getBooleanExtra("is_new", false);
if (isNew) {
// 新建模式:清空表单,隐藏删除按钮,直接进入可编辑状态
setViewMode(true);
btnDelete.setVisibility(View.GONE);
} else if (contactId != -1) {
// 查看模式:加载已有联系人数据,默认不可编辑
loadContactData(contactId);
setViewMode(false);
} else {
// 异常兜底:既不是新建也没有有效 ID,直接关闭页面
finish();
}
}
要点说明:
isNew优先判断:新建模式不需要加载已有数据,直接进入可编辑状态,并隐藏删除按钮(因为还没有可删除的记录)。contactId != -1判断:查看模式需要先从数据库加载联系人数据填充表单,再进入只读状态。- 异常兜底 :如果既不是新建也没有有效 ID,说明跳转参数异常,直接
finish()关闭页面,避免出现空白表单。
3. 封装 loadContactData 加载数据
把"根据 ID 加载联系人并填充表单"的逻辑抽成独立方法,让 onCreate 更清晰:
java
private void loadContactData(long id) {
for (Contact c : dbManager.queryAll()) {
if (c.getId() == id) {
etName.setText(c.getName());
etPhone.setText(c.getPhone());
etEmail.setText(c.getEmail());
break;
}
}
}
要点说明:
- 遍历查询 :这里为了演示简单,直接遍历
queryAll()的结果。实际项目中更推荐在DatabaseManager中新增queryById(long id)方法,用WHERE _id = ?精确查询,避免全表扫描。 - 字段填充 :把
Contact对象的name、phone、email分别设置到对应的EditText上。
4. 封装 setViewMode 统一切换状态
setViewMode 是状态切换的核心方法,根据传入的 editable 参数统一控制输入框和按钮的可用性:
java
private void setViewMode(boolean editable) {
etName.setEnabled(editable);
etPhone.setEnabled(editable);
etEmail.setEnabled(editable);
btnSave.setVisibility(editable ? View.VISIBLE : View.GONE);
}
要点说明:
setEnabled:控制输入框是否可编辑。查看模式下禁用,编辑模式下启用。setVisibility:控制保存按钮是否显示。查看模式下隐藏,编辑模式下显示。- 删除按钮:删除按钮在查看模式下保持可见(用于删除当前联系人),仅在新建模式下隐藏。
5. 保存逻辑与状态机保持一致
保存按钮的点击逻辑需要根据 isNew 标志决定调用 insert 还是 update:
java
btnSave.setOnClickListener(v -> {
String name = etName.getText().toString().trim();
if (name.isEmpty()) {
Toast.makeText(this, "姓名不能为空", Toast.LENGTH_SHORT).show();
return;
}
Contact contact = new Contact();
contact.setName(name);
contact.setPhone(etPhone.getText().toString().trim());
contact.setEmail(etEmail.getText().toString().trim());
if (isNew) {
// 新建:插入新记录
dbManager.insert(contact);
Toast.makeText(this, "已新增联系人", Toast.LENGTH_SHORT).show();
} else {
// 编辑:更新已有记录
contact.setId(contactId);
dbManager.update(contact);
Toast.makeText(this, "已更新联系人", Toast.LENGTH_SHORT).show();
}
finish();
});
要点说明:
- 输入校验 :保存前先校验姓名是否为空,避免写入空数据。
TextInputLayout的setError()可以在这里配合使用,给出更友好的错误提示。 isNew分支 :新建时insert会自动生成主键,无需手动设置id;编辑时必须setId(contactId)才能定位到要更新的记录。finish():保存成功后关闭详情页,返回列表页。列表页的onResume会重新查询数据并刷新,保证用户看到最新结果。
6. 删除逻辑
删除按钮在查看模式下可见,点击后根据 contactId 删除对应记录:
java
btnDelete.setOnClickListener(v -> {
if (contactId != -1) {
dbManager.delete(contactId);
Toast.makeText(this, "已删除联系人", Toast.LENGTH_SHORT).show();
finish();
}
});
要点说明:
- 二次确认 :删除是不可逆操作,生产环境建议用
AlertDialog弹窗让用户二次确认,避免误删。 contactId校验:只有存在有效 ID 时才执行删除,防止对无效记录操作。
7. 设计要点
- 状态机思想 :把"新建 / 查看 / 编辑"三种模式抽象为标志变量的组合,通过
setViewMode统一切换界面状态,避免在多个回调里散落地修改控件属性,逻辑更清晰、不易出错。 - 标志变量命名 :
isNew、isEditMode等布尔变量命名要语义明确,避免使用含义模糊的flag1、flag2。命名清晰能让后续维护者一眼看懂状态含义。 - 单一数据源 :
contactId是当前操作记录的唯一标识,所有增删改查都围绕它展开。不要在多个地方重复维护"当前记录"的概念,避免状态不同步。 - 界面与数据解耦 :
setViewMode只负责界面状态,loadContactData只负责数据加载,保存/删除逻辑只负责数据操作。各方法职责单一,便于测试和复用。 - 生命周期配合 :详情页保存或删除后
finish()返回列表页,列表页在onResume中重新查询并刷新,形成"详情页修改 → 返回自动刷新"的闭环,用户无需手动刷新。
⑩ 常见运行报错排查与性能优化技巧
即使严格遵循了上述步骤,运行时仍可能遇到各种挑战。下面我们按「报错排查」和「性能优化」两个维度,系统梳理通讯录应用开发中最常见的问题与解决方案,帮助你快速定位并修复。
1. 常见运行报错排查
1.1 CursorIndexOutOfBoundsException(游标越界)
这是遍历 Cursor 时最高频的错误,通常由以下两种原因引起:
- 未移动指针就读取数据 :
Cursor初始位置在第一条记录之前(索引为 -1),如果直接调用cursor.getString()会抛出越界异常。 - 未判断
moveToNext()返回值 :当游标已经遍历到末尾时,moveToNext()返回false,此时再读取数据同样会越界。
正确写法 :始终使用 while (cursor.moveToNext()) 循环,确保每次读取前指针都已移动到有效位置:
java
Cursor cursor = db.query(...);
while (cursor.moveToNext()) {
// 此时指针已指向有效行,可以安全读取
String name = cursor.getString(cursor.getColumnIndex(ContactDbHelper.COL_NAME));
}
cursor.close();
1.2 NullPointerException(空指针异常)
空指针是 Android 开发中最常见的崩溃原因,在通讯录应用中多发生于以下场景:
findViewById返回 null :通常是因为布局文件中控件的 ID 与代码中引用不一致,或控件不在当前setContentView的布局中。务必在onCreate中确认findViewById返回了有效对象。- Context 传递错误 :在
Adapter或DatabaseManager中传入的Context为 null,或传入了已销毁的 Activity 引用。建议统一使用context.getApplicationContext()。 - 列表数据为空 :
contactList为 null 时调用contactList.size()会崩溃。在getCount()中先做判空处理:
java
@Override
public int getCount() {
return contactList == null ? 0 : contactList.size();
}
1.3 SQLiteException: no such table(表不存在)
通常发生在数据库升级或首次创建时建表语句未执行成功。排查思路:
- 确认
ContactDbHelper的onCreate中建表语句是否正确执行。 - 检查
DB_VERSION是否在表结构变更后递增,并同步更新onUpgrade逻辑。 - 如果应用已安装过旧版本,卸载重装或清除应用数据可强制重建数据库。
1.4 ClassNotFoundException / NoClassDefFoundError(类找不到)
多发生于依赖未正确引入或混淆配置错误。排查思路:
- 确认
build.gradle中已添加com.google.android.material等必要依赖。 - 检查
MainActivity、DetailActivity是否已在AndroidManifest.xml中正确声明。 - 若开启了混淆(
minifyEnabled true),需在proguard-rules.pro中保留模型类和数据库相关类。
1.5 android.content.ActivityNotFoundException(Activity 找不到)
跳转详情页时若 DetailActivity 未在 AndroidManifest.xml 中注册,会抛出此异常。务必在 <application> 标签内添加:
xml
<activity android:name=".DetailActivity" />
2. 性能优化技巧
2.1 数据库操作严禁放在主线程
SQLite 的读写操作是 IO 密集型任务,如果放在主线程(UI 线程)执行,当数据量增大时会阻塞界面渲染,导致 ANR(应用无响应)。正确做法是使用异步任务或线程池:
java
private void loadContactsAsync() {
new Thread(() -> {
List<Contact> contacts = dbManager.queryAll();
runOnUiThread(() -> {
adapter = new ContactAdapter(this, contacts);
listView.setAdapter(adapter);
});
}).start();
}
对于更复杂的场景,推荐使用 ExecutorService 线程池或 Kotlin 协程,避免频繁创建线程带来的开销。
2.2 使用 RecyclerView 替代 ListView
当联系人数量达到数千条时,ListView 的滚动性能会明显下降。RecyclerView 在以下方面更优:
- 强制使用 ViewHolder 模式:从架构上保证视图复用,避免遗漏导致性能损耗。
- 局部刷新 :配合
DiffUtil可以只更新变化的列表项,而不是重绘整个列表。 - 更精细的布局管理 :支持
LinearLayoutManager、GridLayoutManager等多种布局,扩展性更强。
迁移到 RecyclerView 后,ContactAdapter 改为继承 RecyclerView.Adapter<ContactAdapter.ViewHolder>,核心逻辑与 BaseAdapter 类似,但 onCreateViewHolder 和 onBindViewHolder 分离,职责更清晰。
2.3 数据库索引优化
当 contacts 表数据量较大时,频繁按姓名或时间排序会变慢。可以为常用排序字段建立索引:
java
private static final String SQL_CREATE_INDEX =
"CREATE INDEX idx_contacts_name ON " + TABLE_NAME + " (" + COL_NAME + ")";
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL(SQL_CREATE_TABLE);
db.execSQL(SQL_CREATE_INDEX);
}
注意:索引会占用额外存储空间,且写入时有一定开销,只对高频查询字段建立索引即可。
2.4 合理管理数据库连接
虽然 SQLiteOpenHelper 内部会管理连接,但频繁开关数据库连接仍有一定开销。优化建议:
- 避免在循环中反复开关连接 :批量操作时尽量复用同一个
SQLiteDatabase实例,或使用事务(beginTransaction/endTransaction)批量提交。 - 使用事务批量插入 :新增大量联系人时,逐条
insert会非常慢。用事务包裹可提升数倍性能:
java
public void insertBatch(List<Contact> contacts) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
db.beginTransaction();
try {
for (Contact contact : contacts) {
ContentValues values = new ContentValues();
values.put(ContactDbHelper.COL_NAME, contact.getName());
values.put(ContactDbHelper.COL_PHONE, contact.getPhone());
values.put(ContactDbHelper.COL_EMAIL, contact.getEmail());
values.put(ContactDbHelper.COL_CREATE_TIME, System.currentTimeMillis());
db.insert(ContactDbHelper.TABLE_NAME, null, values);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
db.close();
}
}
2.5 列表项布局优化
- 减少嵌套层级 :列表项布局尽量扁平化,避免多层
LinearLayout嵌套,减少测量和绘制开销。 - 使用
wrap_content谨慎 :在列表项中,TextView的宽高尽量使用固定值或match_parent,避免频繁测量。 - 图片懒加载 :如果列表项包含头像等图片,务必使用
Glide或Coil等图片加载库,避免在主线程解码大图导致卡顿。
3. 调试与日志技巧
- 善用
Logcat:在关键方法(如insert、update、delete)中打印日志,便于定位问题。使用Log.d(TAG, "insert id=" + id)格式,TAG建议用类名常量。 - 使用
adb查看数据库 :开发阶段可通过adb shell进入应用沙盒,用sqlite3命令直接查看contacts.db的表结构和数据,快速验证数据库操作是否正确。 - 开启 StrictMode :在
Application或MainActivity的onCreate中开启StrictMode,可以在开发阶段及时发现主线程 IO 等性能问题:
java
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build());
}
4. 设计要点
- 先定位再修复 :遇到崩溃先看
Logcat的堆栈信息,定位到具体文件和行号,再针对性修复,避免盲目猜测。 - 异常兜底 :在
Cursor遍历、findViewById等关键位置做好判空和异常处理,避免应用直接崩溃。 - 异步优先:所有数据库操作、网络请求等耗时任务一律放到后台线程,主线程只负责 UI 渲染和轻量计算。
- 性能与可读性平衡:优化性能时不要牺牲代码可读性。优先使用成熟的模式(如 ViewHolder、事务、索引),避免过度优化导致代码难以维护。
- 持续监控 :上线后通过
ANR日志、崩溃统计平台(如 Bugly、Firebase Crashlytics)持续监控应用稳定性,及时修复线上问题。
通过这些排查思路和优化手段,你的通讯录应用将更加稳定流畅,即使面对大量数据也能保持良好的用户体验。
⑪ 完整实战代码整合
前面我们分章节讲解了通讯录应用的各个组成部分,下面将这些代码整合为一个可直接运行的完整项目。为便于阅读,这里按文件路径组织,你可以对照前文说明逐一核对。
1. 数据模型类 Contact.java
java
package com.example.contacts;
import android.database.Cursor;
public class Contact {
private long id;
private String name;
private String phone;
private String email;
private long createTime;
public Contact() {
}
public Contact(long id, String name, String phone, String email, long createTime) {
this.id = id;
this.name = name;
this.phone = phone;
this.email = email;
this.createTime = createTime;
}
public static Contact getContactFromCursor(Cursor cursor) {
Contact contact = new Contact();
contact.setId(cursor.getLong(cursor.getColumnIndex("_id")));
contact.setName(cursor.getString(cursor.getColumnIndex("name")));
contact.setPhone(cursor.getString(cursor.getColumnIndex("phone")));
contact.setEmail(cursor.getString(cursor.getColumnIndex("email")));
contact.setCreateTime(cursor.getLong(cursor.getColumnIndex("create_time")));
return contact;
}
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public long getCreateTime() { return createTime; }
public void setCreateTime(long createTime) { this.createTime = createTime; }
}
2. 数据库帮助类 ContactDbHelper.java
java
package com.example.contacts;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
public class ContactDbHelper extends SQLiteOpenHelper {
private static final String DB_NAME = "contacts.db";
private static final int DB_VERSION = 1;
public static final String TABLE_NAME = "contacts";
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_EMAIL = "email";
public static final String COL_CREATE_TIME = "create_time";
private static final String SQL_CREATE_TABLE =
"CREATE TABLE " + TABLE_NAME + " (" +
COL_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
COL_NAME + " TEXT NOT NULL, " +
COL_PHONE + " TEXT, " +
COL_EMAIL + " TEXT, " +
COL_CREATE_TIME + " LONG)";
public ContactDbHelper(Context context) {
super(context, DB_NAME, null, DB_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL(SQL_CREATE_TABLE);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
db.execSQL("DROP TABLE IF EXISTS " + TABLE_NAME);
onCreate(db);
}
}
3. 数据库操作工具类 DatabaseManager.java
java
package com.example.contacts;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import java.util.ArrayList;
import java.util.List;
public class DatabaseManager {
private static DatabaseManager instance;
private final ContactDbHelper dbHelper;
private DatabaseManager(Context context) {
dbHelper = new ContactDbHelper(context.getApplicationContext());
}
public static synchronized DatabaseManager getInstance(Context context) {
if (instance == null) {
instance = new DatabaseManager(context);
}
return instance;
}
public long insert(Contact contact) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
ContentValues values = new ContentValues();
values.put(ContactDbHelper.COL_NAME, contact.getName());
values.put(ContactDbHelper.COL_PHONE, contact.getPhone());
values.put(ContactDbHelper.COL_EMAIL, contact.getEmail());
values.put(ContactDbHelper.COL_CREATE_TIME, System.currentTimeMillis());
long id = db.insert(ContactDbHelper.TABLE_NAME, null, values);
db.close();
return id;
}
public int update(Contact contact) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
ContentValues values = new ContentValues();
values.put(ContactDbHelper.COL_NAME, contact.getName());
values.put(ContactDbHelper.COL_PHONE, contact.getPhone());
values.put(ContactDbHelper.COL_EMAIL, contact.getEmail());
int rows = db.update(ContactDbHelper.TABLE_NAME, values,
ContactDbHelper.COL_ID + "=?",
new String[]{String.valueOf(contact.getId())});
db.close();
return rows;
}
public int delete(long id) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
int rows = db.delete(ContactDbHelper.TABLE_NAME,
ContactDbHelper.COL_ID + "=?",
new String[]{String.valueOf(id)});
db.close();
return rows;
}
public List<Contact> queryAll() {
List<Contact> list = new ArrayList<>();
SQLiteDatabase db = dbHelper.getReadableDatabase();
Cursor cursor = db.query(ContactDbHelper.TABLE_NAME,
null, null, null, null, null,
ContactDbHelper.COL_CREATE_TIME + " DESC");
while (cursor.moveToNext()) {
list.add(Contact.getContactFromCursor(cursor));
}
cursor.close();
db.close();
return list;
}
}
4. 列表适配器 ContactAdapter.java
java
package com.example.contacts;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.TextView;
import java.util.List;
public class ContactAdapter extends BaseAdapter {
private final Context context;
private final List<Contact> contactList;
public ContactAdapter(Context context, List<Contact> contactList) {
this.context = context;
this.contactList = contactList;
}
@Override
public int getCount() {
return contactList.size();
}
@Override
public Object getItem(int position) {
return contactList.get(position);
}
@Override
public long getItemId(int position) {
return contactList.get(position).getId();
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
ViewHolder holder;
if (convertView == null) {
convertView = LayoutInflater.from(context)
.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.getTag();
}
Contact contact = contactList.get(position);
holder.tvName.setText(contact.getName());
holder.tvPhone.setText(contact.getPhone());
return convertView;
}
static class ViewHolder {
TextView tvName;
TextView tvPhone;
}
}
5. 主界面 MainActivity.java
java
package com.example.contacts;
import android.content.Intent;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ListView;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import java.util.List;
public class MainActivity extends AppCompatActivity {
private ListView listView;
private ContactAdapter adapter;
private DatabaseManager dbManager;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
dbManager = DatabaseManager.getInstance(this);
listView = findViewById(R.id.contact_list_view);
loadContacts();
listView.setOnItemClickListener((parent, view, position, id) -> {
Contact contact = (Contact) parent.getItemAtPosition(position);
Intent intent = new Intent(MainActivity.this, DetailActivity.class);
intent.putExtra("contact_id", contact.getId());
startActivity(intent);
});
findViewById(R.id.fab_add).setOnClickListener(v -> {
Intent intent = new Intent(MainActivity.this, DetailActivity.class);
intent.putExtra("is_new", true);
startActivity(intent);
});
}
private void loadContacts() {
List<Contact> contacts = dbManager.queryAll();
adapter = new ContactAdapter(this, contacts);
listView.setAdapter(adapter);
}
@Override
protected void onResume() {
super.onResume();
loadContacts();
adapter.notifyDataSetChanged();
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.menu_main, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == R.id.action_sort) {
Toast.makeText(this, "排序功能已触发", Toast.LENGTH_SHORT).show();
return true;
}
return super.onOptionsItemSelected(item);
}
}
6. 详情页 DetailActivity.java
java
package com.example.contacts;
import android.os.Bundle;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
public class DetailActivity extends AppCompatActivity {
private EditText etName, etPhone, etEmail;
private Button btnSave, btnDelete;
private DatabaseManager dbManager;
private long contactId = -1;
private boolean isEditMode = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_detail);
dbManager = DatabaseManager.getInstance(this);
etName = findViewById(R.id.et_name);
etPhone = findViewById(R.id.et_phone);
etEmail = findViewById(R.id.et_email);
btnSave = findViewById(R.id.btn_save);
btnDelete = findViewById(R.id.btn_delete);
contactId = getIntent().getLongExtra("contact_id", -1);
boolean isNew = getIntent().getBooleanExtra("is_new", false);
if (!isNew && contactId != -1) {
// 查看模式:加载已有联系人数据
for (Contact c : dbManager.queryAll()) {
if (c.getId() == contactId) {
etName.setText(c.getName());
etPhone.setText(c.getPhone());
etEmail.setText(c.getEmail());
break;
}
}
setViewMode(false);
} else {
// 新建模式
setViewMode(true);
btnDelete.setVisibility(View.GONE);
}
btnSave.setOnClickListener(v -> {
String name = etName.getText().toString().trim();
if (name.isEmpty()) {
Toast.makeText(this, "姓名不能为空", Toast.LENGTH_SHORT).show();
return;
}
Contact contact = new Contact();
contact.setName(name);
contact.setPhone(etPhone.getText().toString().trim());
contact.setEmail(etEmail.getText().toString().trim());
if (isNew) {
contact.setId(contactId);
dbManager.insert(contact);
Toast.makeText(this, "已新增联系人", Toast.LENGTH_SHORT).show();
} else {
contact.setId(contactId);
dbManager.update(contact);
Toast.makeText(this, "已更新联系人", Toast.LENGTH_SHORT).show();
}
finish();
});
btnDelete.setOnClickListener(v -> {
if (contactId != -1) {
dbManager.delete(contactId);
Toast.makeText(this, "已删除联系人", Toast.LENGTH_SHORT).show();
finish();
}
});
}
private void setViewMode(boolean editable) {
etName.setEnabled(editable);
etPhone.setEnabled(editable);
etEmail.setEnabled(editable);
btnSave.setVisibility(editable ? View.VISIBLE : View.GONE);
}
}
7. 布局与资源文件
res/layout/activity_main.xml:
xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ListView
android:id="@+id/contact_list_view"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_add"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:src="@android:drawable/ic_input_add"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
res/layout/item_contact.xml:
xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_phone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="#666666" />
</LinearLayout>
res/layout/activity_detail.xml:
xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="姓名">
<EditText
android:id="@+id/et_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="电话">
<EditText
android:id="@+id/et_phone"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="phone" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="邮箱">
<EditText
android:id="@+id/et_email"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="16dp">
<Button
android:id="@+id/btn_save"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="保存" />
<Button
android:id="@+id/btn_delete"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="删除" />
</LinearLayout>
</LinearLayout>
res/menu/menu_main.xml:
xml
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_sort"
android:title="排序"
app:showAsAction="never" />
</menu>
8. 运行说明
将上述文件按对应路径放入项目后,即可直接编译运行。首次启动时 ContactDbHelper 会自动创建 contacts.db 数据库和 contacts 表。点击右下角浮动按钮可新增联系人,点击列表项可查看或编辑详情,长按菜单中的"排序"可扩展排序逻辑。整个项目完整串联了「界面 → 适配器 → 数据操作 → SQLite」的完整数据流转链路,与文首的架构图一一对应。