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

Paging3调用adapter.retry时RecyclerView闪烁问题求助

问题描述

分页功能运行正常,但断开网络后调用adapter.retry()时,RecyclerView会出现闪烁,不过新加载的电影能正确添加到对应位置。我严格遵循Android Paging Codelab实现,仅针对NYTimes电影API做了适配,正常滚动时分页表现正常。

代码片段

RemoteMediator

override suspend fun load(
    loadType: LoadType,
    state: PagingState<Int, Movie>
): MediatorResult {

    val page = when(loadType) {
        LoadType.APPEND -> {
            val remoteKeys = getRemoteKeyForLastItem(state)
            val nextKey = remoteKeys?.nextKey
            if (nextKey == null) {
                return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
            }
            nextKey
        }
        LoadType.PREPEND -> {
            val remoteKeys = getRemoteKeyForFirstItem(state)
            val prevKey = remoteKeys?.prevKey
            if (prevKey == null) {
                return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
            }
            prevKey
        }
        LoadType.REFRESH -> {
            val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
            remoteKeys?.nextKey?.minus(1) ?: STARTING_PAGE_INDEX
        }
    }

    try {
        val response = api.fetchMovieCatalog(offset).toMovieCatalog()
        offset+=20
        val movies = response.movieCatalog
        val endOfPaginationReached = movies.isEmpty()
        db.withTransaction {
            if (loadType == LoadType.REFRESH) {
                db.remoteKeysDao.clearRemoteKeys()
                db.moviesDao.clearMovies()
            }
            val prevKey = if (page == STARTING_PAGE_INDEX) null else page - 1
            val nextKey = if (endOfPaginationReached) null else page + 1
            val keys = movies.map {
                RemoteKeys(movie = it.title, prevKey = prevKey, nextKey = nextKey)
            }
            db.remoteKeysDao.insertAll(keys)
            db.moviesDao.insertMovies(movies)
        }
        return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
    }  catch (e: IOException) {
        return MediatorResult.Error(e)
    } catch (e: HttpException) {
        return MediatorResult.Error(e)
    }
}

private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Movie>): RemoteKeys? {
    // 获取最后一个包含数据的分页,再取其中最后一项
    return state.pages.lastOrNull() { it.data.isNotEmpty() }?.data?.lastOrNull()
        ?.let { movie ->
            Log.e("TAG", "Key - ${db.remoteKeysDao.remoteKeysMovieId(movie.title)}, last item")
            db.remoteKeysDao.remoteKeysMovieId(movie.title)
        }
}

private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Movie>): RemoteKeys? {
    // 获取第一个包含数据的分页,再取其中第一项
    return state.pages.firstOrNull() { it.data.isNotEmpty() }?.data?.firstOrNull()
        ?.let { movie ->
            Log.e("TAG", "Key - ${db.remoteKeysDao.remoteKeysMovieId(movie.title)}, first item")
            db.remoteKeysDao.remoteKeysMovieId(movie.title)
        }
}

private suspend fun getRemoteKeyClosestToCurrentPosition(state: PagingState<Int, Movie>): RemoteKeys? {
    // 获取锚点位置附近的条目
    return state.anchorPosition?.let { position ->
        state.closestItemToPosition(position)?.title?.let { movie ->
            Log.e("TAG", "Key - ${db.remoteKeysDao.remoteKeysMovieId(movie)}, refresh")
            db.remoteKeysDao.remoteKeysMovieId(movie)
        }
    }
}

Fragment

override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
): View {
    _binding = FragmentMovieCatalogBinding.inflate(inflater, container, false)

    binding.recyclerView.addItemDecoration(
        DividerItemDecoration(
            context,
            DividerItemDecoration.VERTICAL
        )
    )
    binding.recyclerView.itemAnimator = DefaultItemAnimator()

    binding.bindState(
        uiAction = viewModel.accept,
        uiState = viewModel.state,
        pagingData = viewModel.pagingDataFLow
    )

    return binding.root
}

private fun FragmentMovieCatalogBinding.bindState(
    uiAction: (UiAction) -> Unit,
    pagingData: Flow<PagingData<UiModel>>,
    uiState: StateFlow<UiState>
) {
    val adapter = MovieCatalogAdapter(requireActivity())

    val header = MoviesLoadStateAdapter {
        adapter.retry()
    }
    val footer = MoviesLoadStateAdapter {
        adapter.retry()
    }

    recyclerView.adapter = adapter.withLoadStateHeaderAndFooter(
        header = header,
        footer = footer
    )

    bindList(adapter, pagingData, header)
}

private fun FragmentMovieCatalogBinding.bindList(
    adapter: MovieCatalogAdapter,
    pagingData: Flow<PagingData<UiModel>>,
    header: MoviesLoadStateAdapter
) {
    swipeRefresh.setOnRefreshListener { adapter.refresh() }

    lifecycleScope.launch {
        pagingData.collectLatest(adapter::submitData)
    }

    lifecycleScope.launch {
        adapter.loadStateFlow.collect { loadState ->

            header.loadState = loadState.mediator
                ?.refresh
                ?.takeIf { it is LoadState.Error && adapter.itemCount > 0 }
                ?: loadState.prepend

            // 显示空列表
            emptyList.isVisible =
                loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0
            // 仅在刷新成功后显示列表
            recyclerView.isVisible =
                loadState.source.refresh is LoadState.NotLoading || loadState.mediator?.refresh is LoadState.NotLoading
            // 初始加载或刷新时显示进度条
            swipeRefresh.isRefreshing = loadState.mediator?.refresh is LoadState.Loading
        }
    }
}
原因分析与解决方法

1. DefaultItemAnimator 动画触发闪烁

DefaultItemAnimator的默认添加/移除动画会在重试加载时触发不必要的视图过渡效果,导致闪烁。可以通过两种方式解决:

  • 直接禁用ItemAnimator:
binding.recyclerView.itemAnimator = null
  • 自定义ItemAnimator关闭添加动画:
class NoFlickerItemAnimator : DefaultItemAnimator() {
    override fun animateAdd(holder: RecyclerView.ViewHolder): Boolean {
        dispatchAddFinished(holder)
        return false
    }
}
// 替换默认动画
binding.recyclerView.itemAnimator = NoFlickerItemAnimator()

2. RemoteMediator 中 offset 变量的逻辑问题

代码中用成员变量offset控制API请求偏移量,但page是通过RemoteKeys计算的,两者可能出现不一致。重试时offset已累加过一次,会导致请求错误页码,建议改为用page计算偏移量:

const val PAGE_SIZE = 20
// 替换原offset使用逻辑
val requestOffset = (page - 1) * PAGE_SIZE
val response = api.fetchMovieCatalog(requestOffset).toMovieCatalog()
// 移除offset+=20这行代码

3. PagingAdapter 的 DiffUtil 实现错误

确保MovieCatalogAdapter的DiffUtil.ItemCallback实现正确,错误的条目判断会导致Paging错误刷新视图引发闪烁:

object MovieDiffCallback : DiffUtil.ItemCallback<UiModel>() {
    override fun areItemsTheSame(oldItem: UiModel, newItem: UiModel): Boolean {
        // 用电影唯一标识判断,比如title或ID
        return when {
            oldItem is UiModel.MovieItem && newItem is UiModel.MovieItem -> 
                oldItem.movie.title == newItem.movie.title
            else -> oldItem == newItem
        }
    }

    override fun areContentsTheSame(oldItem: UiModel, newItem: UiModel): Boolean {
        return oldItem == newItem
    }
}

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.22 10:27:45