Android BaseListAdapter要这样搞?

📅 2026/8/7 0:53:28
Android BaseListAdapter要这样搞?
引言一个被低估的基石现在提到 Android 列表大家第一反应都是 RecyclerView 甚至 Compose 的 LazyColumn。BaseAdapter那不就是老古董了吗不少开发者对它的印象还停留在“面试才用得上”的阶段。但在实际工作中大量老项目仍然依赖 ListView BaseAdapter理解它的底层逻辑是你解决历史遗留 Bug、优化老旧列表性能的唯一钥匙。更重要的是BaseAdapter 背后的复用机制、适配器模式和性能优化思想是 Android View 体系的抽象基础。读懂了它你再看 RecyclerView 和 Compose就会有一种“原来如此”的通透感。本文不会只扔给你几个代码片段而是从源码、设计模式、性能调优、面试陷阱、工程封装等维度用近两万字的篇幅把 BaseAdapter 掰开揉碎讲清楚。无论你是正准备面试的初学者还是接手了十年陈酿项目的工程师这篇文章都值得你收藏。1. 基础篇重新认识 Adapter 家族1.1 继承体系全景图在讲 BaseAdapter 之前必须先把整个适配器家族的谱系梳理清楚。因为很多时候你并不是直接 new 一个 BaseAdapter而是在 ArrayAdapter、CursorAdapter 等子类上踩坑。它们的继承关系如下android.widget.Adapter ├── ListAdapter │ └── BaseAdapter │ ├── ArrayAdapterT │ ├── CursorAdapter │ │ └── SimpleCursorAdapter │ └── SimpleAdapter └── SpinnerAdapter └── BaseAdapter (同样被 Spinner 使用)重点关注 BaseAdapter它是一个抽象类实现了 ListAdapter 和 SpinnerAdapter 两个接口。这意味着同一套 Adapter 可以同时给 ListView、GridView、Spinner、Gallery 使用。这也是为什么我们在很多老代码里看到同一个 Adapter 被传给了不同控件——它的抽象层次足够高。ArrayAdapter 和 SimpleAdapter 虽然方便但内部逻辑非常死板一旦你的 Item 布局稍微复杂一点、或者数据结构不是简单的字符串/Map就必须自己继承 BaseAdapter 来写。1.2 四个必须重写的方法任何一个自定义 BaseAdapter不看源码也要背熟下面四个方法方法签名作用调用场景int getCount()返回数据源总条数每次测量、布局、滚动都会调用频率极高Object getItem(int position)获取某位置的数据对象点击事件、数据绑定等调用频率中等long getItemId(int position)获取某位置的稳定 ID当 ListView 需要判断两个条目是否为同一数据时调用View getView(int position, View convertView, ViewGroup parent)创建或复用 Item 视图整个列表显示期间调用最多是性能优化的主战场这四个方法是 BaseAdapter 的灵魂任何一行的实现出现问题轻则崩溃重则列表性能跌入深渊。我们后面的所有优化、封装、面试考点全都是围绕它们展开的。1.3 从零实现一个最简单的 Adapter以及它为什么不行下面是很多人写的第一个 BaseAdapter。它看起来没什么毛病数据也能正常显示但当你用它加载上千条数据时App 就开始原形毕露public class NaiveAdapter extends BaseAdapter { private ListString items; private LayoutInflater inflater; public NaiveAdapter(Context context, ListString items) { this.inflater LayoutInflater.from(context); this.items items; } Override public int getCount() { return items.size(); } Override public Object getItem(int pos) { return items.get(pos); } Override public long getItemId(int pos) { return pos; } Override public View getView(int pos, View convertView, ViewGroup parent) { View row inflater.inflate(R.layout.item_text, parent, false); TextView tv row.findViewById(R.id.tv_content); tv.setText(items.get(pos)); return row; } }这段代码的致命问题有两个一是每次 getView 都在 inflate二是每次都在 findViewById。对于一屏只能显示七八条的设备上下快速滑动时会产生成百上千次布局膨胀和视图查找。GC 疯狂工作主线程不断卡顿。如果你刚好在做性能优化Profile 工具里那根紫红色的 Layout Measure 柱子源头大概率就在这里。2. 复用篇convertView 与 ViewHolder 的演进之路2.1 convertView系统递给你的复用护照getView 方法的第二个参数 convertView不是随便命名的。它代表系统回收的一个 Item View。当列表顶部的一个条目被完全滑出屏幕ListView 内部的复用池RecycleBin并不会立刻销毁这个 View而是把它暂存起来。等到底部需要显示新的条目时这个暂存的 View 就会作为 convertView 传入 getView 中。你要做的事情就是判断 convertView 是否为 null如果是 null 才 inflate否则直接复用只更新数据。Override public View getView(int pos, View convertView, ViewGroup parent) { if (convertView null) { convertView inflater.inflate(R.layout.item_text, parent, false); } TextView tv convertView.findViewById(R.id.tv_content); tv.setText(items.get(pos)); return convertView; }这段代码解决了布局膨胀的问题但 findViewById 依然每次都在执行。当你的 Item 里有五六个控件时每滑一次就调用五六次 findViewById依然不够理想。2.2 ViewHolder把 findViewById 彻底锁死在创建阶段ViewHolder 的思想可以用一句话概括用空间换时间把布局里所有子 View 的引用缓存到一个对象里挂载在 convertView 上。这样一来只要 convertView 不是 null就可以直接从它的 tag 里取出 ViewHolder完全跳过 findViewById。static class ViewHolder { TextView tvTitle; ImageView ivIcon; TextView tvDesc; } Override public View getView(int pos, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView null) { convertView inflater.inflate(R.layout.item_complex, parent, false); holder new ViewHolder(); holder.tvTitle convertView.findViewById(R.id.tv_title); holder.ivIcon convertView.findViewById(R.id.iv_icon); holder.tvDesc convertView.findViewById(R.id.tv_desc); convertView.setTag(holder); } else { holder (ViewHolder) convertView.getTag(); } ItemData data items.get(pos); holder.tvTitle.setText(data.getTitle()); holder.tvDesc.setText(data.getDesc()); // 图片异步加载 return convertView; }ViewHolder 现在几乎是每个 Android 工程师必备的基本功但在早期 Android 开发中这可是面试的必考题。事实上RecyclerView 直接把这个模式做成了强制的 API说明这个模式的正确性无可争议。2.3 RecycleBin 源码级剖析你可能会好奇convertView 到底是从哪来的答案在 AbsListView 的内部类 RecycleBin 中。虽然我们不应该依赖内部实现写业务代码但理解它的原理能帮你解释很多奇怪的滑动行为。// AbsListView.RecycleBin 简化关键代码 class RecycleBin { private View[] mActiveViews new View[0]; private ArrayListView mScrapViews; View getScrapView(int position) { // 先尝试从活跃视图数组里取适用于数据未变化时的快速位置匹配 // 否则从复用池列表中取出最后一个 if (mScrapViews.size() 0) { return mScrapViews.remove(mScrapViews.size() - 1); } return null; } void addScrapView(View scrap, int position) { // 当一个 View 完全滑出屏幕后根据当前 adapter 的 view type 放入对应池子 int viewType mAdapter.getItemViewType(position); mScrapViews[viewType].add(scrap); } }这里有两个值得注意的点第一mActiveViews 用于暂存当前屏幕上的活跃 View当数据没有变化且布局尺寸不变时系统可以直接从数组里按 position 取回 View甚至不需要调用 getView——这是 ListView 比 ScrollView 快的一个原因。第二当存在多种 ViewType 时mScrapViews 不是一个简单的列表而是一个数组每种 type 拥有独立的复用池。这个设计直接关联到后面的多布局适配。3. 多布局篇getItemViewType 的真正含义3.1 为何需要多布局绝大多数 App 列表都不是只有一种 Item 布局。聊天界面有发送和接收两种气泡设置页有开关项、跳转项、标题项新闻列表可能插入广告卡片。这些场景都需要 Adapter 支持多种 ViewType。BaseAdapter 提供了两个关键方法来实现多布局int getItemViewType(int position)返回当前位置的布局类型标识返回值必须是 0 到 getViewTypeCount()-1 之间的整数。int getViewTypeCount()声明总共有几种布局类型默认返回 1。如果你不覆写这两个方法所有 View 都视为同一种类型。后果就是一个左对齐气泡的 View 被回收后复用时你可能用 findViewById 去找一个只有右对齐布局才有的控件直接 NPE。更隐蔽的情况是两个布局里相同 id 的控件类型不同findViewById 拿到错误类型的 View强转失败程序崩溃。3.2 多布局的复用池隔离机制当你正确覆写了 getViewTypeCount 返回 3 时ListView 内部会初始化三个独立的 mScrapViews 列表。类型为 0 的 View 永远不会被当作 convertView 传给类型为 1 的 getView 调用。这就是“隔离”的具体实现。实现多布局的典型代码模板如下public class ChatAdapter extends BaseAdapter { private static final int TYPE_SENT 0; private static final int TYPE_RECEIVED 1; private static final int TYPE_TIMESTAMP 2; private ListMessage messages; Override public int getViewTypeCount() { return 3; } Override public int getItemViewType(int position) { Message msg messages.get(position); if (msg.isTimestamp()) return TYPE_TIMESTAMP; return msg.isSentByMe() ? TYPE_SENT : TYPE_RECEIVED; } Override public View getView(int pos, View convertView, ViewGroup parent) { int type getItemViewType(pos); ViewHolder holder; if (convertView null) { int layoutId (type TYPE_SENT) ? R.layout.item_sent : (type TYPE_RECEIVED) ? R.layout.item_received : R.layout.item_timestamp; convertView inflater.inflate(layoutId, parent, false); holder new ViewHolder(convertView, type); convertView.setTag(holder); } else { holder (ViewHolder) convertView.getTag(); } holder.bind(messages.get(pos)); return convertView; } // ... getCount / getItem / getItemId 略 }注意 ViewHolder 的创建逻辑必须根据 type 来初始化不同的子 View 引用否则你仍然可能在绑定数据时拿到 null。3.3 getViewTypeCount 的隐含约定有一点很少被提及getViewTypeCount 的返回值必须始终是一个正整数而且一旦返回ListView 内部就会分配等量的复用池。如果中途你让 getViewTypeCount 返回的值变大比如从 3 变成 4系统不会重新分配池子getItemViewType 返回 3 时就可能发生越界。因此ViewType 的数量应该在 Adapter 构造时就完全确定并且再也不会变化。4. 性能篇让你的 ListView 帧率跑满 60fps4.1 布局层级——被忽视的滑动杀手有了 ViewHolder 之后findViewById 的消耗已经不是瓶颈真正的性能瓶颈转移到了测量和绘制阶段。Item 布局越深onMeasure 和 onLayout 的递归计算量就越大。如果一个列表每个 Item 的布局都是 LinearLayout 套 LinearLayout那性能基本无解。改进建议优先使用 ConstraintLayout 减少嵌套层级。如果必须使用 LinearLayout尽量控制在两层以内。使用merge标签作为布局根节点但要配合 inflate 时传入 parent 参数。避免在 Item 中使用 RelativeLayout 做复杂相对定位——它的测量可能触发两次 layout。!-- 推荐一层 ConstraintLayout 搞定复杂布局 -- androidx.constraintlayout.widget.ConstraintLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightwrap_content ImageView android:idid/iv_avatar ... / TextView android:idid/tv_name ... / TextView android:idid/tv_msg ... / /androidx.constraintlayout.widget.ConstraintLayout4.2 异步加载与线程安全getView 方法运行在主线程任何耗时操作都会直接导致丢帧。网络请求、数据库查询、大图解码都必须完全异步。我们用图片加载举例Override public View getView(int pos, View convertView, ViewGroup parent) { ViewHolder holder getViewHolder(convertView, parent); holder.tvName.setText(data.get(pos).getName()); // Glide 内部自动处理了 cancel、占位和复用错位 Glide.with(holder.ivAvatar.getContext()) .load(data.get(pos).getAvatarUrl()) .placeholder(R.drawable.ic_placeholder) .error(R.drawable.ic_error) .into(holder.ivAvatar); return holder.getConvertView(); }如果你不用成熟的图片框架而是自己开线程下载图片就必须额外处理“View 被复用时取消上一个下载任务”的问题否则就会出现经典的图片错位闪烁。4.3 对象创建与 GC 压力不要小看在 getView 里 new 一个对象带来的开销。即使只是一个 OnClickListener如果你给每个 Item 都 new 一次滑动时大量的匿名内部类对象会让 GC 频繁触发。正确做法有几种把 ClickListener 提升为 Adapter 的成员变量通过 view.getTag() 或者 position 判断当前点击的是哪一项。使用一个静态 Handler 或者单例的 Listener 实例数据绑定时不创建新对象。字符串格式化、DecimalFormat 等创建成本高的操作提前在数据层做好getView 中只做纯绑定。// 反例每次都在创建匿名内部类 holder.btnDelete.setOnClickListener(v - deleteItem(pos)); // 推荐成员变量 通过 tag 获取 position private View.OnClickListener deleteListener v - { int pos (int) v.getTag(); deleteItem(pos); }; Override public View getView(int pos, View convertView, ViewGroup parent) { // ... holder.btnDelete.setTag(pos); holder.btnDelete.setOnClickListener(deleteListener); // ... }4.4 分页与增量更新BaseAdapter 没有内置分页机制但你应该在数据层面做好控制。不要把几万条数据全部 load 到内存再塞给 Adapter。一般的做法是使用一个 ArrayList 作为数据缓存初始只加载前 50 条。监听 ListView 的 OnScrollListener当最后一个可见条目接近数据缓存末尾时异步加载下一页追加到 ArrayList 然后调用 notifyDataSetChanged。如果你需要更平滑的体验可以手工计算出新增条目在列表中的位置范围可惜 BaseAdapter 没有类似 notifyItemRangeInserted 的精准通知方法所以还是得承受一次全局刷新。这也是 RecyclerView 出现的一个重要推动力——分页加载时全局刷新的代价太大。5. 面试篇那些年关于 BaseAdapter 的送命题5.1 getView 到底会被调用多少次这道题几乎没有标准答案但可以从几个角度分析首次加载时getView 一般会被调用屏幕可显示条目数 1 次因为 ListView 会多预加载一个 Item 用于测量和滑动缓冲。在测量阶段同一个 position 可能被多次调用——ListView 可能需要先拿到一个 View 测量高度再根据整体布局方案重新请求一次。调用 notifyDataSetChanged 之后所有当前屏幕上的 Item 都会重新走 getView之前缓存的 View 全部作废。滑动过程中每滑入一个新条目getView 就调用一次convertView 非 null且不保证 position 是递增的因为来回滑动时你可能看到 position 前后跳跃。如果你在面试中能深入到这个粒度加上对 mActiveViews 的理解面试官基本会对你刮目相看。5.2 notifyDataSetChanged 的代价有多大简而言之代价是 O(n)其中 n 是当前屏幕上的条目数。更糟的是它会让整个列表里所有和 RecycleBin 相关的缓存失效所有 mActiveViews 清空所有回收 View 被打上“脏”标记。下一次 layout 时屏幕上的每一个条目哪怕数据根本没变也要重新走一次 getView。这也是为什么 RecyclerView 引入了 DiffUtil 和精准通知——只对真正变化的条目触发重新绑定。5.3 getItem 和 getItemId 到底有什么用很多开发者只在 getView 里调用 data.get(position)从来不依赖 getItem这其实会埋雷。因为 ListView 内部在一些场景下会调用 getItem 来判定数据集是否变化。getItemId 则用于区分两个 position 是否代表同一个逻辑数据。如果你覆写了 getItemId 并返回了唯一标识比如数据库主键系统可以在 notifyDataSetChanged 后通过比对 id 来判定某些 Item 实际上没有变化从而减少部分刷新操作。可惜的是这个优化在 ListView 上的表现并不稳定到了 RecyclerView 才被稳定地利用。5.4 多布局复用池的隔离面试官还想听什么你可以进一步展开如果 getViewTypeCount 返回 5但 getItemViewType 只返回 0~3会发生什么答案是ListView 会为类型 4 创建一个空的复用池虽然不会崩溃但浪费了内存。另外不同类型之间虽然复用池隔离但 RecyclerView 中的 RecycledViewPool 默认是共享的你可以通过 setMaxRecycledViews 控制每种 type 的最大缓存数——这一点在对比两者时是很加分的细节。6. 封装篇打造你自己的 BaseListAdapter6.1 封装目标任何超过两个 Adapter 的项目都值得做一层封装。我们希望达到的效果是不必再手写 ViewHolder 内部类。不必在每个 getView 里写 findViewById。支持多布局时子类只需要声明 type 和对应布局不需要处理 convertView 复用细节。内置 Item 点击回调不污染 Activity。6.2 通用 ViewHolderpublic class ViewHolder { private SparseArrayView views new SparseArray(); private View convertView; private ViewHolder(View convertView) { this.convertView convertView; convertView.setTag(this); } public static ViewHolder get(View convertView, ViewGroup parent, int layoutId) { if (convertView null) { convertView LayoutInflater.from(parent.getContext()) .inflate(layoutId, parent, false); return new ViewHolder(convertView); } return (ViewHolder) convertView.getTag(); } public T extends View T getView(int viewId) { View v views.get(viewId); if (v null) { v convertView.findViewById(viewId); views.put(viewId, v); } return (T) v; } public View getConvertView() { return convertView; } // 便捷链式方法 public ViewHolder setText(int viewId, String text) { ((TextView) getView(viewId)).setText(text); return this; } public ViewHolder setImageRes(int viewId, int resId) { ((ImageView) getView(viewId)).setImageResource(resId); return this; } }6.3 抽象通用 Adapterpublic abstract class CommonAdapterT extends BaseAdapter { protected Context context; protected ListT data; private OnItemClickListenerT clickListener; public CommonAdapter(Context context, ListT data) { this.context context; this.data data; } Override public int getCount() { return data null ? 0 : data.size(); } Override public T getItem(int pos) { return data.get(pos); } Override public long getItemId(int pos) { return pos; } protected abstract int getItemLayoutId(int pos); protected abstract void convert(ViewHolder holder, T item, int pos); Override public View getView(int pos, View convertView, ViewGroup parent) { ViewHolder holder ViewHolder.get(convertView, parent, getItemLayoutId(pos)); convert(holder, getItem(pos), pos); holder.getConvertView().setOnClickListener(v - { if (clickListener ! null) clickListener.onItemClick(getItem(pos), pos); }); return holder.getConvertView(); } public void setOnItemClickListener(OnItemClickListenerT listener) { this.clickListener listener; } public interface OnItemClickListenerT { void onItemClick(T item, int position); } }使用这个封装你只需要写两样东西布局 ID 和 convert 方法。任何只包含一种布局的列表三分钟就能写完 Adapter。6.4 支持多布局的扩展版本如果你需要多布局只需要再增加三个抽象方法public abstract class MultiCommonAdapterT extends BaseAdapter { // ... 同前 ... protected abstract int getViewTypeCount_(); protected abstract int getItemViewType_(int pos); protected abstract int getLayoutIdByType(int type); protected abstract void convert(ViewHolder holder, T item, int pos, int type); Override public int getViewTypeCount() { return getViewTypeCount_(); } Override public int getItemViewType(int pos) { return getItemViewType_(pos); } Override public View getView(int pos, View convertView, ViewGroup parent) { int type getItemViewType(pos); ViewHolder holder ViewHolder.get(convertView, parent, getLayoutIdByType(type)); convert(holder, getItem(pos), pos, type); return holder.getConvertView(); } }经过这一层抽象团队里任何人写 Adapter 都只需要关注数据和布局复用逻辑被彻底锁死在基类里。7. 踩坑篇生产环境中的那些诡异 Bug7.1 CheckBox 选中状态满天飞这是新人最容易踩的坑列表每个 Item 有一个 CheckBox你选中了第 2 条滑动到底部再回来发现第 10 条也被选中了。原因很简单View 被复用时CheckBox 的选中状态没有被重置数据源里也没有记录哪些位置被选中。解决方式在数据源维护一个 SetInteger 记录被选中的 positionconvert 方法中显式调用 setChecked。7.2 EditText 输入内容漂移当 Item 包含 EditText 时你输入了一些文字滑动几下内容就不翼而飞甚至跑到了另一个 Item 上。根本原因是 EditText 的内容没有写回数据模型。解决方案是为 EditText 设置 TextWatcher在 afterTextChanged 里更新数据源对应 position 的数据。注意防止死循环在代码里 setText 时先移除 TextWatcher设置完再加回来或者通过 tag 标记跳过回调。7.3 内存泄漏三剑客Adapter 持有 Activity 引用而 ListView 又持有 Adapter形成了 Activity → ListView → Adapter → Activity 的引用链。如果 Activity 销毁时没有清空 ListView 的 Adapter就会泄漏。最佳实践Adapter 用静态内部类Context 使用 ApplicationContext但注意 inflate 主题问题。在 onDestroy 中调用 listView.setAdapter(null)。回调使用 WeakReference 包装。7.4 快速滑动时图片还是错位即使用了 Glide在极端快速滑动时可能因异步回调时序问题导致短暂错位。这并不是 Glide 的 Bug而是你手动给 ImageView 设置了默认图片或动画后没有正确处理 View 复用时的 cancel。解决方案在 ViewHolder 中持有 Request在 View 离开屏幕时可以在 Adapter 里维护一个 Map 或者利用 View 的 onDetachedFromWindow取消之前的请求。8. 进阶篇BaseAdapter vs RecyclerView.Adapter 全面对比维度BaseAdapter (ListView)RecyclerView.Adapter复用机制手动 convertView ViewHolder 模式强制 ViewHolder内置缓存局部刷新仅 notifyDataSetChangednotifyItemInserted / Removed / Changed / Moved动画无内置支持ItemAnimator 实现增删移动动画布局管理仅垂直列表LayoutManager 支持线性、网格、瀑布流项装饰只支持 dividerItemDecoration 自定义绘制缓存层级单级缓存 (mScrapViews)四级缓存 (mAttachedScrap, mCachedViews, ViewCacheExtension, RecycledViewPool)代码规范自由度高易写出差性能代码强约束引导最佳实践总结一句话新项目永远用 RecyclerView。但你如果还在维护老代码或者面试时被问到“ListView 和 RecyclerView 有什么区别”上面的每一行你都要能展开讲三分钟。9. 设计哲学篇BaseAdapter 中的软件工程思想9.1 享元模式复用池的本质RecycleBin 是享元模式在 Android 中最经典的落地之一。通过共享有限数量的 View 实例避免了大批量创建和销毁的昂贵开销。如果你做过 Java 后端可以把它类比为数据库连接池如果你写游戏这就是对象池。理解这个模式你就知道为什么“不复用”的列表在大数据量下会直接崩盘。9.2 适配器模式统一接口的价值BaseAdapter 屏蔽了底层数据源可能是 List、Cursor、数组的差异为 ListView 暴露了一个统一的接口。这种解耦让 ListView 可以毫不关心数据从哪来你甚至可以写一个 Adapter 从网络流式读取数据。这也是为什么你能把同一个 Adapter 传给 ListView 和 Spinner——适配器抹平了消费端的差异。9.3 从 BaseAdapter 到 RecyclerView再到 Compose技术的演进有一条清晰的主线BaseAdapter 阶段解决“有得用”的问题定义了 Android 列表编程的范式。RecyclerView 阶段解决“用得好”的问题引入插件化、局部刷新和动画。Compose 阶段解决“写得爽”的问题声明式 UI 彻底消灭了 Adapter 和 ViewHolder 的概念。如果你能从 BaseAdapter 一路走下来你会非常自然地理解 RecyclerView 的每一项设计决策也会对 Compose 的“去 Adapter 化”有更深的理解。这就是基础的力量。10. 总结看了近两万字我们最后收一收。BaseAdapter 不是一个过时的类库它是一座桥梁。一端连着早期 Android 开发者的血泪教训另一端连着现代 RecyclerView 和 Compose 的设计源头。理解它你掌握的不仅仅是四个方法和一个复用池而是整个 Android View 体系关于性能、复用和解耦的设计脉络。如果你的项目还在用 ListView这篇文章里的封装代码和优化清单可以直接拿去用。如果你正在准备面试今晚把文章里提到的面试题再看一遍明天面试时你就能把面试官问住。如果这是你 Android 学习路的起点恭喜你你已经站在了最重要的那块基石上。