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架构规范
- 状态流转清晰,便于扩展其他搜索状态(比如空状态、重试逻辑)
额外优化建议:
- 调整加载指示器位置:把全屏加载圈改成列表顶部的小进度条(比如
LinearProgressIndicator),或者悬浮在列表右上角,这样用户在加载时还能滚动查看旧结果,体验更好。 - 处理空结果的加载状态:如果旧结果是空的,加载时不要只显示加载圈,同时保留“无结果”文本,避免用户困惑。
- 清理旧状态:当查询清空时,记得重置
rememberedPreviousItems或ViewModel中的previousResults,避免切换回空查询时还显示旧结果。
内容来源于stack exchange




