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 │ ├── ArrayAdapter<T> │ ├── 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 List<String> items; private LayoutInflater inflater; public NaiveAdapter(Context context, List<String> 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 ArrayList<View> 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 List<Message> 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 帧率跑满 60fps
4.1 布局层级——被忽视的滑动杀手
有了 ViewHolder 之后,findViewById 的消耗已经不是瓶颈,真正的性能瓶颈转移到了测量和绘制阶段。Item 布局越深,onMeasure 和 onLayout 的递归计算量就越大。如果一个列表每个 Item 的布局都是 LinearLayout 套 LinearLayout,那性能基本无解。
改进建议:
- 优先使用 ConstraintLayout 减少嵌套层级。
- 如果必须使用 LinearLayout,尽量控制在两层以内。
- 使用
<merge>标签作为布局根节点,但要配合 inflate 时传入 parent 参数。 - 避免在 Item 中使用 RelativeLayout 做复杂相对定位——它的测量可能触发两次 layout。
<!-- 推荐:一层 ConstraintLayout 搞定复杂布局 --> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content"> <ImageView android:id="@+id/iv_avatar" ... /> <TextView android:id="@+id/tv_name" ... /> <TextView android:id="@+id/tv_msg" ... /> </androidx.constraintlayout.widget.ConstraintLayout>4.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. 封装篇:打造你自己的 BaseListAdapter
6.1 封装目标
任何超过两个 Adapter 的项目都值得做一层封装。我们希望达到的效果是:
- 不必再手写 ViewHolder 内部类。
- 不必在每个 getView 里写 findViewById。
- 支持多布局时,子类只需要声明 type 和对应布局,不需要处理 convertView 复用细节。
- 内置 Item 点击回调,不污染 Activity。
6.2 通用 ViewHolder
public class ViewHolder { private SparseArray<View> 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 抽象通用 Adapter
public abstract class CommonAdapter<T> extends BaseAdapter { protected Context context; protected List<T> data; private OnItemClickListener<T> clickListener; public CommonAdapter(Context context, List<T> 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(OnItemClickListener<T> listener) { this.clickListener = listener; } public interface OnItemClickListener<T> { void onItemClick(T item, int position); } }使用这个封装,你只需要写两样东西:布局 ID 和 convert 方法。任何只包含一种布局的列表,三分钟就能写完 Adapter。
6.4 支持多布局的扩展版本
如果你需要多布局,只需要再增加三个抽象方法:
public abstract class MultiCommonAdapter<T> 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. 踩坑篇:生产环境中的那些诡异 Bug
7.1 CheckBox 选中状态满天飞
这是新人最容易踩的坑:列表每个 Item 有一个 CheckBox,你选中了第 2 条,滑动到底部再回来,发现第 10 条也被选中了。原因很简单:View 被复用时,CheckBox 的选中状态没有被重置,数据源里也没有记录哪些位置被选中。解决方式:在数据源维护一个 Set<Integer> 记录被选中的 position,convert 方法中显式调用 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,内置缓存 |
| 局部刷新 | 仅 notifyDataSetChanged | notifyItemInserted / Removed / Changed / Moved |
| 动画 | 无内置支持 | ItemAnimator 实现增删移动动画 |
| 布局管理 | 仅垂直列表 | LayoutManager 支持线性、网格、瀑布流 |
| 项装饰 | 只支持 divider | ItemDecoration 自定义绘制 |
| 缓存层级 | 单级缓存 (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 学习路的起点,恭喜你,你已经站在了最重要的那块基石上。