简介这套Android Studio通讯录开发资源是一份面向初学者的完整实战工程涵盖注册登录、联系人增删改查、查询展示等核心功能适合刚入门Android开发的学生或自学者用作课程设计、结课作业或练手参考。压缩包共1034个文件大小仅5.67MB其中包含402个xml布局与配置、401个png图片资源、124个json文件以及少量java源代码、aidl接口、gradle构建脚本和debug版apk繁多的资源文件便于观察一个真实Android工程的结构bin、jar、class等文件则可辅助理解构建与打包过程。资源内已附带可直接安装在手机运行的apk下载后可快速预览效果再对照源码和布局文件分析实现思路提高学习效率。目前已有5567人学习或下载说明这套资料在同类入门项目中具有一定认可度值得初学者参考使用。1. Android Studio 通讯录开发为什么值得从零写一个联系人管理App很多人觉得通讯录是系统自带功能自己再写一遍纯属重复造轮子。但从教学和练手角度看通讯录恰好是 Android Studio 开发里最完整的“麻雀”它需要你独立设计数据表结构、处理运行时权限、对接 RecyclerView 列表、做好增删改查交互还要考虑搜索、排序、刷新这些真实产品才有的细节。把这一套走通等于把 Android 开发里最常用的几块拼图全摸了一遍后面再去做电商、IM、工具类 App底层思路是相通的。这篇文章面向两类读者一是刚学完 Android 基础、想找项目巩固的新手二是已经在做业务功能、但没认真打磨过本地数据的开发者。我会按“数据层 → 界面层 → 权限处理 → 踩坑 → 进阶技巧”的顺序展开每一段都给出可以复制的代码和参数说明保证你照着敲就能跑起来。先说结论通讯录开发不难难的是把权限、数据库和界面刷新这三件事衔接好而这正是本文要解决的核心问题。2. 数据层先行Room 与 SQLite 的选型以及联系人数据模型设计2.1 为什么不用系统 ContactsContract先自己建表开始写通讯录时你很容易纠结一个问题Android 系统自带联系人数据库 ContactsContract我是不是直接读它就行如果你的目标是“读取系统联系人”那确实该用它但如果你是想练习开发或者要给 App 做一套独立的联系人管理功能我建议你从零建自己的表。原因有三个第一系统联系人表结构复杂光字段关系就能绕晕新手第二读写系统联系人需要敏感权限在部分机型上还有兼容性挑战第三自己建表可以按产品需求设计字段比如重要联系人标记、备注、头像路径等不会被系统表结构限制住。常见做法是用 Room它是 Google 官方的 SQLite 封装库。相比手写 SQLiteOpenHelperRoom 带来的好处非常直接编译期帮你校验 SQL 是否正确不用再写一大坨 Cursor 解析代码还能用协程或 LiveData 做响应式更新。对通讯录这种数据量不大但字段稳定的小项目Room 是最舒服的选择。如果你手头是纯 Java 老项目不想引入 Room那继续用 SQLiteOpenHelper 也能实现只是代码量会多不少。2.2 建表三件套Entity、Dao、DatabaseRoom 的用法非常固定核心是三个文件Entity 数据类、Dao 接口、Database 数据库类。先写联系人实体Entity(tableName contacts) data class Contact( PrimaryKey(autoGenerate true) val id: Long 0, ColumnInfo(name name) val name: String, ColumnInfo(name phone) val phone: String, ColumnInfo(name email) val email: String?, ColumnInfo(name note) val note: String?, ColumnInfo(name create_time) val createTime: Long System.currentTimeMillis() )这段代码定义了一张名为 contacts 的表id 是自增主键name、phone 是必填字段email、note 可空。注意 createTime 用的是 Long 类型存毫秒时间戳而不是用 String 存日期字符串——这样后续按时间排序效率更高不用做字符串比较。再写操作数据库的接口Dao interface ContactDao { Insert suspend fun insert(contact: Contact): Long Update suspend fun update(contact: Contact) Delete suspend fun delete(contact: Contact) Query(SELECT * FROM contacts ORDER BY create_time DESC) fun getAllContacts(): LiveDataListContact Query(SELECT * FROM contacts WHERE name LIKE :keyword OR phone LIKE :keyword ORDER BY create_time DESC) fun searchContacts(keyword: String): LiveDataListContact }逻辑说明getAllContacts 按创建时间倒序返回全部联系人因为返回的是 LiveData界面层监听这个数据源后数据库一变化列表自动刷新不需要手动调 notifyDataSetChanged。searchContacts 用 LIKE 模糊匹配同时支持按姓名和手机号搜索关键字是拼在 SQL 里的参数不是直接拼接字符串能防止 SQL 注入。最后是数据库类Database(entities [Contact::class], version 1, exportSchema false) abstract class AppDatabase : RoomDatabase() { abstract fun contactDao(): ContactDao companion object { Volatile private var INSTANCE: AppDatabase? null fun getInstance(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { INSTANCE ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, contact_app.db ).build().also { INSTANCE it } } } } }这里用的是双重锁单例保证全局只有一份数据库实例。Room 创建数据库文件名为 contact_app.db存在 App 私有目录下不需要申请存储权限。version 参数很关键以后表结构变了要升级版本号并写 Migration 迁移逻辑否则直接升级会闪退。参数说明如果你不需要异步操作可以把 suspend 关键字去掉改回同步方法。但我的建议是保留 suspend因为 Room 做的是磁盘 I/O放进主线程会卡住 UI特别是通讯录字段多、列表要频繁刷新时协程写法能避免很多 ANR 问题。3. 界面层落地RecyclerView 列表、搜索框与增删改交互3.1 主界面与适配器的标准写法数据库做好了接下来要把联系人显示出来。界面层推荐用 RecyclerView ListAdapterListAdapter 自带 DiffUtil 差量更新比常规 Adapter 更聪明它只刷新变化的行不会让整个列表闪一下或者丢焦点。先看主界面的布局一个搜索框加一个 RecyclerView 就够。适配器写法如下class ContactAdapter(private val onClick: (Contact) - Unit) : ListAdapterContact, ContactAdapter.ContactViewHolder(DiffCallback) { companion object DiffCallback : DiffUtil.ItemCallbackContact() { override fun areItemsTheSame(oldItem: Contact, newItem: Contact) oldItem.id newItem.id override fun areContentsTheSame(oldItem: Contact, newItem: Contact) oldItem newItem } class ContactViewHolder(val binding: ItemContactBinding) : RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContactViewHolder { val binding ItemContactBinding.inflate(LayoutInflater.from(parent.context), parent, false) return ContactViewHolder(binding) } override fun onBindViewHolder(holder: ContactViewHolder, position: Int) { val contact getItem(position) holder.binding.tvContactName.text contact.name holder.binding.tvContactPhone.text contact.phone holder.binding.root.setOnClickListener { onClick(contact) } } }逻辑说明onClick 是点击回调由 Activity 或 Fragment 传入这样适配器不用关心跳转逻辑。DiffUtil 的 areItemsTheSame 判断是否同一行areContentsTheSame 判断内容是否变化两者配合才能实现精准的局部刷新。如果你的列表里还带了 Headers 或 Footers就不适合直接用 ListAdapter得换 LayoutWrapper 方案。3.2 增删改交互从弹窗到数据库的完整链路通讯录的增删改一般用 Dialog 或底部弹窗完成。用一个 AlertDialog 加自定义 View 最省事代码结构清晰fun showAddOrEditDialog(contact: Contact?) { val dialogView LayoutInflater.from(this).inflate(R.layout.dialog_contact_edit, null) val etName dialogView.findViewByIdEditText(R.id.etName) val etPhone dialogView.findViewByIdEditText(R.id.etPhone) val etEmail dialogView.findViewByIdEditText(R.id.etEmail) val etNote dialogView.findViewByIdEditText(R.id.etNote) contact?.let { etName.setText(it.name) etPhone.setText(it.phone) etEmail.setText(it.email) etNote.setText(it.note) } AlertDialog.Builder(this) .setTitle(if (contact null) 添加联系人 else 编辑联系人) .setView(dialogView) .setPositiveButton(保存) { _, _ - val name etName.text.toString().trim() val phone etPhone.text.toString().trim() if (name.isEmpty() || phone.isEmpty()) { Toast.makeText(this, 姓名和手机号不能为空, Toast.LENGTH_SHORT).show() returnsetPositiveButton } val newContact Contact( id contact?.id ?: 0, name name, phone phone, email etEmail.text.toString().trim().ifEmpty { null }, note etNote.text.toString().trim().ifEmpty { null }, createTime contact?.createTime ?: System.currentTimeMillis() ) viewModel.saveContact(newContact) } .setNegativeButton(取消, null) .show() }这段代码的关键在于新增和编辑共用一个弹窗。新增时 contact 为 null保存默认 id0 和当前时间戳编辑时沿用原 id 和 createTime这样 update 操作会保留创建时间不会因为改了资料让排序跳变。空值校验放在保存前避免数据脏。ViewModel 里对应的保存逻辑没贴出来但思路很清晰判断 contact.id 是否为 0为 0 就调 insert否则调 update。删除操作更简单长按列表项弹出确认框确认后调 delete(contact)配合 LiveData 自动更新界面马上消失那行。这套流程跑通后你拥有的就是一个完整的本地通讯录了。但别高兴太早权限这关还没过很多人的 App 就是在读取联系人时莫名闪退或者拿不到数据的。4. 系统联系人读取运行时权限与 Android 6.0 的权限禁忌4.1 Manifest 声明与运行时权限请求如果你的需求是“把系统通讯录读进自己的 App”那就绕不开权限。Manifest 里要声明 READ_CONTACTS同时因为部分旧机型或特殊项目需要 WRITE_CONTACTS 写入联系人不要贪多按需申请。声明写进 AndroidManifest.xmluses-permission android:nameandroid.permission.READ_CONTACTS /光声明不够Android 6.0 以后运行时权限必须动态申请。最稳妥的写法是先检查、再请求、再处理回调用 ActivityResultContracts 简化代码private val requestPermissionLauncher registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted - if (isGranted) { readSystemContacts() } else { Toast.makeText(this, 没有联系人权限无法读取系统通讯录, Toast.LENGTH_LONG).show() } } private fun checkAndRequestPermission() { val permissionGranted ContextCompat.checkSelfPermission( this, Manifest.permission.READ_CONTACTS ) PackageManager.PERMISSION_GRANTED if (permissionGranted) { readSystemContacts() } else { requestPermissionLauncher.launch(Manifest.permission.READ_CONTACTS) } }逻辑说明registerForActivityResult 是 AndroidX 推荐的授权方式比老式 onRequestPermissionsResult 更容易管理。首次弹出系统权限框时用户可以勾选“拒绝后不再询问”这时候再次调用 launch 不会弹窗会直接走失败回调。所以更完整的做法是在请求前判断 shouldShowRequestPermissionRationale给用户一个解释弹窗但这个需求在通讯录场景里不常见多数用户会给权限。4.2 读取系统联系人的查询参数与 ContentResolver 用法拿到权限后读取系统联系人用的是 ContentResolver.query查询 URI 是 ContactsContract.CommonDataKinds.Phone.CONTENT_URI。一次查询就能同时拿到姓名和手机号原理是系统内部已经把联系人表和号码表做了关联fun readSystemContacts(context: Context): ListContact { val result mutableListOfContact() val resolver context.contentResolver val projection arrayOf( ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.NUMBER ) resolver.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, projection, null, null, ${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} ASC )?.use { cursor - val nameIndex cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME) val numberIndex cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER) while (cursor.moveToNext()) { val name cursor.getString(nameIndex) val number cursor.getString(numberIndex) if (number.isNotBlank()) { result.add(Contact(name name, phone number.replace( , ))) } } } return result }参数说明DISPLAY_NAME 是联系人显示名NUMBER 是电话号码。查询返回的 Cursor 必须在 use 块内操作系统会帮忙关闭 Cursor。电话号码里的空格和横线要做清洗不然展示、搜索、拨号全都会出问题。这一段读完你已经把系统联系人数据捞到自己 App 里了。但这里有一个容易被忽略的分水岭你读到的只是快照数据如果用户改了系统通讯录App 里不会自动同步你需要提供一个手动刷新按钮或者定时同步逻辑。这个我在下一章的避坑清单里会展开说。5. 通讯录开发避坑指南数据库、界面刷新与真机调试的 5 个典型问题5.1 数据库版本升级后直接闪退现象测试机上装了旧版本 App数据库表结构改过之后装新版本一打开就崩日志报 SQLiteException: no such column。原因Room 要求版本号和 Migration 严格对应你只改 Entity 加了字段但没改 version或者改了 version 没写 Migration就会抛异常。这是 Room 最著名的强制约束它宁可闪退也不愿意让你在不知情的情况下读到错乱的数据。解决在 Database 类里升级 version比如从 1 升到 2同时写一个 Migrationval MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(ALTER TABLE contacts ADD COLUMN avatar_path TEXT DEFAULT null) } }然后在 databaseBuilder 里加上 .addMigrations(MIGRATION_1_2)。如果忘了加Room 会直接抛 IllegalStateException 提醒你。这是好事别想通过 fallbackToDestructiveMigration 糊弄过去那会清空用户数据。5.2 列表刷新后 EditText 焦点丢失或列表瞬跳现象输入搜索关键词后列表项发生局部更新输入法收起或者某一行突然被顶到顶部。原因ListAdapter 的 DiffUtil 在部分场景下检测到的是结构变化而非内容变化导致 Item 被复用了。另一个常见原因是搜索框位于列表头部列表布局变化时输入框焦点被弹走。解决搜索框不要放进 RecyclerView 的 header 里把它单独放在 Fragment 布局顶部固定住。输入框的输入法弹出模式建议用 android:windowSoftInputModeadjustResize这样列表会压缩而不是整体上滑。搜索数据的防抖也别忘——每次输入都重新查数据库会造成频繁刷新建议配合 Room 的 Flow/LiveData 让数据库在子线程跑线程问题找错了真的会玄学闪退。5.3 真机调试时拿不到系统联系人现象代码在模拟器上运行正常换到某品牌真机上点击读取按钮没有反应也没有崩溃Toast 也不弹。原因部分国产 ROM 在系统设置里默认禁止了第三方 App 读取通讯录即便你在 App 里点了授权弹窗系统设置里的开关仍可能是关的。另一个原因是 targetSdkVersion 过高导致某些兼容模式失效。解决让测试用户在“系统设置 → 应用 → 你的应用 → 权限”里手动打开联系人权限然后再返回 App 重试。同时建议在读取失败的回调里加一个诊断界面显示当前权限状态和错误详情方便出问题时快速定位是权限没给还是查询异常。想要代码上更健壮就在首次授权成功后再检查一次系统设置两处都确认再发起查询能省掉一半的反馈工单。5.4 保存联系人时明明成功却查不到现象调用 insert 返回了 Long 型的 id一切正常但 App 重启后新添加的记录消失了。原因绝大多数情况是你插入时用的是一个临时的数据库实例或者拿到的 context 不对导致你看的是另一个数据库文件。如果你在 Application 和 Activity 里分别调了 getInstance就要确认是不是传了不同的 context。解决getInstance 强制要求 context.applicationContext不要传 Activity 实例。如果你做过 Hilt 注入也要保证 DataBase 实例是单例提供的。判断是不是这个原因可以用 Android Studio 的 Database Inspector 查看 App 实际路径下的数据库内容如果 Inspector 里能看到记录但界面查不到那就是查询条件写错了。5.5 重复联系人把自己坑了现象手动添加一个联系人和系统已有的张三重名导致列表里出现两个一模一样的张三同步、拨号、搜索行为都变得很混乱。原因自己建表设计的 unique 约束没加到字段上。系统通讯录本身就允许多个相同姓名但手机号一般不能重复占位所以要判断是姓名维度去重还是号码维度去重。解决如果产品允许重名就给 phone 加 unique 索引如果不允许就建联合唯一约束name, phone二选一。Room 写法是在 Entity 的 Index 上指定 uniqueEntity( tableName contacts, indices [Index(value [phone], unique true)] ) data class Contact(...)插库之前用并发查询判断手机号是否已存在比依赖数据库抛约束异常更友好。这五个坑是我做类似项目时真实遇到过的尤其是数据库升级和权限这两个问题几乎每个上手的同学都栽进去过。接下来聊点让通讯录更像产品的高级技巧。6. 进阶技巧模糊搜索、字母索引与联系人导出备份通讯录做到能增删改查只是基础版加上模糊搜索和字母索引才像是一个能上架的产品。搜索逻辑在上面的 Dao 里已经实现了但界面上通常要在姓氏首字母或汉字字数上做二次处理。按拼音首字母分组是最常见的做法思路是给 Contact 增加一个 pinyin 字段录入时把姓名的拼音拆出来列表 header 显示 A-Z。具体的实现方式是用拼音库常见做法是引入轻量拼音库在插入联系人时生成全拼和首字母两个字段例如fun generatePinyin(name: String): String { val pinyinList Pinyin.toPinyin(name, ).split( ) return pinyinList.joinToString() { it.first().uppercase() } }注意这只适合汉字场景英文名直接返回原文首字母。分组时用 HashMap 遍历一次即可。如果你不想引入额外依赖也可以改用 SQL 的 substr 截取首字符做粗略分组但遇到生僻字和多音字会比较尴尬我建议上拼音库。另一个实用功能是导出备份到本地文件。因为 App 私有目录没法直接跟用户分享通常要导出到公共 Downloads 目录用 MediaStore API 在 Android 10 上不需要存储权限fun exportContactsToFile(context: Context, contacts: ListContact) { val contentValues ContentValues().apply { put(MediaStore.Files.FileColumns.DISPLAY_NAME, contacts_backup_${System.currentTimeMillis()}.csv) put(MediaStore.Files.FileColumns.MIME_TYPE, text/csv) put(MediaStore.Files.FileColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) } val uri context.contentResolver.insert(MediaStore.Files.getContentUri(external), contentValues) uri?.let { context.contentResolver.openOutputStream(it)?.use { os - os.bufferedWriter().use { writer - writer.appendLine(姓名,手机号,邮箱,备注) contacts.forEach { c - writer.appendLine(${c.name},${c.phone},${c.email ?: },${c.note ?: }) } } } } }这段代码没有申请任何存储权限系统直接把文件写到 Downloads 目录对用户来讲可见、能分享、不占私有空间。导出成功后弹一个 Toast 提示文件位置再用 Intent 打开 Downloads 目录体验就很完整了。我把这个 App 做完后最大的教训是一开始图省事把所有字段都塞进一个表后面加字段时就苦不堪言。后来把联系人头像路径独立成表、号码和姓名拆开设计升级和扩展都轻松多了。做通讯录这类项目数据结构设计永远比界面样式重要——界面可以慢慢调数据字段一开始定错后面每一步都在还债。希望这篇笔记帮你思路更清晰把通讯录这个项目真正吃透做出来能用的作品。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?