Compose中从ViewModel的UiState获取分页数据并解决滚动回顶问题
背景说明
之前ViewModel单独维护分页数据,实现方式如下:
private var _data: MutableStateFlow<PagingData<Pokemon>> = MutableStateFlow(PagingData.empty()) val data: StateFlow<PagingData<Pokemon>> = _data.asStateFlow()
对应的Composable中可以正常获取分页数据:
val pagingItems = viewModel.data.collectAsLazyPagingItems()
现在为了统一状态管理,把分页数据整合进了UiState对象:
data class UiState( var loading: Boolean = false, var error: Boolean = false, val dataSource: PagingData<Pokemon> = PagingData.empty(), )
ViewModel的代码也同步修改为:
private var _state: MutableStateFlow<UiState> = MutableStateFlow(UiState(loading = true)) val state: StateFlow<UiState> = _state.asStateFlow()
遇到的问题
尝试用以下方式在Composable中收集UiState里的dataSource时,发现RemoteMediator加载新数据会导致列表自动滚动到接近顶部的位置:
// 此方式无效,分页加载时列表会滚动到顶部 val state by viewModel.state.collectAsState() val pagingItems = flowOf(state.dataSource).collectAsLazyPagingItems()
对应的列表Composable实现代码如下:
@Composable fun PokemonListScreen(navController: NavController, pokemon: LazyPagingItems<Pokemon>) { LazyColumn( modifier = Modifier.padding(4.dp) ) { item { Spacer(modifier = Modifier.padding(4.dp)) } items(count = pokemon.itemCount, key = pokemon.itemKey { it.id }) { index -> pokemon[index]?.let { poke -> PokemonItem( poke, onRepoClicked = { navController.navigate(Routes.RepoDetailScreen.route) } ) } } pokemon.apply { when { loadState.refresh is LoadState.Loading -> { item { PageLoader(modifier = Modifier.fillParentMaxSize()) } } loadState.refresh is LoadState.Error -> { val error = pokemon.loadState.refresh as LoadState.Error item { ErrorMessage( modifier = Modifier.fillParentMaxSize(), message = error.error.localizedMessage, onClickRetry = { retry() }) } } loadState.refresh is LoadState.NotLoading && pokemon.itemCount > 0 -> { item { ErrorMessage( modifier = Modifier.fillParentMaxSize(), message = stringResource(id = R.string.str_no_results), onClickRetry = { retry() }) } } loadState.append is LoadState.Loading -> { item { LoadingNextPageItem(modifier = Modifier) } } loadState.append is LoadState.Error -> { val error = pokemon.loadState.append as LoadState.Error item { ErrorMessage( modifier = Modifier, message = error.error.localizedMessage, onClickRetry = { retry() }) } } } } item { Spacer(modifier = Modifier.padding(4.dp)) } } }
解决方案
问题根源在于flowOf(state.dataSource)每次UiState更新时都会创建一个新的Flow实例,导致collectAsLazyPagingItems()重新订阅,进而触发LazyPagingItems重建,最终引发列表滚动异常。以下两种方法可以解决这个问题:
方法1:在ViewModel中单独暴露dataSource的Flow
修改ViewModel,从state Flow中映射出独立的dataSource Flow,这样Composable可以直接订阅这个Flow:
val dataSourceFlow: StateFlow<PagingData<Pokemon>> = _state.map { it.dataSource }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = PagingData.empty() )
然后在Composable中使用:
val pagingItems = viewModel.dataSourceFlow.collectAsLazyPagingItems()
这种方式既保留了UiState的统一管理,又能保证分页数据订阅的稳定性,不会触发不必要的重组。
方法2:在Composable中直接映射原StateFlow(无需修改ViewModel)
如果不想改动ViewModel代码,可以在Composable中直接基于原state Flow进行映射,避免创建新Flow:
val pagingItems = viewModel.state .map { it.dataSource } .collectAsLazyPagingItems()
这种方式会直接订阅原StateFlow的映射结果,确保每次数据更新时都是同一个Flow实例,不会导致LazyPagingItems频繁重建,自然也不会出现滚动异常。
额外提示
其实LazyPagingItems本身的loadState已经包含了加载状态和错误状态,不一定需要在UiState中重复维护loading和error字段,这样可以减少状态冗余。如果确实需要在UiState中统一管理,注意更新UiState时不要重复发送相同的dataSource实例,避免Flow不必要的发射。
内容的提问来源于stack exchange,提问作者alGhul7

