首页 / 资讯中心 / 文章详情

Android ListView优化:从item高度到convertView复用与局部刷新

Android ListView优化:从item高度到convertView复用与局部刷新 ★ FEATURED ARTICLE
我一向觉得如果让刚接触Android的新人用一个词形容ListView大概率是“玄学”。明明在布局编辑器里把item根布局的高度设好了一运行起来却被内容撑得老高明明想让每一条等高列表里却总有人高矮胖瘦不一致你盯着getView和XML翻来覆去查也说不清问题出在哪里。后来陆续带了几个新人我才意识到ListView的许多坑根本不是写错了代码而是它底层的测量、复用和刷新机制从一开始就和我们直觉不太一样。这篇就把ListView掰开揉碎集中解决四件事item高度问题、控件本身的缺点、第一轮性能优化面试必问以及再往深走一步的二次优化。准备入行Android或者正在准备面试的朋友都可以从里面找到能直接用的思路。1. 先拆掉item高度这个“入门刺客”1.1 高度为什么总是不听指挥先说结论ListView中每个item的高度最终由item根View在measure阶段计算出的高度决定而上层的宽高约束又来自ListView的MeasureSpec。很多新手第一时间会设置item根布局的layout_height但会发现它“不生效”。最常见的一种情况是根布局写成了match_parent在ListView垂直滚动方向上父控件给出的测量规格往往不会把你当成“填满父容器”来处理而是按照内容自适应来处理结果自然是内容多高item就有多高。第二种常见情况是根布局是固定的高度比如40dp但item里面有一个TextView没有限制行数内容太长就自动折行。TextView一旦折行它的高度就会增加而外层布局又可能是wrap_content于是整体高度被顶开了。你明明设置了根布局的高度却忘了检查子View是否会把父布局撑开。对应到TextView上最简单的方法就是加android:maxLines1和android:ellipsizeend或者给TextView设置一个明确的layout_height。另一种容易让人困惑的问题是“设置了minHeight却无效”。比如你给根布局加了android:minHeight40dp但内容比较矮时ListView的测量模式可能让minHeight在某些情况下不生效。这种情况最稳妥的做法不是去折腾minHeight而是直接把根布局的高度写成固定dp再把内部控件的对齐方式处理好。如果你确实需要“至少某个高度但内容更高时自动扩展”那就用wrap_content minHeight的写法但要记得子View不能给它施加额外的高度约束。再看一个更隐蔽的坑getView()里动态设置LayoutParams。有些同学为了使某几行的item呈现不同高度直接在getView里holder.content.setLayoutParams(new LayoutParams(...))。这样在快速滚动时可能带来两个问题一是每次复用都要重新创建LayoutParams对象GC压力变大二是参数如果设置错了position会出现“高度错乱”的脏复用现象。我的建议是能用布局种类解决的不要用动态LayoutParams如果真要做类似“气泡消息宽度不同”的效果也尽量通过自定义View的onMeasure去计算避免在getView里反复new对象。下面整理一个我日常给新人讲的高度排查表遇到“高度不对”可以按表检查现象常见原因解决思路固定高度item被内容撑开根布局是wrap_content子View折行或子View设置了额外高度根布局改固定dp子View加maxLines/ellipsize检查子View layout_heightitem高度不统一部分item的根布局不同或item内控件高度自适应统一item布局结构统一数据展示规则考虑用多typeminHeight不生效父容器测量模式限制了最小高度或子View内部强制了高度改用固定高度或自定义View重写onMeasure滑动时item高度变化getView中动态改LayoutParams或动态改Visibility避免动态改LayoutParams改用多type或占位View列表整体高度计算异常嵌套在ScrollView中导致测量无限大避免ScrollView嵌套ListView或重写ListView的onMeasure1.2 等高item与动态item高度怎么处理如果需求就是“整条列表都是等高”最简单有效的办法是让所有item共用同一个固定高度的根布局内部控件垂直居中同时把可能导致内容变高的TextView收敛成一行。例如行首是头像、中间是标题、后面是时间的列表完全可以约定高度为56dp或64dp所有内容都在这条高度内布局。这样ListView在滚动时不需要反复重新测量高度性能也更好。如果需求是“每条item高度跟着内容走”比如聊天列表、动态列表那就用wrap_content但要控制好内部文本的最大行数。实际上聊天列表也不是无限高的图片和文字组合通常有最大比例文本超过一定行数会被截断。设置好maxLines之后measure阶段的耗时也会明显下降因为系统不必要为“可以无限换行的TextView”计算超出屏幕的高度。还有一类需求是“部分item高部分item矮”。这种情况我强烈建议不要用一套布局硬改而是使用多类型item。ListView本身支持getItemViewType()和getViewTypeCount()你可以为高item和矮item各做一套布局这样在复用时会自动进入不同类型的缓存池不会出现“高矮错乱”的复用问题。面试里如果被问到item高度不一致怎么办这也是一个很好的加分回答点。2. ListView控件缺点盘点面试官问“为什么不用它”该如何回答2.1 复用机制看似省心实则半自动ListView的缓存复用机制叫RecycleBin听起来很智能但它只解决了“View对象重复inflate”的问题。当你滑出屏幕再滑回来时getView收到的convertView可能在多数复用场景下不是null这时你可以复用它。但这里有个关键前提只有同样itemType的View才能复用如果列表里有多种布局你必须正确重写getItemViewType和getViewTypeCount否则convertView会被混用轻则显示错乱重则直接崩溃。另外一个半自动的体现是RecycleBin并没有告诉开发者“你需要在绑定数据时重置所有状态”。一个item如果包含CheckBox、EditText、Switch这类带状态的控件滑出再滑入时状态可能残留。我见过不少新手写出“滑到第5条发现第2条的输入框内容还留着”的Bug这就是因为复用后没有主动清理状态。相比后来的RecyclerView它把这个问题用onBindViewHolder也暴露给开发者了但ListView时代很多人没意识到。面试时如果被问“ListView缓存机制是什么”最好能分两层回答第一层是View对象的复用也就是convertView第二层是子控件的复用也就是ViewHolder。光说“用了ViewHolder”还不够要把整个流程串起来讲。2.2 刷新机制全量重建像“重置整个页面”ListView的数据更新接口绝大多数人只会用notifyDataSetChanged()。这个方法一调用ListView会通知所有可见item的数据源发生改变然后重新遍历当前可见区域逐个调用getView绑定数据。这个过程中item的视图会有一定概率经历重新layout视觉上可能出现闪烁同时滚动位置、焦点都容易发生轻微跳动。本质上它是“全量重建可见条目”而不是“哪条变了就改哪条”。更麻烦的是ListView没有内置的局部更新API。我在老项目里要做“点赞数量变化只更新这一条”只能自己用getFirstVisiblePosition()配合getChildAt()去拿可见的item更新。数据删除、插入、移动也都需要手动处理而且ListView在增删数据后没有过渡动画。后来RecyclerView通过notifyItemChanged()和DefaultItemAnimator把这些体验补齐了这也是它取代ListView的一个重要原因。2.3 复杂布局和无边界嵌套ListView的性能瓶颈很多时候不在ListView本身而在item布局。如果item里用了一堆嵌套的LinearLayout背景图又大绘制时每一层的measure和draw都会被放大到整个列表的滚动过程中。比如一个层级很深的item在屏幕上有8条可见时每条都要执行完整的measure/layout/draw链主线程压力可想而知。还有一个很经典的坑就是嵌套滚动。ScrollView里套ListView、ListView里套GridView这类写法在入门阶段几乎随处可见。问题是ListView在垂直方向会测量所有子item的总高度而ScrollView给它的测量空间是无限的于是你看到的结果就是ListView把自己所有的item都展开了根本没法复用。而ListView套GridView时GridView的高度不会自动计算往往只显示“一格”甚至不显示。真要用这种结构一般依赖重写onMeasure来展开子列表但这又牺牲了复用和性能。2.4 图片与异步任务容易“错乱”异步加载是另一个重灾区。如果直接在getView里同步解码本地图片主线程会被卡住滑动起来就是一帧一帧跳。如果换成异步加载新手很容易写出这样的代码在异步线程里加载完图片后直接setImageBitmap到ImageView上完全没考虑ImageView复用了没有。快速滑动时同一个ImageView会被反复用来显示不同position的图片。异步任务返回时这个ImageView很可能已经没在原来的position了于是出现“这张图片一闪而过变成了另一张图”“头像是别人的”等错乱问题。ListView本身又没有提供“停止加载”“复用校验”的机制需要开发者自己设tag、自己校验身份属于典型的半自动控件。2.5 交互和辅助功能偏弱ListView的点击事件只有OnItemClickListener它基于坐标去判断点击的是哪一项在headerView、footerView混入后还要自己算position很容易出偏差。批量操作、多选、拖拽排序、侧滑删除这类交互ListView要么没有API要么需要大量自绘。无障碍支持也差系统没法很好地向辅助服务描述列表项的语义。相比之下RecyclerView通过LayoutManager、ItemDecoration、ItemTouchHelper、RecyclerViewAccessibilityDelegate等把很多能力标准化了所以现代新项目基本都是直接上手RecyclerView。但这不意味着ListView“一无是处”。很多老项目、低版本兼容、嵌入式收银台App里ListView存量依然很大而且它的面试题被反复考本质上是在考察你对View复用和列表性能优化的底层理解。3. 第一轮优化convertView ViewHolder把findViewById从“每帧”降到“每个类型一次”3.1 初始写法 vs 优化写法先看一段新手写的最常见的getViewOverride public View getView(int position, View convertView, ViewGroup parent) { View item LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_list, parent, false); TextView tvName item.findViewById(R.id.tv_name); ImageView ivIcon item.findViewById(R.id.iv_icon); ItemData data mData.get(position); tvName.setText(data.getName()); ivIcon.setImageResource(data.getIconRes()); return item; }这段代码的功能没有任何问题但它每次都做两件事inflate一个XML、findViewById遍历控件树。当列表快速滑动时getView会被高频调用每次调用都要创建View对象、解析属性、遍历层级主线程很容易掉帧。优化后的标准写法是convertView ViewHolderOverride public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView null) { convertView LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_list, parent, false); holder new ViewHolder(); holder.tvName convertView.findViewById(R.id.tv_name); holder.ivIcon convertView.findViewById(R.id.iv_icon); convertView.setTag(holder); } else { holder (ViewHolder) convertView.getTag(); } ItemData data mData.get(position); holder.tvName.setText(data.getName()); holder.ivIcon.setImageResource(data.getIconRes()); return convertView; } static class ViewHolder { TextView tvName; ImageView ivIcon; }两种写法的核心差异在于inflate和findViewById只在convertView为null时执行一次ViewHolder把子控件引用保存在Tag中后续复用直接取引用绑定数据。从整个滑动生命周期看findViewById的执行次数从“每个item每次滑动”降到了“每个item类型只执行一次”。3.2 几个容易忽略的细节第一inflate时第三个参数必须传false。如果传trueXML根布局会被立刻添加到parent中而getView返回的View随后又要被ListView添加到parent会造成“目标View已有父视图”的崩溃。很多新手第一次写就在这里报错。第二ViewHolder类最好声明成static class。因为它不依赖外部类的实例可以减少内存泄漏风险也让引用链更清晰外部Adapter持有list而ViewHolder不反向持有Adapter数据绑定完全靠参数传入。第三item里有CheckBox、EditText这类状态控件时必须在绑定数据阶段重置状态。比如滑动后CheckBox可能残留之前选中状态你需要根据这条数据重新setChecked并且小心处理监听器的触发时机避免setChecked时又去改数据源造成回环更新。第四列表里有多种item类型时不能只写一套ViewHolder。要在getViewTypeCount()里返回类型数量在getItemViewType(int position)里根据数据返回当前类型然后convertView为null时才inflate对应布局之后所有相关控件都要放入对应的ViewHolder。如果类型做错了最常见的现象是item错乱、布局混用、甚至ArrayIndexOutOfBoundsException。3.3 为什么说“ViewHolder convertView”是面试必答核心面试官问“ListView如何优化”底层其实想听你回答三件事你是否理解convertView复用、你是否知道ViewHolder是缓存控件的容器、你是否意识到频繁inflate和findViewById是性能瓶颈。有个很常见的误区是候选人张口就说“我用了ViewHolder”让他展开讲convertView是什么、为什么用却答不上来。这会给面试官一种“只是背了优化清单”的感觉。我在讲这部分时会建议新人把getView的三个参数完整解释一遍position代表当前要渲染的位置convertView代表系统复用的“旧Item容器”可能为nullparent代表父容器主要用于inflate时传入布局参数和检查父容器。然后再讲为什么复用后还要ViewHolder因为convertView只是复用了View骨架子控件的查找仍然是树遍历ViewHolder相当于给这棵树做了一个“索引表”通过Tag一次性拿到所有控件引用。这个“从View复用到控件引用复用”的递进关系比单纯背结论要加分得多。4. 二次优化局部刷新、图片错乱规避与滚动节流4.1 把全量刷新改成局部刷新在ListView的使用中notifyDataSetChanged()是效率最低的一种刷新方式。它会让所有可见item重新走一遍绑定流程即使只有一条数据的文本变了。要解决这个问题可以自己维护一个局部更新方法思路是先更新数据源再判断目标position是否在可见范围内如果在就拿到对应的View通过ViewHolder直接更新控件内容。示例代码private void updateItem(int position, ItemData newData) { // 1. 先更新数据源 mData.set(position, newData); // 2. 拿到当前可见区域的首尾position int first listView.getFirstVisiblePosition(); int last listView.getLastVisiblePosition(); // 3. 如果目标位置在可见范围内才去更新对应View if (position first position last) { View itemView listView.getChildAt(position - first); if (itemView ! null) { ViewHolder holder (ViewHolder) itemView.getTag(); holder.tvName.setText(newData.getName()); } } }这里的关键是position - first这个偏移量因为getChildAt(0)拿到的是第一个可见项对应的是getFirstVisiblePosition()的位置。局部更新不会触发整个列表的重新布局肉眼看起来没有闪烁也不会丢失滚动位置。需要提醒的是局部更新只适合“值变化”不太适合“增删移动”。因为增删移动后所有元素的position都会变化你很难用局部刷新去同步。这类场景要么用notifyDataSetChanged()要么则是迁移到RecyclerView后用DiffUtil计算增删差异动画和性能都会好很多。4.2 图片异步加载与错乱规避ListView里加载图片最基础的方案是“异步线程池 回调主线程”。但在ListView复用的场景里回调回来时ImageView可能已经滚到了别的position所以必须做“复用校验”。最简单的校验方式是给ImageView设置一个Tag加载完成后判断Tag是否仍是当前请求的目标holder.ivIcon.setTag(position); ImageLoader.loadAsync(data.getImageUrl(), new ImageCallback() { Override public void onSuccess(Bitmap bitmap) { // 回调时再检查一次ImageView展示的还是不是这一项 Object tag holder.ivIcon.getTag(); if (tag ! null tag.equals(position)) { holder.ivIcon.setImageBitmap(bitmap); } } });这里用position做Tag在入门阶段很好理解但真实场景中position会随数据变化更稳妥的用法是拿数据本身的唯一标识比如图片URL或数据Id。只要记住一个原则异步任务回来时ImageView很可能已经不是原来的那个了一定要在下发结果前确认。内存方面我建议至少加一层LruCache图片解码得到的Bitmap先放入缓存下一次请求直接命中缓存省去重复IO和解码。对入门项目来说LruCache的size可以按应用可用内存的1/8估算这不算精确但足够起步。如果使用成熟图片库比如Glide或Coil这些库内部都做了缓存和生命周期管理但你仍然需要知道“复用错乱问题存在”理解库在底层帮你做了哪些事。4.3 滑动节流即使在getView里不加载图片异步任务也会疯狂发出去。快速滑动一屏可能触发几十条加载请求线程池排队内存瞬间膨胀滑动必然掉帧。更好的做法是监听滑动状态滑动过程中暂停加载停止滑动后只加载当前可见item。ListView的标准做法是设置OnScrollListenerlistView.setOnScrollListener(new AbsListView.OnScrollListener() { Override public void onScrollStateChanged(AbsListView view, int scrollState) { if (scrollState SCROLL_STATE_IDLE) { imageLoader.resume(); // 需要刷新当前可见项时可以调用一次局部刷新 } else { imageLoader.pause(); } } Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { } });这个思路很通用。在RecyclerView里对应的是addOnScrollListener判断scrollState RecyclerView.SCROLL_STATE_IDLE再做加载。这种“空闲时加载滚动时暂停”的节流策略能明显减少主线程的瞬时压力。4.4 布局与绘制层面的二次优化如果getView已经优化过了图片也做了延迟加载列表还是卡就要考虑布局和绘制层面了。第一减少item布局层级。一个item里如果套了四层LinearLayoutmeasure的阶段就会成倍增加成本。可以用ConstraintLayout拍平层级或者把重复的背景抽成drawable用merge标签减少嵌套。第二不要在getView里频繁创建对象。比如颜色值可以提前在外面通过ContextCompat.getColor()拿好存起来Drawable资源也可以复用同一个对象而不是每次getDrawable()。很多人没意识到每次getView里new一个对象在快速滚动时都会对GC造成压力。第三避免不必要的requestLayout。在getView里如果改了某个View的宽高、margin、Visibility很可能触发它重新测量连锁到整条item重新布局。如果可以尽量在XML阶段就把布局结构调整好尽量别在运行时动态改布局参数。如果实在要改只针对那一行局部修改也不要影响整列表的测量。第四用ViewStub懒加载不常用的子区域。比如item里有一个“展开详情”的按钮默认不显示可以放在ViewStub里需要时再inflate避免一开始就创建所有控件占内存。5. 面试现场关于ListView优化这样答可以从“知道”升级到“做过”5.1 回答框架从现象到底层如果面试官让你“聊一聊ListView的优化”不要上来就背“用ViewHolder、用复用”这些名词。我给个建议结构先点明瓶颈在哪里再给优化动作最后说效果和代价。比如“ListView的性能瓶颈主要在getView的高频调用和数据全量刷新。getView里最容易出问题的是重复inflate和findViewById所以第一层优化是用convertView复用View骨架第二层是用ViewHolder缓存控件引用避免findViewById的树遍历第三层是如果列表有多种item类型要重写getViewTypeCount和getItemViewType不同type走不同缓存池第四层是配合OnScrollListener做图片加载节流滚动时只显示占位图停止滑动后再加载同时用LruCache做内存缓存第五层是局部更新可见item避免一改动就notifyDataSetChanged。”这个回答把“优化”和“缓存机制”“刷新机制”“监听机制”串起来了。面试官接着问任何一个点比如“LruCache的原理是什么”“为什么notifyDataSetChanged效率低”你都能往下接。如果生产项目已经切换到RecyclerView还可以补充一句“我在新项目里已经优先使用RecyclerView DiffUtil但ListView这套优化思路理解透了RecyclerView的缓存机制和局部刷新原理其实是一脉相承的。”5.2 高频追问列表再整理几个面试里常追问的问题和回答要点5.3 如何把“二次优化”写进简历写到简历上的时候很多人只会写“使用ListView展示列表”。这句话太单薄体现不出你真正做过优化。我建议按“问题 手段 结果”来写比如“基于ListView实现消息流列表通过convertView复用与ViewHolder缓存控件引用减少重复inflate和findViewById带来的性能损耗使用线程池结合LruCache实现图片异步加载利用OnScrollListener在滚动结束后加载图片避免滑动卡顿针对点赞等单项数据更新改为局部刷新可见item降低全量刷新造成的闪烁与滚动跳动。”这里没有编造复杂的数字指标但每一句都对应实际工作里的一个操作点面试官一眼就能看出来你是在实战里摸过这些坑的。如果后续还用过RecyclerView也可以把“进一步用RecyclerView DiffUtil做增量更新”接在后面形成“从老控件到新控件”的成长路径。最后再分享一个我自己的体会很多新人觉得ListView已经是老技术觉得直接学RecyclerView就完了。但如果你把ListView的优化流程从头到尾动手写一遍你对“View复用”“缓存池”“局部刷新”“异步加载时的身份校验”这些概念的理解会比只背概念深刻得多。RecyclerView再牛底层思想也跳不出这套列表虚拟化引擎只是把这些事情做得更标准化、更顺手了。这一讲如果你能跟着代码敲一遍再去碰RecyclerView会发现很多东西都是相通的。
阅读完成 · 觉得有帮助?
咨询建站