10、Flow实战:搜索防抖、数据刷新、分页加载等常见场景
好,咱们终于到了Flow的实战环节。前面几章讲了那么多概念和原理,说实话,不落地到具体场景里,总觉得差点意思。这一章我就带大家看看,在真实的Android开发中,Flow到底能帮我们解决哪些痛点。
我个人习惯把Flow的实战场景分成三类:输入防抖、数据刷新、分页加载。这三个场景几乎每个App都会遇到,而且用Flow处理起来,代码会干净很多。
10.1 搜索防抖:别再让用户等你的网络请求了
搜索框防抖,这应该是Flow最经典的入门案例了。你想想看,用户每敲一个字就发一次请求,那体验得多糟糕。尤其是调用后端接口,频繁请求不仅浪费流量,还可能被服务器限流。
用Flow处理这个,核心就两个操作符:debounce 和 distinctUntilChanged。
// 搜索框的防抖实现
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,前面的请求还在跑,后面的又来了,最后界面显示的结果可能是乱序的。
10.2 数据刷新:下拉刷新与自动刷新
数据刷新这个场景,说白了就是怎么让UI和数据源保持同步。传统的做法是用LiveData + 手动触发,但用Flow的话,我们可以做得更优雅。
我常用的模式是:用一个 MutableStateFlow 作为刷新触发器,然后用 flatMapLatest 或 switchMap 来重新加载数据。
// 支持下拉刷新的数据流
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上了,建议大家自己跑一遍,改改参数看看效果。实践出真知嘛。