自打开始做Android客户端列表控件这东西就绕不开。早年写代码最常用的就是ListView后来Google推出了RecyclerView大家慢慢都切了过去。但不管用哪一个有个问题始终存在项目里的列表页面越来越多每个页面都重新写一套Adapter声明ViewHolder、处理item点击、填充数据代码大量重复。我这个封装项目就是把RecyclerView和ListView的这部分共性问题抽出来形成一套通用的快速开发基类让业务方不再重复造轮子。这套封装的核心价值有几点第一把ViewHolder从手工书写变成泛型自动创建第二把多类型item的判断逻辑收敛到一处第三把列表点击事件和安全头尾视图Header/Footer的添加全部统一第四配合分页加载和刷新机制实现列表页的“一套代码多处复用”。文章会结合实际的封装思路、代码片段和踩坑记录来讲适合对RecyclerView和ListView已经有基础使用经验、希望提升开发效率的Android开发者参考新手也能通过这篇文章理解列表控件背后的设计思路。1. 整体设计从ListView迁移到RecyclerView的封装思路1.1 为什么还要回头封装ListView很多新项目早就用了RecyclerView但存量老项目和新项目共存的情况在不少团队里很常见。老项目的历史包袱里ListView高居榜首因为早期Android开发基本离不开它再加上部分第三方库和老业务代码都建立在ListView体系之上短期内没法全部推翻。而且ListView本身有个非常好的特性addHeaderView和addFooterView是体系内建支持的用起来特别顺手。做列表页时经常要加一个Banner、加一个加载更多的底部视图ListView直接往头部和底部塞View就行RecyclerView原生的Adapter里却没有这个能力必须自己写逻辑。这就导致封装时不能只盯着一套控件得同时处理好两套体系让业务层尽量感知不到差异。我的封装思路是这样以“通用Adapter基类”为核心ListView和RecyclerView各自实现自己的适配器子类对外暴露一致的接口。业务方只需要继承基类实现绑数据和创建ViewHolder的抽象方法剩下的交给基类处理。这种做法避免了两套控件各自为政也让后续把老页面从ListView迁到RecyclerView时业务代码改动量降到最低。1.2 封装前必须想清楚的三个问题动手封装之前有三件事必须想透不然后面越改越乱。第一Adapter的生命周期归谁管。列表控件销毁时Adapter持有的数据、监听器都要跟着释放。尤其自定义监听器里如果引用了Activity/Fragment处理不当就是内存泄漏的隐患。第二多类型item的判定方式。这个决定了Adapter的扩展性。初期可能只有两三种item类型到后期可能膨胀到七八种判定逻辑写死在getItemViewType里就是灾难。封装时应该把“类型判定”作为子类可覆写的扩展点并提供默认实现。第三list数据的增删改查入口。业务代码里最频繁的操作就是更新数据源和刷新界面这部分接口是否统一、是否支持占位和空态直接影响List页面开发的效率。把这三件事定好封装的大框架就出来了。接下来就是具体到类和方法的落地。2. 核心封装实现基类设计、ViewHolder与多类型Item2.1 抽象ListView适配器ViewHolder自动创建与绑定ListView的经典写法里getView方法要自己判断convertView是否为null是则加载布局并new ViewHolder否则复用然后再执行bindData。每个Adapter都要重复这套模板代码。封装的目标就是消灭这套样板逻辑。我设计了一个抽象基类CommonListAdapterT核心代码如下public abstract class CommonListAdapterT extends BaseAdapter { protected ListT mDataList; private OnItemClickListener mItemClickListener; private OnItemLongClickListener mItemLongClickListener; public CommonListAdapter(ListT dataList) { this.mDataList dataList null ? new ArrayListT() : dataList; } Override public int getCount() { return mDataList null ? 0 : mDataList.size(); } Override public T getItem(int position) { return mDataList.get(position); } Override public long getItemId(int position) { return position; } Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder null; if (convertView null) { holder createViewHolder(parent); convertView holder.itemView; convertView.setTag(holder); } else { holder (ViewHolder) convertView.getTag(); } holder.position position; bindData(holder, getItem(position), position); return convertView; } // 子类实现创建ViewHolder内含布局inflate protected abstract ViewHolder createViewHolder(ViewGroup parent); // 子类实现绑定数据到ViewHolder protected abstract void bindData(ViewHolder holder, T item, int position); }ViewHolder是一个简单的内部静态类public static class ViewHolder { public View itemView; public int position; private SparseArrayView views new SparseArray(); public ViewHolder(View itemView) { this.itemView itemView; } public V extends View V getView(int viewId) { View view views.get(viewId); if (view null) { view itemView.findViewById(viewId); views.put(viewId, view); } return (V) view; } }这样做的好处是View查找不再反复调用findViewById通过SparseArray缓存起来滑动时的性能开销比原始findViewById循环低不少。把这个SparseArray缓存机制用到RecyclerView里同样成立甚至RecyclerView官方推荐的ViewHolder写法本身就隐含了类似的意图。2.2 RecyclerView版封装ViewHolder范式与DiffUtil的引入RecyclerView的Adapter强制要求使用ViewHolder天然消灭了convertView判断封装起来更省心。但官方写法里createViewHolder和onBindViewHolder的套路仍然重复。基类CommonRecyclerAdapterT的结构就更简洁public abstract class CommonRecyclerAdapterT extends RecyclerView.AdapterCommonRecyclerAdapter.VH { protected ListT mDataList; public CommonRecyclerAdapter(ListT dataList) { this.mDataList dataList null ? new ArrayListT() : dataList; } Override public VH onCreateViewHolder(ViewGroup parent, int viewType) { return createViewHolder(parent, viewType); } Override public void onBindViewHolder(VH holder, int position) { holder.position position; bindData(holder, mDataList.get(position), position); } Override public int getItemCount() { return mDataList.size(); } public abstract VH createViewHolder(ViewGroup parent, int viewType); public abstract void bindData(VH holder, T item, int position); public static class VH extends RecyclerView.ViewHolder { public int position; public VH(View itemView) { super(itemView); } } }这里有一处容易踩坑的地方RecyclerView的onCreateViewHolder里传进来的viewType默认永远是0。所以子类里如果要支持多类型item一定要同时覆写getItemViewType。我在基类里留了默认实现返回值恒为0并提供了业务可覆写的钩子。为了性能业务覆写时建议直接返回数据bean里的type字段不要去new对象。DiffUtil的引入是这个阶段的另一个关键点。列表页刷新数据时旧做法是notifyDataSetChanged()一下简单但粗暴不仅有闪烁问题还会丢失RecyclerView的动画效果。DiffUtil则帮你计算新旧列表的差异只更新变化的部分。实践下来数据量几百条时差异计算耗时可忽略收益非常明显。2.3 多类型Item的收敛方案TypeKey映射模式多类型item常见于信息流、订单列表这类页面。一条数据里有个type字段标识它渲染成哪种视图。如果写if-else摆在Adapter里每加一种类型就要改一遍Adapter违背开闭原则。我的做法是定义一个ItemViewFactory专门负责根据item生成对应的View。Adapter里只维护一个LinkedList 注册顺序就是优先级顺序public interface ItemViewFactoryT { boolean isMatch(T item, int position); View createView(ViewGroup parent); void bindView(View view, T item, int position); }然后在Adapter里对外暴露一个registerFactory(ItemViewFactoryT factory)方法。业务侧用起来像这样mAdapter.registerFactory(new TextItemFactory()); mAdapter.registerFactory(new ImageItemFactory()); mAdapter.registerFactory(new VideoItemFactory());新增加一种条目时不影响已有代码只追加工厂类。这个东西本质上是策略模式的变体实际效果不错尤其适合展示类条目特别多的页面。如果条目类型固定不超过三种if-else其实也够用不用过度设计这点要有清醒认识。3. 实操落地封装基类的完整实现过程3.1 环境选择与项目结构规划实战中我用的是Gradle构建的Android项目minSdkVersion 21targetSdkVersion 33RecyclerView版本用的AndroidX库1.2.1。这种封装类不挑版本也不依赖特殊APIAndroidX和support库都能用老项目迁移也友好。项目结构分成三层base包放通用基类adapter包放ListView和RecyclerView的具体实现listener包放点击监听、加载更多回调等接口。跟业务相关的工厂、Holder都放到业务模块自己的代码里保持通用层零业务依赖。这样的好处是后期如果要把某项能力单独做成库直接复制base包就能用不用连带一堆业务代码。库的边界清楚了复用才顺理成章。3.2 ListView封装从BaseAdapter到ViewHolder的完整对齐ListView场景下我的封装的完整类图关系是CommonListAdapter继承自BaseAdapter内部管理数据源和监听器对外提供数据操作入口。调用方需要覆写的只有onCreateViewHolder和onBindViewHolder两个方法。业务层写起来是这样mListView.setAdapter(new CommonListAdapterString(dataList) { Override protected ViewHolder onCreateViewHolder(ViewGroup parent) { View itemView LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_simple_text, parent, false); return new ViewHolder(itemView); } Override protected void onBindViewHolder(ViewHolder holder, String item, int position) { TextView tv holder.getView(R.id.tv_text); tv.setText(item); } });同时列表数据变更接口统一为public void setDataList(ListT newList) { mDataList.clear(); mDataList.addAll(newList); notifyDataSetChanged(); } public void addDataList(ListT newList) { mDataList.addAll(newList); notifyDataSetChanged(); }这里有个细节notifyDataSetChanged()在数据变化频繁时会丢掉ListView的平滑效果甚至触发整个列表重建。所以封装里单独提供了notifyItemInserted(int position)式的细粒度方法把新数据批量插入的位置裸露出来业务方可以按需调用。3.3 RecyclerView封装添加Header/Footer与加载更多RecyclerView原生没有addHeaderView的能力封装时需要在Adapter上做文章。我的做法是维护一个内部列表mHeaders和mFooters在getItemCount里把它们的数量加进去同时通过getItemViewType区分避免业务数据和尾布局相互混淆。private static final int TYPE_HEADER Integer.MIN_VALUE; private static final int TYPE_FOOTER Integer.MAX_VALUE; Override public int getItemCount() { return mDataList.size() mHeaders.size() mFooters.size(); } Override public int getItemViewType(int position) { if (position mHeaders.size()) { return TYPE_HEADER position; } if (position mHeaders.size() mDataList.size()) { return TYPE_FOOTER (position - mHeaders.size() - mDataList.size()); } return super.getItemViewType(position); }加载更多逻辑封装成一个可插拔的OnLoadMoreListener当列表滚动到底部、且满足“当前数据未空”和“没有正在加载”两个条件时自动触发加载加载完成后调用loadMoreComplete()解锁下一轮加载。这块最容易踩的坑是重复回调——快速滑动到底部时会触发多次加载请求。我经验里的稳妥做法是通过一个isLoadingMore布尔位加锁在回调里置true加载完成后置false。3.4 点击事件的统一分发与参数传递列表点击是最高频的交互如果每个页面都单独写setOnItemClickListener代码会越来越乱。封装的点击事件统一封装成两个接口OnItemClickListener和OnItemLongClickListener在Adapter内部完成position映射和数据传递。public interface OnItemClickListener { void onItemClick(int position, Object item); }点击区域如果涉及item内部多个子View的区分比如点赞按钮和卡片区域可以在Holder的bind阶段单独给子View设置监听用position去数据源拿对象。我的建议是不要试图用一个通用回调覆盖所有场景Adapter层的职责边界是分发不是业务处理。4. 核心细节参数计算、ViewHolder复用与性能调优4.1 嵌套滑动的性能取舍列表页里最常见的性能坑是RecyclerView嵌套RecyclerView。比如首页外层是一个纵向列表里面内嵌一个横向滚动的RecyclerView展示推荐内容。这种嵌套如果处理不好两个方向同时滚动会导致事件冲突、卡顿、甚至OOM。我的封装里对嵌套场景有两个处理手段。第一个是内层RecyclerView必须设置setNestedScrollingEnabled(false)阻止内层消费嵌套滑动事件减少事件冲突。第二个是内层RecyclerView的LayoutManager在onMeasure里尽量使用固定高度避免反复测量。实测下来内层列表数据条数不多一般二三十条时这样处理后滑动帧率能保持在接近满帧的水平。4.2 LayoutManager的局部刷新生效条件DiffUtil最大的优势是局部刷新。但很多开发者用了它后说页面没有变化或者闪了一下。问题往往出在两个地方。第一DiffUtil比较的是item内容要求bean类必须正确实现equals和hashCode否则每一项都被判定为“内容不同”仍然全量刷新。第二DiffUtil只对相同position的item做差异对比如果列表顺序变化很大新增删除的动画就不会生效。我在基类里对DiffUtil做了薄封装把calculateDiff放到后台线程执行主线程只负责notifyDataSetChangedDiffUtil.DiffResult diffResult DiffUtil.calculateDiff(new DiffUtil.Callback() { // 回调实现关键是areItemsTheSame和areContentsTheSame }); diffResult.dispatchUpdatesTo(mAdapter);结果就是列表更新后滑动位置保持稳定、动画流畅。实测数据量5000条时DiffUtil计算时间也就几毫秒到几十毫秒完全可接受。4.3 数据量大时避免anr的异步加载策略列表初始化和分页加载的数据量一旦大起来直接丢到主线程解析会卡界面。封装里单独提供了一套DataDispatcher的接口专用于大数据量的异步加载回调public interface DataDispatcherT { void dispatchData(ListT dataList, int pageIndex); }业务侧实现时网络请求和本地数据库查询都放到子线程执行拿到结果后切回主线程调用setDataList。这样即使一页加载几百条数据界面也不会掉帧。这套思路实际上是AsyncListUtil和PagedList的简化版精度上不如它们细腻但胜在简单直接不引入额外依赖。5. 常见问题与排查技巧实录5.1 Header/Footer误触与布局错乱的修复列表加了Header后点击监听里的position会比业务数据的position大。很多新手在onItemClick里直接用position去数据源取数据结果发现取错了。封装里我统一做了position偏移计算监听器回调出去的position永远是对应业务数据的下标Headers和Footers的点击单独走各自的监听回调互不干扰。另一个高频问题是Footer通过GridView或者复杂布局实现时根布局高度算不对导致Footer显示不全。解决办法是给Footer根View设置合适的高度或者使用ViewGroup.LayoutParams.WRAP_CONTENT并在onBindViewHolder阶段根据内容动态调整。5.2 复用混乱Item类型冲突与findViewById缓存失效多类型复用最经典的崩溃就是ClassCastException。原因在于RecyclerView的ViewHolder缓存是按viewType区分的如果两个类型的viewType返回值相同就会复用错对象。排查经验是检查getItemViewType里返回的值是否出现一致尤其当type字段是负数或常量重复时最容易出问题。修复方案是把类型标识升级为int型唯一键或者直接使用object.hashCode()做兜底但要注意hashCode冲突概率极低但不为零工程上可以接受。SparseArray缓存View的坑在于如果自定义View里对相同的viewId重复使用比如同一个布局里两个TextView的id一样缓存的键就冲突。我自己就给自定义组合控件踩过这坑解决方式是不用holder.getView(id)而是直接通过holder.itemView.findViewById(id)动态拿这在特殊View结构里反而是最稳的。5.3 内存泄漏高发区监听器与Context的隐形引用Adapter里如果用了匿名内部类持有Activity的ContextActivity退出时Adapter还没释放就会导致内存泄漏。常见场景是Adapter内部创建了Handler或者Runnable比如分页加载失败后自动重试。我的规避做法第一通用层不直接持Context只在onCreateViewHolder里通过LayoutInflater临时取用parent的Context第二所有监听器都设置成static内部类或者用弱引用包装宿主第三列表销毁时主动调用destroy()释放数据源和监听器引用。这套组合拳下来LeakCanary再没报过这个泄漏。5.4 空布局与加载失败的UI兜底方案列表为空时总不能白屏。封装里加入了setEmptyView(View emptyView)的能力列表数据为空时自动把空View显示到ListView或RecyclerView的父容器里。加载失败时也有setErrorView(View errorView)点击后可以触发重新加载。这部分的实现不复杂但要考虑布局层级不能因为空View的添加导致原有列表位置跳动。实际项目里我见过不少同学在Activity里写一堆if-else去控制空态和错误态的显示隐藏一旦有多个地方要展示列表就处处重复。封装之后这些状态切换在Adapter内部统一处理业务侧只看数据和结果。这个体验差异用过的人才会懂。6. 使用明细与项目落地效果6.1 接入步骤从依赖到第一个页面的最小路径第一步在项目的build.gradle里引入RecyclerView如果是老项目继续用ListView则不需要额外依赖。第二步把通用基类代码复制到base目录去掉业务相关的注释和示例。第三步新列表页面继承CommonRecyclerAdapter或者CommonListAdapter实现两个抽象方法。第四步把列表控件和Adapter绑定。完整接入一个简单列表页面的耗时大概在15分钟以内。我这里所指的15分钟是包括写布局、写bean和调试的时间纯写Adapter代码其实五六分钟就能完成。6.2 三个实际场景的效果数据场景一某订单列表页面原先Adapter代码300多行封装后缩到90行其中大部分是业务赋值和点击跳转。场景二某信息流页面原先有6种条目类型改动频繁。引入工厂模式后新增一种条目只需要新增一个工厂类平均改动时间从1小时缩到20分钟。场景三某搜索历史页面数据量不大但更新频繁。使用DiffUtil后列表刷新不再闪白用户反馈的视觉问题消除前后端联调时的查询体验也更流畅。这组数据说明封装的收益不是单一维度的代码量减少而是混合了维护成本、视觉体验、开发效率的综合提升。6.3 封装之外的扩展方向如果后续项目演进到Jetpack架构可以考虑把这套Adapter和ViewModel、LiveData进一步结合。比如数据源来自LiveData时让Adapter自动订阅数据变化定义DataBinding的通用ViewHolder这样布局和数据绑定都能在XML里完成代码量还能再降一层。如果数据量继续增长到万级建议切换到Paging 3库那套东西在分页加载和生命周期上做得更深入。我的封装可以和Paging 3共存Adapter照样能用DataSource变化时只需要替换数据填充的方式。7. 写代码之外的经验折腾这套封装前前后后也有两个多月期间踩过不少坑也验证了不少设计决策。有一句话想送给做同样事情的开发者封装一定要从“能解决当前问题的程度”开始不要一步到位。我第一版设计里想支持所有场景结果类越来越复杂业务方反而不敢用。后来砍掉三分之一的接口只保留真正高频的能力使用率反而上来了。另一个重要心得是工具要在代码库里“活”起来必须有真实的业务场景来验证和打磨。只靠闭门造车的Demo永远发现不了真实页面里的数据更新、空态展示、点击穿透这些细节问题。我现在再动工这类通用组件一定会先找两个不同形态的业务页面作为验收标准通过之后才会推广到全项目。最后分享一个实际的小技巧封装类里统一加日志输出开关。平时用BuildConfig.DEBUG控制运行时如果列表数据与预期不符打开日志就能看到每一条item的类型、position、是否复用、耗时多少。这个开关在排查列表问题时帮了我不少也推荐大家加上。
阅读完成 · 觉得有帮助?