综合实战:带搜索、分页、刷新的新闻列表 App
终于到了综合实战环节。说实话,前面二十多章的知识点,就像一堆散落的零件——Paging 3 是发动机,DiffUtil 是变速箱,SwipeRefreshLayout 是刹车踏板。今天咱们就把它们全部组装起来,跑一个真正的新闻列表 App。
这个项目我去年在做一个资讯聚合类 App 时正好用过类似的架构。当时踩了不少坑,比如分页和搜索同时开启时数据错乱、下拉刷新后列表回弹位置不对……嗯,今天我会把这些经验都揉进代码里。
项目整体架构
先看看我们要搭什么。说白了就是一个三层结构:
- UI 层:Activity + RecyclerView + SearchView + SwipeRefreshLayout
- 数据层:Repository + PagingSource + RemoteMediator
- 适配器层:PagingDataAdapter + DiffUtil
你想想看,这三层各司其职,互不干扰。UI 层只管展示和交互,数据层只管从网络或本地拿数据,适配器层只管高效更新列表。这就是我常说的「各扫门前雪」——每个模块只操心自己的事。
第一步:搭建数据层
数据层是整个 App 的基石。我习惯先写 Repository,再写 PagingSource。为什么?因为 PagingSource 只是 Repository 的一个「数据搬运工」,它不关心数据从哪来,只关心怎么按页取。
先看新闻数据模型:
data class NewsArticle(
val id: Long,
val title: String,
val summary: String,
val source: String,
val publishTime: Long,
val imageUrl: String?
)
然后定义 API 接口。这里我用 Retrofit,分页参数是 page 和 pageSize:
interface NewsApiService {
@GET("news")
suspend fun fetchNews(
@Query("page") page: Int,
@Query("pageSize") pageSize: Int,
@Query("query") query: String? = null
): NewsResponse
}
data class NewsResponse(
val articles: List<NewsArticle>,
val totalPages: Int
)
接下来是 PagingSource。这是分页的核心,它负责告诉 Paging 3:「当前是第几页,下一页的 key 是什么」。
class NewsPagingSource(
private val api: NewsApiService,
private val query: String?
) : PagingSource<Int, NewsArticle>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, NewsArticle> {
return try {
val page = params.key ?: 1
val response = api.fetchNews(page, params.loadSize, query)
LoadResult.Page(
data = response.articles,
prevKey = if (page == 1) null else page - 1,
nextKey = if (page < response.totalPages) page + 1 else null
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, NewsArticle>): Int? {
return state.anchorPosition?.let { anchorPos ->
state.closestPageToPosition(anchorPos)?.prevKey?.plus(1)
}
}
}
这里有个细节:getRefreshKey 方法。我曾经忽略过它,结果下拉刷新后列表直接跳到了第一页,用户体验很差。这个方法的作用是告诉 Paging 3:「刷新后应该回到刷新前的位置附近」。说白了,就是保持滚动位置不丢失。
第二步:搭建适配器层
适配器层我选 PagingDataAdapter,配合自定义的 DiffUtil。为什么不用普通的 RecyclerView.Adapter?因为 PagingDataAdapter 天生支持分页数据的增量更新,你不需要手动调用 notifyItemInserted,它自己就能搞定。
class NewsAdapter : PagingDataAdapter<NewsArticle, NewsAdapter.ViewHolder>(
object : DiffUtil.ItemCallback<NewsArticle>() {
override fun areItemsTheSame(oldItem: NewsArticle, newItem: NewsArticle): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: NewsArticle, newItem: NewsArticle): Boolean {
return oldItem == newItem
}
}
) {
// ViewHolder 和 onBindViewHolder 省略,和普通适配器一样
}
另外,我还加了一个 LoadStateAdapter,用来在列表底部显示「加载中」或「加载失败」的提示。这个在 Paging 3 里是标配:
class LoadMoreAdapter(private val retry: () -> Unit) : LoadStateAdapter<LoadMoreAdapter.ViewHolder>() {
// 根据 LoadState 显示不同 UI:Loading / Error / NotLoading
}
第三步:搭建 UI 层
UI 层是用户直接看到的。我把它拆成三个部分:搜索框、下拉刷新、列表展示。
先看布局文件:
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<SearchView
android:id="@+id/searchView"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
然后在 Activity 里把它们串联起来。这里的关键是搜索和分页的联动——搜索时重置分页,分页时保留搜索关键词。
class NewsActivity : AppCompatActivity() {
private lateinit var binding: ActivityNewsBinding
private val viewModel: NewsViewModel by viewModels()
private val adapter = NewsAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityNewsBinding.inflate(layoutInflater)
setContentView(binding.root)
setupRecyclerView()
setupSearch()
setupRefresh()
observeData()
}
private fun setupRecyclerView() {
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.adapter = adapter.withLoadStateFooter(
footer = LoadMoreAdapter { adapter.retry() }
)
}
private fun setupSearch() {
binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
query?.let { viewModel.search(it) }
return true
}
override fun onQueryTextChange(newText: String?): Boolean {
// 防抖处理,避免每次输入都触发搜索
return false
}
})
}
private fun setupRefresh() {
binding.swipeRefresh.setOnRefreshListener {
viewModel.refresh()
}
}
private fun observeData() {
lifecycleScope.launch {
viewModel.newsFlow.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
// 监听刷新状态
viewModel.isRefreshing.observe(this) { isRefreshing ->
binding.swipeRefresh.isRefreshing = isRefreshing
}
}
}
第四步:ViewModel 里的数据流设计
ViewModel 是整个 App 的「大脑」。它接收 UI 层的指令(搜索、刷新),然后重新构建数据流。
class NewsViewModel(
private val repository: NewsRepository
) : ViewModel() {
private val searchQuery = MutableStateFlow<String?>(null)
private val refreshTrigger = MutableStateFlow(0)
val newsFlow: Flow<PagingData<NewsArticle>> = combine(
searchQuery, refreshTrigger
) { query, _ -> query }
.flatMapLatest { query ->
Pager(PagingConfig(pageSize = 20)) {
NewsPagingSource(repository, query)
}.flow
}
.cachedIn(viewModelScope)
private val _isRefreshing = MutableLiveData<Boolean>()
val isRefreshing: LiveData<Boolean> get() = _isRefreshing
fun search(query: String) {
searchQuery.value = query
}
fun refresh() {
_isRefreshing.value = true
refreshTrigger.value++
// 刷新完成后,在 Repository 或 PagingSource 里设置 _isRefreshing = false
}
}
你发现没有?我用 combine 把搜索和刷新合并成一个数据源。搜索时改变 searchQuery,刷新时改变 refreshTrigger。两者互不干扰,而且通过 flatMapLatest 保证每次变化都重新创建 Pager。
第五步:处理边界情况
写到这里,基本功能已经能跑了。但实际项目中,还有几个边界情况需要处理:
- 搜索为空时:显示「没有找到相关新闻」的空状态
- 网络错误时:显示重试按钮,点击后重新加载当前页
- 下拉刷新时搜索框内容不变:刷新应该保留当前搜索关键词
- 快速切换搜索词:取消上一次的请求,避免数据覆盖
这些在 Paging 3 里都有现成的 API。比如空状态可以通过监听 adapter.itemCount 来实现:
adapter.addLoadStateListener { loadState ->
if (loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0) {
binding.emptyView.visibility = View.VISIBLE
} else {
binding.emptyView.visibility = View.GONE
}
}
网络错误则通过 LoadStateAdapter 的 Error 状态来展示重试按钮。嗯,这些细节虽然小,但直接影响用户体验。我当初上线第一个版本时,就是因为没处理空状态,用户搜索不到内容时看到的是一个空白页面,还以为是 App 卡死了。
总结
这个综合实战项目,说白了就是把搜索、分页、刷新三个功能塞进同一个 RecyclerView 里。核心就三点:
- 数据层用 PagingSource 封装分页逻辑,搜索关键词作为参数传入
- ViewModel 用 Flow 的 combine + flatMapLatest 组合搜索和刷新事件
- UI 层用 PagingDataAdapter 自动处理增量更新,配合 SwipeRefreshLayout 实现下拉刷新
代码量其实不大,但每个环节都要想清楚数据流的走向。你想想看,用户输入搜索关键词 → ViewModel 更新 searchQuery → flatMapLatest 取消旧请求 → 创建新的 Pager → PagingSource 带着新关键词去请求第一页 → 数据回来 → DiffUtil 对比 → 列表更新。这一整套链路,任何一个环节断了,功能就出问题。
好了,今天的实战就到这里。代码我已经上传到课程仓库了,你可以下载下来跑一跑。遇到问题欢迎在评论区留言,我会一一回复。
公众号:蓝海资料掘金营,微信deep3321