资讯动态

Android联系人读取全解析:ContentResolver、权限与性能优化

发布时间:2026/10/14 15:31:59 来源:尧图企业网站定制
很多人第一次在项目里接触“ContentResolver访问联系人”这类需求往往是接手通讯录应用或者做数据迁移、备份工具。这个需求看起来就三行代码的事——拿权限、查表、遍历——但实际操作里权限授权了查不到数据、联系人重复、游标崩溃各种问题能让你折腾一整天。我最近在维护一个通讯录管理类项目时又把这条路完整走了一遍顺手把代码、原理、踩坑记录整理出来分享给需要的人。这篇文章会覆盖五个层面为什么访问联系人必须走ContentResolver而不是直接读数据库、权限处理到底怎么才稳妥、系统的联系人表结构长什么样、完整的读取实现代码、以及我在实测中遇到的典型问题和排查链路。无论你是刚接触Android开发还是已经在项目里被联系人读取问题折腾过都能从中找到能直接用的东西。1. 为什么访问联系人必须走ContentResolver沙箱和“门卫”机制1.1 一个直连数据库的失败幻想先把话说破在Android系统里普通应用没有任何办法直接打开系统联系人数据库文件。联系人数据实际存在系统联系人供应商的SQLite数据库里路径类似/data/data/com.android.providers.contacts/databases/contacts2.db但那个目录受SELinux和应用沙箱的双重保护第三方应用连目录列表都看不到更别说读取文件了。即使设备root了正规发布的应用也不可能靠root权限去打开别人的数据库文件——一来这种方案在绝大多数用户设备上根本跑不起来二来一旦系统升级、ROM更换目录结构代码立刻失效。所以从设计上Android把联系人这类系统敏感数据全部收口到 ContentProvider 这一层对外只暴露统一的访问接口。1.2 ContentProvider ContentResolver 的设计逻辑ContentProvider 是系统对外提供数据的“门卫”ContentResolver 是你作为客户端访问数据的“公共入口”。两者通过 Binder 跨进程通信底层细节被完全屏蔽。你发起一个query()系统在联系人供应商进程里执行SQL、封装结果把 Cursor 传回你的进程。这个设计最大的意义在于权限控制可以做得非常细。系统可以给不同的调用方授予不同的读写权限比如只读权限和读写权限分离也可以针对某个具体URI授权。你的应用永远拿不到对方的SQLiteDatabase对象只能拿到查询结果这从架构上避免了数据被绕过权限随意篡改。打个比方你去银行办事不需要知道金库钥匙长什么样也不需要知道账本放在哪个保险柜里。你只需要走到柜台ContentResolver递上证件权限说清楚要办什么业务URI和查询参数柜员ContentProvider会帮你把事办了。1.3 URI就是联系人的“定位地址”ContentResolver 所有的操作都依赖 URI。联系人相关URI不是随便写的字符串而是由系统在ContactsContract里定义好的常量格式统一为content://authority/path/id。我自己在工作里强制要求团队不许硬编码URI字符串一律用系统常量因为不同ROM的authority偶尔会有差异用常量才能保证可移植性。最常用的是这几个ContactsContract.Contacts.CONTENT_URI所有联系人聚合表的URI即content://com.android.contacts/contactsContactsContract.CommonDataKinds.Phone.CONTENT_URI所有电话号码数据行的URI即content://com.android.contacts/data/phonesContactsContract.RawContacts.CONTENT_URI原始联系人表的URIContactsContract.Data.CONTENT_URI联系人明细数据表的URI如果知道某个联系人的_ID想要精确访问这一条可以用ContentUris.withAppendedId()拼接IDval singleUri: Uri ContentUris.withAppendedId( ContactsContract.Contacts.CONTENT_URI, contactId )这个content://前缀的URI体系贯穿整个联系人访问过程后续所有查询、插入、更新、删除操作都是围绕它展开的。理解了URI和ContentResolver的关系再看系统联系人访问的其他问题就会顺很多。2. 权限是第一道坑声明了READ_CONTACTS怎么还会崩2.1 运行时权限的分水岭Android 6.0 是个分水岭。在那之前只要在 manifest 里声明READ_CONTACTS安装时系统就会直接授权应用拿到权限后随便读。6.0 之后READ_CONTACTS被划入危险权限即使 manifest 里写了uses-permission应用运行时依然要先向用户申请用户点了允许才真正拿到授权。很多新手写联系人功能时会犯同一个错误manifest声明没漏代码看着也没问题一运行却抛SecurityException或者query()返回的 Cursor 是空。原因就一个——运行时权限没有申请。还要注意一点声明权限和授权是两码事。manifest 只是告诉系统“我这个应用可能会用到这个权限”真正能不能用取决于用户在你的界面上有没有点“允许”。所以我习惯在代码里把“申请权限”和“检查权限”分开所有入口处先检查、再申请。2.2 动态权限申请的标准姿势以Kotlin为例完整的权限检查与申请逻辑大致是这样private val REQUEST_READ_CONTACTS 1001 private fun ensureContactPermission(): Boolean { if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { return true } return if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) PackageManager.PERMISSION_GRANTED ) { true } else { ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.READ_CONTACTS), REQUEST_READ_CONTACTS ) false } } override fun onRequestPermissionsResult( requestCode: Int, permissions: Arrayout String, grantResults: IntArray ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode REQUEST_READ_CONTACTS) { if (grantResults.isNotEmpty() grantResults[0] PackageManager.PERMISSION_GRANTED) { loadContacts() } else { showPermissionDeniedTips() } } }这套代码本身不复杂复杂的是用户拒绝之后怎么办。shouldShowRequestPermissionRationale()的返回值很容易被误解它表示的是“这次拒绝之后系统是否允许我再弹一次申请框”。用户第一次拒绝时它通常返回true第二次再拒绝、并且勾选了“不再询问”之后它返回false。很多人把这个当成“是否已被永久拒绝”的判断其实不太准确更稳妥的判断方式是当申请被拒绝并且shouldShowRequestPermissionRationale已经为false时直接引导用户去系统设置页手动开启。2.3 用户拒绝、永久拒绝后的产品设计权限拿不到功能就不能硬做。一个合格的应用在用户拒绝授权时不应该反复弹窗也不应该直接闪退。我见过的处理方案里最实用的组合是拒绝一次给解释文案拒绝两次并勾选“不再询问”跳转设置页。跳转设置页的代码非常简单val intent Intent( Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse(package:$packageName) ) startActivity(intent)这套处理逻辑跑通之后权限部分才算真正过关。不要觉得多余联系人权限是所有危险权限里用户戒心比较重的一种很多用户会拒绝你必须把拒绝后的路也给用户铺好。我在实际项目里还遇到过个别定制ROM对动态权限做了二次弹窗的这种兼容问题只能在拿到反馈后针对性适配提前在代码层面把判断写严谨一点能省掉很多线上问题。3. 容易被忽视的数据模型Contacts、RawContacts和Data三张表3.1 从“名片盒”理解三表结构联系人相关的表结构是整个ContactsContract里最容易被忽略、却又最影响理解的部分。系统并不是简单地把联系人存在一张大表里而是拆成了三张关联表。ContactsContract.Data是最细粒度的数据行每一行只存一件事一个手机号、一个邮箱、一个名字、一个备注。你可以把它想象成一张一张的“素材纸条”。ContactsContract.RawContacts是原始联系人条目它把一组“素材纸条”归拢到一起形成一条来自某个来源的完整记录。比如你的手机里联系人“张工”可能有一条本地记录也有一条来自SIM卡的记录这两条在RawContacts表里就是两行。ContactsContract.Contacts是聚合后的联系人表系统通过一定的聚合算法把同一个自然人对应的多条RawContacts合并成一行展示给用户看。你可以把它理解成整理好的名片盒——你看到的是“张工”一个人背后其实可能有多条来源。用一个具体例子说明联系人“李工”设备本地存储里存了他的手机号SIM卡里又存了他的公司座机号。系统会为这个人生成两个RawContacts条目一个来自本地一个来自SIM卡两条RawContacts被聚合到同一个Contacts条目下。在Contacts表里你只看到一行“李工”但通过这个联系人的_ID去查号码能查出两个不同的号码。这个设计带来的直接后果是你遍历Contacts表拿到的联系人数量永远小于或等于RawContacts表里的原始记录数量。做数据统计类功能时如果你需要的是“来源粒度”的数据就要去查RawContacts而不是Contacts。3.2 高频字段速查表实际开发中常用字段不算多我把高频的都整理在下面这张表里方便对照字段常量所属表含义使用说明Contacts._IDContacts聚合后的联系人ID全表主键关联查询的锚点Contacts.DISPLAY_NAMEContacts显示名称系统聚合出的最佳姓名Contacts.HAS_PHONE_NUMBERContacts是否有电话号码0或1可做快速筛选Contacts.LOOKUP_KEYContacts稳定查找键跨同步版本稳定可用于深层链接RawContacts._IDRawContacts原始联系人条目ID一个来源账户一条记录RawContacts.ACCOUNT_TYPERawContacts账户类型区分本地、SIM、云端等来源RawContacts.ACCOUNT_NAMERawContacts账户名称具体账户标识Data.RAW_CONTACT_IDData关联RawContacts._ID数据行的归属Data.MIMETYPEData数据类型phone_v2、email_v2等CommonDataKinds.Phone.NUMBERData电话号码最常用字段CommonDataKinds.Phone.NORMALIZED_NUMBERDataE.164标准号码国际格式可能为空CommonDataKinds.Phone.TYPEData号码类型手机、座机、工作等枚举值查询时一定要养成用getColumnIndexOrThrow()获取列索引的习惯避免字段名拼错之后返回-1又拿不到值排查半天才发现是列索引的问题。3.3 为什么要用CommonDataKinds.Phone而不是直接查Data很多第一次写联系人功能的人会纠结一个问题既然电话号码都存在于Data表里为什么不直接查Data表呢当然可以查但性能和代码可读性都不如直接查CommonDataKinds.Phone.CONTENT_URI。直接查Data表你需要手动加上Data.MIMETYPE vnd.android.cursor.item/phone_v2这个过滤条件还要自己拼一些列名。而CommonDataKinds.Phone等于把MIMETYPE过滤和常用列名都封装好了你只需要关心业务字段就行。更重要的是通过CommonDataKinds.Phone.CONTENT_URI拿到的每一行数据天然就是一条电话号码记录你可以直接取CONTACT_ID、NUMBER、TYPE、NORMALIZED_NUMBER不需要关心它会不会混入邮箱、地址等无关数据。查询条件也更清晰比如按联系人ID过滤号码val selection ${CommonDataKinds.Phone.CONTACT_ID} ? val selectionArgs arrayOf(contactId.toString())这也是官方文档推荐的做法。如果你需要同时查手机号和邮箱那可以考虑一次查Data表用Data.MIMETYPE的IN条件减少IO次数这种方式也是合法的只是需要把MIMETYPE判断写清楚。4. 核心代码落地一行一行读完所有联系人4.1 动工前的环境准备写过联系人功能的开发者都知道动手之前必须确认三件事第一manifest里声明权限。也就是前面说的READ_CONTACTS这一步不能省。第二确认targetSdk对应的权限行为。现在的项目基本都target 30运行时权限是硬性要求没办法绕。第三准备一个简单的数据模型来承接查询结果。我会定义一个ContactBean里面放联系人ID、姓名、号码列表data class ContactBean( val id: Long, val displayName: String, val photoUri: String?, val phoneNumbers: ListString )实际项目中你可能还需要头像、邮箱、备注等字段原理都一样就是多几个字段和多几次关联查询的事。先把最核心的姓名和号码跑通再逐步加字段是我一贯的做法。4.2 第一次查询遍历Contacts表第一步是查询ContactsContract.Contacts.CONTENT_URI拿到所有联系人ID和姓名。这里我直接给出一个完整的读取方法fun fetchAllContacts(): ListContactBean { val contacts mutableListOfContactBean() val uri ContactsContract.Contacts.CONTENT_URI val projection arrayOf( ContactsContract.Contacts._ID, ContactsContract.Contacts.DISPLAY_NAME, ContactsContract.Contacts.PHOTO_URI, ContactsContract.Contacts.HAS_PHONE_NUMBER ) contentResolver.query(uri, projection, null, null, null)?.use { cursor - val idIndex cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID) val nameIndex cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME) val photoIndex cursor.getColumnIndexOrThrow(ContactsContract.Contacts.PHOTO_URI) val hasPhoneIndex cursor.getColumnIndexOrThrow(ContactsContract.Contacts.HAS_PHONE_NUMBER) while (cursor.moveToNext()) { val id cursor.getLong(idIndex) val name cursor.getString(nameIndex) ?: val photoUri cursor.getString(photoIndex) val hasPhone cursor.getInt(hasPhoneIndex) 1 val phones if (hasPhone) fetchPhonesByContactId(id) else emptyList() contacts.add(ContactBean(id, name, photoUri, phones)) } } return contacts }这里有几个细节值得说明。HAS_PHONE_NUMBER字段可以快速过滤掉那些没有电话号码的纯联系人但注意它只是一个统计值有的ROM在同步不完整时可能不准所以它只适合做初筛不能完全代替你再查一次号码表。另一个细节是Cursor的释放。Kotlin里用?.use {}可以确保游标最终被关闭避免内存泄漏。在Java里就必须用try/finally这是个高发错误我见过不少线上OOM是游标没关导致的。4.3 第二次查询抓取每个联系人的电话号码上面代码里每个有电话的联系人都会调用一个fetchPhonesByContactId()方法去查号码。这个方法的实现是fun fetchPhonesByContactId(contactId: Long): ListString { val phones mutableListOfString() val uri ContactsContract.CommonDataKinds.Phone.CONTENT_URI val projection arrayOf( ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER ) val selection ${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} ? val selectionArgs arrayOf(contactId.toString()) contentResolver.query(uri, projection, selection, selectionArgs, null)?.use { cursor - val numberIndex cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER) while (cursor.moveToNext()) { val number cursor.getString(numberIndex) if (!number.isNullOrBlank()) { phones.add(number) } } } return phones }这段代码的逻辑很简单但我要提醒一件事这种写法在联系人很多的时候会触发严重的N1查询问题。每遍历一个联系人就去ContentResolver查一次号码如果手机里有2000个联系人就要发起2000次跨进程查询肉眼可见的卡顿用户很容易直接划掉你的应用。4.4 一步到位的批量查询优化优化的思路是把“按联系人ID分别查号码”改成“一次查出所有号码在内存里按联系人ID分组”。代码如下fun fetchAllContactsOptimized(): ListContactBean { val phonesByContact HashMapLong, MutableListString() val phoneUri ContactsContract.CommonDataKinds.Phone.CONTENT_URI val phoneProjection arrayOf( ContactsContract.CommonDataKinds.Phone.CONTACT_ID, ContactsContract.CommonDataKinds.Phone.NUMBER ) contentResolver.query(phoneUri, phoneProjection, null, null, null)?.use { cursor - val contactIdIndex cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.CONTACT_ID) val numberIndex cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER) while (cursor.moveToNext()) { val contactId cursor.getLong(contactIdIndex) val number cursor.getString(numberIndex) if (!number.isNullOrBlank()) { phonesByContact.getOrPut(contactId) { mutableListOf() }.add(number) } } } val contacts mutableListOfContactBean() val uri ContactsContract.Contacts.CONTENT_URI val projection arrayOf( ContactsContract.Contacts._ID, ContactsContract.Contacts.DISPLAY_NAME, ContactsContract.Contacts.PHOTO_URI ) contentResolver.query(uri, projection, null, null, null)?.use { cursor - val idIndex cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID) val nameIndex cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME) val photoIndex cursor.getColumnIndexOrThrow(ContactsContract.Contacts.PHOTO_URI) while (cursor.moveToNext()) { val id cursor.getLong(idIndex) val name cursor.getString(nameIndex) ?: val photoUri cursor.getString(photoIndex) val phones phonesByContact[id] ?: emptyList() contacts.add(ContactBean(id, name, photoUri, phones)) } } return contacts }优化之后整个读取过程只需要两次跨进程查询一次拿联系人列表一次拿全部号码剩下的分组逻辑都在本地内存里完成。号码数量在几千条以内时HashMap分组的开销可以忽略不计。这个优化在联系人上千时效果非常明显是我实测下来最值得做的一步优化。5. 实测中反复出现的问题从空数据到数据翻倍5.1 权限已授权但查询为空完整排查链路我调试联系人功能时最常遇到的现象是权限已经弹窗授权了代码逻辑也看不出问题cursor.moveToNext()一次都没进去查出来的结果是空列表。遇到这种情况不要急着改代码按下面这条链路一步步排查。第一步确认数据源。打开系统自带联系人应用看看里面到底有没有数据。有些测试机、模拟器刚恢复出厂联系人列表是空的你查询结果自然为空。特别是模拟器默认不一定有“设备本地账户”需要在设置里添加一个本地账户或者先登录一个测试账户才能正常写入联系人。第二步确认查询字段。检查projection里是否写对了字段名selection条件是否过于严格。比如你写了HAS_PHONE_NUMBER 1而测试联系人恰好没有手机号查出来就是空。第三步确认账户来源。部分定制ROM会把联系人分成“设备存储”和“SIM卡”两类系统联系人应用里能看到SIM上的联系人但你直接查ContactsContract.Contacts.CONTENT_URI可能只返回设备存储的那部分。这时候你需要去查SIM卡相关的URI或者引导用户在系统联系人应用里把联系人复制到设备存储。第四步检查Uri拼接。如果你用了ContentUris.withAppendedId()拼单条URI确认传入的id确实存在。拼错ID查出来的Cursor不为null但是空游标很容易让人误判。我自己的习惯是在排查初期写一段临时的调试日志把权限状态、查询Uri、Cursor列名、行数全部打出来用LOG说话。这一步能过滤掉大部分低级错误剩下的才值得深挖。5.2 联系人重复翻倍和去重的处理口径“联系人重复”是我这次项目里收到最多的用户反馈。你读出来的联系人里同一个姓名出现了十几次或者同一个手机号对应多个联系人都可能由不同原因造成。系统层面同一个自然人可能同时存在于多个账户设备本地、SIM、云同步或者同一账户因为多次同步产生多条RawContacts这些条目会被系统聚合到同一个Contacts记录里但聚合也不是完美的失败时就表现为重复。应用层面如果用户之前用第三方工具导入过通讯录而导入工具没有做去重那么数据本身就是脏的。去重策略要分场景。如果只是展示最安全的做法是“展示聚合后的Contacts表不主动做合并”——这是系统已经做过的合并结果你再强行合并反而可能弄巧成拙。如果是数据统计、导出备份那就需要自己定义去重键。我用的比较简单可靠的做法是“去掉所有非数字字符后的号码规范化后的姓名”作为组合键号码优先级高于姓名因为同名不同人是正常的同号不同名才更可疑。还要注意一个反直觉的点Contacts表里每行的_ID都不一样哪怕姓名、号码完全相同它们也是两条独立的聚合记录。系统聚合不是简单按姓名合并它还有自己的聚合算法。你如果直接按姓名做“合并”很容易把两个同名的不同联系人合并掉这是很多通讯录工具被用户骂“乱合并”的原因。所以除非有明确的产品需求否则不要过度合并。5.3 游标移动、释放和N1查询的性能陷阱游标是最容易被忽略又最容易出问题的对象。先记住三条铁律第一拿到Cursor后必须先moveToFirst()或者用while(moveToNext())很多新手直接getString()然后崩溃就是因为游标还在position -1的位置。第二用完必须关闭Kotlin用.useJava用try/finally。第三不要在循环体里做重活游标遍历本身不慢慢的是每行都去发起新的查询。N1查询问题我前面已经演示过。这里再说一个更隐蔽的性能问题第一次在UI线程直接调用contentResolver.query()如果联系人几万条照样会卡到ANR。正确的做法是把查询放到IO线程。现在的项目基本都用协程简单写法就是Dispatchers.IOviewModelScope.launch { val contacts withContext(Dispatchers.IO) { fetchAllContactsOptimized() } // 回到主线程更新UI }如果项目还在用旧的AsyncTask或者Thread逻辑也是一样的核心就是别在主线程查库。这一点怎么强调都不过分我自己就见过一个项目在高端机上读一次联系人要两三秒后来排查发现是在主线程跑了嵌套查询改成协程之后瞬间丝滑。6. 联系人数据的增量感知与写回操作6.1 用ContentObserver监听联系人表格变化做通讯录相关应用只做一次性读取往往不够。用户在系统通讯录里新增、修改、删除了联系人你的应用还拿着旧数据肯定不行。系统提供了ContentObserver机制可以监听联系人数据的变更通知。注册监听很简单private val contactObserver object : ContentObserver(Handler(Looper.getMainLooper())) { override fun onChange(selfChange: Boolean, uri: Uri?) { // 收到变化通知注意防抖 refreshContacts() } } contentResolver.registerContentObserver( ContactsContract.Contacts.CONTENT_URI, true, contactObserver )页面销毁时一定要取消注册override fun onDestroy() { contentResolver.unregisterContentObserver(contactObserver) super.onDestroy() }这里有一个实际体验上的坑系统的变更通知有时候会很频繁用户连续编辑几个联系人你的界面就刷新好几次容易闪烁。我自己的做法是加一个简单的防抖记录上次刷新时间如果间隔小于500毫秒就丢弃这次通知。一个Handler加一个时间戳变量就能实现不需要引入额外的框架。6.2 增删改联系人时被忽略的事务要求读取联系人只是访问里的半部分很多备份、导入类应用还需要写入联系人。写入联系人有个和普通数据库操作非常不一样的地方它不允许你单条往Data表里硬插。系统要求新增联系人时必须先插入一条RawContacts记录拿到生成的_ID再往Data表里插入这条RawContacts对应的姓名、手机号等多条数据。如果中途失败这些数据必须保持一致不能出现“有了号码但没名字”的孤儿记录。所以系统提供了applyBatch()用事务的方式一次性提交一组操作。代码如下val ops ArrayListContentProviderOperation() // 第一步插入RawContactsaccount_type和account_name设为null表示本地存储 ops.add( ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI) .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null) .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null) .build() ) // 第二步插入姓名DataRAW_CONTACT_ID用back reference指向上一步的结果 ops.add( ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0) .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, 张三) .build() ) // 第三步插入手机号Data ops.add( ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0) .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE) .withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, 10086) .withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE) .build() ) contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)withValueBackReference(RAW_CONTACT_ID, 0)里的0表示引用第0个操作也就是插入RawContacts生成的自增ID。这个机制第一次接触需要点时间理解但只要记住“RawContacts先行、Data绑定”的原则写起来就不会出错。写操作里还容易踩的坑是ACCOUNT_TYPE和ACCOUNT_NAME的处理。不同系统对这两个字段的容错不一样设置为null表示保存到设备本地这个方案兼容性相对最好。如果你硬要写入某个不存在的账户类型有些ROM会直接报错。6.3 把这套逻辑放进真实项目的几条个人建议整套走下来最大的感受是真正的通讯录功能绝大多数时间不是在写SQL或者调API而是在处理权限、账户、聚合差异和异常数据。这里给三条很实在的建议。第一先写一个能打Log的最小Demo。把联系人读取结果直接通过Log.d打印出来看清楚了再封装成业务接口。这样能最快定位问题也方便你在真机上验证各种ROM的行为差异。第二始终保持“两次查询”的思路。一次拿联系人主表一次拿关联数据内存里组装效率和可读性都好。一旦发现代码里出现了循环查ContentResolver的写法就要警觉。第三把数据模型和系统表结构解耦。你的应用内部不要直接到处传Contacts._ID而是封装成自己的领域模型这样以后即使系统字段升级也只需要改一处映射。我这次项目里把所有查询都收敛到了一个Repository里UI层根本不知道Cursor是什么排查问题时清爽很多。联系人访问这个功能从“能跑”到“跑得稳”之间还是有一段距离的。把原理和踩坑点都弄明白之后再回头看那个“三行代码”的需求你会发现自己已经能预判很多线上问题了。这就是这篇分享想达到的目的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑