Android Paging 3 - 滚动和加载新页面时出现闪烁、故障或位置跳跃

Posted

技术标签:

【中文标题】Android Paging 3 - 滚动和加载新页面时出现闪烁、故障或位置跳跃【英文标题】:Android Paging 3 - experiencing flickers, glitches or jumps in position when scrolling & loading new pages 【发布时间】:2021-06-12 02:59:19 【问题描述】:

大家好,我正在使用 android Jetpack Paging library 3,我正在创建一个实现网络 + 数据库场景的新闻应用程序,并且我按照 google https://codelabs.developers.google.com/codelabs/android-paging 的 codelab 进行操作,我几乎就像在 codelab 中一样,我几乎匹配了所有示例https://github.com/android/architecture-components-samples/tree/main/PagingWithNetworkSample中显示的操作。

它几乎可以正常工作...但是我的后端响应是页面键控的,我的意思是响应带有新闻列表和下一页 url,远程调解器获取数据,填充数据库,设置存储库,viewmodel设置好了……

问题是: 当 recyclerview 加载数据时,会发生以下情况:recyclerview 闪烁,项目跳转,被删除,再次添加等等。 我不知道为什么 recyclerview 或其 itemanimator 会这样,看起来如此丑陋和故障。 更重要的是,当我滚动到列表的末尾时,新项目被提取,并且再次发生故障和跳跃效果。

如果你能帮助我,我将非常感激,我坐了三天,非常感谢你。这是我的代码sn-ps:

@Entity(tableName = "blogs")
data class Blog(
@PrimaryKey(autoGenerate = true)
val databaseid:Int,

@field:SerializedName("id")
val id: Int,
@field:SerializedName("title")
val title: String,

@field:SerializedName("image")
val image: String,

@field:SerializedName("date")
val date: String,

@field:SerializedName("share_link")
val shareLink: String,

@field:SerializedName("status")

val status: Int,

@field:SerializedName("url")
val url: String
) 
var categoryId: Int? = null
var tagId: Int? = null
 

这是 DAO

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(blogs: List<Blog>)

 @Query("DELETE FROM blogs")
suspend fun deleteAllBlogs()

 @Query("SELECT * FROM blogs WHERE categoryId= :categoryId ORDER BY id DESC")
fun getBlogsSourceUniversal(categoryId:Int?): PagingSource<Int, Blog>

 @Query("SELECT * FROM blogs WHERE categoryId= :categoryId AND tagId= :tagId ORDER BY id DESC")
fun getBlogsSourceUniversalWithTags(categoryId:Int?,tagId:Int?): PagingSource<Int, Blog>

NewsDatabaseKt

abstract class NewsDatabaseKt : RoomDatabase() 

abstract fun articleDAOKt(): ArticleDAOKt
abstract fun remoteKeyDao(): RemoteKeyDao

companion object 


    @Volatile
    private var INSTANCE: NewsDatabaseKt? = null


    fun getDatabase(context: Context): NewsDatabaseKt =
        INSTANCE ?: synchronized(this) 
            INSTANCE ?: buildDatabase(context).also  INSTANCE = it 
        


    private fun buildDatabase(context: Context) = 
   Room.databaseBuilder(context.applicationContext,
            NewsDatabaseKt::class.java,
            "news_database_kt")
            .build()
    

远程调解器

    @ExperimentalPagingApi
   class BlogsRemoteMediator(private val categoryId: Int,
                      private val service: NewsAPIInterfaceKt,
                      private val newsDatabase: NewsDatabaseKt,
                      private val tagId : Int? = null ,
                      private val initialPage:Int = 1
    ) : RemoteMediator<Int, Blog>() 

override suspend fun initialize(): InitializeAction 
    
    return InitializeAction.LAUNCH_INITIAL_REFRESH


override suspend fun load(loadType: LoadType, state: PagingState<Int, Blog>): MediatorResult 
    try 
        val page = when (loadType) 
            REFRESH -> 
                initialPage
                
            
            PREPEND -> 
                return MediatorResult.Success(endOfPaginationReached = true)
            APPEND -> 
              
                val remoteKey = newsDatabase.withTransaction 
                    newsDatabase.remoteKeyDao().remoteKeyByLatest(categoryId.toString())
                
                if(remoteKey.nextPageKey == null)
                    return MediatorResult.Success(endOfPaginationReached = true)
                
                remoteKey.nextPageKey.toInt()
                


            


        val apiResponse =
                if(tagId == null) 
            service.getCategoryResponsePage(RU, categoryId, page.toString())
        else
            service.getCategoryTagResponsePage(RU,categoryId,tagId,page.toString())
        
        val blogs = apiResponse.blogs
        val endOfPaginationReached = blogs.size < state.config.pageSize

        newsDatabase.withTransaction 
            // clear all tables in the database
            if (loadType == LoadType.REFRESH) 
              
                newsDatabase.remoteKeyDao().deleteByLatest(categoryId.toString())
                if(tagId == null) 
                    newsDatabase.articleDAOKt().clearBlogsByCatId(categoryId)
                else 
                    newsDatabase.articleDAOKt().clearBlogsByCatId(categoryId,tagId)
                
            

            blogs.map blog ->
                blog.categoryId = categoryId
                if(tagId != null) 
                    blog.tagId = tagId
                
            
        newsDatabase.remoteKeyDao().insert(LatestRemoteKey(categoryId.toString(),
        apiResponse.nextPageParam))
            newsDatabase.articleDAOKt().insertAll(blogs)

        

        return MediatorResult.Success(
                endOfPaginationReached = endOfPaginationReached
        )
     catch (exception: IOException) 
        return MediatorResult.Error(exception)
     catch (exception: HttpException) 
        return MediatorResult.Error(exception)
    


PagingRepository

 class PagingRepository(
    private val service: NewsAPIInterfaceKt,
    private val databaseKt: NewsDatabaseKt
    )
    @ExperimentalPagingApi
 fun getBlogsResultStreamUniversal(int: Int, tagId : Int? = null) : Flow<PagingData<Blog>>
    val pagingSourceFactory =  
        if(tagId == null) 
            databaseKt.articleDAOKt().getBlogsSourceUniversal(int)

        else databaseKt.articleDAOKt().getBlogsSourceUniversalWithTags(int,tagId)
    
    return Pager(
            config = PagingConfig(
                    pageSize = 1
            )
            ,remoteMediator = 
            BlogsRemoteMediator(int, service, databaseKt,tagId)
            ,pagingSourceFactory = pagingSourceFactory
    ).flow
  

博客视图模型

class BlogsViewModel(private val repository: PagingRepository):ViewModel()

private var currentResultUiModel: Flow<PagingData<UiModel.BlogModel>>? = null
private var categoryId:Int?=null

@ExperimentalPagingApi
fun getBlogsUniversalWithUiModel(int: Int, tagId : Int? = null): 
Flow<PagingData<UiModel.BlogModel>> 

    val lastResult = currentResultUiModel


    if(lastResult != null && int == categoryId)
        return lastResult
    

    val newResult: Flow<PagingData<UiModel.BlogModel>> = 
     repository.getBlogsResultStreamUniversal(int, tagId)
            .map  pagingData -> pagingData.map  UiModel.BlogModel(it)
            .cachedIn(viewModelScope)

    currentResultUiModel = newResult
    categoryId = int
    return newResult


sealed class UiModel
    data class BlogModel(val blog: Blog) : UiModel()

PoliticsFragmentKotlin

      @ExperimentalPagingApi
   class PoliticsFragmentKotlin : Fragment() 

     private lateinit var recyclerView: RecyclerView
     private lateinit var pagedBlogsAdapter:BlogsAdapter

     lateinit var viewModelKt: BlogsViewModel
     lateinit var viewModel:NewsViewModel

     private var searchJob: Job? = null

      @ExperimentalPagingApi
     private fun loadData(categoryId:Int, tagId : Int? = null) 

    searchJob?.cancel()
    searchJob = lifecycleScope.launch 
        

        viewModelKt.getBlogsUniversalWithUiModel(categoryId, tagId).collectLatest 
            pagedBlogsAdapter.submitData(it)
           
        
     
   

    @ExperimentalPagingApi
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                          savedInstanceState: Bundle?): View? 
    val view = inflater.inflate(R.layout.fragment_blogs, container, false)   
      viewModelKt = ViewModelProvider(requireActivity(),Injection.provideViewModelFactory(requireContext())).get(BlogsViewModel::class.java)

  viewModel = ViewModelProvider(requireActivity()).get(NewsViewModel::class.java)
 pagedBlogsAdapter = BlogsAdapter(context,viewModel)
  val decoration = DividerItemDecoration(context, DividerItemDecoration.VERTICAL)
   recyclerView = view.findViewById(R.id.politics_recyclerView)
   recyclerView.addItemDecoration(decoration)

    initAdapter()
    loadData(categoryId)
    initLoad()
 return view


       private fun initLoad() 
    lifecycleScope.launchWhenCreated 
        Log.d("meylis", "lqunched loadstate scope")
        pagedBlogsAdapter.loadStateFlow
                // Only emit when REFRESH LoadState for RemoteMediator changes.
                .distinctUntilChangedBy  it.refresh 
                // Only react to cases where Remote REFRESH completes i.e., NotLoading.
                .filter  it.refresh is LoadState.NotLoading 
                .collect  recyclerView.scrollToPosition(0) 
    


  private fun initAdapter() 
    recyclerView.adapter = pagedBlogsAdapter.withLoadStateHeaderAndFooter(
            header = BlogsLoadStateAdapter  pagedBlogsAdapter.retry() ,
            footer = BlogsLoadStateAdapter  pagedBlogsAdapter.retry() 
    )

    lifecycleScope.launchWhenCreated 
        pagedBlogsAdapter.loadStateFlow.collectLatest 
            swipeRefreshLayout.isRefreshing = it.refresh is LoadState.Loading
        
    

       pagedBlogsAdapter.addLoadStateListener  loadState ->
        // Only show the list if refresh succeeds.
        recyclerView.isVisible = loadState.source.refresh is LoadState.NotLoading
                // Show loading spinner during initial load or refresh.
        progressBar.isVisible = loadState.source.refresh is LoadState.Loading
        // Show the retry state if initial load or refresh fails.
        retryButton.isVisible = loadState.source.refresh is LoadState.Error

        // Toast on any error, regardless of whether it came from RemoteMediator or PagingSource
        val errorState = loadState.source.append as? LoadState.Error
                ?: loadState.source.prepend as? LoadState.Error
                ?: loadState.append as? LoadState.Error
                ?: loadState.prepend as? LoadState.Error
        errorState?.let 
            Toast.makeText(context, "\uD83D\uDE28 Wooops $it.error", Toast.LENGTH_LONG
            ).show()
        
    



     companion object 

    @JvmStatic
    fun newInstance(categoryId: Int, tags : ArrayList<Tag>): PoliticsFragmentKotlin 
        val args = Bundle()
        args.putInt(URL, categoryId)
        args.putSerializable(TAGS,tags)
        val fragmentKotlin = PoliticsFragmentKotlin()
        fragmentKotlin.arguments = args
        Log.d("meylis", "created instance")
        return fragmentKotlin
    

博客适配器

class BlogsAdapter(var context: Context?, var newsViewModel:NewsViewModel) : 
  PagingDataAdapter<BlogsViewModel.UiModel.BlogModel, RecyclerView.ViewHolder> 
   (REPO_COMPARATOR) 

private val VIEW = 10

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder 
    return when (viewType) 
        VIEW -> MyViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.card_layout, parent, false))



override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) 
  
   val uiModel = getItem(position)
  
    if(uiModel == null)
        if(uiModel is BlogsViewModel.UiModel.BlogModel)(holder as MyViewHolder).bind(null)
    
      
        if(uiModel is BlogsViewModel.UiModel.BlogModel)(holder as 
         MyViewHolder).bind(uiModel.blog)




override fun getItemViewType(position: Int): Int  
    return VIEW
 


companion object 
    private val REPO_COMPARATOR = object : DiffUtil.ItemCallback<BlogsViewModel.UiModel.BlogModel>() 
        override fun areItemsTheSame(oldItem: BlogsViewModel.UiModel.BlogModel, newItem: BlogsViewModel.UiModel.BlogModel): Boolean =
                oldItem.blog.title == newItem.blog.title
        override fun areContentsTheSame(oldItem: BlogsViewModel.UiModel.BlogModel, newItem: BlogsViewModel.UiModel.BlogModel): Boolean =
                oldItem == newItem
    


MyViewHolder

class MyViewHolder(var container: View) : RecyclerView.ViewHolder(container) 
var cv: CardView
@JvmField
var mArticle: TextView
var date: TextView? = null
@JvmField
var time: TextView
@JvmField
var articleImg: ImageView
@JvmField
var shareView: View
var button: MaterialButton? = null
@JvmField
var checkBox: CheckBox

var progressBar: ProgressBar

private var blog:Blog? = null

init 
    cv = container.findViewById<View>(R.id.cardvmain) as CardView
    mArticle = container.findViewById<View>(R.id.article) as TextView
    articleImg = container.findViewById<View>(R.id.imgvmain) as ImageView
    //button = (MaterialButton) itemView.findViewById(R.id.sharemain);
    checkBox = container.findViewById<View>(R.id.checkboxmain) as CheckBox
    time = container.findViewById(R.id.card_time)
    shareView = container.findViewById(R.id.shareView)
    progressBar = container.findViewById(R.id.blog_progress)


fun bind(blog: Blog?)
    if(blog == null)
        mArticle.text = "loading"
        time.text = "loading"
        articleImg.visibility = View.GONE
    else 
        this.blog = blog
        mArticle.text = blog.title
        time.text = blog.date

        if (blog.image.startsWith("http")) 
            articleImg.visibility = View.VISIBLE
            val options: RequestOptions = RequestOptions()
                    .centerCrop()
                    .priority(Priority.HIGH)

            GlideImageLoader(articleImg,
                    progressBar).load(blog.image, options)
         else 
            articleImg.visibility = View.GONE
        
    



NewsApi接口

interface NewsAPIInterfaceKt 

 @GET("sort?")
suspend fun getCategoryResponsePage(@Header("Language") language: String, @Query("category") 
categoryId: Int, @Query("page") pageNumber: String): BlogsResponse

@GET("sort?")
suspend fun getCategoryTagResponsePage(@Header("Language") language: String, 
@Query("category") categoryId: Int,@Query("tag") tagId:Int, @Query("page") pageNumber: String)
:BlogsResponse

     companion object 

    fun create(): NewsAPIInterfaceKt 
        val logger = HttpLoggingInterceptor()
        logger.level = HttpLoggingInterceptor.Level.BASIC


        val okHttpClient = UnsafeOkHttpClient.getUnsafeOkHttpClient()

        return Retrofit.Builder()
                .baseUrl(BASE_URL)
                .client(okHttpClient)
                .addConverterFactory(GsonConverterFactory.create())
                .build()
                .create(NewsAPIInterfaceKt::class.java)
    

我尝试设置 initialLoadSize = 1 但问题依然存在

编辑:感谢您的回答@dlam,是的,确实如此,我的网络 API 返回按 id 排序的结果列表。顺便说一句,当应用程序脱机运行时,项目也会发生这种跳转。

在线刷新和加载时的视频

online loading and paging

online loading and paging(2)

离线刷新和加载时的视频

offline loading and refreshing

再次感谢,这是我的要点链接https://gist.github.com/Aydogdyshka/7ca3eb654adb91477a42128de2f06ea9

编辑 非常感谢@dlam,当我设置 pageSize=10 时,跳转消失了……然后我想起了为什么我首先设置 pageSize=1……当我刷新时,加载了 3 x pageSize 的项目,即使我覆盖了 initialLoadSize = 10 ,刷新后它仍然加载 3 x pageSize 调用 append 2x ,我做错了什么,当我刷新时只加载第一页的正确方法是什么?

【问题讨论】:

您的网络 API 是否返回按 id 排序的结果?如果可能的话,你能分享一个项目跳跃的视频吗?PagingSource 和 RemoteMediator 加载的页面日志也很有帮助。你可能想把它放在一个要点中,因为它可能很长。 @dlam,是的,我的网络响应返回按 id 排序的结果。我还附上了我的问题的视频和要点链接 您可以尝试将您的pageSize 放大吗?或者至少更改initialPageSize。它应该覆盖 > viewport * 2,所以在你的情况下至少 8,也许尝试pageSize = 10?当您刷新时,它会替换列表并通过将两个列表传递给DiffUtil 来恢复位置。默认情况下,Room 的PagingSource 根据最后绑定位置选择索引,并根据pageSize 选择偏移量。为了在正确的位置附近加载刷新并在更新中流畅地进行动画处理,pageSize 必须足够大。 @dlam, yasss, 它起作用了)我设置了 pageSize=10 ,非常感谢,跳跃已经消失了……然后我想起了为什么我首先设置 pageSize=1 ……当我刷新,即使我覆盖了initialLoadSize = 10,也会加载3xpageSize的项目,刷新后它仍然加载3 x pageSize调用append 2x,我做错了什么? 如果你查看 PagingConfig 源代码,它会说参数 pageSizeinitialLoadSize 可以被忽略。所以调整这两个参数并不是 100% 的解决方案。 Note: [initialLoadSize] is used to inform [PagingSource.LoadParams.loadSize], but is not enforced. A [PagingSource] may completely ignore this value and still return a valid initial [Page][PagingSource.LoadResult.Page]. 【参考方案1】:

刚刚从 cmets 跟进:

设置pageSize = 10 解决了这个问题。

问题在于pageSize 太小,导致PagingSource 刷新加载未覆盖视口的页面。由于源刷新会替换列表并通过DiffUtil,因此您需要提供足够大的initialLoadSize,以便有一些重叠(否则滚动位置会丢失)。

顺便说一句 - 分页会根据 PagingConfig.prefetchDistance 自动加载附加数据。如果RecyclerView 将项目绑定到足够靠近列表的边缘,它将自动触发 APPEND / PREPEND 加载。这就是为什么 initialLoadSize 的默认值是 3 * pageSize 的原因,但是如果您仍然遇到额外的负载,我建议您调整 prefetchDistance,或者进一步增加 initialLoadSize

【讨论】:

嗨,我仍然有附加附加负载的问题,当我将 initialLoadSize 设置为小于页面大小时,应用程序崩溃,当我将其设置为 initialLoadSize = pageSize 时,它​​仍然附加 2 次,我试过了prefetchDistance 的值不同,但问题仍然存在,我该怎么办?感谢您的帮助 屏幕上一次可以看到多少个项目?当 recyclerview 绑定它们时,分页将自动获取项目。页面获取由 prefetchDistance 根据最近对PagingDataAdapter.get(index) 的调用触发。 如果您将initialLoadSize 设置为小于pageSize,应用程序可能会崩溃,因为这是一个无效的PagingConfig,您是否阅读了抛出的异常消息? 屏幕上一次可见 4 个项目,我尝试将 initialLoadSize 设置为更大,并且小于 page 但它会使应用程序崩溃,我尝试将预取距离设置为 1,但它仍会加载额外的 2 个页面 闪烁问题如何?我遇到了类似的问题,但闪烁只发生在adapter.refresh() 的第一项上【参考方案2】:

recyclerview 闪烁,因为您从 dao 获得的项目与从网络响应的顺序不同。 我会建议你我的解决方案。 我们将按主键、databaseid、降序从数据库 order 中获取项目。 首先删除自动生成=真。 我们将手动设置 databaseid,按照我们从网络获取项目的顺序。

接下来让我们编辑 remoteMediator 加载函数。

when (loadType) 
            LoadType.PREPEND -> 
                blogs.map 
                    val databaseid = getFirstBlogDatabaseId(state)?.databaseid?:0
                    movies.forEachIndexed
                            index, blog ->
                        blog.databaseid = roomId - (movies.size -index.toLong())
                    
                
            
            LoadType.APPEND -> 
                val roomId = getLastBlogDatabaseId(state)?.databaseid ?:0
                blogs.forEachIndexed
                        index, blog ->
                    blog.databaseid = roomId + index.toLong() + 1
                
            
            LoadType.REFRESH -> 
                blogs.forEachIndexed
                    index, blog ->
                    blog.databaseid = index.toLong()
                
            
        


private fun getFirstBlogDatabaseId(state: PagingState<Int, Blog>): Blog? 
    return state.pages.firstOrNull  it.data.isNotEmpty() ?.data?.firstOrNull()


private fun getLastBlogDatabaseId(state: PagingState<Int, Blog>): Blog? 
    return state.lastItemOrNull()

【讨论】:

以上是关于Android Paging 3 - 滚动和加载新页面时出现闪烁、故障或位置跳跃的主要内容,如果未能解决你的问题,请参考以下文章

Android Paging 库不会触发 loadAfter()

Jetpack Compose 无限加载列表(滚到底部自动加载更多)

将 Paging 3 alpha 更新为稳定导致索引问题 Android

Android Paging 3 在 invalidate() 上清除 recyclerview

Android Paging3 Footer踩坑优化

Android jetpack的Paging和Room结合使用