综合实战:带搜索、分页、刷新的新闻列表 App

终于到了综合实战环节。说实话,前面二十多章的知识点,就像一堆散落的零件——Paging 3 是发动机,DiffUtil 是变速箱,SwipeRefreshLayout 是刹车踏板。今天咱们就把它们全部组装起来,跑一个真正的新闻列表 App。

这个项目我去年在做一个资讯聚合类 App 时正好用过类似的架构。当时踩了不少坑,比如分页和搜索同时开启时数据错乱、下拉刷新后列表回弹位置不对……嗯,今天我会把这些经验都揉进代码里。

项目整体架构

先看看我们要搭什么。说白了就是一个三层结构:

  • UI 层:Activity + RecyclerView + SearchView + SwipeRefreshLayout
  • 数据层:Repository + PagingSource + RemoteMediator
  • 适配器层:PagingDataAdapter + DiffUtil

你想想看,这三层各司其职,互不干扰。UI 层只管展示和交互,数据层只管从网络或本地拿数据,适配器层只管高效更新列表。这就是我常说的「各扫门前雪」——每个模块只操心自己的事。

核心思路:搜索、分页、刷新这三个功能,本质上是对数据流的三种不同操作。搜索是过滤数据源,分页是分段加载数据源,刷新是重置数据源。只要把数据源设计成可观察的 Flow,三者就能和谐共存。
新闻列表 App 三层架构 UI 层 Activity / Fragment SearchView SwipeRefreshLayout RecyclerView 适配器层 PagingDataAdapter DiffUtil.ItemCallback LoadStateAdapter FooterAdapter 数据层 Repository PagingSource RemoteMediator Room 缓存 提交列表 请求数据 返回 PagingData 数据流方向:用户操作 → UI 层 → 适配器层 → 数据层 → 适配器层 → UI 层 🔍 搜索(过滤数据源) 📄 分页(分段加载) 🔄 刷新(重置数据源) 三者通过 Flow 的 combine / flatMapLatest 组合,互不冲突 Paging 3 + Flow + Repository 模式

第一步:搭建数据层

数据层是整个 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
)
小技巧:query 参数设为可空,搜索时传值,不搜索时传 null。这样同一个接口既能处理搜索,又能处理普通列表。我在项目中一直这么用,省去了写两个接口的麻烦。

接下来是 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 省略,和普通适配器一样
}
注意:areItemsTheSame 和 areContentsTheSame 一定要实现正确。areItemsTheSame 判断「是不是同一个对象」,areContentsTheSame 判断「内容有没有变化」。如果搞反了,列表会出现闪烁或者更新不及时的问题。我之前就犯过这个错,排查了半天才发现是 DiffUtil 写反了。

另外,我还加了一个 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。

为什么用 flatMapLatest 而不是 flatMapConcat? 因为搜索或刷新时,旧的分页请求应该被取消。flatMapLatest 会自动取消上一个 Flow,避免数据错乱。我见过有人用 flatMapConcat,结果搜索和分页的请求同时进行,列表里出现了「搜索前」和「搜索后」混在一起的数据。

第五步:处理边界情况

写到这里,基本功能已经能跑了。但实际项目中,还有几个边界情况需要处理:

  • 搜索为空时:显示「没有找到相关新闻」的空状态
  • 网络错误时:显示重试按钮,点击后重新加载当前页
  • 下拉刷新时搜索框内容不变:刷新应该保留当前搜索关键词
  • 快速切换搜索词:取消上一次的请求,避免数据覆盖

这些在 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 里。核心就三点:

  1. 数据层用 PagingSource 封装分页逻辑,搜索关键词作为参数传入
  2. ViewModel 用 Flow 的 combine + flatMapLatest 组合搜索和刷新事件
  3. UI 层用 PagingDataAdapter 自动处理增量更新,配合 SwipeRefreshLayout 实现下拉刷新

代码量其实不大,但每个环节都要想清楚数据流的走向。你想想看,用户输入搜索关键词 → ViewModel 更新 searchQuery → flatMapLatest 取消旧请求 → 创建新的 Pager → PagingSource 带着新关键词去请求第一页 → 数据回来 → DiffUtil 对比 → 列表更新。这一整套链路,任何一个环节断了,功能就出问题。

好了,今天的实战就到这里。代码我已经上传到课程仓库了,你可以下载下来跑一跑。遇到问题欢迎在评论区留言,我会一一回复。

课后练习:试着给这个 App 加上本地缓存功能。用 Room 把新闻数据存到本地,实现「先展示缓存,再请求网络」的效果。提示:可以用 RemoteMediator 来实现。

公众号:蓝海资料掘金营,微信deep3321