做移动端的人对通讯录这块功能大概都有同感:写起来不复杂,真要写扎实,坑一点都不少。原生 Contacts 联系人模块,说白了就是直接调用系统提供的联系人数据源和读写接口——Android 上的 ContactsContract、iOS 上的 Contacts 框架、鸿蒙上的联系人 Kit,而不是自己搭一套云端通讯录或者接一个第三方 SDK。它的价值在于,用户手机上早已沉淀了几百上千条联系人,你的 App 如果能直接读取、写入、同步这部分数据,就能省掉一整套账号体系和用户手动导入的成本。我这次接到的需求,就是给一个已经在跑的 App 加上原生 Contacts 联系人模块:读取本地通讯录做号码匹配、支持在 App 内新增联系人、监听系统通讯录变化做增量更新。下面把我从选型、拆解、实现到踩坑的完整过程摊开来写,安卓、iOS、鸿蒙三端都会涉及,无论你是刚接手通讯录需求的新手,还是想优化已有实现的老手,应该都能直接拿去用。
1. 需求拆解:为什么最后落地的是原生 Contacts 联系人模块
1.1 第三方通讯录 SDK 的三个隐性成本
最开始团队里有人提议接一个第三方通讯录 SDK,理由是“跨端统一、省事”。我把几个方案捞出来做了对比,发现事情没那么简单。
第一是数据归属的成本。第三方 SDK 的典型模式是把通讯录整体上传到他们的服务器做匹配和索引,这对一个非通讯录类 App 来说是很重的合规负担。你得在隐私政策里单独列一条、得在应用内做二次告知、还得处理用户撤回授权的场景。原生 Contacts 联系人模块最大的好处就是数据不出端,读取、匹配、写入全在本地完成,合规链路短得多。
第二是能力上限的成本。第三方 SDK 为了跨端统一,通常只暴露“名字 + 号码 + 头像”这一层最粗的数据,像分组、备注、多号码类型(工作/家庭/其他)、生日、社交账号这些字段,要么拿不到,要么拿到的是残缺的。我这次的业务场景恰恰需要区分号码类型,因为要给不同标签的号码发不同的模板消息,第三方方案直接卡在这里。
第三是版本滞后。系统侧的通讯录能力每年都在变,比如新版本系统上用户可以只授权部分联系人给 App,第三方 SDK 要跟进这个变化通常滞后半年以上,而原生方案只需要改几行判断。
所以最终结论很明确:上原生。跨端差异用一层薄薄的封装来抹平,成本远低于把数据交出去。
1.2 原生方案到底能干什么,边界在哪
动手之前我把三端的能力边界列清楚了,这一步很关键,因为很多后期返工都是因为一开始对“能做什么”判断错误。
原生 Contacts 联系人模块能做四件事:查询(按名字、号码、分组检索)、读取(批量拉取字段)、写入(新增、修改、删除联系人,或写入到指定的账户)、监听(系统通讯录发生变化时收到回调)。
它做不到或者很难做好的事情也有几件:一是跨设备的通讯录一致性,系统通讯录本身由用户的多账户(本地账户、各类云端账户)共同聚合,App 只能看到聚合后的结果,看不到同步过程;二是精确的变更 diff,系统给的变更通知粒度通常是“通讯录变了”,而不是“第 123 条记录的号码改了”,想要精确 diff 得自己维护一份快照做比对;三是超大规模下的全量遍历,一万条联系人全字段读取在任何一端都要花掉可观的时间,必须做分页和懒加载。
注意:把“系统通讯录”当成一个只读的展示层来设计,写入只做必要的单向操作(App 新增 → 写入系统),不要试图做双向同步。双向同步的冲突处理复杂度是指数级上升的,而且用户完全感知不到你在后台做了什么。
1.3 三端核心能力对照
我习惯在动手前先做一张对照表,后面写封装层的时候直接照着填。
| 能力项 | Android(ContactsContract) | iOS(Contacts 框架) | 鸿蒙(联系人 Kit) |
|---|---|---|---|
| 读权限 | READ_CONTACTS | 通讯录访问授权 | ohos.permission.READ_CONTACTS |
| 写权限 | WRITE_CONTACTS | 同上(一次授权覆盖读写) | ohos.permission.WRITE_CONTACTS |
| 聚合模型 | 有(Contacts 聚合 RawContacts) | 有(unifiedContact 关联 linkedContacts) | 无显式聚合层 |
| 长期稳定 ID | LOOKUP_KEY | CNContact.identifier | 联系人 id |
| 批量写入 | ContentProviderOperation + applyBatch | CNSaveRequest | 逐条或批处理接口 |
| 变更监听 | ContentObserver | CNContactStoreDidChange 通知 | 无系统级回调,需轮询或自建 |
| 号码检索 | CONTENT_FILTER_URI | predicateForContacts(matching:) | queryContactsByPhoneNumber |
这张表里最值得说的是鸿蒙那一行——它没有系统级的通讯录变更回调,这一点直接决定了我的同步策略要单独设计一套。
2. 权限与数据模型:动手前必须啃下来的两块硬骨头
2.1 权限申请时机决定了一半的用户体验
通讯录权限属于典型的“高敏感权限”,用户看到弹窗的第一反应往往是拒绝。我在三个版本里试过不同的时机,最后稳定下来的策略是:不要一进 App 就申请,也不要在用户还没理解为什么要之前申请。
具体做法是把权限申请绑定到明确的用户动作上。用户点了“导入通讯录好友”、点了“从通讯录选择”、或者进入了通讯录匹配页,这时候才弹窗,并且弹窗之前先用自己的 UI 解释一句:“读取通讯录仅用于匹配已注册好友,数据不会上传”。实测下来授权率比冷启动就弹要高出将近一倍。
Android 侧还有一个容易被忽略的点:READ_CONTACTS和WRITE_CONTACTS属于同一个权限组,但运行时申请时要分开列,只申请读的话写入会直接抛SecurityException。如果产品上只做读取,就不要写权限声明,多一个权限就多一条应用商店的审核问询。
private val contactsPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> val readGranted = result[Manifest.permission.READ_CONTACTS] == true val writeGranted = result[Manifest.permission.WRITE_CONTACTS] == true when { readGranted && writeGranted -> onContactsReady() readGranted -> onContactsReadOnly() else -> showPermissionRationale() } }iOS 侧的降级更微妙。新版本系统里用户可以只授权“选中的联系人”,这时候你调全量枚举拿到的是一个被裁剪过的集合,代码不会报错,但结果会少一大截。所以任何读取逻辑里都要显式判断授权等级,而不是简单地“有权限/没权限”二元判断。
let store = CNContactStore() let status = CNContactStore.authorizationStatus(for: .contacts) switch status { case .authorized: loadAllContacts() case .limited: // 只拿到用户选中的那部分,UI 上要给出"管理可访问联系人"的入口 loadAllContacts() showManageAccessEntry() case .denied, .restricted: showPermissionRationale() case .notDetermined: requestAccess() @unknown default: break }2.2 数据模型映射:Android 的三层结构最容易踩空
Android 的通讯录模型是三层的:Contacts(聚合视图)→RawContacts(原始账户条目)→Data(具体字段)。新手最常见的错误是直接查Data表然后自己拼,结果同一个人的两个号码、三个邮箱拼出一堆重复记录。正确的顺序是先查Contacts拿到聚合后的联系人列表,需要具体字段时再按联系人 ID 去Data表查。
iOS 的模型相对直白,一个CNContact就是一条聚合后的联系人,需要具体号码时读phoneNumbers数组,每个元素是CNLabeledValue<CNPhoneNumber>,label 就是“家庭/工作/其他”。
鸿蒙这边是Contact对象,里面挂phoneNumbers: PhoneNumber[]、emails: Email[],结构上更接近 iOS。
我在封装层里统一定义了一个ContactRecord,把三端映射到同一个结构:
| 统一字段 | Android 来源 | iOS 来源 | 鸿蒙来源 |
|---|---|---|---|
| id | Contacts.LOOKUP_KEY | identifier | id |
| 显示名 | DISPLAY_NAME_PRIMARY | givenName + familyName | name.fullName |
| 号码列表 | Data where MIMETYPE = Phone | phoneNumbers | phoneNumbers |
| 邮箱列表 | Data where MIMETYPE = Email | emailAddresses | emails |
| 头像 | PHOTO_THUMBNAIL_URI | thumbnailImageData | portrait |
| 分组 | GroupMembership | groups | 无 |
这里有个细节值得敲一下:Android 的_ID不要用作长期存储,因为系统在聚合去重时可能重建记录,_ID会变。要做持久映射,用LOOKUP_KEY。iOS 的identifier相对稳定,但也不是绝对,跨设备恢复时可能变,做业务映射时最好还是自己生成一个基于号码归一化后的本地 ID。
注意:
Data表查询在不同的设备上返回的列顺序不保证一致,所有字段索引一律用getColumnIndexOrThrow(),绝对不要硬编码数字下标,我见过不止一次有人写cursor.getString(3)然后在特定机型上崩掉。
2.3 隐私合规的硬约束,别等审核被打回来才想起来
通讯录数据在国内应用商店和海外平台的审核里都属于重点项,几条底线必须守住:读取目的必须和产品功能对应,不能“为了可能的用途”先存着;读取到的通讯录不建议整体上传服务器,如果业务确实需要匹配,用哈希后的号码做一次性比对,比对完即弃;隐私政策里要写清楚读哪些字段、用于什么、是否共享给第三方;用户撤回授权后,本地缓存的通讯录数据要能一键清除。
我这次的实现里专门加了一个ContactsCacheCleaner,在权限状态变为拒绝时触发,把本地数据库里缓存的联系人快照全部清空。这个逻辑不难写,但如果不做,测试同学在验收的时候一定会问。
3. 核心实现:查、读、写、同步四步走
3.1 第一步:查询——投影写对,性能省一半
查询慢的原因九成出在projection上。Android 的query()如果不传 projection,默认等价于SELECT *,Contacts表加上视图关联会有几十个字段,其中还包含头像这类大对象,一页拉下来内存和耗时都很难看。
我的做法是只取列表页真正要用的四个字段:LOOKUP_KEY、DISPLAY_NAME_PRIMARY、HAS_PHONE_NUMBER、PHOTO_THUMBNAIL_URI。详细字段等用户点进详情页再按 id 单独查。
private val LIST_PROJECTION = arrayOf( ContactsContract.Contacts.LOOKUP_KEY, ContactsContract.Contacts.DISPLAY_NAME_PRIMARY, ContactsContract.Contacts.HAS_PHONE_NUMBER, ContactsContract.Contacts.PHOTO_THUMBNAIL_URI ) fun queryContactsPage(offset: Int, limit: Int): List<ContactBrief> { val uri = ContactsContract.Contacts.CONTENT_URI.buildUpon() .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY, "$offset,$limit") .build() val cursor = resolver.query( uri, LIST_PROJECTION, "${ContactsContract.Contacts.HAS_PHONE_NUMBER} = 1", null, "${ContactsContract.Contacts.SORT_KEY_PRIMARY} ASC" ) ?: return emptyList() // ... 逐行读取 }分页这里有个坑:LIMIT_PARAM_KEY的写法是offset,limit,不是limit offset这种 SQL 语法,参数顺序反了不会报错,只会返回空或者错乱的结果。我第一次就写反了,排查了半小时才发现。
排序字段用SORT_KEY_PRIMARY而不是DISPLAY_NAME_PRIMARY,这是中文联系人排序的关键。DISPLAY_NAME_PRIMARY按 Unicode 码点排序,中文名字会排得乱七八糟;SORT_KEY_PRIMARY是系统预先算好的拼音排序键,直接用它就能得到符合中文习惯的排序。不过要注意,读SORT_KEY_PRIMARY需要READ_CONTACTS权限,权限被拒时这一列会返回 null。
iOS 侧的对应实现是CNContactFetchRequest配合keysToFetch:
let request = CNContactFetchRequest(keysToFetch: [ CNContactIdentifierKey as CNKeyDescriptor, CNContactGivenNameKey as CNKeyDescriptor, CNContactFamilyNameKey as CNKeyDescriptor, CNContactPhoneNumbersKey as CNKeyDescriptor, CNContactThumbnailImageDataKey as CNKeyDescriptor ]) request.sortOrder = .userDefault request.unifyResults = true var result: [ContactBrief] = [] try store.enumerateContacts(with: request) { contact, stop in result.append(ContactBrief(from: contact)) }enumerateContacts比unifiedContacts(matching:)更适合全量遍历,因为它是流式回调,不会一次性把所有联系人压进内存。一万条联系人用后者很可能直接触发内存告警。
3.2 第二步:读取——绕开 N+1 查询这个经典陷阱
列表页只拿了名字,详情页要号码,很自然的写法是在列表的onBindViewHolder里为每个联系人单独查一次号码。这就是经典的 N+1 问题:一屏 10 个联系人就是 10 次跨进程查询,滑动起来卡得肉眼可辨。
正确的做法是批量取。Android 上最有效的方式是用Contacts.Entity,它把某个联系人下的所有Data行一次性返回:
fun queryPhonesByIds(lookupKeys: List<String>): Map<String, List<PhoneItem>> { val result = mutableMapOf<String, MutableList<PhoneItem>>() // SQLite 单条语句的变量上限是 999,超过要分批 lookupKeys.chunked(500).forEach { chunk -> val placeholders = chunk.joinToString(",") { "?" } val selection = "${ContactsContract.Contacts.LOOKUP_KEY} IN ($placeholders)" resolver.query( ContactsContract.Contacts.Entity.CONTENT_URI, arrayOf( ContactsContract.Contacts.Entity.LOOKUP_KEY, ContactsContract.Contacts.Entity.MIMETYPE, ContactsContract.Contacts.Entity.DATA1, ContactsContract.Contacts.Entity.DATA2 ), selection, chunk.toTypedArray(), null )?.use { cursor -> // 只处理 MIMETYPE 为 Phone 的行,DATA1 是号码,DATA2 是类型 } } return result }chunked(500)这一步是必须的。SQLite 的SQLITE_MAX_VARIABLE_NUMBER默认是 999,超过的话查询会直接抛异常。我曾经在测试机上联系人少,一直没测出问题,上线后在一台存了 2000 多条联系人的设备上崩了,日志里就是变量超限。
号码类型DATA2的取值是固定的几个常量(TYPE_MOBILE、TYPE_WORK、TYPE_HOME等),做一个映射表转成业务语义就行。号码本身建议在读取时就做一次归一化:去掉空格、横线、括号,保留开头的+,这样后面做匹配和去重都省事。
fun normalizeNumber(raw: String): String { val trimmed = raw.trim() val hasPrefix = trimmed.startsWith("+") val digits = trimmed.filter { it.isDigit() } return if (hasPrefix) "+$digits" else digits }3.3 第三步:写入——批量提交和去重是两件必做的事
写入分两种场景:单条新增和批量导入。单条新增用最简单的方式就行,Android 上构造一个ContentProviderOperation,指定RawContacts的账户信息,再往Data表插号码和名字。
账户信息这里有个关键选择:ACCOUNT_TYPE和ACCOUNT_NAME传 null 表示写入本地账户,传具体值表示写入对应的云端账户。除非产品明确要求和某个账户同步,否则一律写本地账户。写到云端账户里,用户在其他设备上会莫名其妙多出一条联系人,这是很典型的用户投诉来源。
fun insertContact(displayName: String, phone: String, type: Int): Boolean { val ops = arrayListOf<ContentProviderOperation>() ops.add( ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI) .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null) .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null) .build() ) 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, displayName) .build() ) 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, phone) .withValue(ContactsContract.CommonDataKinds.Phone.TYPE, type) .build() ) val results = resolver.applyBatch(ContactsContract.AUTHORITY, ops) return results.isNotEmpty() }withValueBackReference(..., 0)这个写法是批量操作的核心:它把当前批次里第 0 个操作的返回 ID 回填到后续操作里,这样你就不用先插入再查 ID,两次跨进程调用变成一次。批量导入时同理,把所有操作攒成一个 list,一批 100 个左右提交一次,比逐条提交快一个数量级。
iOS 的批量写入用CNSaveRequest:
let saveRequest = CNSaveRequest() for item in newContacts { let contact = CNMutableContact() contact.givenName = item.givenName contact.phoneNumbers = item.phones.map { CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: $0)) } saveRequest.add(contact, toContainerWithIdentifier: nil) } do { try store.execute(saveRequest) } catch { // 部分失败的处理见 5.2 }CNSaveRequest 有两个实测出来的经验值:单次提交的联系人数量控制在 500 以内比较稳妥,超过之后耗时曲线明显变陡;另外每次execute之后建议重新创建 store 实例或至少重新拉一次数据,因为系统内部缓存可能不会立即刷新。
去重是所有写入场景都绕不过去的。我的策略是三级判断:先按归一化号码精确匹配,命中则认为重复;号码没命中再按显示名精确匹配,同时要求至少有号码;最后对名称做一次编辑距离模糊匹配,只做提示不做自动合并,交给用户确认。第二级和第三级之间要留一个人工确认的环节,因为重名这件事在通讯录里太常见了。
3.4 第四步:监听变化与增量同步
Android 用ContentObserver监听ContactsContract.Contacts.CONTENT_URI:
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { override fun onChange(selfChange: Boolean, uri: Uri?) { // 系统可能短时间内连续回调多次,做 500ms 防抖 scheduleSync(debounceMs = 500) } } resolver.registerContentObserver( ContactsContract.Contacts.CONTENT_URI, true, observer )三个要点。第一,registerContentObserver本身就需要READ_CONTACTS权限,权限被撤销后再注册会抛SecurityException,所以注册前必须判权限,权限变化时还要主动注销。第二,onChange的回调很频繁,一次通讯录同步可能触发几十次,必须做防抖,我用的 500ms 合并窗口。第三,回调里的uri参数粒度很粗,通常拿不到具体是哪条记录变了,所以同步策略是“拉取全量快照做比对”,而不是“按 uri 增量更新”。
增量同步我的方案是维护一张本地contact_snapshot表,存lookupKey、归一化号码、显示名的哈希值。每次收到变更通知后,后台线程拉一遍精简投影(只要这四个字段,成本很低),和快照做集合比对,算出新增、删除、修改三个集合,然后只对这三个集合做业务处理。5000 条联系人的精简投影在中等机型上大概 200-400ms,放在后台线程完全可以接受。
注意:不要在
onChange回调里直接做耗时操作。虽然我把 Handler 绑在了主线程 Looper 上,但真正同步的逻辑一定要 post 到后台队列,否则通讯录批量同步期间你的 App 主线程会被拖死。
鸿蒙侧没有系统级变更回调,我的做法是退化为“主动触发 + 低频轮询”的组合:用户进入通讯录相关页面时主动拉一次比对;同时在页面可见期间以 30 秒为间隔做一次轻量比对(只比对数量和最新修改时间戳)。这个方案不如 Android/iOS 的推送式回调优雅,但实测下来用户几乎感知不到延迟,成本也可控。
4. 性能与体验:五千条联系人怎么做到滑动不卡
4.1 加载策略:分页 + 懒加载 + 预取
通讯录列表的性能优化,第一步永远是分页。我的实现是首屏加载 50 条,滚动到距底部 20 条时异步加载下一页,每页 50 条。听起来很朴素,但配合游标复用,效果立竿见影。
Android 上比LIMIT分页更好的方式是长驻一个 Cursor,配合CursorAdapter或者分页库,避免反复重建游标。不过要注意游标的生命周期管理,Activity 销毁时必须 close,否则会泄漏。我这边因为要跨端统一,还是选了LIMIT分页,代价是每次翻页都有一次查询开销,实测在 5000 条规模下每次翻页 30-60ms,可接受。
iOS 侧把enumerateContacts的结果先落到内存里的一个轻量数组(只存 id、名字、号码首位的结构体),列表渲染读数组,详情页再按 id 回查完整信息。一万条联系人的轻量数组占用大约 2-3MB,完全在可控范围内。
头像是最容易被忽略的性能杀手。列表页不要加载原图,一律用缩略图。Android 用PHOTO_THUMBNAIL_URI,iOS 用thumbnailImageData(注意 iOS 上还有一个imageData是全尺寸的,别用错)。而且缩略图也要做内存缓存和磁盘缓存两层,我用的方案是内存 LRU 缓存 200 张,磁盘缓存按 id 命名存文件,避免每次滑动都重新解压。
4.2 搜索:拼音首字母和号码中间匹配都要支持
通讯录搜索的用户预期比一般搜索高得多,因为大家已经习惯了系统拨号盘的体验:输入zhangsan、zs、138都得能搜到。
Android 有一个现成的CONTENT_FILTER_URI,它同时支持按名字和按号码匹配:
val filterUri = Uri.withAppendedPath( ContactsContract.CommonDataKinds.Phone.CONTENT_FILTER_URI, Uri.encode(keyword) )这个接口的好处是系统侧已经做了名字和号码的双路匹配,坏处是它不支持拼音首字母。用户输入zs搜不到“张三”,因为系统匹配的是 “zhangsan” 这个完整拼音串的前缀。
要支持首字母,得自己建索引。我的做法是首次全量同步时,给每个联系人算一个pinyinFull(全拼)和一个pinyinInitials(首字母),存进本地表并建索引。搜索时先查本地索引表,拿到 id 列表后再回系统查详情。中文转拼音用的是常见的拼音库,注意多音字要按姓氏优先,比如“单”作姓氏读 shan 不读 dan,“解”作姓氏读 xie 不读 jie,这两条规则在通讯录场景里命中率很高。
号码搜索还有一个细节:用户输入的可能是号码中间一段,比如存的是13812345678,用户输入2345。系统接口对这种情况支持不完全一致,所以我在本地索引表里也存了号码,用LIKE '%keyword%'做兜底匹配,两边结果取并集后按“是否前缀匹配”排序。
4.3 列表渲染:少做一次布局就少一次卡顿
列表项的布局越简单越好。我在列表项里只放三样东西:头像、名字、号码。不要放分组标签、不要放多个号码、不要放同步状态角标——这些全部挪到详情页。列表项布局层级控制在三层以内,避免RelativeLayout套LinearLayout再套ConstraintLayout这种。
Android 上还有一个容易忽略的点:DISPLAY_NAME_PRIMARY可能是 null(用户没填名字只填了号码),列表里要做兜底显示成号码,而且这个兜底逻辑不要在onBindViewHolder里每次都算,放在数据转换层做一次就行。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
下面这些是我这两周实际遇到并在测试中复现过的问题,按出现频率排了序。
| 现象 | 根因 | 处理方式 |
|---|---|---|
| 查询返回空列表但通讯录里明明有人 | projection 里带了无权限的列,或 LIMIT 参数顺序写反 | 先只查 id 和名字验证,再逐步加列 |
| 列表重复出现同一个联系人 | 直接查了 Data 表没走聚合视图 | 改查 Contacts 聚合视图,或按 LOOKUP_KEY 去重 |
| 写入后系统通讯录里看不到 | 写到了某个不可用的云端账户 | ACCOUNT_TYPE/ACCOUNT_NAME 传 null 写本地 |
| 滑到 200 条以后明显卡顿 | N+1 查询,每项单独拉号码 | 改批量查询,按 500 一批 |
| 权限拒绝后 App 崩溃 | 未捕获 SecurityException 就注册 Observer | 注册前判权限,权限变化时注销 |
| 中文联系人排序错乱 | 用了 DISPLAY_NAME 排序 | 改用 SORT_KEY_PRIMARY |
| iOS 读到的联系人数量偏少 | 用户只授权了部分联系人 | 判断授权等级,UI 给出管理入口 |
| 写入大量联系人时中途失败 | 单批数据量过大或超时 | 分片提交,每片 100-500 条 |
| 头像显示为占位图 | 用了全尺寸图字段或未做缓存 | 改用缩略图字段 + 两级缓存 |
5.2 五条踩出来的避坑经验
第一条,关于部分失败的处理。批量写入时如果中途某一条失败,是整个批次回滚还是跳过继续?Android 的applyBatch是原子性的,一条失败整批回滚;iOS 的CNSaveRequest也是要么全成要么全败。这意味着如果你一批塞了 100 条,其中第 99 条因为号码格式非法失败,前面 98 条也白写了。我的处理是预校验 + 分段提交:提交前先把所有数据做一次格式校验(号码长度、非法字符、空值),把不合法的挑出来单独标记为失败;剩下的合法数据再按每 100 条一批提交,某一批失败时对这批做二分重试,快速定位到具体哪一条有问题。
第二条,别相信任何机型上返回的字段完整性。我在三台不同厂商的设备上做过对比,同一个联系人,有的设备返回了PHOTO_URI但PHOTO_THUMBNAIL_URI为 null,有的反过来,还有的设备对没有写入过账户的联系人返回的LOOKUP_KEY长度都不一样。所有字段读取一律做空判断,头像字段两个都试,取到哪个用哪个。
第三条,ContentObserver 的注册要绑生命周期。我一开始在 Application 里注册,结果 App 切到后台之后系统通讯录一变化就唤醒进程,耗电问题被用户投诉。改成在通讯录相关页面onStart注册、onStop注销,配合页面级的防抖,耗电和实时性都兼顾到了。
第四条,号码归一化的边界比你想象的多。除了空格横线括号,还要处理:带国家码和不带国家码的同一号码(+8613812345678和13812345678)、带分机号的号码、纯短号(如 5 位内线)。我的归一化函数分两层:第一层做通用清洗,第二层做业务匹配时按“末尾 11 位相同即认为是同一号码”的宽松规则兜底。宽松匹配只在提示层面用,绝不用于自动删除或自动合并。
第五条,写测试用例时一定要构造脏数据。我在测试库里手动造了这么几类数据:只有号码没有名字的、名字里带特殊符号和空格的、同一个号码存了三种类型的、名字完全相同但号码不同的、号码里混了全角数字的。这几类数据覆盖了线上 90% 以上的边界情况,比随机生成一万条正常数据有价值得多。
// 号码归一化的完整实现,包含全角转半角和末尾匹配 fun normalize(raw: String): String { val halfWidth = raw.map { c -> if (c.code in 0xFF01..0xFF5E) (c.code - 0xFEE0).toChar() else c }.joinToString("") val hasPlus = halfWidth.trimStart().startsWith("+") val digits = halfWidth.filter { it.isDigit() } return if (hasPlus) "+$digits" else digits } // 宽松匹配:去掉国家码后比较末 11 位 fun isSameNumber(a: String, b: String): Boolean { val na = normalize(a).trimStart('+').takeLast(11) val nb = normalize(b).trimStart('+').takeLast(11) return na.isNotEmpty() && na == nb }最后再提一个体感层面的东西:通讯录模块的“快”不是靠某一个优化点堆出来的,而是靠整条链路上每一环都别拖后腿。查询别拉全字段、列表别做 N+1、头像别用原图、搜索别每次都全表扫、变更别在主线程处理。这五件事做到位,五千条联系人的体验就已经很顺了,剩下的边际收益会越来越小。我现在回头看我第一版实现,最大的问题是总想着一步到位把功能全做齐,结果每加一个功能就把性能拖下去一截。分阶段做、每阶段压一次性能,反而是最省时间的路径。