10、Flow实战:搜索防抖、数据刷新、分页加载等常见场景

好,咱们终于到了Flow的实战环节。前面几章讲了那么多概念和原理,说实话,不落地到具体场景里,总觉得差点意思。这一章我就带大家看看,在真实的Android开发中,Flow到底能帮我们解决哪些痛点。

我个人习惯把Flow的实战场景分成三类:输入防抖、数据刷新、分页加载。这三个场景几乎每个App都会遇到,而且用Flow处理起来,代码会干净很多。

Flow 实战三大场景 Kotlin Flow 搜索防抖 数据刷新 分页加载 debounce 操作符 distinctUntilChanged StateFlow + flatMapLatest Paging 3 集成 Channel + callbackFlow 三个场景覆盖了大多数响应式UI需求 核心思路:将事件转化为数据流,用操作符处理

10.1 搜索防抖:别再让用户等你的网络请求了

搜索框防抖,这应该是Flow最经典的入门案例了。你想想看,用户每敲一个字就发一次请求,那体验得多糟糕。尤其是调用后端接口,频繁请求不仅浪费流量,还可能被服务器限流。

用Flow处理这个,核心就两个操作符:debouncedistinctUntilChanged

// 搜索框的防抖实现
class SearchViewModel : ViewModel() {

    private val _query = MutableStateFlow("")

    val searchResults: StateFlow<List<SearchItem>> = _query
        .debounce(300) // 300ms 内没有新输入,才发射
        .filter { it.length >= 2 } // 少于2个字不搜索
        .distinctUntilChanged() // 内容没变就不重复搜索
        .flatMapLatest { query ->
            repository.search(query)
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )

    fun onQueryChanged(query: String) {
        _query.value = query
    }
}

这里有个细节我想强调一下:flatMapLatest 的作用是,当新的搜索请求发出时,自动取消上一个还在进行的网络请求。我在项目中遇到过这样的情况——用户快速输入"Android",中间会经过"An"、"And"、"Andr"等中间状态。如果没有 flatMapLatest,前面的请求还在跑,后面的又来了,最后界面显示的结果可能是乱序的。

💡 我的小技巧: debounce 的时间不要设太短,300ms 是个比较稳妥的值。太短了起不到防抖效果,太长了用户会觉得搜索反应慢。你可以根据实际场景微调,比如输入法联想场景可以设到 500ms。

10.2 数据刷新:下拉刷新与自动刷新

数据刷新这个场景,说白了就是怎么让UI和数据源保持同步。传统的做法是用LiveData + 手动触发,但用Flow的话,我们可以做得更优雅。

我常用的模式是:用一个 MutableStateFlow 作为刷新触发器,然后用 flatMapLatestswitchMap 来重新加载数据。

// 支持下拉刷新的数据流
class DashboardViewModel(
    private val repository: DashboardRepository
) : ViewModel() {

    private val _refreshTrigger = MutableStateFlow(0L)

    val dashboardData: StateFlow<UiState<Dashboard>> = _refreshTrigger
        .flatMapLatest {
            repository.getDashboard()
                .map { result ->
                    UiState.Success(result)
                }
                .catch { e ->
                    emit(UiState.Error(e.message ?: "未知错误"))
                }
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = UiState.Loading
        )

    fun refresh() {
        _refreshTrigger.value = System.currentTimeMillis()
    }
}

为什么用 Long 类型的时间戳作为触发器?嗯,这里有个小讲究。用 Int 自增的话,如果连续调用两次 refresh(),值可能没变,distinctUntilChanged 会过滤掉。用时间戳就保证每次都不一样。

⚠️ 我曾经踩过的坑: 如果使用 MutableStateFlow<Unit> 作为触发器,连续发射两个 Unit 会被认为是同一个值,因为 StateFlow 会做相等性检查。所以一定要用 Long 或自定义数据类来保证每次发射的值都不同。

10.3 分页加载:用Flow实现无限滚动

分页加载这个场景,说实话,用Flow处理起来比传统的回调方式要清爽得多。Android官方推荐用Paging 3库,它本身就是基于Flow构建的。但如果你不想引入Paging 3,自己用Flow实现一个轻量级的分页方案也不难。

我给大家展示一个我自己项目中用过的方案:

// 轻量级分页加载实现
class PaginatedRepository(
    private val api: ApiService
) {

    fun loadPage(page: Int, pageSize: Int = 20): Flow<List<Item>> = flow {
        val response = api.getItems(page, pageSize)
        emit(response.data)
    }.retry(2) // 失败重试2次
     .catch { e ->
        emit(emptyList()) // 出错时发射空列表
     }

    // 使用 callbackFlow 处理基于回调的分页
    fun loadWithCallbackFlow(): Flow<List<Item>> = callbackFlow {
        val callback = object : PaginationCallback {
            override fun onPageLoaded(items: List<Item>) {
                trySend(items)
            }
            override fun onError(error: Throwable) {
                close(error)
            }
        }
        // 注册回调
        api.registerPaginationCallback(callback)
        // 协程取消时自动清理
        awaitClose {
            api.unregisterPaginationCallback(callback)
        }
    }
}

在ViewModel中,我们可以这样组合使用:

class PaginatedViewModel(
    private val repository: PaginatedRepository
) : ViewModel() {

    private val _loadMore = MutableSharedFlow<Int>()

    val items: StateFlow<List<Item>> = _loadMore
        .scan(emptyList<Item>()) { acc, page ->
            acc + repository.loadPage(page).first()
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )

    fun loadNextPage(page: Int) {
        viewModelScope.launch {
            _loadMore.emit(page)
        }
    }
}

📌 核心要点:

  • 搜索防抖:debounce + distinctUntilChanged + flatMapLatest 三件套
  • 数据刷新:用时间戳作为触发器,保证每次刷新都生效
  • 分页加载:scan 操作符可以优雅地累积分页数据
  • 错误处理:catch 和 retry 是Flow自带的错误恢复机制

这三个场景其实有一个共同点:把事件转化为数据流。不管是用户的输入、下拉刷新动作,还是滚动到底部的事件,都可以抽象成一个Flow,然后用各种操作符去组合、变换。这就是响应式编程的核心思想。

我个人觉得,Flow最大的价值不在于它提供了多少操作符,而在于它让异步数据流的处理变得可组合、可测试、可预测。你想想看,如果用传统的回调方式实现分页加载,代码得嵌套多少层?用Flow的话,几行操作符就搞定了。

好了,这一章的内容就到这里。代码示例我都放在GitHub上了,建议大家自己跑一遍,改改参数看看效果。实践出真知嘛。


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