简介:这是面向Qt开发者的QListView自定义Item实践资源,重点解决默认列表项无法满足复杂布局与个性化视觉的问题。压缩包内共有18个文件,包含3个cpp与3个h源码、1个pro工程、1个ui界面、2个qrc资源文件、1个qss样式表及6张jpg演示图,整体大小仅37KB,结构简洁便于查阅。已有2139人学习下载。资源通过自定义QStandardItem子类与QStyledItemDelegate代理类,完整演示了重写paint()与sizeHint()定制绘制逻辑的方法,并结合QSS样式与qrc资源管理,展示出类似网易云PC客户端的交互反馈和视觉风格。代码划分清晰,涵盖MuListView视图、MuItemDelegate代理、Widget界面与MuListItemData数据封装等模块,可直接改造成包含图片、文本和按钮的自定义列表项,同时传递了hover状态处理、数据绑定及性能优化方面的经验。
1. ListView自定义Item:真正决定列表体验的不是布局,是复用
在某个消息流项目里,一组列表页的Item类型超过十种:纯文本、单图、九宫格、带进度条的上传卡片、需要倒计时的活动卡片。最初直接把整块布局放进getView()里,findViewById每次都执行一遍,列表一超过两百条就开始丢帧,滑动起来像老式翻页。后来把复用逻辑理顺,同样一堆Item,帧率从十几帧拉到接近满帧。这就是ListView自定义Item的核心:写出几百行布局不难,难的是让BaseAdapter在快速滑动时不做无畏的重复工作。
ListView的条目复用机制(convertView)、ViewHolder缓存和多类型分发,是这套方案的地基。对它理解到位,自定义Item才算真正过关;理解不到位,后面所有优化都是往已经歪的地基上补墙。这篇文章按「机制 → 最小实现 → 多类型 → 数据绑定 → 避坑 → 验证」展开,新手可以照着一步步跑通,熟手可以直接跳到第5章和第6章对照排查。无论哪种情况,看完都应该能回答一个问题:我写的Item在真实设备上滚动时,每秒创建了多少个不必要的对象。
2. 从最小可运行开始:convertView + ViewHolder 的标准写法
2.1 为什么convertView是性能分水岭
BaseAdapter最核心的方法是getView(int position, View convertView, ViewGroup parent)。新手看这个方法容易忽略第二个参数,以为每次都传入null。实际上,当列表滚动时,即将滚出屏幕的Item会被回收,重新交给新进入屏幕的position使用,这个被回收的View就是convertView。
如果每次getView都执行inflate并findViewById,等于每个item进入屏幕就重新创建一次视图树。Android的View创建成本并不低:inflate要解析XML、创建视图层级、应用样式,findViewById也要遍历整棵树。当列表快速滑动,每秒会有多个item进入屏幕,重复创建和重复查找的性能消耗会被明显放大。
所以业界标准的处理方式分两层:第一层,判断convertView是否为空,为空才inflate;第二层,把item里的子控件用ViewHolder缓存起来,避免每次findViewById。第一层解决重复创建,第二层解决重复查找。这两个动作加起来,就是ListView Item复用的全部秘密。
当然,这里有个前提:Item类型相同才能复用。如果列表里混着不同布局,直接复用会显示错乱,这就引出了第3章的多类型处理。先看单类型怎么落地。
2.2 一个可直接抄的BaseAdapter骨架
下面这段代码是我在普通业务列表里最常用的一套写法,去掉业务字段,只剩骨架:
public class MessageAdapter extends BaseAdapter { private final LayoutInflater inflater; private final List<MessageBean> data; public MessageAdapter(Context context, List<MessageBean> data) { this.inflater = LayoutInflater.from(context); this.data = data; } @Override public int getCount() { return data == null ? 0 : data.size(); } @Override public Object getItem(int position) { return data.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView == null) { // 只有第一次进入屏幕时才加载布局 convertView = inflater.inflate(R.layout.item_message, parent, false); holder = new ViewHolder(); holder.title = convertView.findViewById(R.id.tv_title); holder.content = convertView.findViewById(R.id.tv_content); holder.time = convertView.findViewById(R.id.tv_time); // 把holder挂到convertView上,下次复用直接取 convertView.setTag(holder); } else { // 复用了,直接把之前缓存的holder取出来 holder = (ViewHolder) convertView.getTag(); } // 绑定数据 MessageBean bean = data.get(position); holder.title.setText(bean.getTitle()); holder.content.setText(bean.getContent()); holder.time.setText(bean.getTime()); return convertView; } // 用static类保存子控件引用 static class ViewHolder { TextView title; TextView content; TextView time; } }这套代码的关键在convertView.setTag(holder)。第一次inflate之后,把holder放进View的tag里;下次同一类型的item复用convertView时,直接getTag()取回holder,跳过inflate和findViewById两步。View的tag机制在这里被当作一个轻量级的对象存储使用,是ViewHolder模式能生效的底层原因。
2.3 两个必须注意的细节:不是写完就万事大吉
第一个细节:inflate的第三个参数必须传parent,但传false。inflate(resource, root, attachToRoot)中,attachToRoot为true时会立即把布局添加到parent上。ListView的getView返回后会把view加到item容器里,如果这里提前attach,会出现重复添加或崩溃。所以传false只是借用parent获取正确的LayoutParams,不真正attach。这个参数写错是新手最常见的崩溃来源,异常信息通常指向addView或Already added。
第二个细节:ViewHolder里的控件引用如果发生错位,优先检查item_message.xml里是不是有条件显示的View。比如某个item状态为「已读」时隐藏时间,下一个item状态为「未读」时需要显示时间,必须在绑定数据时显式设置setVisibility(View.VISIBLE)或setVisibility(View.GONE),不能只对需要的item设置状态,因为复用会把上一个item的状态带下来。
2.4 和「每次inflate」写法的差距到底有多大
有人说,现在手机性能好了,每次inflate区别也不大。我专门在一台中端设备上做过对照:200条数据的列表,快速滑动10秒,复用写法大概创建20到30个View,每次inflate的写法创建了将近200个View。前者全程稳定,后者在滑动中后期明显掉帧。
更隐蔽的问题是内存抖动。每次inflate都会产生新对象,包括View树里的每一个子View。快速滑动时,这些对象不断被创建和回收,GC频繁触发,而GC本身会卡主线程。这就是为什么有些列表明明布局很简单,滑起来却一顿一顿的。复用写法把创建次数压在极小范围内,GC压力自然小很多。
loader级优化里还有一层:把getView里的类型转换和判空逻辑处理好,别在绑定数据时做耗时操作。比如dateFormat格式化时间,尽量提前把格式化后的字符串存到bean里,而不是每次getView时现算。列表滑动期间每帧只有16.6毫秒,多一次格式化和多一次对象创建,累积效果都会在帧率上体现。
3. 多种Item类型:getViewTypeCount 与 getItemViewType 的正确组合
3.1 为什么列表混着不同布局必须要多类型机制
回到开头的那个消息流列表:十种item布局,有文字卡片,有图片卡片,有进度条卡片。如果只用单类型的convertView复用,getView里会根据position判断应该显示哪个布局,但复用的view可能是另一种布局,直接往里填数据就会出现布局和内容不匹配的错乱。
BaseAdapter为此预留了两个方法:getViewTypeCount()返回列表一共有多少种不同类型的item,getItemViewType(int position)返回当前position属于哪一种类型。系统维护一个「每个类型各自的回收池」。只有类型相同的item才会互相复用,不同类型之间井水不犯河水。
这两个方法值得单独强调,因为它们的返回值直接决定回收池的划分。getViewTypeCount返回的是「你声明的类型上限」,getItemViewType返回的必须是0到getViewTypeCount()-1之间的整数。两者不匹配,轻则复用混乱,重则崩溃。
3.2 多类型Adapter的完整实例
以下是一个包含三类型item的示例,类型分别是:纯文本、带图片、带按钮的进度卡片:
public class MultiTypeAdapter extends BaseAdapter { private static final int TYPE_TEXT = 0; private static final int TYPE_IMAGE = 1; private static final int TYPE_PROGRESS = 2; private static final int TYPE_COUNT = 3; private final LayoutInflater inflater; private final List<BaseItem> data; public MultiTypeAdapter(Context context, List<BaseItem> data) { this.inflater = LayoutInflater.from(context); this.data = data; } @Override public int getViewTypeCount() { // 声明列表一共有3种item类型 return TYPE_COUNT; } @Override public int getItemViewType(int position) { BaseItem item = data.get(position); if (item instanceof TextItem) { return TYPE_TEXT; } else if (item instanceof ImageItem) { return TYPE_IMAGE; } else if (item instanceof ProgressItem) { return TYPE_PROGRESS; } // 默认归到纯文本类型,避免返回越界值 return TYPE_TEXT; } @Override public int getCount() { return data == null ? 0 : data.size(); } @Override public Object getItem(int position) { return data.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { int type = getItemViewType(position); if (convertView == null) { // 这里的convertView只会和当前类型复用 switch (type) { case TYPE_TEXT: convertView = inflater.inflate(R.layout.item_text, parent, false); convertView.setTag(new TextHolder(convertView)); break; case TYPE_IMAGE: convertView = inflater.inflate(R.layout.item_image, parent, false); convertView.setTag(new ImageHolder(convertView)); break; case TYPE_PROGRESS: convertView = inflater.inflate(R.layout.item_progress, parent, false); convertView.setTag(new ProgressHolder(convertView)); break; } } // 复用转换 switch (type) { case TYPE_TEXT: bindText((TextHolder) convertView.getTag(), (TextItem) data.get(position)); break; case TYPE_IMAGE: bindImage((ImageHolder) convertView.getTag(), (ImageItem) data.get(position)); break; case TYPE_PROGRESS: bindProgress((ProgressHolder) convertView.getTag(), (ProgressItem) data.get(position)); break; } return convertView; } private void bindText(TextHolder holder, TextItem item) { holder.title.setText(item.title); } private void bindImage(ImageHolder holder, ImageItem item) { holder.image.setImageResource(item.resId); } private void bindProgress(ProgressHolder holder, ProgressItem item) { holder.progress.setProgress(item.progress); holder.label.setText(item.label); } static class TextHolder { TextView title; TextHolder(View v) { title = v.findViewById(R.id.tv_title); } } static class ImageHolder { ImageView image; ImageHolder(View v) { image = v.findViewById(R.id.iv_pic); } } static class ProgressHolder { ProgressBar progress; TextView label; ProgressHolder(View v) { progress = v.findViewById(R.id.pb_progress); label = v.findViewById(R.id.tv_label); } } }这里有几个值得注意的写法选择。getItemViewType里用了instanceof判断,这是最常见的做法,因为它让adapter对数据类型的感知完全基于对象本身,而不是维护一套映射关系。如果列表的数据结构本身就是抽象类的不同子类,这个方案最简单可靠。
convertView == null分支里,每种类型各自inflate、各自设置tag。系统的回收池会按照getItemViewType的返回值把缓存view分类存放,所以这里getTag()返回的holder一定和当前type匹配,不需要做额外判断。
3.3 多类型List里那些「看起来合理但会翻车」的写法
有一种写法是:把所有类型的item都塞进同一个布局文件,用View.GONE控制显示哪些控件。这种方案确实能绕开多类型,但代价是列表里最轻量的item也要承载最重型的布局,内存占用大且inflate时间更长。十种类型的消息流如果用单布局实现,每屏都会构建所有控件,测量和布局时间白白浪费,不建议这么干。
还有一种错误写法:getViewTypeCount()返回了类型数,但getItemViewType()在某些position上返回了负数或越界值。比如图片类型的item加载失败后,有人会在数据层把对象替换成另一个类型,但adapter的getItemViewType没有同步更新,导致返回类型错乱。排查这类问题看Logcat里ArrayIndexOutOfBoundsException的堆栈,基本都能定位到类型数组越界。
再有一个容易被忽略的细节:getViewTypeCount()和getItemViewType()的调用时机比getView早,而且系统可能在任何一次notifyDataSetChanged之后重新调用它们。业务代码里如果在类型判断里用了position对应的数据,要确保数据层在刷新时已经就绪,否则可能在UI更新之前就抛异常。
3.4 头尾View对Item类型的影响
给ListView设置了addHeaderView或addFooterView之后,一个隐蔽的坑就出现了:getItemViewType(position)拿到的position包含了header和footer占用的位置。也就是说,position=0时可能拿到的是header view,不是你的第一条数据。
处理方式是在所有涉及position的计算里减去headerView的数量。更稳妥的做法是避免在getItemViewType里依赖原始position,而是通过listView.getHeaderViewsCount()获取偏移量。另一种思路是不要自己维护header/footer,而是把头部内容作为普通数据项混入adapter的数据源,这样position映射统一由adapter管理,不会错位。
从维护成本看,多类型item是ListView里最容易写乱的部分。建议从一开始就把类型常量收敛在一个类里,用static final int定义,不要散落在adapter各处。类型多了之后,也可以把每种类型的绑定逻辑拆到独立的Binder类中,adapter只负责分发,具体绑定交给各自的Binder。这是列表复杂到一定程度后值得做的重构方向。
4. 绑定数据的细节:异步加载图片与条目复用的错位问题
4.1 复用机制和异步回调的天然冲突
ListView的setTag只解决了视图缓存,但没有解决数据绑定的一致性问题。当一个item滚出屏幕被回收时,它可能正挂着一个尚未完成的异步任务——网络请求图片、读取本地文件、加载缩略图。结果这个item滚回来复用时,异步任务才回调,把旧数据渲染到了新position上。这就是经典的图片错乱问题。
错位的根源是:position对应的view和异步任务的发起者已经不是同一个数据项了。ListView的复用机制让view和数据之间的关系是动态的,而异步回调携带的是发起任务时的position,两者一脱节,显示自然出错。
这个问题不是ListView独有的,但ListView的复用频率高、回调时机不可控,导致它在这里表现得最明显。用RecyclerView同样会遇到,只不过RecyclerView通过ViewHolder.getBindingAdapterPosition()提供了一些补救手段。先把问题原理搞清楚,不管用什么列表控件,思路都是通用的。
4.2 一个带校验的图片绑定写法
下面是基于AsyncTask加载图片的绑定逻辑,重点看如何在回调时校验item是否仍然需要这次结果:
public class ImageLoaderTask extends AsyncTask<String, Void, Bitmap> { private final ImageView targetView; private final String url; public ImageLoaderTask(ImageView targetView, String url) { this.targetView = targetView; this.url = url; } @Override protected Bitmap doInBackground(String... params) { // 这里执行真正的图片加载 return loadBitmap(url); } @Override protected void onPostExecute(Bitmap bitmap) { if (bitmap == null) { return; } // 核心校验:当前view的tag是否还指向本次请求的url Object tag = targetView.getTag(R.id.tag_image_url); if (url.equals(tag)) { targetView.setImageBitmap(bitmap); } } }在adapter的getView方法里,绑定图片时先给ImageView设置一个tag,再发起任务:
// 绑定图片的代码 String url = item.getImageUrl(); holder.image.setTag(R.id.tag_image_url, url); // 每次绑定前先取消上一个任务(如有记录) new ImageLoaderTask(holder.image, url).execute();这段逻辑的关键在于targetView.getTag(R.id.tag_image_url)和url的比较。当异步任务完成时,如果这个ImageView已经被复用到另一个数据项上,它的tag会被新的url覆盖,旧任务的回调就会被拦截,不会把错误的图片渲染上去。用带resource id的tag是为了避免和setTag(holder)冲突,两者存在不同key下,互不干扰。
4.3 图片加载从「手动校验」到「用图片库」
实际项目里,我不建议自己维护这样一套异步任务。图片库已经把这套逻辑封装得很成熟:它们内部维护了和view绑定的请求生命周期,会在view被复用时自动取消旧请求,并在回调时校验view是否仍然绑定同一个请求。手动写一遍的意义是理解原理,生产环境直接用图片库更稳妥。
但有一个问题图片库也解决不了:数据源本身更新导致的闪烁。比如下拉刷新后列表数据整体替换,图片库能正确加载新图片,但在新图片加载完成前,旧图片如果没有被清空,用户会看到旧图闪烁一下再变新图。解决办法是在绑定数据时先setImageResource一个占位图,或者复用一张内存中的模糊占位图,让视觉变化平滑。
从更基础的角度说,异步数据绑定的通用规则是:getView里发起异步任务前,必须保证view的tag和数据源有唯一对应关系;异步回调里,必须校验tag一致才更新UI。这个规则不仅适用于图片,也适用于其他异步加载场景,比如加载本地数据库记录、计算文本长度等。
4.4 notifyDataSetChanged不能解决所有刷新问题
很多开发者遇到列表数据变化时,第一反应是notifyDataSetChanged()。这个方法会触发getView重新执行,但它的粒度是整个列表,不管只有一个item变化,还是要重新测量所有可见item的宽高,成本都不小。
更细粒度的手段是用getChildAt(index)拿到屏幕上的item view,只更新变化的那一项。这个做法的前提是理解ListView的视图结构:ListView本身是AbsListView的子类,所有可见的child view都挂在它下面。通过listView.getChildAt(i)可以从0遍历到getChildCount(),拿到每个可见item的view和对应的position。
问题在于:getChildAt拿到的view和position的关系,在滚动过程中是动态的。你需要用listView.getPositionForView(view)反查position,再去数据源里取对应数据更新UI。这个套路适合局部刷新场景,比如点赞状态变化、进度条推进,它的效率远高于全量notify。
另外还有一个细节:修改adapter的数据源后,如果是局部刷新,记得数据源本身也要同步更新,否则下次notifyDataSetChanged或滚动复用时会用旧数据覆盖新数据。这类问题最隐蔽的表现是:当前屏幕显示正确,滑出去再滑回来,数据变回旧值,白白排查半天。
5. ListView自定义Item避坑:4个高频翻车点与排查思路
5.1 复用错乱:显示的内容跟着滚动「串位」
现象:列表快速滑动后,某些item显示的是其他item的内容,尤其是图片和文本不匹配,或者多类型item之间布局互相污染。
原因:getView里没有使用convertView复用机制,或者多类型item混用了一个回收池。也可能是ViewHolder的缓存和bind逻辑分离得不干净,某个控件的可见性、文本颜色等属性在上一个item设置后没有被重置。
解决:确认getView遵循了「convertView == null时才inflate」的规则;多类型场景下检查getViewTypeCount和getItemViewType的返回值是否一致。还有一种做法是在bind阶段对每个可能变化的状态做显式重置,比如holder.itemView.setBackgroundColor(defaultColor)写在一开始,而不是只在需要的时候设置。用getTag()取holder后,先清一遍展示状态再填新数据,虽然多几行代码,但能阻断多数状态残留问题。
5.2 点击事件失效或回调错位
现象:item上的按钮点击无效,或者点击A条目却触发了B条目的回调。
原因:如果是在getView里用setOnClickListener设置监听,position很可能因为复用而错位;也可能是按钮的clickable属性没有打开,按钮默认就是可点击的,但外层布局如果设置了descendantFocusability,可能拦截掉点击事件。
解决:点击事件优先在AdapterView.OnItemClickListener层面处理,ListView本身会正确传递position。如果需要item内部控件触发特定动作,可以在ViewHolder里持有item的引用,在bind时更新引用,监听器只使用holder里的position,不要用final onPosition这种写法。注意ListView的setOnItemClickListener在item里包含可点击子控件时可能不被触发,这时可以在子控件的setOnClickListener里用getAdapterPosition()换取正确位置。养成一个习惯:写点击回调时永远不把position固定在创建监听器的时刻。
5.3 布局高度异常:item被挤压或撑爆
现象:某些item显示不全,底部被截断,或者item之间出现大段空白。常见于item根布局用了wrap_content但内部有固定高度控件,或者ListView的layout_height设置不符合场景。
原因:ListView在测量item高度时会调用onMeasure,如果item布局里存在match_parent套wrap_content的情况,测量结果可能超出预期。另一个常见原因是inflate时没有传parent,导致布局拿不到正确的LayoutParams,高度被默认成WRAP_CONTENT或MATCH_PARENT之外的值。
解决:inflate务必传parent和false;item的根布局用match_parent表示宽度,高度用wrap_content。如果不同item高度差异较大,建议计算好每个item的实际高度再绑数据,不要依赖系统自动测量。还有一个习惯:xml里不要写死某个控件的layout_height,尤其是图片控件,否则不同屏幕密度下高度会出偏差。
5.4 notifyDataSetChanged后列表「闪一下」或回到顶部
现象:更新数据后,整个列表闪烁,或者滚动位置跳到顶部。如果数据量很大,还会有明显的卡顿。
原因:notifyDataSetChanged触发整个列表重绘,所有可见item重新执行getView;如果holder绑定里有耗时操作或布局重建,闪烁和跳位置就是必然结果。ListView没有内置的「仅更新变化项」的diff机制,它只是粗暴地让adapter重新提供view。
解决:优先考虑局部刷新。确定变化的item范围后,用listView.getChildAt配合getPositionForView手动更新单个view的显示。如果数据源整体替换但结构不变,可以尝试notifyDataSetInvalidated。更彻底的做法是评估是否要迁到RecyclerView,它能用DiffUtil精确计算出item级变化并做动画,ListView做不到这个粒度。但如果只是普通业务列表,把局部刷新的细节做对就够用了。
5.5 布局加载慢:XML嵌套层级过深
现象:首屏加载慢,滚动时有明显jank,用布局检查工具看嵌套层级深达十几层。
原因:item布局里层层嵌套LinearLayout,每多一层,inflate和measure的成本就多一分。消息流项目的同屏item数量通常不多,但每个item的布局复杂度仍然会放大性能问题。
解决:优先用ConstraintLayout拍平层级,将嵌套深度控制在3到4层以内。ListView item的inflate是高频操作,层级越浅收益越直接。其次,避免在item布局里使用weight,因为weight会在measure阶段触发两次测量,复用场景下会额外消耗时间。还有一点:如果item里有RecyclerView(横向滑动),要固定它的高度,不能在wrap_content里嵌套滚动列表,否则高度测量会成为性能黑洞。
6. 用「滑动埋点」检验你的自定义Item是否合格
代码写完了,性能到底行不行,不要靠手感判断。我习惯在调试版本里加一段滑动统计:记录一次快速滑动中getView被调用的次数,以及实际执行inflate的次数。如果getCount是200,快速滑动10秒后getView调用次数在30以内,inflate次数在5以内,说明复用机制在正常工作。如果inflate次数接近getView次数,说明convertView没有正确复用,基本可以断定是写法问题。
具体的埋点方式是在adapter里加两个计数器,第一次inflate时加1,getView每次调用时加1,在onScrollStateChanged的SCROLL_STATE_IDLE回调里打印出来。这个统计办法虽然原始,但比任何性能分析工具都直观,能直接暴露复用失效的现场。
进阶一点的验证是用adb shell dumpsys gfxinfo <package> framestats抓取真实帧耗时,看滑动期间是否有超过16.6ms的帧。也可以打开开发者选项里的「调试GPU过度绘制」,检查item区域是不是出现红色或深红色,红色意味着同一像素被绘制了多次。这两项检查通过,自定义Item的UI层基本就合格了。真正的性能问题往往不在Item本身,而在Item里绑定的数据逻辑,比如数据库查询、位图重采样,这些要单独分析,不能全算在列表头上。
最后一个习惯是:每次改完Item相关的代码,我都会在真机上做一次「从底部快速滑到顶部再滑回来」的测试,旧手机优先,因为中端机对性能问题最敏感。列表这个东西,手感上的差别很多时候就是细微的帧率抖动累积起来的。ListView已经是一套很成熟的方案了,把它的复用机制吃到透,比动不动重写一个RecyclerView列表要踏实得多。希望帮到你。
本文还有配套的精品资源,点击获取