You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

Android Jetpack Compose中如何保留旧搜索结果直至新结果加载完成以消除分页刷新闪烁?

Android Jetpack Compose中如何保留旧搜索结果直至新结果加载完成以消除分页刷新闪烁?

我完全理解你的痛点——每次发起新搜索时,旧结果被全屏加载圈替换,哪怕搜索很快也会出现刺眼的闪烁,这和React的useDeferredValue保留旧UI直到新数据就绪的体验差远了。

先分析下你当前代码的问题根源:
你用了flatMapLatest来切换查询对应的Paging流,这意味着每次新查询到来时,前一个Paging流会被立即取消。collectAsLazyPagingItems会跟着流的切换生成新的实例,此时loadState.refresh进入Loading状态,UI就会用全屏加载圈替换掉旧列表,自然就产生了闪烁。

要解决这个问题,核心思路是在新搜索结果加载完成前,让旧结果依然保持可见,同时叠加加载指示器(而不是替换旧列表)。下面给你两种可行的方案,按需选择:


方案一:UI层快速修复(推荐,改动小)

这种方案不需要大改ViewModel,只在Composable中保留旧的LazyPagingItems实例,直到新结果加载完成再替换。

修改你的MainScreen代码如下:

@OptIn(FlowPreview::class)
@Composable
fun MainScreen(modifier: Modifier = Modifier, viewModel: MainViewModel = viewModel()) {
    val query = rememberTextFieldState()
    // 收集当前查询对应的新结果
    val currentItems = viewModel.searchResultPagingData.collectAsLazyPagingItems()
    // 用remember保存之前加载完成的旧结果
    val rememberedPreviousItems = remember { mutableStateOf<LazyPagingItems<Model>?>(null) }

    // 当新结果加载完成时,更新保存的旧结果
    LaunchedEffect(currentItems.loadState.refresh) {
        if (currentItems.loadState.refresh is LoadState.NotLoading) {
            rememberedPreviousItems.value = currentItems
        }
    }

    // 确定要显示的内容:加载时用旧结果,否则用新结果;第一次搜索无旧结果则用当前(加载中)
    val displayItems = if (currentItems.loadState.refresh is LoadState.Loading) {
        rememberedPreviousItems.value ?: currentItems
    } else {
        currentItems
    }

    // 搜索逻辑保持不变
    LaunchedEffect(Unit) {
        snapshotFlow { query.text.toString() }
            .debounce(150.milliseconds)
            .distinctUntilChanged()
            .collectLatest { viewModel.search(it) }
    }

    Column(modifier = modifier) {
        OutlinedTextField(state = query)

        if (query.text.isNotEmpty()) {
            when (val refreshState = currentItems.loadState.refresh) {
                is LoadState.Loading -> {
                    Box(modifier = Modifier.fillMaxSize()) {
                        // 先显示旧结果(如果有)
                        if (displayItems?.itemCount ?: 0 > 0) {
                            LazyColumn {
                                items(
                                    displayItems!!.itemCount,
                                    displayItems.itemKey(Model::id),
                                ) { index ->
                                    val item = displayItems[index]!!
                                    ListItem(headlineContent = { Text(item.name) })
                                    HorizontalDivider()
                                }
                            }
                        } else if (displayItems === currentItems) {
                            // 第一次搜索无旧结果,显示全屏加载
                            Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                                CircularProgressIndicator(modifier = Modifier.size(96.dp))
                            }
                        } else {
                            // 旧结果是空的,显示无结果+加载
                            Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                                Text("No results")
                                CircularProgressIndicator(
                                    modifier = Modifier.size(96.dp).align(Alignment.Center)
                                )
                            }
                        }

                        // 在旧结果上方叠加加载指示器
                        Box(
                            modifier = Modifier.fillMaxSize(),
                            contentAlignment = Alignment.Center
                        ) {
                            CircularProgressIndicator(modifier = Modifier.size(96.dp))
                        }
                    }
                }
                is LoadState.Error -> Text("Error")
                is LoadState.NotLoading -> {
                    if (currentItems.itemCount > 0) {
                        LazyColumn {
                            items(
                                currentItems.itemCount,
                                currentItems.itemKey(Model::id),
                            ) { index ->
                                val item = currentItems[index]!!
                                ListItem(headlineContent = { Text(item.name) })
                                HorizontalDivider()
                            }
                        }
                    } else {
                        Text("No results")
                    }
                }
            }
        }
    }
}

方案一的优势:

  • 几乎不用改ViewModel,只在UI层做状态保留,改动成本极低
  • 逻辑直观,容易理解和维护
  • 完美保留旧结果直到新数据就绪,彻底消除闪烁

方案二:ViewModel层集中管理状态

如果你希望把状态逻辑都收拢在ViewModel中,让UI层更简洁,可以用密封类来封装搜索状态,在ViewModel中控制旧结果的保留逻辑。

步骤1:定义搜索状态密封类

sealed class SearchState<out T> {
    object Idle : SearchState<Nothing>()
    data class Loading<out T>(val previousResults: PagingData<T>?) : SearchState<T>()
    data class Success<out T>(val results: PagingData<T>) : SearchState<T>()
    data class Error(val message: String) : SearchState<Nothing>()
}

步骤2:修改ViewModel

class MainViewModel : ViewModel() {
    private val query = MutableStateFlow("")
    private val previousResults = mutableStateOf<PagingData<Model>?>(null)

    val searchState: StateFlow<SearchState<Model>> = query
        .filter { it.isNotEmpty() }
        .transformLatest { newQuery ->
            // 发射加载状态,携带旧结果
            emit(SearchState.Loading(previousResults.value))
            try {
                // 等待新的PagingData就绪(初始页加载完成)
                val newResults = Pager(PagingConfig(10)) {
                    SearchPagingSource(newQuery)
                }.flow.first()
                // 缓存新结果作为下一次搜索的旧结果
                previousResults.value = newResults
                // 发射成功状态
                emit(SearchState.Success(newResults))
            } catch (e: Exception) {
                emit(SearchState.Error(e.message ?: "Unknown error occurred"))
            }
        }
        .onStart { emit(SearchState.Idle) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = SearchState.Idle
        )

    fun search(query: String) {
        this.query.value = query
    }
}

步骤3:修改UI层

@OptIn(FlowPreview::class)
@Composable
fun MainScreen(modifier: Modifier = Modifier, viewModel: MainViewModel = viewModel()) {
    val query = rememberTextFieldState()
    val searchState by viewModel.searchState.collectAsState()

    LaunchedEffect(Unit) {
        snapshotFlow { query.text.toString() }
            .debounce(150.milliseconds)
            .distinctUntilChanged()
            .collectLatest { viewModel.search(it) }
    }

    Column(modifier = modifier) {
        OutlinedTextField(state = query)

        if (query.text.isNotEmpty()) {
            when (val state = searchState) {
                is SearchState.Idle -> {}
                is SearchState.Loading<Model> -> {
                    Box(modifier = Modifier.fillMaxSize()) {
                        // 显示旧结果(如果有)
                        state.previousResults?.let { pagingData ->
                            val lazyItems = pagingData.collectAsLazyPagingItems()
                            if (lazyItems.itemCount > 0) {
                                LazyColumn {
                                    items(lazyItems.itemCount, lazyItems.itemKey(Model::id)) { index ->
                                        val item = lazyItems[index]!!
                                        ListItem(headlineContent = { Text(item.name) })
                                        HorizontalDivider()
                                    }
                                }
                            } else {
                                Text("No results")
                            }
                        } ?: run {
                            // 第一次搜索无旧结果,显示全屏加载
                            Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                                CircularProgressIndicator(modifier = Modifier.size(96.dp))
                            }
                        }

                        // 叠加加载指示器
                        Box(
                            modifier = Modifier.fillMaxSize(),
                            contentAlignment = Alignment.Center
                        ) {
                            CircularProgressIndicator(modifier = Modifier.size(96.dp))
                        }
                    }
                }
                is SearchState.Success<Model> -> {
                    val lazyItems = state.results.collectAsLazyPagingItems()
                    if (lazyItems.itemCount > 0) {
                        LazyColumn {
                            items(lazyItems.itemCount, lazyItems.itemKey(Model::id)) { index ->
                                val item = lazyItems[index]!!
                                ListItem(headlineContent = { Text(item.name) })
                                HorizontalDivider()
                            }
                        }
                    } else {
                        Text("No results")
                    }
                }
                is SearchState.Error -> Text(state.message)
            }
        }
    }
}

方案二的优势:

  • 状态逻辑完全集中在ViewModel,UI层只负责渲染,符合MVVM架构规范
  • 状态流转清晰,便于扩展其他搜索状态(比如空状态、重试逻辑)

额外优化建议:

  1. 调整加载指示器位置:把全屏加载圈改成列表顶部的小进度条(比如LinearProgressIndicator),或者悬浮在列表右上角,这样用户在加载时还能滚动查看旧结果,体验更好。
  2. 处理空结果的加载状态:如果旧结果是空的,加载时不要只显示加载圈,同时保留“无结果”文本,避免用户困惑。
  3. 清理旧状态:当查询清空时,记得重置rememberedPreviousItems或ViewModel中的previousResults,避免切换回空查询时还显示旧结果。

内容来源于stack exchange

火山引擎 最新活动