You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.18 02:02:02