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
