Android 通讯录应用开发实战:从零搭建 SQLite 数据持久化完整项目

摘要:本文以通讯录管理应用为实战案例,手把手带读者从零搭建一个完整的 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。

除了主界面的浮动按钮,Android 还提供了选项菜单(Options Menu)来承载更多全局功能,比如"搜索"、"排序"或"关于"。下面我们以"排序"功能为例,完整演示菜单资源的创建、菜单的加载以及点击事件的绑定。

在 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」的完整数据流转链路,与文首的架构图一一对应。

相关推荐
隐擎fox2 小时前
深入 Android 移动端风控与协议层对抗:内核级虚拟环境检测、网络堆栈特征伪装与住宅 IP 协同实战
android·网络·python·网络协议·tcp/ip
换元不配限4 小时前
从 AsyncTask 到现代并发:Android 异步方案的演进与替代实践
android·asynctask·异步编程·方案对比·asynctask替代方案
用户2018792831674 小时前
你的 App 为什么是「电量刺客」?——从一条 WakeLock 追到 Linux 内核的 Android 省电原理全解
android
mmsx4 小时前
Android 安全校验下沉到原生层:防 patch 与构建期自动化
android·安全·自动化
老牛日常随笔6 小时前
Google把Android测试搬进AI对话:ARTEMIS如何用自然语言操作你的手机
android·人工智能·智能手机
朱涛的自习室6 小时前
预算 1000 美金的开源软件,DeepSeek 只花 1 美金就做完了(Day 6)
android·前端·人工智能
日和雫7 小时前
封装、继承、多态 & 组合,为什么组合大于继承?
设计模式
mmsx7 小时前
Android APK 加固方案怎么落地:从加壳到校验的组合取舍
android·网络·安全·签名