使用Paging3初始化加载时实现LazyColumn指定索引项定位
社交应用帖子列表定位问题与实践
应用场景与核心代码实现
开发一款社交应用,个人资料页面通过垂直网格列表展示用户帖子,每个帖子包含UUID类型的id、authorId和图片,点击帖子会跳转到帖子详情列表页,并定位到点击的帖子。
ProfileScreen 代码
@Composable fun PersonalProfileScreen( viewModel: PersonalProfileViewModel, navController: NavHostController, snackbarHostState: SnackbarHostState ) { val state = viewModel.state.collectAsStateWithLifecycle() val postsLazyPagingItems = state.value .userProfileSimplePostsFlow.collectAsLazyPagingItems() LazyVerticalGrid(columns = GridCells.Fixed(3)) { items( count = postsLazyPagingItems.itemCount, key = postsLazyPagingItems.itemKey { it.id }, ) { index -> postsLazyPagingItems[index]?.let { SimplePostItem( imageBitmap = it.image, onClicked = { navController.navigate(PostsScreenRoute(index, it.authorId)) } ) } } } }
点击帖子后导航至PostsScreen,传入点击帖子的索引和authorId,随后调用PostsViewModel的方法加载帖子数据。
PostsScreen 代码
@Composable fun PostsScreen( startItemIndex: Int, authorId: String, viewModel: PostsViewModel ) { val lazyListState = rememberLazyListState() val postsLazyPagingItems = viewModel.postsState .collectAsStateWithLifecycle().value .collectAsLazyPagingItems() LaunchedEffect(key1 = true) { viewModel.initPosts(startItemIndex, authorId) } LazyColumn(state = lazyListState) { items( count = postsLazyPagingItems.itemCount, key = postsLazyPagingItems.itemKey { it.id }, contentType = postsLazyPagingItems.itemContentType { it } ) { index -> postsLazyPagingItems[index]?.let { PostItem( postModel = it, snackbarHost = snackbarHost, navController = navController ) } ?: Text(text = "loading") } } }
PostsViewModel 代码
@HiltViewModel class PostsViewModel @Inject constructor( private val postsUseCases: PostsUseCases, private val postUseCases: PostUseCases, private val database: AppDatabase, private val apiService: PostApiService ) : ViewModel() { private val _postsState = MutableStateFlow(emptyFlow<PagingData<PostModel>>()) val postsState: StateFlow<Flow<PagingData<PostModel>>> = _postsState.asStateFlow() @OptIn(ExperimentalPagingApi::class) fun initPosts(startItemIndex: Int, authorId: String) { viewModelScope.launch { // 每次导航到PostsScreen时清空缓存,再加载帖子 database.postDao.clearAll() postsState.value = Pager( initialKey = startItemIndex, config = PagingConfig( pageSize = POSTS_PAGE_SIZE, prefetchDistance = POSTS_PAGE_SIZE, maxSize = 3 * POSTS_PAGE_SIZE, jumpThreshold = 3, enablePlaceholders = true ), remoteMediator = PostRemoteMediator( appDb = database, apiService = apiService, authorId = authorId, startItemIndex = startItemIndex ), pagingSourceFactory = { database.postDao.pagingSource() } ).flow.map { pagingData -> pagingData.map { it.asPost().asPostModel(postUseCases) } }.cachedIn(viewModelScope) } } }
PostRemoteMediator 代码
@OptIn(ExperimentalPagingApi::class) class PostRemoteMediator( private val appDb: AppDatabase, private val apiService: PostApiService, private val authorId: String, private val startItemIndex: Int ) : RemoteMediator<Int, PostEntity>() { override suspend fun load( loadType: LoadType, state: PagingState<Int, PostEntity> ): MediatorResult { val loadKey: Int = when (loadType) { LoadType.REFRESH -> startItemIndex / POSTS_PAGE_SIZE LoadType.PREPEND -> { var firstLoadedPage: Int withContext(Dispatchers.IO) { firstLoadedPage = appDb.postDao.getFirst().page } if (firstLoadedPage == 0) { return MediatorResult.Success(endOfPaginationReached = true) } else firstLoadedPage - 1 } LoadType.APPEND -> { withContext(Dispatchers.IO) { appDb.postDao.getLast().page + 1 } } } // 自定义API响应包装类,响应码小于300时可通过content获取帖子列表,否则isError为true val apiResponse = apiService.getPosts(authorId, loadKey) if (!apiResponse.isError) { if (apiResponse.content!!.isEmpty()) { return MediatorResult.Success(endOfPaginationReached = true) } val postEntities = apiResponse.content.map { val entity = it.asPostEntity() entity.page = loadKey entity } appDb.postDao.upsertAll(postEntities) return MediatorResult.Success(endOfPaginationReached = false) } return MediatorResult.Error(ApiResponseException(apiResponse.httpStatusCode)) } }
核心定位问题
导航到PostsScreen后,需要将LazyColumn滚动定位到用户点击的帖子,但直接调用requestScrollToItem(startItemIndex)无法实现,原因如下:
- 帖子处于初始化加载状态,目标索引对应的内容可能尚未加载完成;
- 前置加载(prepend)会在列表头部添加新项,导致目标项的索引不断偏移,滚动位置随之改变。
以POSTS_PAGE_SIZE=4、startItemIndex=10为例:
- 初始组合时列表长度为0,无法执行滚动;
- 刷新加载完成后列表长度为4,目标项索引为
10%4=2; - 第一次前置加载后列表长度变为8,目标项索引偏移为
4+2=6; - 第二次前置加载后目标项索引变为
8+2=10。
尝试监听loadState变化调用requestScrollToItem,但效果不稳定;当滚动触发前置加载时,新页面添加后原有可见项会被挤出视口,尝试通过记录前置加载前后的可见项id恢复位置未成功,怀疑是占位符高度与实际项不一致干扰滚动状态,或是Paging3的固有限制。
已实现的初始定位方案
通过帖子ID查找目标项实现初始定位(需启用占位符),代码如下:
// 在PostsScreen中实现,仅在启用占位符时生效 val hasBeenInit = remember { mutableStateOf(false) } val shouldRequestInitialScroll = remember { mutableStateOf(true) } LaunchedEffect(key1 = postsLazyPagingItems.loadState.isIdle) { if (postsLazyPagingItems.loadState.isIdle) { // 初始组合时loadState为idle,需等待实际加载完成后再次变为idle时再处理 if (!hasBeenInit.value) { hasBeenInit.value = true return@LaunchedEffect } else if (shouldRequestInitialScroll.value) { shouldRequestInitialScroll.value = false } } } LaunchedEffect(key1 = postsLazyPagingItems.itemCount) { // 在初始加载完成前持续滚动到目标项,解决前置加载导致的索引偏移问题 if (shouldRequestInitialScroll.value) { var isFound = false for ((index, item) in postsLazyPagingItems.itemSnapshotList.withIndex()) { item?.let { if (it.id.value == startItemId) { lazyListState.requestScrollToItem(index) isFound = true } } if (isFound) break } } }
该方案解决了初始定位问题,但需要等待初始页面加载完成,可通过加载提示优化体验。不过用户向上滚动触发前置加载时,新页面添加后原可见项仍会被挤出视口的问题尚未解决。
内容的提问来源于stack exchange,提问作者Nikolay Ch
相关产品推荐
相关产品推荐

