做 App 练手,资讯类项目一直是我的推荐首选。原因很简单,信息流场景足够典型,列表、图片、接口、缓存、下拉刷新、分页加载这些核心知识点全都能在上面滚一遍,做完以后还特别容易给朋友演示,比做记账本有成就感得多。今天要聊的这个项目,就是用 Android 原生控件复刻一个简化版今日头条,核心放在 ListView 和 RecyclerView 两个列表控件的实战上,不引入复杂架构,用 Java 加 XML 布局就能跑通,适合刚学完四大组件、想系统练列表开发的朋友。
看完这篇,你能得到一个可以直接照着写的新闻聚合列表项目,能分清 ListView 和 RecyclerView 各自适合什么场景,也能避开我实际开发中踩过的那些坑。尤其是多类型 Item、图片错乱、列表卡顿这几个问题,我会把排查思路和最终方案都摊开讲。
1. 仿今日头条的整体设计思路
1.1 为什么选新闻资讯类练手
我自己带过不少新人,观察下来,资讯类 App 是最适合做练手项目的,没有之一。首先它的页面结构非常典型,首页是多个 Tab,每个 Tab 下面都是一条竖向排列的信息流,每一个信息条目就是列表里的一项。这意味着你只要把一个列表写好,整个 App 的主要骨架就立住了。
其次,新闻条目的数据结构足够丰富,标题、摘要、来源、发布时间、评论数、封面图,这些字段能帮你练习定义数据模型、解析数据、适配多布局。对比一下待办事项或者记账本,列表项通常就一两行文字,练不到图片加载和复杂布局,做完以后也很难看出水平差异。资讯类项目则不同,你可以把一个新闻卡片做成单图、三图、纯文字三种样式,直接就把 RecyclerView 的多类型 Item 能力吃透了。
还有一个很实际的好处是后续扩展空间大。列表做完了,可以加 TabLayout 联动 ViewPager2 做多频道,可以加搜索页、收藏页、详情页,甚至可以接入简单缓存做离线阅读。一个项目能反复迭代几个月,每个阶段都有肉眼可见的进步,这比反复做新项目学到的知识更能沉淀下来。
1.2 ListView 和 RecyclerView 怎么选
很多新手会纠结一个点:既然官方早就推荐 RecyclerView,那我直接学 RecyclerView 不就行了,为什么还要碰 ListView?这个想法我能理解,但建议还是两个都写一遍。
ListView 是 Android 早期最核心的列表控件,它的源码里藏着很多理解 RecyclerView 的钥匙。你会在 ListView 的 BaseAdapter 里第一次接触到 convertView 复用机制,会理解为什么列表滚动时不卡、不频繁创建 View,这个“复用”的思路到了 RecyclerView 里被进一步强化成 ViewHolder 模式。如果直接学 RecyclerView,你会知道怎么用,但未必知道它在解决什么问题。
再从实际场景看,有些老项目、系统源码、第三方开源库还在大量使用 ListView,你不会用的话,接手老项目会很痛苦。而 RecyclerView 的优势在于更灵活,它把“列表怎么排列”这件事拆分成了 LayoutManager,横向、竖向、网格、瀑布流都能通过切换管理器实现;它还内置了 ItemDecoration、ItemAnimator、多类型 ViewHolder 机制,做今天的今日头条首页非常顺手。
我的建议是:项目里两个都要用,但用得不一样。比如用 ListView 做一个简单的“频道列表”页,用 RecyclerView 做首页信息流,这样同一个项目里你就能直观感受到两者的差异。ListView 适合结构固定的简单列表,RecyclerView 适合结构复杂、需要高度定制的列表,这个结论你会自己在代码里得出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 项目环境与基础骨架搭建
2.1 工程目录与依赖准备
我用的是 Android Studio Hedgehog 2023.1.1,Gradle 8.2 左右,targetSdk 34。你手里的版本如果比我新,大概率也没问题,只要 Android Gradle Plugin 版本和 Gradle 版本能匹配就行。如果还没装好 Android Studio,直接去官网下载稳定版,安装时间比较长,建议边下载边把项目结构想清楚。
新建工程时选 Empty Views Activity,语言我这边用 Java,主要是 ListView 相关的经典资料基本都是 Java 写的,方便对照。用 Kotlin 的朋友也不用担心,核心逻辑和 API 完全一样,只是语法表达有差异。包名我用的是 com.example.news,后面所有类都放在这个包下。
依赖方面,项目里需要引入三个库:RecyclerView、SwipeRefreshLayout 和图片加载库。在 build.gradle 的 dependencies 里加上:
java复制implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'com.github.bumptech.glide:glide:4.16.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
如果你的项目用到网络图片,记得在 AndroidManifest.xml 里声明网络权限:
xml复制<uses-permission android:name="android.permission.INTERNET" />
工程结构上,我建议分成这样几块:
bean包:放 NewsBean 数据模型adapter包:放 ListView 和 RecyclerView 各自的适配器data包:放模拟数据源ui包:放 Activity 和 Fragment
这么分包不是为了花哨,而是让后面加功能的时候不会被找文件这件事折磨。我见过很多新手把所有类堆在一个包里,项目到了几百个文件的时候,连自己写的代码都翻不到,那体验真的很糟。
2.2 数据模型与模拟数据
新闻列表的每一项,至少要包含这些字段:标题、摘要、来源、发布时间、评论数、封面图地址,还有一个决定布局类型的 newsType。
java复制public class NewsBean {
private String title;
private String summary;
private String source;
private String publishTime;
private int commentCount;
private String imageUrl;
private int newsType; // 1 单图,2 三图,3 纯文字
public NewsBean() {
}
// 这里省略 getter / setter,实际代码要补齐
}
关于图片地址,我强烈建议先用本地占位图,比如在 res/drawable 里放几张不同颜色的图片,或者干脆不加载网络图,用 Glide 加载一个固定颜色的 placeholder。这样做的目的是先把列表逻辑跑通,排除网络和图片变量,等列表稳定了再去接真实图片。
模拟数据可以直接写在 LocalDataSource 类里,返回一个 List<NewsBean>。你不需要写太多条,20 条就够了,但要注意覆盖三种类型:
java复制public class LocalDataSource {
public static List<NewsBean> getNewsList() {
List<NewsBean> list = new ArrayList<>();
for (int i = 0; i < 20; i++) {
NewsBean bean = new NewsBean();
bean.setTitle("今日新闻标题" + i);
bean.setSummary("这里是摘要内容,用来测试列表项的换行和展示效果...");
bean.setSource("来源" + (i % 5));
bean.setPublishTime("2024-01-01 10:0" + i);
bean.setCommentCount(i * 13);
bean.setImageUrl("https://picsum.photos/200/200?random=" + i);
bean.setNewsType(i % 4 == 0 ? 3 : (i % 3 == 0 ? 2 : 1));
list.add(bean);
}
return list;
}
}
注意 picsum.photos 这个图片服务不一定稳定,国内网络环境下可能加载不出来。如果加载失败,Glide 的占位图会兜底,不影响列表功能演示。等后面接真实接口的时候,再换成你们服务端提供的 CDN 地址就行。
3. ListView 实现新闻列表:经典方案拆解
3.1 适配器的基础写法
ListView 的用法核心在一个自定义 Adapter,继承 BaseAdapter 之后需要实现四个方法:getCount、getItem、getItemId 和 getView。前三个很简单,getCount 返回数据条数,getItem 返回当前位置的数据对象,getItemId 返回行 id,直接返回 position 就行。
真正的重点是 getView。ListView 在滚动时会对每个可见条目调用这个方法,所以它的执行频率非常高,如果你在里面写了耗时操作,列表一定会卡顿。最基本的写法是这样:
java复制public class NewsListViewAdapter extends BaseAdapter {
private Context context;
private List<NewsBean> data;
private LayoutInflater inflater;
public NewsListViewAdapter(Context context, List<NewsBean> data) {
this.context = context;
this.data = data;
this.inflater = LayoutInflater.from(context);
}
@Override
public int getCount() {
return data == null ? 0 : data.size();
}
@Override
public Object getItem(int position) {
return data.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
NewsBean bean = data.get(position);
ViewHolder holder;
if (convertView == null) {
convertView = inflater.inflate(R.layout.item_news_list, parent, false);
holder = new ViewHolder();
holder.titleTv = convertView.findViewById(R.id.tv_title);
holder.summaryTv = convertView.findViewById(R.id.tv_summary);
holder.imageIv = convertView.findViewById(R.id.iv_image);
convertView.setTag(holder);
} else {
holder = (ViewHolder) convertView.getTag();
}
holder.titleTv.setText(bean.getTitle());
holder.summaryTv.setText(bean.getSummary());
// 图片加载,这里先用 Glide
Glide.with(context)
.load(bean.getImageUrl())
.placeholder(R.drawable.ic_placeholder)
.into(holder.imageIv);
return convertView;
}
static class ViewHolder {
TextView titleTv;
TextView summaryTv;
ImageView imageIv;
}
}
这里有一个很容易被忽略的细节:inflater.inflate(R.layout.item_news_list, parent, false) 里的第三个参数必须是 false。它的意思是先只创建布局,不添加到父容器,因为 ListView 自己会负责把返回的 View 添加到列表里。如果你传了 true,会出现一个 View 同时有两个父布局的问题,轻则显示异常,重则直接崩溃。
3.2 ViewHolder 缓存与滑动复用
上面代码里的 ViewHolder,是理解 ListView 性能优化的核心。我发现很多新手一开始不明白为什么不能直接在 getView 里每次 findViewById,原因是这个方法的调用频率实在太高了,而 findViewById 又是一个从 View 树里递归查找的过程,在列表快速滑动时积累下来的开销非常可观。
用 ViewHolder 之后,每个 item 的子 View 引用被存进了 convertView 的 Tag 里。第一次创建时执行 findViewById,后面滑动复用的时候直接从 Tag 里取,省掉了重复查找的消耗。从本质上说,这就像你在图书馆借书,第一次要找书的位置,之后记住位置直接去取,效率当然不一样。
还有一个大家容易忽视的点:convertView 参数本身就是复用的载体。ListView 内部有一块回收池,滚出屏幕的 item 不会马上销毁,而是被放回池子里,滚到屏幕内时再取出来,通过 convertView 传给你。所以判空只是第一步,你还得保证 holder 里每个控件都被重新赋值,否则会出现复用后残留旧数据的问题。
我在初次写这个项目时,犯过一个典型错误:只在 if (convertView == null) 里给控件赋值,而没有在 else 分支里重新赋值。结果列表滚动几次后,有的 item 显示的是其他 item 的内容,排查了好久才发现是赋值覆盖不完整。所以不管 convertView 是不是复用来的,最终都要完整设置一遍所有需要变化的字段。
4. RecyclerView 实现资讯流:进阶方案
4.1 与 ListView 的核心差异
把 ListView 跑通以后,再切到 RecyclerView 你会发现 API 风格完全换了。RecyclerView 使用的是 RecyclerView.Adapter 和 RecyclerView.ViewHolder,它强制你必须写 ViewHolder,不像 ListView 那样可以用基础写法敷衍过去。强制不是坏事,它能保证你从一开始就走在正确的性能路线上。
另一个差异是布局管理。ListView 的布局方式写死在系统里,只有垂直列表一种。RecyclerView 把这件事抽象成了 LayoutManager,你想让列表横着排,就传一个 LinearLayoutManager 并设置 setOrientation(RecyclerView.HORIZONTAL);你想做成网格,就换 GridLayoutManager;想做瀑布流,就换 StaggeredGridLayoutManager。在今日头条这个项目里,首页信息流仍然使用垂直的 LinearLayoutManager,但这套机制是你后面做图片瀑布流、频道导航必须掌握的。
先看最基础的适配器骨架:
java复制public class NewsRecyclerAdapter extends RecyclerView.Adapter<NewsRecyclerAdapter.NewsViewHolder> {
private List<NewsBean> data;
public NewsRecyclerAdapter(List<NewsBean> data) {
this.data = data;
}
@NonNull
@Override
public NewsViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_news_recycler, parent, false);
return new NewsViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull NewsViewHolder holder, int position) {
NewsBean bean = data.get(position);
holder.titleTv.setText(bean.getTitle());
holder.summaryTv.setText(bean.getSummary());
}
@Override
public int getItemCount() {
return data == null ? 0 : data.size();
}
static class NewsViewHolder extends RecyclerView.ViewHolder {
TextView titleTv;
TextView summaryTv;
public NewsViewHolder(@NonNull View itemView) {
super(itemView);
titleTv = itemView.findViewById(R.id.tv_title);
summaryTv = itemView.findViewById(R.id.tv_summary);
}
}
}
对比 ListView 的 BaseAdapter,你会发现 onCreateViewHolder 和 onBindViewHolder 把“创建”和“绑定”彻底拆开了。创建只在没有可用 ViewHolder 时发生,绑定则负责填充数据,这种分工让逻辑更清晰,也方便编译器做优化。
如果你是从 ListView 切换过来的,有一个习惯要改:ListView 里有 addHeaderView、addFooterView 这样的 API,RecyclerView 没有。你需要用不同的 ItemViewType,把头部、底部加载状态、加载失败状态都当成一种特殊的数据项来处理。下面要讲的多类型 Item,其实也就是这个思路的延伸。
4.2 多类型 Item 实现图文混排
今日头条的信息流,视觉上最明显的特点就是卡片样式不统一。有的新闻是一张大图加标题,有的是三张小图铺在下面,有的纯文字没有图。在 RecyclerView 里做这种混排,靠的就是 getItemViewType 返回不同值,布局管理器根据这个值来决定该创建哪种 ViewHolder。
我习惯先定义几个常量:
java复制public static final int TYPE_SINGLE_IMAGE = 1;
public static final int TYPE_THREE_IMAGE = 2;
public static final int TYPE_TEXT = 3;
public static final int TYPE_FOOTER = 4;
然后在适配器里重写 getItemViewType:
java复制@Override
public int getItemViewType(int position) {
if (position == data.size()) {
return TYPE_FOOTER;
}
return data.get(position).getNewsType();
}
注意这里我把底部加载状态也当成一个 viewType,这样一来适配器的 getItemCount 要返回 data.size() + 1,不然底部状态那条数据不会被显示。onCreateViewHolder 里就要根据 viewType 分支创建不同的布局和 ViewHolder:
java复制@NonNull
@Override
public NewsViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
if (viewType == TYPE_SINGLE_IMAGE || viewType == TYPE_THREE_IMAGE) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_news_image, parent, false);
return new ImageViewHolder(view);
} else if (viewType == TYPE_TEXT) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_news_text, parent, false);
return new TextViewHolder(view);
} else {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_loading, parent, false);
return new LoadingViewHolder(view);
}
}
为了简单,我这里的 ImageViewHolder 和 TextViewHolder 都直接继承 NewsViewHolder,实际项目中你可以让它们各自独立继承 RecyclerView.ViewHolder。这里只是给大家一个快速入门的写法参考。
onBindViewHolder 里则根据 holder 的类型做对应的数据填充。比如 SingleImage 布局展示标题、来源、时间、一张大图,ThreeImage 布局展示标题和三张小图,Text 布局不展示图片。这样写出来的适配器,虽然代码量比单一类型多一些,但结构很直观。头条页面上那些看似复杂的混合流,拆开以后其实就是这几种卡片的排列组合。
4.3 下拉刷新与加载更多
下拉刷新我用的是 SwipeRefreshLayout,它和 RecyclerView 的组合非常经典。在布局文件里,SwipeRefreshLayout 要作为 RecyclerView 的父容器:
xml复制<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
代码层面,你需要给 SwipeRefreshLayout 设置监听,然后在网络请求完成之后调用 setRefreshing(false) 关闭刷新转圈。如果不关,用户会发现刷新完了转圈还在转,这是新手最常见的 Bug 之一。
加载更多则依赖 RecyclerView 的滚动监听。核心思路是:当最后一个可见位置距离数据总数接近时,就触发加载下一页。代码大致是这样:
java复制recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
LinearLayoutManager layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager();
int lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition();
int totalItemCount = layoutManager.getItemCount();
if (lastVisibleItemPosition >= totalItemCount - 3 && !isLoading) {
loadMore();
}
}
});
这里的 -3 是一个缓冲值,意思是看到倒数第三个 item 的时候就开始加载下一页,避免用户滑到底部以后还要干等。isLoading 是一个布尔变量,用来防止一次滑动触发多次请求。我见过不少人在这里不加状态判断,结果往下滑一下,网络请求发了三次,接口被刷爆,列表也乱了。
加载更多的数据拼到原来的列表后面,然后调用 adapter.notifyDataSetChanged() 刷新。这个方法简单粗暴,但它会重建整个列表的所有可见 item,如果数据量不大,可以接受;如果追求流畅体验,配合 DiffUtil 只更新变化的部分会更好,不过那是后话了。
5. 实战中的常见问题与排查
5.1 列表滑动卡顿
列表卡顿是实际项目里反馈最多的问题,其中一半以上是布局嵌套过深。我在适配器布局里见过一层套一层的情况,一个简单的新闻卡片,根布局用 LinearLayout,里面再套 LinearLayout,图片再包一层 FrameLayout,结果整个 item 的 measure、layout 过程变得非常慢。
解决方法很粗暴:能用 ConstraintLayout 就用 ConstraintLayout,尽量把层级控制在两层以内。头条这种信息流,item 数量动辄几十上百,每个 item 多一层布局,滑动的计算量就是成倍上涨。
第二个常见原因是适配器里做了耗时操作。有些同学会在 getView 或 onBindViewHolder 里直接执行图片下载、JSON 解析、数据库查询,这些都是极其错误的使用方式。列表滚动时主线程要做的事情已经很多,你再把网络请求放进去,必然卡顿。正确的做法是图片加载交给 Glide,数据提前准备好,onBindViewHolder 里面只做赋值和 UI 更新。
第三个原因是图片加载没有做尺寸压缩。如果你从接口拿到原图,直接丢给 ImageView,那么操作系统要为一张几 MB 的图片分配大量内存,列表一多内存很容易爆。Glide 默认就会根据 ImageView 的尺寸做压缩,但前提是你别关闭这个能力,也尽量别把图片塞进一个没有固定宽高的 View 里,那样 Glide 不知道目标尺寸,只能按原图加载,性能影响很大。
最后还有一个容易忽略的点:如果列表的长度确定、数据不会再变,可以在设置 LayoutManager 之后调用 recyclerView.setHasFixedSize(true),这会让 RecyclerView 避免在没有 item 变化时反复计算尺寸,对性能有一定提升。
5.2 图片加载与复用错乱
图片错乱这个问题,几乎每个接触列表的人都会遇到。现象是:你快速滑动列表,然后停下来,发现有些新闻卡片显示的图片是别的新闻的。原因很简单,item 被复用了,ImageView 里还残留着上一个数据加载出来的旧图,在异步加载完成之前,旧图就还在那里。
解决方式有两种。第一种比较“硬核”,在给 ImageView 加载新图之前先 setTag 一个唯一标识,加载完成后判断当前 tag 是否还是自己,不是就忽略。第二种更推荐,直接用 Glide,因为 Glide 会在同一个 ImageView 上启动新加载时,自动取消之前的加载,并且会设置占位图覆盖旧图。
用 Glide 的正确姿势是:
java复制Glide.with(holder.imageView)
.load(bean.getImageUrl())
.placeholder(R.drawable.ic_placeholder)
.error(R.drawable.ic_error)
.centerCrop()
.into(holder.imageView);
placeholder 是加载中的占位图,error 是加载失败的占位图,centerCrop 是让图片按比例裁剪填满 ImageView,新闻列表的封面图基本都是这个需求。如果你不用这些,裸加载图片,遇到反序列化失败或者网络异常,列表里就会出现破图,非常难看。
关于 Glide 的另一个经验是:不要在 Adapter 外把 Bitmap 手动解析成图片再传给 ImageView,除非你明确知道自己要做什么。Glide 已经帮我们处理好了缓存、磁盘、解码、缩放,你再手动处理一遍,等于重复造轮子,还可能把内存占用搞大。
5.3 点击事件与数据联动
列表里的点击事件,通常分为两种:整个 item 点击进入详情,item 内部的子按钮点击做收藏或分享。ListView 时代,很多人习惯直接用 setOnItemClickListener,但到了 RecyclerView,Item 点击监听需要自己写在 Adapter 里,或者通过回调接口暴露给外部。
我比较推荐用接口回调的方式,原因很简单:Adapter 不应该持有 Activity 的具体逻辑,它只负责把“哪个 item 被点了”这件事告诉外部。可以先定义一个接口:
java复制public interface OnNewsItemClickListener {
void onItemClick(int position, NewsBean bean);
void onFavoriteClick(int position, NewsBean bean);
}
在 Adapter 的 ViewHolder 里把点击事件绑定到 itemView 或者子 View,然后在 Activity 里实现这个接口,拿到数据做跳转或收藏操作。这样做的好处是适配器保持独立,便于单元测试和复用。
这里有一个容易踩的坑:在 RecyclerView 里,holder.getAdapterPosition() 在点击回调时可能返回 RecyclerView.NO_POSITION 或者已过期的 position,尤其是在列表数据刷新、删除、插入了以后。官方建议使用 getBindingAdapterPosition(),如果它返回的是负数,这次点击就不该执行。我建议在回调里面把 position 合法性判断加进去,避免越界崩溃。
还要注意的是,如果你在 item 子控件上设置了点击事件,并且这个子控件覆盖了 item 的大部分区域,那么父布局的点击事件可能收不到。解决方案是给对应子控件设置 android:clickable="false",或者在父布局点击回调里手动判断点击区域。头条页面里的“分享”按钮就是一个典型例子,它单独响应点击,但不希望点击到图片区域时触发两次逻辑,这里要把事情分清楚。
6. 两个容易忽略的细节
6.1 空布局和加载状态
列表做出来以后,如果接口返回的数据是空的,页面上会直接显示一个白屏,用户会以为是 Bug。项目上线前一定要处理空状态。最简单的做法是在布局里提前放一个 TextView,文案写“暂无新闻,下拉刷新试试”,然后根据数据量切换显示 RecyclerView 和这个 TextView。
我这里说的“空状态”不只是数据为空,还包括网络加载失败。有些项目把加载失败显示成一条提示条,有些做成整页重试,但要保证用户在失败之后有明确的下一步动作。你可以做一张通用的失败布局,按钮写“点击重试”,点击后重新请求数据。这种小细节,面试官很爱问,因为能从侧面看出你有没有产品思维。
我自己习惯把加载、空、错误、成功这几种状态当成一个状态机来管理,一般用一个 ViewFlipper 或者自定义 LoadingView 来切换。这个项目里不需要做到那么重,但至少要保证:加载中有 loading 转圈,数据为空有提示,加载失败有重试按钮。
6.2 列表项动画与后续扩展
RecyclerView 最大的一个优点,就是内置了一套 Item 增删动画。当你调用 notifyItemInserted、notifyItemRemoved 而不是 notifyDataSetChanged 的时候,列表项会自动播放移动、插入、删除动画。这个效果在今日头条这种动态更新的场景里非常加分。
除了增删动画,还可以给列表设置入场动画。在 res/anim 里定义一点简单的位移和透明过渡,然后用 LayoutAnimationController 控制 RecyclerView 首次加载时的逐条展示。头条 App 刚进入频道时,列表不是瞬间全部出现,而是从上往下慢慢滑入,这个效果就是用入场动画做的。这个细节看起来不明显,但对整体体验的提升非常直观。
项目的后续扩展,我建议往这几个方向走:一是用 TabLayout 加 ViewPager2 做多频道切换,让每个频道一个 Fragment,各自持有 RecyclerView;二是把模拟数据替换成真实网络请求,用 Retrofit 加 Gson 解析;三是接入 DiffUtil,让列表刷新变成局部更新而不是全量刷新。这三步走完,一个能拿来求职或者上架的作品集项目基本就成型了。
7. 项目做完后的一些体会
我一开始以为列表只是展示数据的一个容器,做完这个项目才发现,ListView 到 RecyclerView 的演进,其实代表了 Android UI 开发从“能用”到“高效复用”的思路变化。ListView 的原理简单,正是理解 View 复用最生动的教材;RecyclerView 的机制完善,是日常开发真正的主力军。两个都亲手写过一遍以后,你对“为什么官方推荐 RecyclerView”这个问题的理解,会比背十篇博客都深刻。
最后分享一个做列表时的小技巧:无论你用哪个控件,都要时刻提醒自己“onBindViewHolder 和 getView 里的代码,是会被高频调用的”。凡是能提前算好的数据,在 setData 的时候就准备好;凡是能用缓存解决的,绝不在绑定方法里现算。这样写出来的列表,流畅度不会差。这个项目做完以后,我个人的收获比想象中要大,也希望你亲自动手把它完整写一遍。
