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

使用Pager3 RemoteMediator+Room分页时双标签页刷新丢数据问题

双标签页Pager3 + Room分页数据丢失问题修复方案

问题背景

我的Android应用包含两个标签页:好友动态页和推荐内容页,两者均使用Pager3的RemoteMediator结合Room的PagingSource实现分页,共享同一张数据库表,通过source字段(feed/discover)区分不同标签页的数据,UI基于Jetpack Compose开发。

当前遇到的问题:当同时刷新两个标签页并切换时,其中一个或两个标签页的分页数据会显示0条,直到两者都完成加载/刷新流程。

相关代码片段

PagerFactory实现

@OptIn(ExperimentalPagingApi::class)
fun getFeedPager(currentUserId: String): Flow<PagingData<Post>> {
    return Pager(
        config = PagingConfig(
            pageSize = FEED_PAGE_SIZE,
            prefetchDistance = FEED_PAGE_SIZE,
            initialLoadSize = FEED_PAGE_SIZE
        ),
        pagingSourceFactory = {
            database.postDao().getPostsPagingSource(PostSource.Feed.name)
        },
        remoteMediator = FeedRemoteMediator(
            database = database,
            postApi = postApi,
            profileFirestoreService = profileFirestoreService,
            source = PostSource.Feed,
            appPreferencesDatastoreService = appPreferencesDatastoreService,
            configPreferencesDatastoreService = configPreferencesDatastoreService,
            followersFirestoreService = followersFirestoreService
        )
    ).flow.map {
        it.map { post ->
            PostMapper.mapToDomain(post, currentUserId)
        }
    }
}

FeedRemoteMediator实现

@OptIn(ExperimentalPagingApi::class)
class FeedRemoteMediator(
    private val database: AlyvDatabase,
    private val postApi: PostApi,
    private val source: PostSource,
    private val configPreferencesDatastoreService: ConfigPreferencesDatastoreService,
    private val appPreferencesDatastoreService: AppPreferencesDatastoreService
) : RemoteMediator<Int, PostDb>() {

    private val postRemoteKeysDao: PostRemoteKeysDao = database.remoteKeysDao()
    private val postDao: PostDao = database.postDao()

    override suspend fun initialize(): InitializeAction {
        val cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS)

        return if (System.currentTimeMillis() - (postRemoteKeysDao.getCreationTime(source.name)
                ?: 0) < cacheTimeout
        ) {
            InitializeAction.SKIP_INITIAL_REFRESH
        } else {
            InitializeAction.LAUNCH_INITIAL_REFRESH
        }
    }

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, PostDb>
    ): MediatorResult {
        return withContext(Dispatchers.IO){
            val page: Int = when (loadType) {
                LoadType.REFRESH -> {
                    val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
                    remoteKeys?.nextKey?.minus(1)?.coerceAtLeast(0) ?: 0
                }

                LoadType.PREPEND -> {
                    val remoteKeys = getRemoteKeyForFirstItem(state)
                    val prevKey = remoteKeys?.prevKey
                    prevKey ?: return@withContext MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
                }

                LoadType.APPEND -> {
                    val remoteKeys = getRemoteKeyForLastItem(state)
                    val nextKey = remoteKeys?.nextKey
                    nextKey ?: return@withContext MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
                }
            }
            try {
                val userId = appPreferencesDatastoreService.getUserId()
                    ?: throw IllegalStateException("User id is missing")
                val request = GetFeedRequest(userId = userId, page = page)
                val url = when (source) {
                    PostSource.Feed -> configPreferencesDatastoreService.getFeedUrl()
                    PostSource.Discover -> configPreferencesDatastoreService.getDiscoverFeedUrl()
                    else -> ""
                } ?: throw IllegalStateException("Feed url is missing")
                val response = postApi.getFeed(url, request)
                val posts = response.body() ?: emptyList()
                val endOfPaginationReached = posts.isEmpty()
                database.withTransaction {
                    val prevKey = if (page > 1) page - 1 else null
                    val nextKey = if (endOfPaginationReached) null else page + 1
                    val remoteKeys = posts.map {
                        RemoteKeyDb(
                            postId = it.postId,
                            prevKey = prevKey,
                            currentPage = page,
                            nextKey = nextKey,
                            source = source.name
                        )
                    }
                    val postsForDb = posts.map { post ->
                            PostMapper.mapToDb(
                                post = post,
                            )  
                    }
                    if (loadType == LoadType.REFRESH) {
                        postRemoteKeysDao.clearRemoteKeys(source.name)
                        postDao.clearAllFromSource(source.name)
                    }
                    postRemoteKeysDao.insertAll(remoteKeys)
                    postDao.saveAll(postsForDb)
                }
                return@withContext MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
            } catch(exception: Exception){
                Log.i("FeedRemoteMediator", "Error: ${exception.message}")
                exception.printStackTrace()
                return@withContext MediatorResult.Error(exception)
            }
        }
    }

    // 补充缺失的RemoteKey查询方法,需添加source过滤
    private suspend fun getRemoteKeyClosestToCurrentPosition(state: PagingState<Int, PostDb>): RemoteKeyDb? {
        return state.anchorPosition?.let { position ->
            state.closestItemToPosition(position)?.postId?.let { postId ->
                postRemoteKeysDao.getRemoteKeyForPostId(postId, source.name)
            }
        }
    }

    private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, PostDb>): RemoteKeyDb? {
        return state.pages.firstOrNull { it.data.isNotEmpty() }?.data?.firstOrNull()
            ?.postId?.let { postId ->
                postRemoteKeysDao.getRemoteKeyForPostId(postId, source.name)
            }
    }

    private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, PostDb>): RemoteKeyDb? {
        return state.pages.lastOrNull { it.data.isNotEmpty() }?.data?.lastOrNull()
            ?.postId?.let { postId ->
                postRemoteKeysDao.getRemoteKeyForPostId(postId, source.name)
            }
    }
}

Room Dao方法

@Query("SELECT * FROM post WHERE source = :source ORDER BY creation_date DESC")
fun getPostsPagingSource(source: String): PagingSource<Int, PostDb>

已尝试的方案

  • 自定义PagingSource:可正常刷新并显示两个列表,但出现LazyVerticalGrid布局异常跳动问题
  • 在顶层导航Composable中调用.collectAsLazyPagingItems()后向下传递:问题可解决,但不想采用这种架构方式

修复方案

1. 修复RemoteKey查询的source过滤逻辑

你的RemoteMediator中缺失getRemoteKeyClosestToCurrentPosition等方法的实现,且这些方法必须添加source过滤,否则会读取到其他标签页的RemoteKey,导致分页逻辑混乱,最终触发不必要的REFRESH或数据清空。

确保RemoteKey的Dao方法也添加source过滤:

@Query("SELECT * FROM remote_keys WHERE postId = :postId AND source = :source")
suspend fun getRemoteKeyForPostId(postId: String, source: String): RemoteKeyDb?

2. 每个标签页独立管理LazyPagingItems

在每个标签页对应的ViewModel中初始化Pager的Flow并转换为LazyPagingItems,同时用cachedIn将数据流缓存到ViewModel作用域,避免切换标签页时重复创建Flow和重新加载数据:

class FeedViewModel(
    private val pagerFactory: PagerFactory,
    currentUserId: String
) : ViewModel() {
    val pagingItems = pagerFactory.getFeedPager(currentUserId)
        .cachedIn(viewModelScope)
        .collectAsLazyPagingItems()
}

class DiscoverViewModel(
    private val pagerFactory: PagerFactory,
    currentUserId: String
) : ViewModel() {
    val pagingItems = pagerFactory.getDiscoverPager(currentUserId) // 对应推荐页的Pager方法
        .cachedIn(viewModelScope)
        .collectAsLazyPagingItems()
}

在Compose中直接使用ViewModel的pagingItems,这样每个标签页的分页数据是独立缓存的,不会因为切换导致数据源被重置。

3. 优化PagingConfig参数

调整PagingConfig,禁用占位符并优化加载参数,减少空数据显示的概率:

config = PagingConfig(
    pageSize = FEED_PAGE_SIZE,
    prefetchDistance = FEED_PAGE_SIZE * 2,
    initialLoadSize = FEED_PAGE_SIZE * 2,
    enablePlaceholders = false // 禁用占位符,避免空item占位
)

4. 修复自定义PagingSource的LazyVerticalGrid跳动问题

如果坚持使用自定义PagingSource,可通过以下方式解决布局跳动:

  • 确保getRefreshKey逻辑正确,返回当前列表的锚点位置对应的页码
  • 使用rememberLazyGridState()管理滚动状态,并在数据加载完成后恢复滚动位置:
@Composable
fun DiscoverFeedScreen(viewModel: DiscoverViewModel = viewModel()) {
    val gridState = rememberLazyGridState()
    val pagingItems = viewModel.pagingItems

    LaunchedEffect(pagingItems.itemCount) {
        // 数据加载完成后,若当前在顶部则保持位置,避免跳动
        if (pagingItems.itemCount > 0 && gridState.firstVisibleItemIndex == 0) {
            gridState.scrollToItem(0)
        }
    }

    LazyVerticalGrid(
        state = gridState,
        columns = GridCells.Fixed(2),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        contentPadding = PaddingValues(8.dp)
    ) {
        items(pagingItems.itemCount) { index ->
            // 渲染列表项
        }
    }
}

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.19 04:35:00