文章目录
- [一、引入:RecyclerView 学会了,为什么还是不会做"频道切换"?](#一、引入:RecyclerView 学会了,为什么还是不会做“频道切换”?)
- 二、先认识我们的页面结构
- [三、第一步:先理解 RecyclerView 到底显示什么](#三、第一步:先理解 RecyclerView 到底显示什么)
- 四、一个频道对应一组数据
- [五、为什么需要 Adapter?](#五、为什么需要 Adapter?)
- 六、那么问题来了:点击频道以后怎么办?
- [七、在 Adapter 里面处理点击事件](#七、在 Adapter 里面处理点击事件)
- [八、但是 Adapter 不应该直接修改 Fragment](#八、但是 Adapter 不应该直接修改 Fragment)
- 九、使用接口传递点击事件
- 十、点击的时候通知外部
- [十一、Fragment 接收到这个事件](#十一、Fragment 接收到这个事件)
- [十二、从 position 找到频道名称](#十二、从 position 找到频道名称)
- 十三、再通过频道名称找到对应数据
- [十四、最后让 FeedAdapter 换数据](#十四、最后让 FeedAdapter 换数据)
- [十五、`notifyDataSetChanged()` 是干什么的?](#十五、
notifyDataSetChanged()是干什么的?) - 十六、最终代码结构
- [十七、为什么不直接用 `List<List<FeedItem>>`?](#十七、为什么不直接用
List<List<FeedItem>>?) - [十八、还有一个更重要的问题:频道名称真的适合当 Key 吗?](#十八、还有一个更重要的问题:频道名称真的适合当 Key 吗?)
- [十九、再进一步:不要让 Fragment 管理所有数据](#十九、再进一步:不要让 Fragment 管理所有数据)
- [二十、再进一步:`notifyDataSetChanged()` 并不是最优方案](#二十、再进一步:
notifyDataSetChanged()并不是最优方案) - 二十一、再进一步:如果数据来自网络怎么办?
- 二十二、再进一步:如果用户切换频道很频繁怎么办?
- 二十三、最终可以形成一套完整架构
- 二十四、总结
本文以一个"小红书发现页"的实际开发场景为例,讲解如何使用 RecyclerView + Adapter + 回调 + 数据映射 实现:
点击顶部频道 → 当前频道改变 → 瀑布流显示对应内容
适合已经学习过 RecyclerView 基础,但还不太理解 Adapter 和 Fragment 如何进行数据交互的 Android 初学者。
一、引入:RecyclerView 学会了,为什么还是不会做"频道切换"?
很多人在刚学完 RecyclerView 后,已经知道了RecyclerView, LayoutManager, Adapter, ViewHolder各自负责的内容. 也知道 Adapter 大概需要写onCreateViewHolder(), onBindViewHolder(), getItemCount()这些方法, 但是一旦遇到真正的页面:
text
┌──────────────────────────────┐
│ 推荐 穿搭 美食 彩妆 旅行 │ ← 频道栏
├──────────────────────────────┤
│ │
│ 图片 图片 │
│ │
│ 标题 标题 │
│ │
│ 图片 图片 │ ← 瀑布流
│ │
└──────────────────────────────┘
就会出现一个问题:**点击上面的"美食",下面的 RecyclerView 怎么换成美食内容?RecyclerView 本身并不知道"美食"是什么。所以真正的问题其实不是:"怎么刷新 RecyclerView?". 而是点击事件发生以后,怎么把"用户选择了哪个频道"传递给负责数据的地方,然后让 Adapter 显示新的数据?**这就是本文要解决的问题。
二、先认识我们的页面结构
假设我们正在仿写一个小红书发现页。整个页面可以简化成:
text
DiscoverFragment
│
├── 频道 RecyclerView
│ ├── 推荐
│ ├── 穿搭
│ ├── 美食
│ ├── 彩妆
│ └── ...
│
└── 瀑布流 RecyclerView
├── Note
├── Note
├── Note
└── ...
这里实际上有两个 RecyclerView。
第一个频道 RecyclerView负责显示"推荐、穿搭、美食......", 第二个Feed RecyclerView负责显示具体的笔记内容。因此它们之间存在一个关系是频道 RecyclerView显示对应的子项, 用户点击"美食"后, 找到"美食"对应的数据, 传入到Feed RecyclerView后刷新数据, 显示美食笔记
三、第一步:先理解 RecyclerView 到底显示什么
假设我们的笔记是:
java
public class FeedItem {
private final int imageResId;
private final String title;
private final String nickname;
private final int likeCount;
public FeedItem(
int imageResId,
String title,
String nickname,
int likeCount) {
this.imageResId = imageResId;
this.title = title;
this.nickname = nickname;
this.likeCount = likeCount;
}
}
一条笔记就是一个FeedItem. 那么List就是一组笔记. 例如, 多组笔记构成的推荐页面可以表示成List recommendList;
四、一个频道对应一组数据
接下来就很自然了。我们有三个频道栏目推荐页面, 穿搭页面和美食页面各自可以对应一组笔记, 可以各自表示成一组list列表, 如List, List, List. 如果我们把它放进一个 Map:Map<String, List>
它的含义就是**用频道名称找到这个频道对应的笔记列表。**例如:
java
channelFeedMap.put(
"推荐",
recommendList
);
channelFeedMap.put(
"穿搭",
clothingList
);
channelFeedMap.put(
"美食",
foodList
);
于是就用一个Map可以容纳所有的List
text
channelFeedMap
│
├── "推荐" → recommendList
├── "穿搭" → clothingList
└── "美食" → foodList
这一步其实就是整个功能的数据基础。
五、为什么需要 Adapter?
如果你之前学过 RecyclerView,应该知道RecyclerView自己并不知道"我应该显示哪 20 条笔记?"它需要问 Adapter去索要数据。
Adapter 可以理解成 RecyclerView 的"数据翻译员"。用List存储的数据举例, Adapter通过获得List对应的数据来通知RecyclerView如何显示Item的具体内容
java
FeedAdapter adapter = new FeedAdapter(recommendList);
findRecycler.setAdapter(adapter);
此时recommendList通过FeedAdapter来改变RecyclerView的显示内容
六、那么问题来了:点击频道以后怎么办?
假设当前显示推荐页面, 里面是一组推荐内容, 当用户点击穿搭后, 我们希望程序能找到穿搭的对应数据, 让FeedAdapter里的数据List变成clothingList, 让RecyclerView显示新的内容. 所以我们需要解决两个问题:频道 RecyclerView 怎么知道用户点击了哪个频道?知道以后,怎么把这个信息告诉 DiscoverFragment?
七、在 Adapter 里面处理点击事件
最直接的写法就是在Adapter里面处理点击事件
java
holder.itemView.setOnClickListener(v -> {
int position = holder.getBindingAdapterPosition();
});
八、但是 Adapter 不应该直接修改 Fragment
初学者很容易想到在ChannelAdapter的点击事件里直接找到DiscoverFragment再去修改RecyclerView的内容. 但这种做法不太好。
为什么?因为 Adapter 的职责应该主要是:**把数据显示成 RecyclerView 的 Item,并处理 Item 的交互。**而"点击美食之后应该显示什么". 属于页面业务逻辑。所以我们让 Adapter:**只负责通知外面:"有人点击了第几个频道。"**这就需要一个回调。
九、使用接口传递点击事件
我们可以在 ChannelAdapter 里面定义:
java
public interface OnChannelClickListener {
void onChannelClick(int position);
}
这句话可以理解成:
"谁使用 ChannelAdapter,谁就可以注册一个监听器。当频道被点击时,我把 position 告诉他。"
然后 Adapter 保存这个监听器:
java
private OnChannelClickListener listener;
构造 Adapter 时传进去:
java
public ChannelAdapter(
List<String> list,
OnChannelClickListener listener) {
channelList = list;
this.listener = listener;
}
十、点击的时候通知外部
这里是原来的点击事件
java
holder.itemView.setOnClickListener(v -> {
int newPosition =
holder.getBindingAdapterPosition();
if (newPosition == RecyclerView.NO_POSITION) {
return;
}
});
我们可以通过接口改写成下面的代码
java
holder.itemView.setOnClickListener(v -> {
int newPosition =
holder.getBindingAdapterPosition();
if (newPosition == RecyclerView.NO_POSITION) {
return;
}
if (listener != null) {
listener.onChannelClick(newPosition);
}
});
现在这种代码, 当用户点击频道后, 通知ChannelAdapter, 得到的是position, 通过listener接口去实现对应的点击事件
Adapter 不需要知道"美食应该显示什么。"
它只需要说"用户点了 position = 2。"
十一、Fragment 接收到这个事件
在 DiscoverFragment 中:
java
channelAdapter = new ChannelAdapter(
channels,
position -> {
// position 就是点击的频道位置
}
);
例如:
java
channelAdapter = new ChannelAdapter(
channels,
position -> {
Log.d(
"DiscoverFragment",
"点击了第 " + position + " 个频道"
);
}
);
十二、从 position 找到频道名称
我们有:
java
List<String> channels =
Arrays.asList(
"推荐",
"穿搭",
"美食",
"彩妆",
"旅行",
"游戏",
"摄影",
"健身"
);
如果position = 2 那么有
java
String channelName =
channels.get(position);
这样得到的就是美食
十三、再通过频道名称找到对应数据
现在我们已经有channelName, 例如"美食"
java
List<FeedItem> newFeed =
channelFeedMap.get(channelName);
整个过程如下所示
text
position = 2
↓
channels.get(2)
↓
"美食"
↓
channelFeedMap.get("美食")
↓
foodList
这就是我们前面为什么要建立Map<String, List>的原因
十四、最后让 FeedAdapter 换数据
原来的 FeedAdapter里的List是这样写的
java
public FeedAdapter(List<FeedItem> feedList) {
this.feedList = feedList;
}
我们需要增加一个方法notifyDataSetChanged()可以去刷新显示的列表
java
public void setFeedList(
List<FeedItem> feedList) {
this.feedList = feedList;
notifyDataSetChanged();
}
于是feedAdapter.setFeedList(newFeed);就表示
"Adapter 不要再显示旧的 FeedList 了,现在改成新的 FeedList。"
十五、notifyDataSetChanged() 是干什么的?
这是整个功能最后的一环。我们执行feedAdapter.setFeedList(foodList);
现在实际上变成FeedAdapter传入foodList
但是 RecyclerView 需要知道"数据发生变化了!". 所以notifyDataSetChanged();
就是告诉 RecyclerViewAdapter 的数据变了,请重新刷新。
于是调用notifyDataSetChanged()后会刷新对应的RecyclerView
十六、最终代码结构
最终 DiscoverFragment 中核心逻辑大概是:
java
channelAdapter = new ChannelAdapter(
channels,
position -> {
String channelName =
channels.get(position);
List<FeedItem> newFeed =
channelFeedMap.get(channelName);
if (newFeed == null) {
return;
}
feedAdapter.setFeedList(
newFeed
);
findRecycler.scrollToPosition(0);
}
);
这几行虽然不多,但是实际上完成了很多事情:
text
position
↓
频道
↓
数据
↓
Adapter
↓
RecyclerView
十七、为什么不直接用 List<List<FeedItem>>?
你可能会想到:
java
List<List<FeedItem>> allFeed;
然后:
java
allFeed.get(position);
其实也完全可以。
例如:
text
position 0 → 推荐
position 1 → 穿搭
position 2 → 美食
于是:
java
List<FeedItem> newFeed =
allFeed.get(position);
这种方式也能工作。
那么为什么我们这里使用:
java
Map<String, List<FeedItem>>
?
因为它表达的语义更加直观:
text
"美食" → 美食数据
而不是:
text
2 → 某一组数据
当项目变复杂以后,数据结构表达的含义本身就是代码可读性的一部分。
不过对于纯粹按照 position 管理的页面,List<List<FeedItem>> 也完全合理。
十八、还有一个更重要的问题:频道名称真的适合当 Key 吗?
目前我们写:
java
Map<String, List<FeedItem>>
Key 是:
text
"推荐"
"穿搭"
"美食"
小项目完全没问题。
但真正的 App 通常不会这么设计。
例如:
java
class Channel {
int id;
String name;
}
数据可能变成:
text
Channel
├── id = 1
├── name = "推荐"
│
Channel
├── id = 2
├── name = "穿搭"
│
Channel
├── id = 3
└── name = "美食"
然后:
text
频道 ID
↓
数据查询
这就比直接拿中文名称作为唯一标识更加稳定。
十九、再进一步:不要让 Fragment 管理所有数据
现在我们的结构是:
text
DiscoverFragment
│
├── channels
├── channelFeedMap
├── ChannelAdapter
└── FeedAdapter
对于当前这个学习项目完全可以。
但是项目继续扩大以后,Fragment 会越来越臃肿:
text
DiscoverFragment
├── 创建数据
├── 管理频道
├── 网络请求
├── 刷新
├── 数据缓存
├── 点击事件
├── UI 状态
└── ...
这时候就应该开始考虑:
text
ViewModel
例如:
text
DiscoverFragment
│
↓
DiscoverViewModel
│
↓
Repository
│
├── 网络
└── 本地数据库
Fragment 主要负责:
UI。
ViewModel 负责:
页面状态。
Repository 负责:
数据从哪里来。
二十、再进一步:notifyDataSetChanged() 并不是最优方案
我们目前使用:
java
notifyDataSetChanged();
非常适合学习阶段,因为简单直接。
但是它相当于告诉 RecyclerView:
"别管哪里变了,整个列表重新检查一下。"
如果数据很多,这种方式就比较粗。
Android 后面提供了:
text
ListAdapter
+
DiffUtil
可以比较:
text
旧 List
vs
新 List
然后只更新真正发生变化的 Item。
例如:
text
100 条笔记
只有第 35 条变化
DiffUtil 可以判断:
text
35 → changed
其他 → unchanged
这就是 RecyclerView 性能优化中非常重要的一块。
不过当前阶段不需要为了"高级"而提前使用它。
先把:
text
数据 → Adapter → RecyclerView
理解清楚更重要。
二十一、再进一步:如果数据来自网络怎么办?
现在我们的数据是:
text
代码里写死
真实项目可能是:
text
服务器
↓
JSON
↓
Java/Kotlin 对象
↓
List<FeedItem>
↓
Adapter
↓
RecyclerView
例如:
text
用户点击"美食"
↓
请求:
/feed?channel=food
↓
服务器返回 JSON
↓
解析成 List<FeedItem>
↓
Adapter 更新
这时候你之前学习的:
text
HTTP
JSON
Retrofit
Kotlin 协程
就开始和 RecyclerView 真正联系起来了。
二十二、再进一步:如果用户切换频道很频繁怎么办?
比如:
text
推荐
↓
美食
↓
穿搭
↓
旅行
↓
美食
如果每次都访问网络:
text
点击美食
↓
请求网络
点击穿搭
↓
请求网络
再次点击美食
↓
又请求网络
就会产生重复请求。
这时候可以加入缓存:
text
channelCache
│
├── 推荐 → 已加载
├── 穿搭 → 已加载
├── 美食 → 已加载
└── 旅行 → 未加载
于是:
text
第一次点击美食
↓
网络请求
↓
保存数据
第二次点击美食
↓
直接使用缓存
这就开始从:
RecyclerView UI
逐渐进入:
数据层设计。
二十三、最终可以形成一套完整架构
我们这篇文章最开始只是:
text
点击频道
↓
换 RecyclerView 内容
但一步一步向后扩展,就会得到:
text
┌─────────────────────────────┐
│ UI 层 │
│ │
│ Channel RecyclerView │
│ Feed RecyclerView │
└──────────────┬──────────────┘
↓
ChannelAdapter
↓
OnChannelClick
↓
DiscoverFragment
↓
ViewModel(以后)
↓
Repository
↙ ↘
Network Cache
你会发现,很多 Android 的"高级知识",其实都是在解决最开始这个简单问题:数据到底应该放在哪里、怎么流动、谁负责什么。
二十四、总结
这次我们实现的功能可以浓缩成一句话:
用户点击频道后,通过 Adapter 回调把点击位置传递给 Fragment,Fragment 根据频道找到对应的 Feed 数据,再让 FeedAdapter 更新数据并通知 RecyclerView 刷新。
核心数据流:
text
点击频道
↓
position
↓
OnChannelClickListener
↓
DiscoverFragment
↓
频道 → FeedList
↓
FeedAdapter.setFeedList()
↓
notifyDataSetChanged()
↓
RecyclerView
而这套思路其实不只适用于"小红书频道"。
同样的模式还可以用于:
text
新闻 App
├── 国内
├── 国际
├── 科技
└── 体育
电商 App
├── 推荐
├── 数码
├── 服装
└── 食品
音乐 App
├── 推荐
├── 华语
├── 欧美
└── 日语
本质都是:
text
用户选择
↓
状态改变
↓
找到对应数据
↓
Adapter 更新
↓
UI 更新
这就是 RecyclerView 从"会用"到"真正开始用于实际项目"的一个很好的分水岭。