A
第 9 章JAVA42 分钟

RecyclerView 列表实现

深入 RecyclerView 全家桶:Adapter/ViewHolder/LayoutManager/ItemDecoration/DiffUtil/ItemTouchHelper 与四级缓存。

学习目标

  • 理解 Adapter + ViewHolder 模式与 RecyclerView 的职责分离
  • 能用 LinearLayoutManager / GridLayoutManager 实现线性与网格列表
  • 会用 ItemDecoration 画分割线和间距
  • 掌握 DiffUtil 高效局部刷新替代 notifyDataSetChanged
  • 能用 ItemTouchHelper 实现拖拽排序与侧滑删除
  • 理解 RecyclerView 四级缓存的工作机制

学习目标

  • 能解释 Adapter、ViewHolder、LayoutManager 三者各自负责什么
  • 能用 LinearLayoutManager / GridLayoutManager 搭出可运行的线性/网格列表
  • 能用 ItemDecoration 画分割线和间距,区别addItemDecoration 与 margin
  • 会用 DiffUtil + ListAdapter 做局部刷新,告别 notifyDataSetChanged
  • 能用 ItemTouchHelper 实现长按拖拽和侧滑删除
  • 理解 RecyclerView 的四级缓存(Scrap / Cache / ViewCacheExtension / RecycledViewPool)

RecyclerView 列表实现

1. 为什么是 RecyclerView

老的 ListView 有几个硬伤:只能竖向滚动、getView 里 findViewById 频繁 IO、没有 Diff 局部刷新、无法横向网格。RecyclerView 把职责拆开成多个独立组件,整体变得灵活、可复用、可插拔:

组件 职责
RecyclerView 容器,管理布局、动画、滚动
Adapter 把数据映射成 ViewHolder
ViewHolder 持有 Item 的 View 引用,避免反复 findViewById
LayoutManager 决定 Item 怎么摆(线性、网格、瀑布流)
ItemDecoration 给 Item 画分割线、间距
ItemAnimator 增删改时的动画
ItemTouchHelper 拖拽与侧滑手势

依赖:

implementation 'androidx.recyclerview:recyclerview:1.3.2'

2. Adapter + ViewHolder 基础示例

先定义数据模型和 Item 布局。

item_text.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="16dp">

    <TextView
        android:id="@+id/tvTitle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textSize="16sp"
        android:textColor="@color/text_primary" />

    <TextView
        android:id="@+id/tvSubtitle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="4dp"
        android:textSize="12sp"
        android:textColor="@color/text_secondary" />

</LinearLayout>

数据模型:

package com.example.recyclerviewdemo;

public class Note {
    private final String title;
    private final String subtitle;

    public Note(String title, String subtitle) {
        this.title = title;
        this.subtitle = subtitle;
    }
    public String getTitle() { return title; }
    public String getSubtitle() { return subtitle; }
}

Adapter:

package com.example.recyclerviewdemo;

import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List;

public class NoteAdapter extends RecyclerView.Adapter<NoteAdapter.NoteVH> {

    private final List<Note> data = new ArrayList<>();

    @NonNull
    @Override
    public NoteVH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(parent.getContext())
                .inflate(R.layout.item_text, parent, false);
        return new NoteVH(view);
    }

    @Override
    public void onBindViewHolder(@NonNull NoteVH holder, int position) {
        Note note = data.get(position);
        holder.tvTitle.setText(note.getTitle());
        holder.tvSubtitle.setText(note.getSubtitle());
    }

    @Override
    public int getItemCount() {
        return data.size();
    }

    public void setItems(List<Note> items) {
        data.clear();
        data.addAll(items);
        notifyDataSetChanged();
    }

    static class NoteVH extends RecyclerView.ViewHolder {
        TextView tvTitle;
        TextView tvSubtitle;

        NoteVH(@NonNull View itemView) {
            super(itemView);
            tvTitle = itemView.findViewById(R.id.tvTitle);
            tvSubtitle = itemView.findViewById(R.id.tvSubtitle);
        }
    }
}

注意几个细节:

  • inflate(R.layout.item_text, parent, false) 的第三个参数必须是 false——传 true 会把 root 加进 parent 两次,导致测量尺寸异常
  • ViewHolder 内部只做一次 findViewById,后续 onBindViewHolder 只调 setText 等便宜的方法
  • ViewHolder 强烈建议写成 Adapter 的 static 内部类,避免隐式持有外部 Adapter 引用导致内存泄漏

2.1 RecyclerView Activity 接线

package com.example.recyclerviewdemo;

import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        RecyclerView rv = findViewById(R.id.recyclerView);
        rv.setLayoutManager(new LinearLayoutManager(this));

        NoteAdapter adapter = new NoteAdapter();
        rv.setAdapter(adapter);

        List<Note> notes = new ArrayList<>();
        for (int i = 0; i < 30; i++) {
            notes.add(new Note("标题 " + (i + 1), "这是第 " + (i + 1) + 条副标题"));
        }
        adapter.setItems(notes);
    }
}

布局:

<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/recyclerView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clipToPadding="false"
    android:padding="8dp" />

clipToPadding="false" 让内容能滚到 padding 区域,避免最后一项被 padding 截断。

3. LayoutManager:线性、网格、瀑布流

3.1 LinearLayoutManager

// 垂直
rv.setLayoutManager(new LinearLayoutManager(this));
// 横向
rv.setLayoutManager(
        new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false));
// 反向(数据从底部开始)
rv.setLayoutManager(
        new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, true));

3.2 GridLayoutManager

rv.setLayoutManager(new GridLayoutManager(this, 3));
// 列宽自适应:用 ViewCompat 设置 spanSize

需要某些 Item 跨多列(如 Banner 占满整行)时用 SpanSizeLookup:

GridLayoutManager lm = new GridLayoutManager(this, 3);
lm.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
    @Override
    public int getSpanSize(int position) {
        // 头部 Banner 占 3 列,其他占 1 列
        return position == 0 ? 3 : 1;
    }
});
rv.setLayoutManager(lm);

3.3 StaggeredGridLayoutManager 瀑布流

rv.setLayoutManager(
        new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL));

瀑布流的子 Item 高度允许不一致,由系统测量后排列。

4. ItemDecoration:分割线与间距

ItemDecoration 让你在每个 Item 的四周画装饰。最常见的是分割线。

4.1 简单间距

public class SpaceItemDecoration extends RecyclerView.ItemDecoration {
    private final int space;

    public SpaceItemDecoration(int space) { this.space = space; }

    @Override
    public void getItemOffsets(@NonNull Rect outRect, @NonNull View view,
                               @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
        outRect.left = space;
        outRect.right = space;
        outRect.bottom = space;
        // 第一项上方也加,避免和顶部 padding 叠成两倍
        if (parent.getChildAdapterPosition(view) == 0) {
            outRect.top = space;
        }
    }
}

outRect 是一个 Rect,给 Item 四周增加偏移量(相当于额外的 margin,但不会撑大自己)。使用:

rv.addItemDecoration(new SpaceItemDecoration(8));

4.2 自带 DividerItemDecoration

DividerItemDecoration decoration = new DividerItemDecoration(this, LinearLayoutManager.VERTICAL);
decoration.setDrawable(getDrawable(R.drawable.divider_default));
rv.addItemDecoration(decoration);

divider_default.xml:

<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <size android:height="1dp" />
    <solid android:color="#FFEEEEEE" />
</shape>

注意 DividerItemDecoration 默认只画水平列表的竖直分割线或竖直列表的水平分割线,对 GridLayoutManager 不适用——网格得自己写 ItemDecoration 在 onDraw 里画线。

5. ItemAnimator 默认动画

RecyclerView 自带 DefaultItemAnimator,增删 Item 时有 fade / translation 动画。只在你调用 notifyItemInserted / notifyItemRemoved / notifyItemRangeChanged 时才生效,notifyDataSetChanged 会跳过动画。

关闭动画:

rv.setItemAnimator(null);

调整动画时长:

DefaultItemAnimator animator = new DefaultItemAnimator();
animator.setAddDuration(200);
animator.setRemoveDuration(200);
rv.setItemAnimator(animator);

6. 多类型 Item

需求:列表头部是 Banner(布局 A),中间是普通条目(布局 B)。核心是 getItemViewType 返回不同 type,onCreateViewHolder 根据 type inflate 不同布局。

public class MixedAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {

    private static final int TYPE_BANNER = 1;
    private static final int TYPE_NOTE = 2;

    private final List<Object> items = new ArrayList<>(); // 实际项目用 sealed class 或类型安全封装

    @Override
    public int getItemViewType(int position) {
        Object item = items.get(position);
        if (item instanceof Banner) return TYPE_BANNER;
        return TYPE_NOTE;
    }

    @NonNull
    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        LayoutInflater inflater = LayoutInflater.from(parent.getContext());
        if (viewType == TYPE_BANNER) {
            return new BannerVH(inflater.inflate(R.layout.item_banner, parent, false));
        }
        return new NoteVH(inflater.inflate(R.layout.item_text, parent, false));
    }

    @Override
    public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
        Object item = items.get(position);
        if (holder instanceof BannerVH) {
            ((BannerVH) holder).bind((Banner) item);
        } else if (holder instanceof NoteVH) {
            ((NoteVH) holder).bind((Note) item);
        }
    }

    @Override
    public int getItemCount() { return items.size(); }

    static class BannerVH extends RecyclerView.ViewHolder {
        ImageView ivBanner;
        BannerVH(@NonNull View itemView) {
            super(itemView);
            ivBanner = itemView.findViewById(R.id.ivBanner);
        }
        void bind(Banner b) { /* ... */ }
    }

    static class NoteVH extends RecyclerView.ViewHolder {
        TextView tvTitle, tvSubtitle;
        NoteVH(@NonNull View itemView) {
            super(itemView);
            tvTitle = itemView.findViewById(R.id.tvTitle);
            tvSubtitle = itemView.findViewById(R.id.tvSubtitle);
        }
        void bind(Note n) {
            tvTitle.setText(n.getTitle());
            tvSubtitle.setText(n.getSubtitle());
        }
    }
}

getItemViewType 返回的 type 值会作为 onCreateViewHolder 的 viewType 参数,也参与 RecycledViewPool 的缓存区分(不同 type 不复用同一 ViewHolder)。

7. DiffUtil 高效更新

每次 notifyDataSetChanged 会触发所有可见 Item 重新 bind,且丢失动画。DiffUtil 用 LCS(最长公共子序列)算法,只对真正变化的 Item 发精准 notify,保留动画。

7.1 ListAdapter 模板

AndroidX 提供了 ListAdapter 基类,把 DiffUtil 调用全部封装好:

public class NoteAdapter extends ListAdapter<Note, RecyclerView.ViewHolder> {

    protected NoteAdapter() {
        super(DIFF);
    }

    private static final DiffUtil.ItemCallback<Note> DIFF =
            new DiffUtil.ItemCallback<Note>() {
        @Override
        public boolean areItemsTheSame(@NonNull Note oldItem, @NonNull Note newItem) {
            // 用来判定「是不是同一条」——通常用 id
            return oldItem.getTitle().equals(newItem.getTitle());
        }

        @Override
        public boolean areContentsTheSame(@NonNull Note oldItem, @NonNull Note newItem) {
            // 同一条但内容可能变了——通常用 equals 或字段比较
            return oldItem.getTitle().equals(newItem.getTitle())
                    && oldItem.getSubtitle().equals(newItem.getSubtitle());
        }
    };

    @NonNull
    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        // 同上
        return null;
    }

    @Override
    public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
        // 注意:用 getItem(position) 取数据,不再是 data.get
        Note note = getItem(position);
        // ...
    }
}

提交新数据时一行:

adapter.submitList(newNotes);

submitList 内部异步跑 DiffUtil,比 notifyDataSetChanged 快得多,且保留 ItemAnimator 动画。

7.2 高级:payload 增量更新

如果只想刷新某个字段而非整行 bind,实现 getChangePayload:

@Override
public Object getChangePayload(@NonNull Note oldItem, @NonNull Note newItem) {
    Bundle diff = new Bundle();
    if (!oldItem.getSubtitle().equals(newItem.getSubtitle())) {
        diff.putString("subtitle", newItem.getSubtitle());
    }
    return diff.isEmpty() ? super.getChangePayload(oldItem, newItem) : diff;
}

然后在 onBindViewHolder 的三参重载里处理 payload:

@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position,
                              @NonNull List<Object> payloads) {
    if (payloads.isEmpty()) {
        super.onBindViewHolder(holder, position, payloads);
        return;
    }
    Bundle diff = (Bundle) payloads.get(0);
    if (diff.containsKey("subtitle")) {
        // 只刷新副标题,不重新 bind 整行
        ((NoteVH) holder).tvSubtitle.setText(diff.getString("subtitle"));
    }
}

这是 RecyclerView 性能优化的精髓之一,但使用前请用 Layout Inspector 确认你确实有性能瓶颈——大多数场景 submitList 已经够快。

8. ItemTouchHelper 拖拽与侧滑

ItemTouchHelper 内置了拖拽排序和侧滑删除,给 Adapter 加 ItemTouchHelper.Callback 即可。

ItemTouchHelper.SimpleCallback cb = new ItemTouchHelper.SimpleCallback(
        ItemTouchHelper.UP | ItemTouchHelper.DOWN,  // 拖拽方向
        ItemTouchHelper.START | ItemTouchHelper.END  // 侧滑方向
) {
    @Override
    public boolean onMove(@NonNull RecyclerView rv, @NonNull RecyclerView.ViewHolder vh,
                          @NonNull RecyclerView.ViewHolder target) {
        int from = vh.getAdapterPosition();
        int to = target.getAdapterPosition();
        Collections.swap(data, from, to);
        adapter.notifyItemMoved(from, to);
        return true;
    }

    @Override
    public void onSwiped(@NonNull RecyclerView.ViewHolder vh, int direction) {
        int pos = vh.getAdapterPosition();
        data.remove(pos);
        adapter.notifyItemRemoved(pos);
    }

    @Override
    public boolean isLongPressDragEnabled() {
        return true; // 长按启用拖拽
    }
};
ItemTouchHelper helper = new ItemTouchHelper(cb);
helper.attachToRecyclerView(rv);

注意:

  • onMove 里要同时改数据并 notifyItemMoved,二者必须同步,否则下次 DiffUtil 会算错
  • onSwiped 侧滑删除也要同步删数据,否则 Item 会「弹回」但数据已变
  • 关掉长按拖拽(isLongPressDragEnabled=false)后,需要给某个 handle 绑定 helper.startDrag(vh) 才能拖动

9. 缓存机制:四级缓存

理解缓存能让你的列表又快又省内存。RecyclerView 内部维护 4 级缓存:

层级 名字 容量 作用
1 Scrap(Attached Scrap) 无限 当前还附着在屏幕上、临时 detach 的 ViewHolder,屏幕回滚立即复用,不重新 bind
2 Scrap(Changed Scrap) 无限 已被标记为「数据变更」、待 notifyItemChanged 二次 bind 的 ViewHolder
3 Cache(RecycledViewPool 之前的 Cache) 默认 5 滚动离开屏幕的 ViewHolder 进这里,复用不重新 bind(认为数据没变)
4 RecycledViewPool 默认 5/type 同 type 共享池,复用时必须重新 bind

9.1 复用优先级

Item 滚动离开屏幕 → Cache(5 个)→ 满了后挤进 RecycledViewPool → 池也满了直接 GC。新 Item 出现时反过来找:先 Scrap → Cache → Pool → 实在没人能复用才 onCreateViewHolder。

这意味着只要数据布局相同,ViewHolder 不会被反复创建,避免了 findViewById 的反复 IO。这就是 RecyclerView 性能的根源。

9.2 两个调优点

  • setItemViewCacheSize(n):调高 Cache 容量,让回滚时不重新 bind。代价是更多内存占用
  • 共享 RecycledViewPool:多 Tab 同结构列表共用一个 Pool。常见于 ViewPager2 + 多个 RecyclerView 的场景
RecyclerView.RecycledViewPool pool = new RecyclerView.RecycledViewPool();
rv1.setRecycledViewPool(pool);
rv2.setRecycledViewPool(pool);
rv3.setRecycledViewPool(pool);

注意不同 viewType 之间不互通,多个 Tab 列表 Item 布局必须一致才有意义。

10. 完整示例:带 Diff + 拖拽的笔记列表

把上面综合起来:一个能添加、删除、长按拖拽的列表。Activity 布局略,仅给关键代码:

package com.example.recyclerviewdemo;

import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class MainActivity extends AppCompatActivity {

    private NoteAdapter adapter;
    private final List<Note> data = new ArrayList<>();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        RecyclerView rv = findViewById(R.id.recyclerView);
        rv.setLayoutManager(new LinearLayoutManager(this));
        rv.addItemDecoration(new SpaceItemDecoration(8));

        adapter = new NoteAdapter();
        rv.setAdapter(adapter);

        // 初始化数据
        for (int i = 0; i < 30; i++) {
            data.add(new Note("笔记 " + (i + 1), "副标题 " + (i + 1)));
        }
        adapter.submitList(new ArrayList<>(data)); // submitList 需要 List 的拷贝避免外层改

        ItemTouchHelper.SimpleCallback cb = new ItemTouchHelper.SimpleCallback(
                ItemTouchHelper.UP | ItemTouchHelper.DOWN,
                ItemTouchHelper.START | ItemTouchHelper.END
        ) {
            @Override
            public boolean onMove(@NonNull RecyclerView rv, @NonNull RecyclerView.ViewHolder vh,
                                  @NonNull RecyclerView.ViewHolder target) {
                int from = vh.getAdapterPosition();
                int to = target.getAdapterPosition();
                Collections.swap(data, from, to);
                adapter.notifyItemMoved(from, to);
                return true;
            }
            @Override
            public void onSwiped(@NonNull RecyclerView.ViewHolder vh, int direction) {
                int pos = vh.getAdapterPosition();
                data.remove(pos);
                adapter.submitList(new ArrayList<>(data));
            }
            @Override
            public boolean isLongPressDragEnabled() {
                return true;
            }
        };
        new ItemTouchHelper(cb).attachToRecyclerView(rv);

        findViewById(R.id.btnAdd).setOnClickListener(v -> {
            int size = data.size() + 1;
            data.add(0, new Note("新笔记 " + size, "刚刚添加"));
            adapter.submitList(new ArrayList<>(data));
        });
    }
}

注意 submitList 传的是 new ArrayList<>(data) 拷贝,否则 DiffUtil 看到的是同一引用,会算成「无变化」。

常见坑与最佳实践

  1. notifyDataSetChanged 滥用:会跳过 DiffUtil、丢失动画、强制全部重新 bind。能用 notifyItemInserted/Removed/Moved/Changed 就别用 notifyDataSetChanged;能用 ListAdapter.submitList 更好。
  2. onBindViewHolder 里写耗时操作:列表滚动时这个方法会高频调用,不能在这里做 IO、网络、数据库查询。所有耗时数据应在 submitList 之前准备好。
  3. findViewById 写在 onBindViewHolder:等于放弃了 ViewHolder 的优化。所有 findViewById 在 ViewHolder 构造函数里做一次。
  4. 数据修改后没同步 notify:会出现「屏幕显示的数据和 list 里不一致」的诡异 bug。改数据必须立刻 notifyXxx。
  5. 多类型列表的 getItemViewType 返回 position:这是新手高频错误,会导致每种 position 都是不同 type,缓存完全失效。Type 必须是基于「数据类型」的常量集合。
  6. RecyclerView 高度写 wrap_content + 嵌套在 ScrollView 里:会测量所有 Item,性能极差且只能显示一行高度。要么让 RecyclerView 撑满父容器,要么用 NestedScrollView 单容器思路。
  7. DiffUtil 的 areItemsTheSame 用整个对象 equals:会让同内容不同实例被判定为「不同条目」,触发不必要的删除/插入动画。areItemsTheSame 用 id 比较,areContentsTheSame 才用内容比较。

章节小结

  • RecyclerView 拆分为 Adapter / ViewHolder / LayoutManager / ItemDecoration / ItemAnimator / ItemTouchHelper,各司其职
  • Adapter 的 ViewHolder 写成 static 内部类,onCreateViewHolder 只 inflate + findViewById,onBindViewHolder 只做 setData
  • LinearLayoutManager / GridLayoutManager / StaggeredGridLayoutManager 三种布局管理器
  • ItemDecoration.getItemOffsets 给 Item 加间距,onDraw 画线;网格分割线要自己写
  • ListAdapter + DiffUtil.ItemCallback 实现高效局部刷新,告别 notifyDataSetChanged
  • ItemTouchHelper.SimpleCallback 一行 attachToRecyclerView 实现长按拖拽和侧滑删除
  • 四级缓存:Scrap → Cache → RecycledViewPool → 销毁;同类型 Item 复用,避免反复 inflate

下一章预告

到此你已经能搭出任何「标准」界面。但有时产品要的控件系统库根本没有——圆形进度环、带波纹的按钮、特殊手势的轮盘。下一章我们走进 自定义 View 的世界:measure/layout/draw 绘制流程、onMeasure / onDraw 实战、自定义属性、事件分发机制,最后手写一个圆形进度条综合示例。