19、权限与联系人:READ_CONTACTS、WRITE_CONTACTS、联系人同步权限

联系人,说白了就是手机里的“人脉数据库”。

我做过一个通讯录备份App,当时对这块权限的理解,真的是踩坑踩出来的。今天咱们就聊聊READ_CONTACTS、WRITE_CONTACTS,还有那个容易被忽略的“联系人同步权限”。

19.1 这三个权限到底管什么?

先看一张图,理清它们的关系:

联系人权限体系结构图 READ_CONTACTS 读取联系人数据 姓名、电话、邮箱等 WRITE_CONTACTS 写入/修改联系人 增删改联系人条目 同步权限 GET_ACCOUNTS 账户同步适配器 显示联系人列表 拨号、短信、邮件选择 新建/编辑/删除联系人 通讯录管理类App 账户同步与备份 Google/企业账户同步 注意:Android 10+ 对后台读取联系人有限制

嗯,这张图基本说清楚了。READ_CONTACTS 是“读”,WRITE_CONTACTS 是“写”,同步权限则是“后台同步”的钥匙。

核心要点:READ_CONTACTS 和 WRITE_CONTACTS 属于 dangerous 级别权限,必须运行时申请。同步权限(GET_ACCOUNTS)在 Android 8.0+ 已经降级为普通权限,但涉及账户同步适配器时仍需注意。

19.2 运行时申请:代码怎么写?

我习惯用一个工具类来统一管理权限申请。直接上代码:

// 检查并申请联系人读取权限
public void requestReadContactsPermission(Activity activity) {
    if (ContextCompat.checkSelfPermission(activity, 
            Manifest.permission.READ_CONTACTS) 
            != PackageManager.PERMISSION_GRANTED) {
        
        // 是否需要展示额外解释?
        if (ActivityCompat.shouldShowRequestPermissionRationale(activity,
                Manifest.permission.READ_CONTACTS)) {
            // 这里可以弹个Dialog解释为什么需要这个权限
            showPermissionRationaleDialog(activity);
        } else {
            ActivityCompat.requestPermissions(activity,
                    new String[]{Manifest.permission.READ_CONTACTS},
                    REQUEST_CODE_READ_CONTACTS);
        }
    } else {
        // 权限已授予,直接读取联系人
        loadContacts();
    }
}

你想想看,这段代码其实有个坑——shouldShowRequestPermissionRationale 在用户勾选“不再询问”后会返回 false。我曾经因为这个原因,导致用户拒绝后无法再次弹出申请,只能引导去设置里手动开启。

我的建议:在 onRequestPermissionsResult 回调中,如果权限被拒绝且 shouldShowRequestPermissionRationale 为 false,直接跳转到应用设置页,让用户手动开启。别傻等。

19.3 读取联系人:ContentResolver 的正确姿势

读取联系人,说白了就是查数据库。Android 的联系人数据存在 ContactsContract 这个 ContentProvider 里。

// 读取所有联系人姓名和电话
ContentResolver resolver = context.getContentResolver();
Cursor cursor = resolver.query(
        ContactsContract.Contacts.CONTENT_URI,
        null,   // 查询所有列
        null,   // 无筛选条件
        null,
        ContactsContract.Contacts.DISPLAY_NAME + " ASC"
);

if (cursor != null && cursor.moveToFirst()) {
    do {
        String contactId = cursor.getString(
                cursor.getColumnIndex(ContactsContract.Contacts._ID));
        String displayName = cursor.getString(
                cursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME));
        
        // 查询该联系人的电话号码
        Cursor phoneCursor = resolver.query(
                ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
                null,
                ContactsContract.CommonDataKinds.Phone.CONTACT_ID + " = ?",
                new String[]{contactId},
                null);
        
        // 处理电话号码...
        
        phoneCursor.close();
    } while (cursor.moveToNext());
    cursor.close();
}

这里要注意:联系人可能有多个号码,而且查询时一定要在子线程里做。我见过有人在主线程直接查联系人,结果 UI 卡死,被用户骂惨了。

避坑指南:我曾经在 Android 10 上遇到一个问题——后台读取联系人被系统拦截。Google 从 Android 10 开始限制后台 App 读取联系人数据。如果你的 App 在后台运行,就算有 READ_CONTACTS 权限,也会返回空数据。解决方案是使用前台服务,或者干脆等用户回到前台再读。

19.4 写入联系人:增删改的细节

写入联系人比读取复杂一些。我直接给个添加联系人的例子:

// 添加一个联系人
ArrayList<ContentProviderOperation> ops = new ArrayList<>();

// 先插入一个 RawContact
int rawContactInsertIndex = ops.size();
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, 
                rawContactInsertIndex)
        .withValue(ContactsContract.Data.MIMETYPE,
                ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
        .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, 
                "张三")
        .build());

// 添加电话号码
ops.add(ContentProviderOperation.newInsert(
        ContactsContract.Data.CONTENT_URI)
        .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 
                rawContactInsertIndex)
        .withValue(ContactsContract.Data.MIMETYPE,
                ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
        .withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, "13800138000")
        .withValue(ContactsContract.CommonDataKinds.Phone.TYPE,
                ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE)
        .build());

// 批量执行
ContentResolver resolver = context.getContentResolver();
try {
    resolver.applyBatch(ContactsContract.AUTHORITY, ops);
} catch (Exception e) {
    e.printStackTrace();
}

嗯,这里有个关键点——withValueBackReference。为什么用它?因为 RawContact 的 _ID 是自增的,你没法提前知道。用 BackReference 可以让系统自动关联。

19.5 联系人同步权限:GET_ACCOUNTS 与 SyncAdapter

同步权限,说白了就是让你的 App 能跟服务器“双向同步”联系人。

Android 提供了 SyncAdapter 框架来实现这个功能。但前提是:

  • 你的 App 需要声明 GET_ACCOUNTS 权限(Android 8.0+ 已降级为普通权限)
  • 需要创建一个 Authenticator(即使不做认证,也得有个占位)
  • 需要实现 AbstractThreadedSyncAdapter 的子类
  • 在 AndroidManifest 中注册 SyncServiceSyncAdapter 的 meta-data

说实话,这个框架有点重。我做过一个项目,其实只需要把联系人备份到云端,根本不需要实时同步。后来我直接用 WorkManager 定时上传,省去了 SyncAdapter 的复杂度。

我的建议:除非你真的需要“双向实时同步”(比如企业通讯录),否则别碰 SyncAdapter。用 WorkManager + 手动触发同步,开发成本低得多。

19.6 权限组合使用的常见场景

场景 需要的权限 说明
显示联系人列表 READ_CONTACTS 只读,不修改
通讯录备份 READ_CONTACTS + WRITE_CONTACTS 读取后写入本地文件或云端
通讯录恢复 WRITE_CONTACTS 从备份文件写入系统
企业通讯录同步 READ_CONTACTS + WRITE_CONTACTS + GET_ACCOUNTS 需要 SyncAdapter 支持
来电识别 READ_CONTACTS 匹配来电号码

19.7 避坑总结

最后,我把自己踩过的坑列一下:

  • 权限被撤销:Android 11+ 用户如果一段时间不用你的 App,系统会自动撤销权限。记得在 onResume 里重新检查权限状态。
  • 联系人数据格式:不同厂商的联系人数据可能略有差异。比如小米手机的联系人可能有额外的自定义字段。别假设所有手机都一样。
  • 批量操作性能:applyBatch 而不是逐条插入。我试过逐条插入 1000 个联系人,耗时 30 秒;用 applyBatch 只要 2 秒。
  • 权限申请时机:不要在 App 启动时一股脑申请所有权限。用户会反感。最好在用到某个功能时再申请,比如用户点击“导入联系人”时。

好了,关于联系人权限,今天就聊这么多。记住一句话:权限是用户对你的信任,别滥用。


公众号:蓝海资料掘金营,微信deep3321