☰
QuickContactBadge 在 ListView 中的用法:用 ResourceCursorAdapter 绑定 ContactsContract 头像
2026/10/8 6:27:37 网站建设 项目流程

1. 通讯录列表里头像不显示,问题到底出在哪

QuickContactBadge 这个控件在 Android 通讯录类应用里出镜率很高,它本质上是一个带快捷操作弹窗的头像控件:点一下头像,会弹出打电话、发短信、发邮件、加联系人等入口。把它塞进 ListView 的 item 里,配合 ContactsContract 查询出来的 Cursor,就能做出系统通讯录那种「左边头像、右边姓名电话」的列表效果。

但很多人第一次写会卡在几个地方:头像永远是默认图、点击头像没反应、滑动列表时头像错位、甚至直接崩在assignContactUri上。核心原因通常不是 QuickContactBadge 本身,而是 Adapter 选错了基类、查询列没配对、或者assignContactUri传的 Uri 不对。

这篇就聚焦一个具体场景:在 ListView 中通过自定义 Adapter 与 ResourceCursorAdapter 绑定 ContactsContract 数据,让 QuickContactBadge 正确显示联系人头像并响应快捷操作。适合已经会写基础 ListView、但对 Cursor 系 Adapter 和 ContactsContract 列映射不太熟的同学。下面给出的布局片段、Adapter 关键代码、查询列配置都可以直接复制改包名使用。

先说结论性的判断:QuickContactBadge 要正常显示头像,靠的是assignContactUri()传入一个能唯一定位联系人的 Uri,而不是自己去 setImageBitmap。头像的加载是控件内部通过这个 Uri 去查 PHOTO 数据完成的。所以只要 Uri 对了,头像和点击弹窗会一起生效;Uri 错了,两个都废。理解这一点,后面所有配置都是围绕「怎么拿到正确的 Uri」展开的。

2. ResourceCursorAdapter 与 ContactsContract 查询列的前置准备

在动手写 Adapter 之前,先把数据源这一层理清楚。ContactsContract 是 Android 官方联系人数据库的契约类,查询联系人主表用Contacts.CONTENT_URI,查询电话号码用CommonDataKinds.Phone.CONTENT_URI。ListView 列表一般查主表拿姓名、ID、LOOKUP_KEY,电话再按需二次查询。

这里有个关键点:QuickContactBadge 需要的是Contacts.getLookupUri(contactId, lookupKey)生成的 Uri。为什么不用ContentUris.withAppendedId(Contacts.CONTENT_URI, id)?因为联系人可能被聚合、合并,_ID会变,而LOOKUP_KEY是稳定的。系统通讯录自己也是用 lookupKey 来定位的。所以查询列里Contacts.LOOKUP_KEY必须带上,这是很多人漏掉的一列。

另一个前置是权限。读联系人需要READ_CONTACTS,Android 6.0 以后还要运行时申请。如果权限没给,Cursor 会是空的或者直接抛 SecurityException,表现就是列表空白,别误以为是 Adapter 写错了。

关于 Adapter 基类的选择,这里用的是ResourceCursorAdapter而不是BaseAdapter。区别在于:ResourceCursorAdapter 内部持有一个 Cursor,newView负责 inflate 布局并缓存控件引用,bindView负责把当前行数据填进去。它天然适配 Cursor 数据源,省去了自己管理 Cursor 位置和 swapCursor 的麻烦。用 BaseAdapter 也能做,但你要自己处理 Cursor 生命周期,容易出问题。

查询列投影建议固定成一个常量数组,索引和 Adapter 里的常量一一对应,这样后面改列不容易错位。下面这段投影就是本文要用的:

static final String[] CONTACTS_SUMMARY_PROJECTION = new String[] { Contacts._ID, // 0 Contacts.DISPLAY_NAME, // 1 Contacts.STARRED, // 2 Contacts.TIMES_CONTACTED, // 3 Contacts.CONTACT_PRESENCE, // 4 Contacts.PHOTO_ID, // 5 Contacts.LOOKUP_KEY, // 6 Contacts.HAS_PHONE_NUMBER, // 7 };

选择条件里过滤掉没有名字和没有电话的联系人,排序用DISPLAY_NAME COLLATE LOCALIZED ASC,这样中文名会按拼音排,符合国内用户习惯。这些配置在 Activity 里组装 Cursor 时传入即可。

3. 可复制的布局片段与 Adapter 关键代码配置

先看 item 布局。QuickContactBadge 放在最左边,姓名和电话依次右排,右边放一个 CheckBox 用于多选。注意 CheckBox 要设focusable="false"和clickable="false",否则它会抢走 ListView 的 item 点击事件,导致点头像或整行没反应。

<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="wrap_content" android:minHeight="48dip" android:paddingLeft="0dip" android:paddingRight="9dip" android:id="@+id/contactItemRelLayout"> <QuickContactBadge android:id="@+id/badge" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentLeft="true" android:layout_alignParentTop="true" android:layout_marginLeft="2dip" android:layout_marginRight="14dip" android:layout_marginTop="4dip" android:layout_marginBottom="3dip" android:src="@drawable/default_head_pic" style="?android:attr/quickContactBadgeStyleWindowSmall" /> <TextView android:id="@+id/name" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerVertical="true" android:layout_toRightOf="@id/badge" android:paddingLeft="2dip" android:textAppearance="?android:attr/textAppearanceMedium" /> <TextView android:id="@+id/phone" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerVertical="true" android:layout_toRightOf="@id/name" android:paddingLeft="4dip" android:paddingRight="2dip" android:textAppearance="?android:attr/textAppearanceMedium" /> <CheckBox android:id="@+id/cb" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentRight="true" android:layout_centerVertical="true" android:checkMark="?android:attr/listChoiceIndicatorMultiple" android:clickable="false" android:focusable="false" android:focusableInTouchMode="false" /> </RelativeLayout>

Adapter 继承 ResourceCursorAdapter,核心是newView里做控件缓存、bindView里做数据绑定。缓存类用静态内部类,避免每次 bind 都 findViewById。这里有个细节:姓名用CharArrayBuffer配合copyStringToBuffer,比getString少一次字符串分配,长列表滑动更顺。

public class ContactAdapter extends ResourceCursorAdapter { private Context context2; static final int SUMMARY_ID_COLUMN_INDEX = 0; static final int SUMMARY_NAME_COLUMN_INDEX = 1; static final int SUMMARY_LOOKUP_KEY = 6; private List<Long> contactIDList = new ArrayList<Long>(); public ContactAdapter(Context context, int layout, Cursor c) { super(context, layout, c); contactIDList.clear(); this.context2 = context; } @Override public View newView(Context context, Cursor cursor, ViewGroup parent) { View view = super.newView(context, cursor, parent); ContactListItemCache cache = new ContactListItemCache(); cache.nameView = (TextView) view.findViewById(R.id.name); cache.photoView = (QuickContactBadge) view.findViewById(R.id.badge); cache.phoneView = (TextView) view.findViewById(R.id.phone); cache.checkBox = (CheckBox) view.findViewById(R.id.cb); view.setTag(cache); return view; } @Override public void bindView(View view, Context context, Cursor cursor) { final ContactListItemCache cache = (ContactListItemCache) view.getTag(); cursor.copyStringToBuffer(SUMMARY_NAME_COLUMN_INDEX, cache.nameBuffer); int size = cache.nameBuffer.sizeCopied; cache.nameView.setText(cache.nameBuffer.data, 0, size); final long contactId = cursor.getLong(SUMMARY_ID_COLUMN_INDEX); cache.phoneView.setText(getPhoneNumbersByContactID(contactId)); final String lookupKey = cursor.getString(SUMMARY_LOOKUP_KEY); cache.photoView.assignContactUri(Contacts.getLookupUri(contactId, lookupKey)); } final static class ContactListItemCache { public TextView nameView, phoneView; public QuickContactBadge photoView; public CharArrayBuffer nameBuffer = new CharArrayBuffer(128); public CheckBox checkBox; } private String getPhoneNumbersByContactID(Long _id) { Cursor cursor = context2.getContentResolver().query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, ContactsContract.CommonDataKinds.Phone.CONTACT_ID + "=" + Long.toString(_id), null, null); String phoneNumber = ""; while (cursor.moveToNext()) { String strNumber = cursor.getString( cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)); phoneNumber += strNumber + ","; } cursor.close(); if (phoneNumber.length() == 0) return ""; return phoneNumber.substring(0, phoneNumber.length() - 1); } }

注意getPhoneNumbersByContactID里我加了一个空判断。原写法直接substring(0, length-1),如果某个联系人查不到电话会抛 StringIndexOutOfBoundsException。虽然查询条件里过滤了 HAS_PHONE_NUMBER=1,但聚合联系人偶尔会有边界情况,加一层保护更稳。

Activity 里组装 Cursor 并设置 Adapter:

private void initData() { String select = "((" + Contacts.DISPLAY_NAME + " NOTNULL) AND (" + Contacts.HAS_PHONE_NUMBER + "=1) AND (" + Contacts.DISPLAY_NAME + " != '' ))"; Cursor c = getContentResolver().query( Contacts.CONTENT_URI, CONTACTS_SUMMARY_PROJECTION, select, null, Contacts.DISPLAY_NAME + " COLLATE LOCALIZED ASC"); adapter = new ContactAdapter(this, R.layout.contact_item_layout2, c); listView.setAdapter(adapter); }

ListView 记得设setCacheColorHint(0x00000000),否则滑动时背景会变黑,这是老版本 Android 的经典坑。

4. 验证头像加载与点击响应是否正常

代码写完,怎么确认 QuickContactBadge 真的生效了?分三步验证。

第一步,验证 Cursor 有数据。在initData里 query 之后打一行日志Log.d("Contact", "count=" + c.getCount()),如果 count 是 0,先查权限和选择条件,别往下走。权限没申请的话,在 Activity 里加运行时申请逻辑,或者临时在设置里手动开。

第二步,验证头像加载。给几个联系人设置真实头像(系统通讯录里编辑联系人加照片),然后跑应用。如果头像显示出来了,说明assignContactUri的 Uri 是对的。如果全是默认图,重点查两处:Contacts.LOOKUP_KEY是否在投影里且索引对得上;getLookupUri传的 contactId 和 lookupKey 是否来自同一行。常见错误是投影顺序改了但 Adapter 里的索引常量没同步改,导致 lookupKey 取到了别的列。

第三步,验证点击响应。点任意一个头像,应该弹出快捷操作窗口,里面有电话、短信等入口。如果点了没反应,检查 QuickContactBadge 是否被其他控件覆盖,或者父布局有没有拦截触摸事件。另外,如果 item 根布局设了android:descendantFocusability,也可能影响。

还可以用adb shell content query直接查联系人数据做交叉验证:

adb shell content query --uri content://com.android.contacts/contacts \ --projection _id:display_name:lookup:has_phone_number

这条命令列出的lookup字段就是 LOOKUP_KEY,和你 Adapter 里取到的值对比一下,能快速定位是数据问题还是代码问题。

实测下来,头像不显示十有八九是 LOOKUP_KEY 没取对,或者投影数组和索引常量错位。把这两个对齐,基本就通了。

5. 本篇常见报错排查:401、Cursor 越界与头像错位

这一节把几个高频报错摊开讲,都是真实会遇到的。

SecurityException: Permission Denial: reading com.android.contacts。这是没申请 READ_CONTACTS 权限。Android 6.0 以上要在运行时申请,别只在 Manifest 里声明。表现是 query 直接抛异常或返回空 Cursor。

CursorIndexOutOfBoundsException: Index 6 requested, size 6。投影数组里没有 LOOKUP_KEY,但 Adapter 里用索引 6 去取。检查CONTACTS_SUMMARY_PROJECTION是否包含Contacts.LOOKUP_KEY,且位置和SUMMARY_LOOKUP_KEY常量一致。改投影时一定要同步改常量。

StringIndexOutOfBoundsException。就是前面说的getPhoneNumbersByContactID里对空字符串做 substring。加长度判断即可。

头像错位。滑动列表时头像和姓名对不上,通常是 View 复用导致的。ResourceCursorAdapter 的 newView/bindView 机制本身会处理复用,但如果你在 bindView 里做了异步加载头像又没做 View 复用判断,就会错位。本文用assignContactUri是同步的,不会错位。如果你自己写异步加载,记得给 View 打 tag 判断。

点击头像无响应。检查 CheckBox 是否设了focusable="false",以及 item 根布局有没有android:clickable="true"拦截。QuickContactBadge 自己处理点击,父布局别抢。

关于 401。如果你在接入某些云端联系人同步服务时遇到 401,那是鉴权问题,和本地 ContactsContract 无关。本地查询不涉及网络鉴权。如果确实在做联系人云同步,鉴权配置参考对应服务的文档,Base URL、Key、Model ID 三件套要配对,缺一个都会 401。

排查顺序建议:先看日志有没有异常,再确认 Cursor count,再确认投影和索引,最后看 View 层。按这个顺序走,基本不会绕弯路。

6. 从本地列表到云端联系人能力的延伸

本地 ContactsContract 列表做完,很多人下一步会想:能不能把联系人数据同步到云端、做跨设备通讯录、或者接一个 AI 能力做联系人智能整理?这时候就涉及网络请求和鉴权配置了。

如果你在做这类延伸功能,需要配置 API 的 Base URL、Key 和 Model ID。以接入大模型做联系人信息补全为例,配置项通常长这样:

{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的密钥", "model_id": "claude-sonnet-4-5" }

Base URL 用https://taotoken.net/api,Key 在控制台的 API Keys 页面生成,Model ID 按你实际要调的模型填。这三件套缺一个都会报鉴权错误。生成 Key 的入口在 API Keys,接入文档在 接入文档,想先试模型效果可以去 模型对话。如果是要长期跑编码或 Agent 类任务,Coding Plan 更合适。

回到本文主题,QuickContactBadge 在 ListView 里的用法核心就三件事:投影带 LOOKUP_KEY、Adapter 继承 ResourceCursorAdapter 并在 bindView 里 assignContactUri、权限和焦点处理好。把这三件做对,头像和快捷操作自然就正常了。剩下的就是按你的业务需求去扩展列表的交互和数据处理。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询