29、RecyclerView 与协程:在列表加载中使用 Kotlin 协程

说实话,RecyclerView 本身并不难。难的是数据加载。

你想想看,一个列表要展示数据,总得从网络或数据库拿吧?拿数据就得做异步操作。以前我们用回调、用 AsyncTask,代码写得跟千层饼似的。后来有了协程,嗯,这才像人写的代码。

我个人习惯,只要涉及 RecyclerView 的数据加载,一律上协程。为什么?因为协程能让你用同步的写法,写出异步的代码。说白了,就是省心。

为什么 RecyclerView 需要协程?

我们先看一个典型场景:用户打开一个列表页,我们需要从 API 拉取数据,然后更新 UI。

在 Android 里,网络请求不能在主线程执行。那怎么办?以前的做法是开一个线程,请求完数据,再切回主线程更新适配器。代码大概长这样:

// 老式写法
Thread {
    val data = api.fetchData() // 网络请求
    runOnUiThread {
        adapter.submitList(data)
    }
}.start()

这代码有什么问题?问题大了。线程管理麻烦、生命周期没处理、容易内存泄漏。我在项目中遇到过好几次,Activity 销毁了,线程还在跑,结果更新 UI 时直接崩溃。

协程就是来解决这些问题的。它轻量、可控、还能自动取消。

协程在 RecyclerView 中的基本用法

我们先从最简单的开始。假设你有一个 ViewModel,里面用协程加载数据:

class MyViewModel : ViewModel() {

    private val _items = MutableLiveData<List<String>>()
    val items: LiveData<List<String>> = _items

    fun loadData() {
        viewModelScope.launch {
            val result = withContext(Dispatchers.IO) {
                // 模拟网络请求
                fetchDataFromApi()
            }
            _items.value = result
        }
    }
}

然后在 Activity 或 Fragment 里观察 LiveData,拿到数据后提交给适配器:

viewModel.items.observe(this) { data ->
    adapter.submitList(data)
}

嗯,这样写已经比回调好太多了。但还不够。为什么?因为这里有个细节:数据加载过程中,用户可能已经离开了页面

注意: 如果 ViewModel 的 scope 还在运行,但 Activity 已经销毁,虽然协程不会崩溃,但更新 UI 是没意义的。建议配合 Lifecycle 使用。

结合 Lifecycle 安全加载

我个人建议,在 Fragment 里用 lifecycleScope 来启动协程。这样协程的生命周期就和 Fragment 绑定在一起了。

class MyFragment : Fragment() {

    private lateinit var adapter: MyAdapter

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        adapter = MyAdapter()
        recyclerView.adapter = adapter

        // 使用 lifecycleScope
        lifecycleScope.launch {
            val data = withContext(Dispatchers.IO) {
                fetchData()
            }
            adapter.submitList(data)
        }
    }
}

这样写的好处是:Fragment 销毁时,协程自动取消。不会出现「页面没了,请求还在跑」的尴尬情况。

分页加载与协程

列表加载最头疼的是什么?分页。

用户往下滑,你要加载更多。加载过程中不能卡 UI,还要防止重复请求。我以前用回调写过分页,那代码简直不忍直视。

用协程就好多了。我们可以在适配器的 onBindViewHolder 里触发加载,或者用 addOnScrollListener 监听滚动位置。

recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
    override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
        super.onScrolled(recyclerView, dx, dy)

        val layoutManager = recyclerView.layoutManager as LinearLayoutManager
        val lastVisibleItem = layoutManager.findLastVisibleItemPosition()
        val totalItemCount = layoutManager.itemCount

        // 滑到底部时加载更多
        if (lastVisibleItem >= totalItemCount - 2) {
            loadMore()
        }
    }
})

private fun loadMore() {
    lifecycleScope.launch {
        val moreData = withContext(Dispatchers.IO) {
            api.fetchPage(currentPage + 1)
        }
        currentPage++
        adapter.addItems(moreData)
    }
}

这里要注意一个坑:防止重复触发。用户快速滑动时,onScrolled 可能会被多次调用。我建议加一个标志位:

private var isLoading = false

private fun loadMore() {
    if (isLoading) return
    isLoading = true

    lifecycleScope.launch {
        try {
            val moreData = withContext(Dispatchers.IO) {
                api.fetchPage(currentPage + 1)
            }
            currentPage++
            adapter.addItems(moreData)
        } finally {
            isLoading = false
        }
    }
}

嗯,这样就不会重复请求了。

协程与 ListAdapter 的配合

ListAdapter 本身已经帮我们处理了 diff 计算。但数据加载的时机,还是得我们自己控制。

我常用的模式是:在 ViewModel 里用 StateFlow 暴露数据,Fragment 里收集流,然后提交给适配器。

// ViewModel
class MyViewModel : ViewModel() {

    private val _state = MutableStateFlow<List<String>>(emptyList())
    val state: StateFlow<List<String>> = _state.asStateFlow()

    fun loadData() {
        viewModelScope.launch {
            val data = withContext(Dispatchers.IO) {
                fetchData()
            }
            _state.value = data
        }
    }
}

// Fragment
lifecycleScope.launch {
    viewModel.state.collect { data ->
        adapter.submitList(data)
    }
}

这样写的好处是:数据变化自动通知 UI,不需要手动调用 observe。而且 StateFlow 支持协程取消,生命周期安全。

错误处理与加载状态

列表加载不可能永远成功。网络断了、服务器挂了、数据格式变了……这些都要处理。

我习惯用 sealed class 定义加载状态:

sealed class UiState {
    object Loading : UiState()
    data class Success(val data: List<String>) : UiState()
    data class Error(val message: String) : UiState()
}

然后在 ViewModel 里:

private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow()

fun loadData() {
    viewModelScope.launch {
        _state.value = UiState.Loading
        try {
            val data = withContext(Dispatchers.IO) {
                fetchData()
            }
            _state.value = UiState.Success(data)
        } catch (e: Exception) {
            _state.value = UiState.Error(e.message ?: "未知错误")
        }
    }
}

Fragment 里根据状态显示不同 UI:加载中显示进度条,成功显示列表,失败显示重试按钮。

小技巧: 错误状态下,不要清空已有数据。用户可能想先看看之前加载的内容,而不是看到一个空白页面。

避坑指南:协程与 RecyclerView 的常见问题

我总结几个实际项目中踩过的坑:

  • 不要在 ViewHolder 里启动协程。ViewHolder 会被复用,协程的生命周期很难管理。协程应该在 Fragment 或 ViewModel 里启动。
  • 注意协程的取消。如果用户快速滑动,列表会频繁刷新。旧的协程应该被取消,否则可能造成数据错乱。
  • 不要在主线程做大量计算。即使是用协程,Dispatchers.Main 也不适合做耗时操作。网络请求、数据库读写、JSON 解析,统统丢到 Dispatchers.IO

我曾经在一个项目里,把图片解码放在了主线程。结果列表滑动时卡成 PPT。后来改成 withContext(Dispatchers.Default),瞬间流畅了。

知识体系总览

下面这张图总结了 RecyclerView 与协程配合的核心逻辑:

RecyclerView + 协程 核心流程 用户滑动/打开页面 lifecycleScope / viewModelScope 启动协程 withContext(Dispatchers.IO) 加载数据 adapter.submitList(data) 生命周期自动管理 | 避免内存泄漏 | 支持错误处理

总结

协程和 RecyclerView 的配合,说白了就是三件事:

  • 用协程做异步加载,代码简洁
  • 用 Lifecycle 管理协程生命周期,避免泄漏
  • 用 StateFlow 或 LiveData 桥接数据,自动更新 UI

做到这三点,你的列表加载基本就稳了。剩下的就是根据业务需求,加上分页、错误处理、加载状态这些细节。

核心要点: 协程不是银弹,但它确实让 RecyclerView 的数据加载变得优雅了很多。我个人建议,新项目一律用协程 + Flow 的组合,老项目也尽量迁移过来。你试试看,写起来真的舒服。

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