1. 项目概述与需求拆解
1.1 为什么用 RecyclerView 做新闻列表
先说明白,这个项目解决的是什么问题。现在主流资讯类应用的信息流列表,本质上就是一个高频刷新、多类型Item、图片密集加载的长列表场景。今日头条那种首页信息流,一屏里同时混着纯文字、单图、三图、视频封面、广告卡片,还要支持下拉刷新、上拉加载、点击展开收起,如果只用传统的 ListView 或者 ScrollView 去硬怼,性能很快会崩——滑动掉帧、内存暴涨、ViewHolder写到手抽筋。
RecyclerView 是官方推荐的列表控件,它把“列表项回收复用”“布局管理”“条目动画”“数据刷新”这几件事彻底解耦了。用 RecyclerView 做新闻列表,最大的收益有几点:
- ViewHolder 强制复用机制:不用像 ListView 那样手动判断 convertView 是否为空,模板方法帮你搞定,滑动性能天然更稳。
- LayoutManager 可插拔:想切换线性列表、瀑布流、网格布局,只改一行配置即可,适配不同信息流形态非常方便。
- ItemAnimator 和 DiffUtil 组合:新闻数据刷新时,可以精确到只更新变化的条目,而不是整表 notifyDataSetChanged,这在做“下拉刷新”“点赞红心”“展开全文”时体验差异非常明显。
- 嵌套滚动与协调机制:配合 CoordinatorLayout 可以处理头条那种标题栏折叠、Tab 切换的复杂联动效果。
本次项目代码实践从零搭建一个仿今日头条的新闻列表,核心功能包括多类型 Item、图片加载、下拉刷新、上拉加载、Item 点击展开全文,以及列表性能优化的落地方案。
1.2 项目适用的人群和基础要求
如果你想完整跟着实操,建议你具备以下基础,否则会有一定门槛:
- 会 Android Studio 的基本操作,能新建工程、跑模拟器或真机。
- 了解 Kotlin 基本语法,能看懂数据类、lambda、协程的基本写法。
- 用过 RecyclerView 的简单场景,能理解 Adapter 里 onCreateViewHolder 和 onBindViewHolder 是干什么的。
- 知道 Gradle 依赖的基本导入方式,能自己加第三方库。
如果你完全没接触过 RecyclerView,建议先用官方 Demo 跑一遍最简单的列表,再回来看这篇,不然直接上手多类型列表会有点吃力。当然,我会把每一步都拆得很细,关键代码全部贴出来,注释也写得比较满,完整跟下来应该能跑通一个可用版本。
1.3 最终能达到的效果
做完这个项目,你会得到一个仿今日头条首页的信息流列表,支持以下能力:
- 多类型新闻卡片的加载与展示,不同类型使用不同布局。
- 下拉刷新和上拉加载,与真实业务场景对齐。
- 点击“展开全文”按钮,局部刷新条目,不带动整个列表重绘。
- 图片异步加载与内存缓存,避免 OOM 和滑动卡顿。
- 基于 DiffUtil 的数据增量更新,刷新过程秒级完成。
- 针对长列表的稳定性优化,滑动时无闪烁、不抖动。
下面从基础环境开始逐步实现。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开发环境与项目搭建
2.1 使用的技术栈与版本说明
我在本地实操时使用的环境是:
- Android Studio Hedgehog(2023.1.1)稳定版。
- Gradle 版本 8.2,AGP 版本 8.2.2。
- Kotlin 版本 1.9.20。
- compileSdk 34,minSdk 24,targetSdk 34。
- 图片加载库:Coil 2.4.0(Kotlin 协程友好,配置简单)。
- 刷新库:SmartRefreshLayout 2.0.5(用起来最顺手,功能齐全)。
这里不选用 Glide,不是它不好,而是 Coil 本身就是 Kotlin 协程写的,和现代 Android 工程配合更自然,图片请求可以直接复用协程作用域,配置代码量少很多。而且 Coil 默认支持内存缓存和磁盘缓存,对于新闻列表这种高频图片加载场景完全够用。
2.2 Gradle 依赖配置
打开 app 模块的 build.gradle.kts,添加以下依赖:
kotlin复制dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.10.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
// RecyclerView 官方依赖
implementation("androidx.recyclerview:recyclerview:1.3.1")
// 生命周期与协程
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.2")
// 图片加载
implementation("io.coil-kt:coil:2.4.0")
// 下拉刷新
implementation("io.github.scwang90:refresh-layout-kotlin:2.0.5")
implementation("io.github.scwang90:refresh-header-classics:2.0.5")
implementation("io.github.scwang90:refresh-footer-classics:2.0.5")
}
提示:SmartRefreshLayout 目前的最新稳定版是 2.0.5,别加 2.1.x 的开发版本,实测开发版会有一些兼容性问题。另外,coil 依赖记得用 2.x,3.x 改了不少 API,容易踩坑。
2.3 项目目录结构规划
一个清晰的结构能让后续扩展省很多事。我按功能分包来组织:
text复制com.example.newslist
├── data
│ ├── NewsItem.kt // 列表数据的统一模型
│ ├── NewsRepository.kt // 模拟数据源
│ └── NewsType.kt // Item 类型常量定义
├── ui
│ ├── MainActivity.kt // 宿主页面
│ ├── NewsListAdapter.kt // RecyclerView 多类型适配器
│ ├── NewsListFragment.kt // 可选,建议用 Fragment 承载列表
│ └── viewholder
│ ├── NormalNewsViewHolder.kt
│ ├── ImageNewsViewHolder.kt
│ └── VideoNewsViewHolder.kt
└── widget
└── ItemDecoration.kt // 分割线自定义
这里把 ViewHolder 拆成独立类,而不是全塞在 Adapter 里。虽然代码文件多一点,但每个文件职责清楚,后面加新类型、加新交互时会特别省力。如果你把什么逻辑都放在 Adapter 内部,一旦超过四种 Item 类型,那个文件就能上千行,维护起来非常崩溃。
3. 核心数据模型与多类型 Item 设计
3.1 新闻数据模型定义
新闻列表的数据不能只用一个类表示,因为不同类型的新闻字段不一样。比如视频新闻有封面图和播放时长,三图新闻有三张图片地址,纯文字新闻可能还有“全文”内容。如果全塞进一个大类,会产生大量空字段,内存和代码可读性都不好。
我的做法是用一个父级数据类加子类的方式,或者用 Kotlin 的密封类体系。这里我推荐 sealed class 的写法,类型安全和扩展性都更好:
kotlin复制sealed class NewsItem {
abstract val id: Long
abstract val title: String
abstract val author: String
abstract val commentCount: Int
abstract val publishTime: String
}
data class TextNews(
override val id: Long,
override val title: String,
override val author: String,
override val commentCount: Int,
override val publishTime: String,
val summary: String,
val content: String,
var isExpanded: Boolean = false
) : NewsItem()
data class SingleImageNews(
override val id: Long,
override val title: String,
override val author: String,
override val commentCount: Int,
override val publishTime: String,
val imageUrl: String
) : NewsItem()
data class ThreeImageNews(
override val id: Long,
override val title: String,
override val author: String,
override val commentCount: Int,
override val publishTime: String,
val imageUrls: List<String>
) : NewsItem()
data class VideoNews(
override val id: Long,
override val title: String,
override val author: String,
override val commentCount: Int,
override val publishTime: String,
val coverUrl: String,
val duration: String
) : NewsItem()
这里有几个细节值得说明:
第一,id 的类型用 Long,是为了将来接后端接口时能对上雪花 ID,避免精度丢失。很多新手用 Int,数据量一大就爆掉,上线后就会出现“刷新后条目重复”这种诡异 bug。
第二,TextNews 里的 isExpanded 用 var 而不是 val,是因为它代表条目内部的临时状态,不是从服务端下发的数据。这个字段不参与 DiffUtil 的主键比较,但可以作为“局部刷新”的标记。
3.2 Adapter 里的类型分发机制
RecyclerView 多类型列表的难点在于 Adapter 里需要根据数据来创建不同的 ViewHolder,并绑定不同的数据。常规写法是 getItemViewType 里返回 position,然后新建判断分支。但这套方案实现多了容易变成一坨 if-else。
这里我用 sealed class + when 表达式的方案来实现类型分发,逻辑非常清晰:
kotlin复制class NewsListAdapter(
private val onItemClick: (NewsItem) -> Unit,
private val onExpandClick: (TextNews) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val items = mutableListOf<NewsItem>()
// 注册不同的 ViewHolder,建议在 init 块中完成
init {
setHasStableIds(true)
}
override fun getItemViewType(position: Int): Int {
return when (items[position]) {
is TextNews -> 0
is SingleImageNews -> 1
is ThreeImageNews -> 2
is VideoNews -> 3
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
0 -> NormalNewsViewHolder(
inflater.inflate(R.layout.item_text_news, parent, false),
onExpandClick
)
1 -> ImageNewsViewHolder(
inflater.inflate(R.layout.item_single_image_news, parent, false),
onItemClick
)
2 -> ThreeImageNewsViewHolder(
inflater.inflate(R.layout.item_three_image_news, parent, false),
onItemClick
)
3 -> VideoNewsViewHolder(
inflater.inflate(R.layout.item_video_news, parent, false),
onItemClick
)
else -> throw IllegalArgumentException("未知类型: $viewType")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = items[position]
when (holder) {
is NormalNewsViewHolder -> holder.bind(item as TextNews)
is ImageNewsViewHolder -> holder.bind(item as SingleImageNews)
is ThreeImageNewsViewHolder -> holder.bind(item as ThreeImageNews)
is VideoNewsViewHolder -> holder.bind(item as VideoNews)
}
}
override fun getItemCount(): Int = items.size
override fun getItemId(position: Int): Long = items[position].id
}
注意 onCreateViewHolder 和 onBindViewHolder 都用 when 表达式来分派,这意味着以后要加一种新类型,只需要加一个 when 分支和对应的 ViewHolder 类。类型安全、编译器校验、扩展方便,这三样比传统 int 常量判断要好得多。
3.3 为什么要在 ViewHolder 中封装 bind 方法
我见过不少项目直接在 Adapter 的 onBindViewHolder 里写绑定逻辑:
kotlin复制holder.tvTitle.text = items[position].title
holder.ivCover.load(items[position].coverUrl)
holder.tvDuration.text = items[position].duration
看起来没什么问题,但一旦绑定逻辑变多,比如要处理点击态、展开态、曝光埋点、图片裁剪,Adapter 就会越来越臃肿。
我更推荐的是在 ViewHolder 内部写 bind(item: NewsItem) 方法,把“如何把一条数据变成界面显示”这件事完全收敛到 ViewHolder 内部。Adapter 只管“哪条数据交给哪个 ViewHolder”,不管“具体怎么显示”。这样每个 ViewHolder 只关心自己那一种 Item 的布局和交互,高内聚、低耦合。
下面以单图新闻 ViewHolder 为例,展示 bind 的写法。
4. 各类 ViewHolder 的布局设计与实现
4.1 文本新闻卡片布局
文本新闻卡片是最普通的条目,模拟头条的文字流卡片。布局文件 item_text_news.xml 核心结构如下:
xml复制<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="14dp">
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="17sp"
android:textStyle="bold"
android:textColor="@color/text_primary"
android:lineSpacingExtra="3dp" />
<TextView
android:id="@+id/tv_summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textSize="14sp"
android:textColor="@color/text_secondary"
android:maxLines="3"
android:ellipsize="end" />
<TextView
android:id="@+id/tv_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textSize="15sp"
android:textColor="@color/text_primary"
android:lineSpacingExtra="4dp"
android:visibility="gone" />
<TextView
android:id="@+id/btn_expand"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:padding="4dp"
android:text="展开全文"
android:textSize="14sp"
android:textColor="@color/color_accent" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/tv_author"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="12sp"
android:textColor="@color/text_tertiary" />
<TextView
android:id="@+id/tv_comment"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="@color/text_tertiary" />
</LinearLayout>
</LinearLayout>
这里我特意把“摘要”和“正文”分成两个 TextView。默认状态显示摘要,最多 3 行,点“展开全文”后显示正文并隐藏摘要。为什么不直接在原 TextView 上 expand 到最大行数?因为头条的实际交互是“摘要和正文是两段不同的内容”,摘要相当于导语,正文才是完整文章,分开处理逻辑更自然,而且不用动态修改 maxLines,可以减少布局重新测量的开销。
ViewHolder 的实现:
kotlin复制class NormalNewsViewHolder(
itemView: View,
private val onExpandClick: (TextNews) -> Unit
) : RecyclerView.ViewHolder(itemView) {
private val tvTitle: TextView = itemView.findViewById(R.id.tv_title)
private val tvSummary: TextView = itemView.findViewById(R.id.tv_summary)
private val tvContent: TextView = itemView.findViewById(R.id.tv_content)
private val btnExpand: TextView = itemView.findViewById(R.id.btn_expand)
private val tvAuthor: TextView = itemView.findViewById(R.id.tv_author)
private val tvComment: TextView = itemView.findViewById(R.id.tv_comment)
private var currentItem: TextNews? = null
fun bind(item: TextNews) {
currentItem = item
tvTitle.text = item.title
tvSummary.text = item.summary
tvAuthor.text = item.author
tvComment.text = "${item.commentCount} 评论"
if (item.isExpanded) {
tvContent.visibility = View.VISIBLE
tvContent.text = item.content
tvSummary.visibility = View.GONE
btnExpand.text = "收起全文"
} else {
tvContent.visibility = View.GONE
tvSummary.visibility = View.VISIBLE
btnExpand.text = "展开全文"
}
btnExpand.setOnClickListener {
onExpandClick(item)
}
}
}
这里有一个关键点需要提醒:因为 ViewHolder 会复用,你必须在 bind 里把所有可能变化的状态都重新设置一遍。比如 isExpanded 为 true 时把摘要隐藏、正文显示,为 false 时要把正文隐藏、摘要显示。如果只在 isExpanded 为 true 时改,不处理 false,复用后就会出现“上一屏展开的卡片,滚回来时还是展开状态”的 bug。
4.2 单图新闻卡片布局
单图新闻在头条里非常常见,一般是左侧标题右侧图片,或者上面标题下面大图。我这里用头条主流样式之一:右侧 120dp 的方形缩略图,左侧是标题和作者信息。
布局文件 item_single_image_news.xml 我这里不完整贴了,核心约束是用 ConstraintLayout 把图片约束在右边、上下居中。图片的关键设置是 scaleType 用 centerCrop,保证图片填满控件且不变形。
ViewHolder 的图片加载使用 Coil:
kotlin复制class ImageNewsViewHolder(
itemView: View,
private val onItemClick: (NewsItem) -> Unit
) : RecyclerView.ViewHolder(itemView) {
private val tvTitle: TextView = itemView.findViewById(R.id.tv_title)
private val tvSource: TextView = itemView.findViewById(R.id.tv_source)
private val ivImage: ImageView = itemView.findViewById(R.id.iv_image)
private val tvComment: TextView = itemView.findViewById(R.id.tv_comment)
fun bind(item: SingleImageNews) {
tvTitle.text = item.title
tvSource.text = item.author
tvComment.text = "${item.commentCount} 评论"
ivImage.load(item.imageUrl) {
crossfade(300)
placeholder(R.drawable.ic_image_placeholder)
error(R.drawable.ic_image_error)
size(300, 200)
scale(Scale.FILL)
}
itemView.setOnClickListener {
onItemClick(item)
}
}
}
Coil 的 load 扩展是直接在 ImageView 上调用的,默认会使用协程加载,并且自动处理生命周期。这里设置 size 是为了缩小加载尺寸,避免加载原图浪费内存。新闻列表的缩略图一般像素不用很高,300x200 足够清晰,但内存占用会小很多。
4.3 三图新闻卡片布局
三图新闻是信息流里比较占空间的卡片,三张图排一行,下面跟标题。布局核心是一个横向 LinearLayout,权重各为 1,里面放三个 ImageView。
ViewHoder 绑定三张图时,还要注意处理图片数量不够 3 张的边界情况。比如后端返回的列表里只有 2 张图,第三个 ImageView 要隐藏或者显示占位图。我用 Coil 加载时判断:
kotlin复制fun bind(item: ThreeImageNews) {
tvTitle.text = item.title
tvSource.text = item.author
tvComment.text = "${item.commentCount} 评论"
val urls = item.imageUrls
ivImage1.load(urls.getOrNull(0)) { crossfade(200); placeholder(R.drawable.ic_image_placeholder) }
ivImage2.load(urls.getOrNull(1)) { crossfade(200); placeholder(R.drawable.ic_image_placeholder) }
ivImage3.load(urls.getOrNull(2)) { crossfade(200); placeholder(R.drawable.ic_image_placeholder) }
ivImage1.visibility = if (urls.size > 0) View.VISIBLE else View.GONE
ivImage2.visibility = if (urls.size > 1) View.VISIBLE else View.GONE
ivImage3.visibility = if (urls.size > 2) View.VISIBLE else View.GONE
}
getOrNull 是 Kotlin 标准库扩展,索引越界时返回 null,不会抛异常。这样即使服务端数据异常,列表也不至于闪退。
4.4 视频新闻卡片布局
视频卡片需要一个封面图,封面中间加一个播放按钮图标,左下角显示时长。布局除 ImageView 外,需要再叠一层 FrameLayout,把播放按钮和时长放在图片之上。
ViewHolder 里我直接给 itemView 设置点击事件,点击后可以跳转到播放页面。项目里我就不做真实播放了,只弹一个 Toast 表示事件传递正确:
kotlin复制class VideoNewsViewHolder(
itemView: View,
private val onItemClick: (NewsItem) -> Unit
) : RecyclerView.ViewHolder(itemView) {
private val ivCover: ImageView = itemView.findViewById(R.id.iv_cover)
private val tvDuration: TextView = itemView.findViewById(R.id.tv_duration)
fun bind(item: VideoNews) {
ivCover.load(item.coverUrl) {
crossfade(300)
placeholder(R.drawable.ic_video_placeholder)
}
tvDuration.text = item.duration
itemView.setOnClickListener {
onItemClick(item)
}
}
}
到这里,四种类型的卡片布局和 ViewHolder 已经全部完成。接下来讲列表数据和刷新加载的完整接入。
5. 模拟数据源与刷新加载实现
5.1 模拟网络请求的数据仓库
真实项目中,数据源应该来自 Retrofit 请求后端接口。这里为了完整演示,我用 Repository 模式封装一个模拟数据源,用协程的 delay 模拟网络延迟,然后在主线程回调结果。
kotlin复制class NewsRepository {
suspend fun fetchNews(page: Int, pageSize: Int): List<NewsItem> {
// 模拟网络请求耗时
delay(800)
val startIndex = page * pageSize
return generateMockNews(startIndex, pageSize)
}
private fun generateMockNews(startIndex: Int, count: Int): List<NewsItem> {
val list = mutableListOf<NewsItem>()
var typeIndex = 0
for (i in 0 until count) {
val id = startIndex + i + 1L
val title = "今日新闻标题 ${id}:这里是一段新闻标题"
val author = "作者${id % 10}"
val commentCount = Random.nextInt(0, 10000)
val publishTime = "${Random.nextInt(1, 24)}小时前"
when (typeIndex % 4) {
0 -> list.add(
TextNews(
id = id,
title = title,
author = author,
commentCount = commentCount,
publishTime = publishTime,
summary = "这是摘要内容,主要概括新闻的核心信息,控制在三行以内,不包含所有细节。",
content = "这是完整的新闻正文,用于展开全文时展示。${"这里是很长的正文内容,模拟真实新闻的段落。".repeat(8)}"
)
)
1 -> list.add(
SingleImageNews(
id = id,
title = title,
author = author,
commentCount = commentCount,
publishTime = publishTime,
imageUrl = "https://example.com/images/$id.jpg"
)
)
2 -> list.add(
ThreeImageNews(
id = id,
title = title,
author = author,
commentCount = commentCount,
publishTime = publishTime,
imageUrls = listOf(
"https://example.com/images/${id}_1.jpg",
"https://example.com/images/${id}_2.jpg",
"https://example.com/images/${id}_3.jpg"
)
)
)
3 -> list.add(
VideoNews(
id = id,
title = title,
author = author,
commentCount = commentCount,
publishTime = publishTime,
coverUrl = "https://example.com/images/${id}_cover.jpg",
duration = "${Random.nextInt(1, 30)}:${Random.nextInt(0, 60)}"
)
)
}
typeIndex++
}
return list
}
}
这里图片地址我写的 example.com 占位。实际操作时建议使用 https://picsum.photos 这类稳定的占位图服务,例如:
text复制https://picsum.photos/seed/1/300/200
https://picsum.photos/seed/2/300/200
这个服务会根据 seed 生成同一张固定图片,非常适合开发调试,不会因为外网图片挂掉导致界面空白。
5.2 下拉刷新与上拉加载的接入
SmartRefreshLayout 的用法非常直白。在布局里把 RecyclerView 包一层:
xml复制<com.scwang.smart.refresh.layout.SmartRefreshLayout
android:id="@+id/refreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:overScrollMode="never"
android:scrollbars="vertical" />
</com.scwang.smart.refresh.layout.SmartRefreshLayout>
然后在代码中配置:
kotlin复制refreshLayout.setOnRefreshListener {
viewModel.refresh()
}
refreshLayout.setOnLoadMoreListener {
viewModel.loadMore()
}
这里我用 ViewModel + Repository 管理列表数据,避免旋转屏幕时数据丢失。ViewModel 里维护一个列表状态,提供两个方法:
kotlin复制class NewsViewModel(
private val repository: NewsRepository = NewsRepository()
) : ViewModel() {
private val _newsItems = MutableLiveData<List<NewsItem>>()
val newsItems: LiveData<List<NewsItem>> = _newsItems
private val _refreshState = MutableLiveData<Boolean>()
val refreshState: LiveData<Boolean> = _refreshState
private val _loadMoreState = MutableLiveData<Boolean>()
val loadMoreState: LiveData<Boolean> = _loadMoreState
private var currentPage = 0
private val pageSize = 20
private val allItems = mutableListOf<NewsItem>()
fun refresh() {
viewModelScope.launch {
_refreshState.value = true
currentPage = 0
allItems.clear()
val newData = repository.fetchNews(currentPage, pageSize)
allItems.addAll(newData)
_newsItems.value = allItems.toList()
_refreshState.value = false
}
}
fun loadMore() {
viewModelScope.launch {
currentPage++
val newData = repository.fetchNews(currentPage, pageSize)
allItems.addAll(newData)
_newsItems.value = allItems.toList()
_loadMoreState.value = false
}
}
}
这里需要解释一下为什么 refresh 里先给 _newsItems 赋值为空列表,而不是等请求完成后一次性替换。因为新闻列表的下拉刷新体验要求“立即清空旧数据,显示加载状态”,如果等数据回来再刷,界面会停在旧数据上好几百毫秒,用户会以为没点到。当然更高级的玩法是用 DiffUtil 做增量合并,保留旧数据并提示“已更新 XX 条”,这放在后文优化章节细说。
5.3 底部加载状态的处理
SmartRefreshLayout 的默认 Footer 会显示加载动画,但有时候我们希望显示“加载中”“没有更多了”这样的文字状态。可以自定义 Footer,也可以直接在列表底部加一个 footer 类型的 Item。我更推荐后者,因为它可控性更强,方便控制加载状态和错误状态。
实现方式是在 Adapter 里额外加一种类型:
kotlin复制sealed class ListItem {
data class News(val item: NewsItem) : ListItem()
object LoadingFooter : ListItem()
object NoMoreFooter : ListItem()
}
然后 Adapter 的 getItemViewType 根据这个类型分发。当滚动到底部时,如果还有更多数据,显示 LoadingFooter,加载完成后如果数据耗尽,把 LoadingFooter 替换为 NoMoreFooter。
这个方案的缺点是 Adapter 的类型分发逻辑变得更复杂,但换来的是完全可控的加载状态。头条的信息流底部就有“加载中...”“没有更多了”这些状态,直接用这个方案模拟是非常贴近实战的。
6. DiffUtil 与列表局部刷新的核心实现
6.1 为什么不能无脑 notifyDataSetChanged
新闻列表最重要的交互之一就是“展开全文”。如果不做任何优化,最简单的方式是拿到点击位置后修改数据,然后调用 notifyItemChanged(position)。但 notifyItemChanged(position) 默认会触发整个条目重新绑定,如果在 onBindViewHolder 里有图片加载或复杂计算,会有肉眼可见的闪烁。
更麻烦的是,当列表很长时,如果任意数据变化都用 notifyDataSetChanged,整个列表的所有可见 Item 都会重新绑定和重绘,用户滑动时能明显感觉到卡顿,尤其是图片较多的场景,每次刷新都可能导致图片重新加载。
正确的做法是:
- 用 DiffUtil(或 ListAdapter)计算新旧数据集差异。
- 只更新变化的那一项。
- 用 payload 机制区分“局部刷新”和“全量刷新”,避免无意义的重新绑定。
6.2 DiffUtil 的完整实现
DiffUtil 是官方提供的差异计算工具,它通过比较新旧列表,产出最小更新操作集合。先定义一个 Callback:
kotlin复制class NewsDiffCallback(
private val oldList: List<NewsItem>,
private val newList: List<NewsItem>
) : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldList.size
override fun getNewListSize(): Int = newList.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldList[oldItemPosition].id == newList[newItemPosition].id
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return when {
oldItem is TextNews && newItem is TextNews ->
oldItem.id == newItem.id &&
oldItem.isExpanded == newItem.isExpanded &&
oldItem.title == newItem.title &&
oldItem.summary == newItem.summary &&
oldItem.content == newItem.content
oldItem is SingleImageNews && newItem is SingleImageNews ->
oldItem.id == newItem.id &&
oldItem.title == newItem.title &&
oldItem.imageUrl == newItem.imageUrl
oldItem is ThreeImageNews && newItem is ThreeImageNews ->
oldItem.id == newItem.id &&
oldItem.title == newItem.title &&
oldItem.imageUrls == newItem.imageUrls
oldItem is VideoNews && newItem is VideoNews ->
oldItem.id == newItem.id &&
oldItem.title == newItem.title &&
oldItem.coverUrl == newItem.coverUrl &&
oldItem.duration == newItem.duration
else -> false
}
}
}
areItemsTheSame 判断的是“是不是同一条数据”,用 id 判断。areContentsTheSame 判断的是“同一条数据的展示内容有没有变化”,需要逐字段比较。
这里最重要的就是 TextNews 把 isExpanded 一起比较了,这样点击展开后,DiffUtil 能识别出这一项内容发生了变化,从而只刷新这一条。
6.3 用 ListAdapter 简化 diff 流程
手写 DiffUtil 再用 AsyncListDiffer 有点繁琐,我直接使用 ListAdapter,它内部封装了 AsyncListDiffer、线程调度和 diff 回调。
改造 Adapter 如下:
kotlin复制class NewsListAdapter(
private val onItemClick: (NewsItem) -> Unit,
private val onExpandClick: (TextNews) -> Unit
) : ListAdapter<NewsItem, RecyclerView.ViewHolder>(NewsDiffCallback()) {
override fun submitList(list: List<NewsItem>?) {
super.submitList(list?.let { ArrayList(it) })
}
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is TextNews -> 0
is SingleImageNews -> 1
is ThreeImageNews -> 2
is VideoNews -> 3
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
// 和之前一致
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = getItem(position)
when (holder) {
is NormalNewsViewHolder -> holder.bind(item as TextNews)
is ImageNewsViewHolder -> holder.bind(item as SingleImageNews)
is ThreeImageNewsViewHolder -> holder.bind(item as ThreeImageNews)
is VideoNewsViewHolder -> holder.bind(item as VideoNews)
}
}
}
ListAdapter 在后台线程用 DiffUtil 计算差异,然后在主线程自动调度 dispatchUpdatesTo,整个过程对开发者透明。使用后你会发现一个明显变化:点击“展开全文”,只更新当前 Item,列表其他条目完全不动,没有闪烁,没有跳动。
6.4 局部刷新的高阶玩法:带 payload 的刷新
DiffUtil 能区分“内容不同”的条目,但有时候内容不同不代表所有字段都要重新绑定。比如展开全文时,我们其实只需要改变 tv_summary、tv_content、btn_expand 这三个控件的状态,标题、作者、评论数根本不变。
如果 bind 里又重新 setText 标题和作者,虽然是同一个值,但还是会触发 View 的 requestLayout 或 invalidate,极端情况下也是性能损耗。
用 payload 可以精确到“这一项里我改了哪些字段”。在 Adapter 中重写 onBindViewHolder 的带 payload 重载:
kotlin复制override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: MutableList<Any>
) {
if (payloads.isEmpty()) {
super.onBindViewHolder(holder, position, payloads)
return
}
// 处理局部更新
val item = getItem(position)
if (holder is NormalNewsViewHolder) {
val bundle = payloads[0] as Bundle
if (bundle.getBoolean("expandChanged")) {
holder.updateExpandState(item as TextNews)
}
}
}
配合 DiffUtil 中的 getChangePayload:
kotlin复制override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
if (oldItem is TextNews && newItem is TextNews) {
if (oldItem.isExpanded != newItem.isExpanded) {
val bundle = Bundle()
bundle.putBoolean("expandChanged", true)
return bundle
}
}
return null
}
然后 ViewHolder 里单独写一个 updateExpandState 方法,只更新展开相关的控件:
kotlin复制fun updateExpandState(item: TextNews) {
if (item.isExpanded) {
tvContent.visibility = View.VISIBLE
tvContent.text = item.content
tvSummary.visibility = View.GONE
btnExpand.text = "收起全文"
} else {
tvContent.visibility = View.GONE
tvSummary.visibility = View.VISIBLE
btnExpand.text = "展开全文"
}
}
这样做的好处非常明显:不涉及标题、作者、评论的重新 setText,ViewHolder 不用全文重绑,布局测量次数大幅减少。一个列表滑到很长的场景,这个优化能显著提升流畅度。
7. 列表滑动性能优化方案
7.1 setHasStableIds 与 setRecycledViewPool 的配合
Adapter 中调用 setHasStableIds(true) 并重写 getItemId 返回唯一 id,这一点经常被忽略,但它对列表动画和复用池影响很大。StableId 能保证 RecyclerView 在数据集变化时,准确追踪每个 ViewHolder 对应的数据项,DiffUtil 的移动动画才能正常播放。
另外,如果页面里有多个 RecyclerView,比如“推荐 Tab”和“热点 Tab”共用同一套 ViewHolder 类型,可以共享 RecycledViewPool:
kotlin复制val pool = RecyclerView.RecycledViewPool()
recyclerView1.setRecycledViewPool(pool)
recyclerView2.setRecycledViewPool(pool)
默认每个 RecyclerView 都有独立的 ViewHolder 池,共享后可以减少 ViewHolder 的创建次数,尤其是图片较多的卡片,创建 ViewHolder 的 inflate 开销能省下一大块。同时可以调大 pool 的容量:
kotlin复制pool.setMaxRecycledViews(0, 10) // 文本类型缓存 10 个
pool.setMaxRecycledViews(1, 15) // 单图类型缓存 15 个
pool.setMaxRecycledViews(2, 15) // 三图类型缓存 15 个
pool.setMaxRecycledViews(3, 8) // 视频类型缓存 8 个
这个数字不是越大越好,太大会占用更多内存,一般建议每种类型 8~15 个足够。
7.2 图片加载的尺寸控制与占位图策略
新闻列表的性能瓶颈大部分在图片。图片加载的优化最核心的一点是:加载的图片尺寸必须接近控件实际显示尺寸,不要直接加载原图。
结合 Coil,有几种做法:
kotlin复制ivImage.load(item.imageUrl) {
size(300, 200) // 固定目标尺寸
crossfade(300)
placeholder(R.drawable.ic_image_placeholder)
error(R.drawable.ic_image_error)
allowHardware(false) // 低端机避免硬件位图 OOM
}
在 Adapter 的 onViewRecycled 回调里,还可以释放图片资源:
kotlin复制override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
super.onViewRecycled(holder)
when (holder) {
is ImageNewsViewHolder -> holder.recycle()
is ThreeImageNewsViewHolder -> holder.recycle()
is VideoNewsViewHolder -> holder.recycle()
}
}
ViewHolder 里 recycle 方法调用 ImageView 的 clear:
kotlin复制fun recycle() {
ivImage.clear()
}
Coil 的 ImageView.clear 会取消正在进行的加载任务、清空 drawable,并释放 ImageView 持有的资源引用。这个动作在 ViewHolder 被回收时很有用,可以避免图片加载回调晚于 ViewHolder 复用时导致的错图问题。很多人遇到过“滑动过程中图片闪现成上一屏的图”,多半就是没做回收处理。
7.3 嵌套滚动卡顿的排查方向
新闻列表上下滑动时,如果出现一卡一卡的情况,优先检查这几个地方:
第一,Item 根布局不要用太深的嵌套层级。能用 ConstraintLayout 的尽量用 ConstraintLayout,减少测量时间。
第二,不要在 onBindViewHolder 里做耗时操作,包括但不限于:文件读写、数据库查询、Bitmap 创建、正则匹配复杂文本。这些操作必须放到后台线程或预处理。
第三,注意 item 里的 TextView 是否设置了过大的行间距和自动换行。中文长文本在 low-end 设备上的 measure 很耗时,如果正文很长,建议限定展开的最大行数,或者用 WebView 承载长文内容。
第四,检查是否在滑动过程中频繁调用 notifyDataSetChanged。比如点赞、收藏、关注按钮的状态变更,如果每次都全量刷新列表,卡顿是必然的。建议使用 payload 局部刷新,或者单独处理点击条目的状态。
7.4 关于 setOnClickListener 的最佳实践
很多人的习惯是在 onBindViewHolder 里写:
kotlin复制holder.itemView.setOnClickListener { ... }
这样做的问题在于,每次 bind 都会重新设置一个 listener 对象,虽然 Kotlin lambda 开销不算大,但在列表快速滚动的过程中会产生大量短生命周期对象,增加 GC 压力。
更好的做法是让 ViewHolder 实现 View.OnClickListener,在构造时统一设置监听,然后在 listener 里根据当前绑定的数据派发事件:
kotlin复制class ImageNewsViewHolder(
itemView: View,
private val onItemClick: (NewsItem) -> Unit
) : RecyclerView.ViewHolder(itemView), View.OnClickListener {
private var currentItem: SingleImageNews? = null
init {
itemView.setOnClickListener(this)
}
fun bind(item: SingleImageNews) {
currentItem = item
...
}
override fun onClick(v: View) {
currentItem?.let { onItemClick(it) }
}
}
这样监听器在整个 ViewHolder 生命周期内只创建一次,复用时不重新设置。也别小看这个细节,列表项多、点击频繁的场景下,确实能减少 Jank 出现的概率。
8. Item 分割线与间距的精细控制
8.1 自定义 ItemDecoration 实现分割线
RecyclerView 不像 ListView 能直接设 divider,分割线需要用 ItemDecoration 自己画。一个最基础的线性分割线实现如下:
kotlin复制class SimpleDividerDecoration(
private val dividerHeight: Int = 1,
@ColorInt private val dividerColor: Int = Color.parseColor("#E8E8E8")
) : RecyclerView.ItemDecoration() {
private val paint = Paint().apply {
color = dividerColor
isAntiAlias = true
style = Paint.Style.FILL
}
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val position = parent.getChildAdapterPosition(view)
if (position != RecyclerView.NO_POSITION && position != parent.adapter?.itemCount?.minus(1)) {
outRect.bottom = dividerHeight
}
}
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val childCount = parent.childCount
for (i in 0 until childCount) {
val child = parent.getChildAt(i)
val position = parent.getChildAdapterPosition(child)
if (position == RecyclerView.NO_POSITION) continue
if (position == (parent.adapter?.itemCount ?: 0) - 1) continue
val dividerTop = child.bottom.toFloat()
val dividerBottom = dividerTop + dividerHeight
val dividerLeft = child.left + startPadding
val dividerRight = child.right - endPadding
c.drawRect(dividerLeft, dividerTop, dividerRight, dividerBottom, paint)
}
}
}
这是一个比较完善的分割线实现。里面的 startPadding 和 endPadding 是自定两个属性,用来控制分割线的左右缩进。头条的分割线就有明显左缩进,大概是从左边缘开始 12dp 到右边缘。
在 Activity 或 Fragment 中加:
kotlin复制recyclerView.addItemDecoration(
SimpleDividerDecoration(
dividerHeight = dp2px(1),
dividerColor = Color.parseColor("#F0F0F0")
)
)
需要说明的是,getItemOffsets 是给每个 Item 留出绘制空间,onDraw 是真正画线的位置。如果你只重写了 onDraw 但 getItemOffsets 返回 0,那分割线会绘制在 Item 的边界上,被 Item 的内容遮挡,看不到效果。
8.2 首尾间距的特殊处理
新闻列表第一个 item 离顶部的距离和最后一个 item 离底部的距离,一般需要单独控制。比如头条的第一个 item 上面有个 Tab 栏,Item 和 Tab 之间需要一段留白。
实现方式有两种:
一种是在 getItemOffsets 里判断 position:
kotlin复制override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val position = parent.getChildAdapterPosition(view)
if (position == 0) {
outRect.top = dp2px(8)
} else if (position == (parent.adapter?.itemCount ?: 0) - 1) {
outRect.bottom = dp2px(12)
}
}
另一种是直接在 RecyclerView 的 padding 上做文章,配合 clipToPadding=false:
xml复制<androidx.recyclerview.widget.RecyclerView
android:paddingTop="8dp"
android:paddingBottom="12dp"
android:clipToPadding="false" />
clipToPadding 设为 false 后,RecyclerView 的滚动内容可以绘制到 padding 区域里。这样做的好处是:
- 首尾间距不受分割线绘制影响。
- 滚动条长度计算正确。
- 滑动到边缘时,Item 可以滚到 padding 区域内,不会出现“顶死”的感觉。
这个属性用对了,列表的手感会好很多。
9. 事件响应与点击交互的扩展
9.1 点击展开全文的完整链路
展开全文这个功能看着简单,但要处理完善需要注意几个点。
首先,点击事件必须传回 ViewModel,由 ViewModel 修改数据状态,然后通过 DiffUtil 触发 UI 更新。绝不能直接在 ViewHolder 里修改 item.isExpanded 然后 notifyItemChanged,因为 ViewHolder 持有的 item 对象只是一个数据副本,下一次 bind 时会被新的数据覆盖,状态不可控。
我在 MainActivity 里观察点击回调:
kotlin复制val adapter = NewsListAdapter(
onItemClick = { item ->
// 这里可以做跳转详情页、弹窗等操作
Toast.makeText(this, "点击了: ${item.title}", Toast.LENGTH_SHORT).show()
},
onExpandClick = { textNews ->
viewModel.toggleExpand(textNews.id)
}
)
ViewModel 里实现 toggleExpand:
kotlin复制fun toggleExpand(id: Long) {
val currentList = _newsItems.value ?: return
val newList = currentList.map { item ->
if (item is TextNews && item.id == id) {
item.copy(isExpanded = !item.isExpanded)
} else {
item
}
}
_newsItems.value = newList
}
这里的关键点是用了 copy,避免修改原对象。因为 ListAdapter 的 DiffUtil 需要比较新旧列表,如果你直接修改了原有对象,DiffUtil 对比时新旧引用指向同一对象,可能检测不到变化,导致 UI 不刷新。
然后 Activity 观察 _newsItems:
kotlin复制viewModel.newsItems.observe(this) { newsList ->
adapter.submitList(newsList)
}
ListAdapter 自动在后台线程计算 diff,主线程更新 UI。整个“点击展开 → 数据变化 → diff 计算 → 局部刷新”的链路就是完整闭环。
9.2 条目点击与事件总线的选型
关于列表里的点击事件传递,不建议用 EventBus 这类全局事件总线来做。RecyclerView 的点击事件本质上是 View 层的回调,通过构造参数传给 Adapter,再传给 Fragment 或 Activity,链路清晰、类型安全,不会出现“事件发出去但没人接收”的情况。
在 Adapter 构造参数里传 lambda 的方式是我比较推荐的。唯一的注意点是 lambda 数量不要太多。如果一个列表有七八种点击事件,比如点击 item、点击点赞、点击评论、点击分享、点击关注、点击播放,塞五个六个 lambda 进去,调用处会非常难读。
这种情况建议定义一个接口,比如:
kotlin复制interface NewsItemActionListener {
fun onItemClick(item: NewsItem)
fun onLikeClick(item: NewsItem, isLiked: Boolean)
fun onCommentClick(item: NewsItem)
fun onShareClick(item: NewsItem)
fun onExpandClick(item: TextNews)
fun onVideoClick(item: VideoNews)
}
然后 Adapter 构造参数只需要传入这个 listener。代码可读性比多个 lambda 高很多。
9.3 点击态的水波纹效果
列表项的点击反馈不能少,否则用户会感觉点了没反应。在根布局设置:
xml复制android:background="?attr/selectableItemBackground"
这种方法会有水波纹效果,在 API 21 以上是 RippleDrawable,在低版本是普通的选择态背景。注意,如果你的根布局本身有背景颜色,要使用 foreground 而不是 background,否则水波纹会被覆盖。
xml复制android:foreground="?attr/selectableItemBackground"
如果整项点击不要求反馈,只是某个按钮有点击,那在按钮上设置这个属性即可。我的建议是视频卡片、单图卡片整项可点击,文本新闻卡片的“展开全文”和“点击标题”分开处理,标题点击进详情,展开全文只在卡片内部响应。
10. 常见问题与调试经验
10.1 图片闪烁、错乱、闪白怎么排查
滑动列表时图片闪烁是最常见的问题之一,我的经验是按下述顺序排查:
第一步,检查图片是否复用了同一个 Drawable。Coil 内部的管理已经避免了这个问题,如果你是自己写图片加载,要注意 ImageView 复用时一定要 setImageDrawable(null),否则旧图片会残留。
第二步,检查是否设置了占位图,且占位图和正式图差异过大。闪烁往往是因为占位图颜色和正式图差异太大,加载完成后从占位图切换到正式图,视觉上有闪烁感。解决方法是给 ImageView 设置固定的宽高或比例,避免加载完成时因为图片比例不同重新测量布局而闪跳。
第三步,检查 onViewRecycled 是否清空了 ImageView。不清空的话,当前图片加载完成回调和 viewholder 复用的时间点错位,就会把图片显示到错误的 Item 上。
第四步,如果使用了 crossfade 动画,注意动画时长不要太长,300ms 足够。太长的 fade 在快速滚动时会造成明显卡顿和闪烁。
10.2 数据更新后列表自动滚动到顶部
用 ListAdapter 时,如果直接 submitList 新数据,且新数据列表和旧列表在位置上有差异,有可能会触发滚动。如果你不希望刷新后自动滚动到顶部,有两种情况:
第一种情况,下拉刷新,用户希望回到顶部重新看最新内容,那应该平滑滚动:
kotlin复制recyclerView.scrollToPosition(0)
或者:
kotlin复制recyclerView.smoothScrollToPosition(0)
第二种情况,比如“展开全文”这种局部更新,你希望列表停在原地,不要让用户感觉到位置变化。只要 DiffUtil 判断 id 一致,位置不变,默认就不会跳动。但如果你的 DiffCallback 里 areItemsTheSame 没有正确实现(比如返回了 false),ListAdapter 会认为旧条目和新条目完全不同,把所有条目都当作新增处理,就可能出现列表回顶。
所以遇到“点展开全文后列表跳到顶部”这种问题,先检查 DiffCallback 的 areItemsTheSame 是不是真的用 id 来判断,而不是用内容。
10.3 RecyclerView 与 ViewPager2 嵌套滑动冲突
新闻列表页面经常和 ViewPager2 嵌套使用。如果列表是 ViewPager2 的一个页面,左右切换时列表的上下滑动和页面的左右滑动不会冲突,因为方向不同,RecyclerView 的 touch 事件默认能正确处理。
但如果列表里面有横向滚动的子元素,比如三图新闻中图片支持左右滑动切换,就可能出现事件冲突。解决办法是在子 RecyclerView 的 onInterceptTouchEvent 里,根据横向滑动距离来判断是否拦截事件。
如果遇到具体冲突,可以试试用 NestedScrollingChild 和 NestedScrollingParent 的机制来协调,或者在自定义 View 的 onInterceptTouchEvent 中根据 VelocityTracker 判断方向。这个课题展开能写一篇独立的文章,这里提醒你出现类似现象不要慌,先定位冲突的双方是谁,再针对性拦截即可。
10.4 低端机上列表滑动掉帧的分析工具
如果项目在低端机上跑得卡,只靠眼测很难定位是哪里出了问题。Android Studio 自带的 Profiler 可以查看 GPU 渲染时间、CPU 使用率、内存占用。具体排查路径:
- 打开 Profiler,切到 CPU 面板,录一段滑动操作,查看主线程耗时函数的调用耗时。
- 切到 GPU 面板,拖动帧时间轴,如果出现很多红色帧,说明主线程有耗时操作。
- 开启开发者选项里的 Profile GPU Rendering,观察柱状图是否经常超过绿色基准线。
对于大部分 RecyclerView 卡顿问题,定位到“哪个函数耗时最多”之后就成功了一半。最常见的耗时大头集中在:
- inflate 新 ViewHolder(解决办法:复用池调大)
- 图片加载(解决办法:压缩尺寸、复用 Bitmap、减少跨fade)
- 复杂布局的 measure/layout(解决办法:简化布局层级、用 ConstraintLayout)
- 数据转换(解决办法:提前在后台线程完成)
10.5 关于内存泄漏的一个容易踩的坑
RecyclerView.Adapter 如果持有 Activity 或 Fragment 的强引用,可能导致内存泄漏。最常见的写法是在 Adapter 内部创建 Handler 或者 Runnable 做延迟操作,比如延迟加载、延迟曝光上报。
解决方案是:Adapter 的构造参数只传必要的回调 lambda,lambda 不要捕获外部的 Activity 引用。如果确实需要,考虑用 WeakReference 包装。
另外,RecyclerView 本身不主动释放 Adapter 和 LayoutManager 的引用,如果你在 Fragment 的 onDestroyView 里不处理,RecyclerView 会持有整个 View 树的引用。建议在 onDestroyView 时:
kotlin复制recyclerView.adapter = null
recyclerView.layoutManager = null
这样能帮助 GC 更快地回收页面对象。
11. 最终代码组织与后续扩展思路
到这里,仿今日头条新闻列表的核心能力已经全部实现。整体代码量不大,但把 RecyclerView 的重要知识点都有机串起来了:多类型 Item、ListAdapter + DiffUtil、局部刷新 payload、下拉刷新、上拉加载、ItemDecoration、图片加载性能优化。
我个人的建议是,你不要把这个项目当作一个练手 Demo 做完就丢。可以按照以下方向继续扩展,每个方向都是真实工作中会遇到的场景:
第一种,把模拟数据源替换成 Retrofit 请求真实接口,处理加载态、错误态、重试逻辑,这是从 Demo 走向真实项目的必经之路。
第二种,增加新闻详情页的跳转,用 Activity Result API 传递数据,再配合 Transition 做共享元素过渡动画,体验接近真实头条。
第三种,加入 Feed 流的内容推荐权重逻辑,列表项顺序调整时用 DiffUtil 的 move 操作来感知,观察 RecyclerView 的动画效果。
第四种,把 Adapter 泛型化、模块化,做成一个通用的 FeedListAdapter,为团队提供一套可复用的列表方案。
最后再补充一个我在实际项目中最受益的调试小技巧:开发时在代码里加一段 RecyclerView 的日志监听,打印 onBindViewHolder 被调用的次数。如果发现同一个位置反复多次 bind,说明 diff 计算可能有问题,或者你误调用了 notifyDataSetChanged。这个指标能非常直接地反映列表性能健康度。
我用这个方法定位过不少线上卡顿问题,比单纯看 Profiler 快得多。你把这段日志代码加在 Adapter 里,用 tag 输出,上线前移除即可。
