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

Jetpack Compose中Paging3结合HorizontalPager加载异常求助

问题描述

我的应用包含两个页面:

  • 第一个页面通过Paging3库以垂直网格展示设备中的图片
  • 用户点击图片后,将点击位置传入第二个页面,使用Accompanist的HorizontalPager全屏展示图片,两个页面共用同一个ViewModel通过Paging3获取数据

当前第二个页面的HorizontalPager实现代码如下:

val images: LazyPagingItems<MediaStoreImage> =
    viewModel.getImages(initialLoadSize = args.currentImagePosition + 1, pageSize = 50)
        .collectAsLazyPagingItems()

val pagerState = rememberPagerState(initialPage = currentImagePosition)

Box(modifier = modifier) {
    HorizontalPager(
        count = images.itemCount,
        state = pagerState,
        itemSpacing = 16.dp
    ) { page ->
        ZoomableImage(
            modifier = modifier,
            imageUri = images[page]?.contentUri
        )
    }
}

其中currentImagePosition是第一个页面中被点击图片的索引,我将initialLoadSize设置为currentImagePosition + 1,确保要展示的点击图片已被分页库获取。

现在遇到的问题:打开第二个页面时,点击的图片能正常全屏显示,但用户滑动查看下一张图片时,并未加载下一张,而是直接加载索引为50及之后的图片。


补充代码:ViewModel、Repository及分页实现

ViewModel

fun getImages(initialLoadSize: Int = 50): Flow<PagingData<MediaStoreImage>> {
        return Pager(
            config = PagingConfig(
                pageSize = 50,
                initialLoadSize = initialLoadSize,
                enablePlaceholders = true
            )
        ) {
            repository.getImagesPagingSource()
        }.flow.cachedIn(viewModelScope)
    }

Repository

fun getImagesPagingSource(): PagingSource<Int, MediaStoreImage> {
        return ImagesDataSource { limit, offset ->
            getSinglePageImages(
                limit,
                offset
            )
        }
    }
private fun getSinglePageImages(limit: Int, offset: Int): List<MediaStoreImage> {
        val images = ArrayList<MediaStoreImage>()
        val cursor = getCursor(limit, offset)

        cursor?.use {
            val idColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
            val dateModifiedColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED)
            val displayNameColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
            val sizeColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)

            while (it.moveToNext()) {
                val id = it.getLong(idColumn)
                val dateModified =
                    Date(TimeUnit.SECONDS.toMillis(it.getLong(dateModifiedColumn)))
                val dateModifiedString = getFormattedDate(dateModified)
                val displayName = it.getString(displayNameColumn)
                val size = it.getLong(sizeColumn)
                val sizeInMbKb = getFileSize(size)
                val contentUri = ContentUris.withAppendedId(
                    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                    id
                )

                images.add(
                    MediaStoreImage(
                        id,
                        displayName,
                        dateModifiedString,
                        contentUri,
                        sizeInMbKb
                    )
                )
            }
        }

        cursor?.close()
        return images
    }
private fun getCursor(limit: Int, offset: Int): Cursor? {
        val projection = arrayOf(
            MediaStore.Images.Media._ID,
            MediaStore.Images.Media.DISPLAY_NAME,
            MediaStore.Images.Media.DATE_MODIFIED,
            MediaStore.Images.Media.SIZE
        )

        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            val bundle = bundleOf(
                ContentResolver.QUERY_ARG_SQL_SELECTION to "${MediaStore.Images.Media.RELATIVE_PATH} like ? ",
                ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to arrayOf("%${context.getString(R.string.app_name)}%"),
                ContentResolver.QUERY_ARG_OFFSET to offset,
                ContentResolver.QUERY_ARG_LIMIT to limit,
                ContentResolver.QUERY_ARG_SORT_COLUMNS to arrayOf(MediaStore.Images.Media.DATE_MODIFIED),
                ContentResolver.QUERY_ARG_SORT_DIRECTION to ContentResolver.QUERY_SORT_DIRECTION_DESCENDING
            )

            context.contentResolver.query(
                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                projection,
                bundle,
                null
            )
        } else {
            context.contentResolver.query(
                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                projection,
                "${MediaStore.Images.Media.DATA} like ? ",
                arrayOf("%${context.getString(R.string.app_name)}%"),
                "${MediaStore.Images.Media.DATE_MODIFIED} DESC LIMIT $limit OFFSET $offset",
                null
            )
        }
    }

分页数据源

class ImagesDataSource(private val onFetch: (limit: Int, offset: Int) -> List<MediaStoreImage>) :
    PagingSource<Int, MediaStoreImage>() {

    override fun getRefreshKey(state: PagingState<Int, MediaStoreImage>): Int? {
        return state.anchorPosition?.let {
            state.closestPageToPosition(it)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(it)?.nextKey?.minus(1)
        }
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MediaStoreImage> {
        val pageNumber = params.key ?: 0
        val pageSize = params.loadSize
        val images = onFetch.invoke(pageSize, pageNumber * pageSize)
        val prevKey = if (pageNumber > 0) pageNumber.minus(1) else null
        val nextKey = if (images.isNotEmpty()) pageNumber.plus(1) else null

        return LoadResult.Page(
            data = images,
            prevKey = prevKey,
            nextKey = nextKey
        )
    }
}

问题分析与解决方案

问题核心在于初始加载逻辑与分页数据源的offset计算不匹配,导致后续分页请求跳过了中间的图片。

关键问题点

  1. initialLoadSize的误用:你把它当成了偏移量来设置,但Paging3中initialLoadSize是指首次加载的条目总数,不是起始位置。当设置为currentImagePosition + 1后,Paging会认为第一个页面只包含这么多数据,后续直接请求第2页(offset=50),跳过了中间的图片。
  2. 分页请求的offset计算:ImagesDataSource中offset = pageNumber * pageSize的逻辑是基于页码的,初始加载的小范围数据让Paging无法识别当前位置所在的完整页码,导致后续请求直接跳页。

修正方案

1. 调整ViewModel的Pager配置

去掉动态的initialLoadSize参数,改用符合Paging默认逻辑的固定值(建议为pageSize * 2),让Paging从起始位置完整加载页面:

fun getImages(): Flow<PagingData<MediaStoreImage>> {
    return Pager(
        config = PagingConfig(
            pageSize = 50,
            initialLoadSize = 100, // 用pageSize的2倍覆盖更大范围,确保包含目标位置
            enablePlaceholders = true
        )
    ) {
        repository.getImagesPagingSource()
    }.flow.cachedIn(viewModelScope)
}

2. 利用预加载机制确保附近数据被加载

在第二个页面中,通过LazyPagingItems的prefetchDistance控制预加载范围,让Paging自动加载当前页面附近的图片:

val images: LazyPagingItems<MediaStoreImage> =
    viewModel.getImages().collectAsLazyPagingItems().apply {
        prefetchDistance = 10 // 当前页面前后10条数据会被提前加载
    }

val pagerState = rememberPagerState(initialPage = currentImagePosition)

Box(modifier = modifier) {
    HorizontalPager(
        count = images.itemCount,
        state = pagerState,
        itemSpacing = 16.dp
    ) { page ->
        images[page]?.let { image ->
            ZoomableImage(
                modifier = modifier,
                imageUri = image.contentUri
            )
        }
    }
}

3. 保持分页数据源的offset逻辑不变

ImagesDataSource中的offset计算是正确的,配合enablePlaceholders = true,Paging会根据总条目数自动识别目标位置所在的页码,触发对应的分页请求。

额外优化

  • 由于两个页面共用同一个ViewModel,cachedIn(viewModelScope)会让分页数据在页面间共享,避免重复请求。
  • 如果currentImagePosition超过初始加载范围,Paging会自动加载目标位置所在的页面,无需手动干预。

内容的提问来源于stack exchange,提问作者Mehul Kanzariya

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.23 06:54:23