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

22

大家好,我正在使用 Android Jetpack Paging library 3,创建了一个实现网络+数据库场景的新闻应用程序,并且正在按照 Google 的 CodeLab 进行操作https://codelabs.developers.google.com/codelabs/android-paging,我几乎像在 CodeLab 中一样完成了所有示例中展示的操作https://github.com/android/architecture-components-samples/tree/main/PagingWithNetworkSample。它基本上运作正常...但我的后端响应是基于页面密钥的,这意味着响应附带了新闻列表和下一页的 URL,远程 mediator 获取数据,填充数据库,设置存储库、ViewModel...

问题是: 当 RecyclerView 加载数据时,发生以下情况:RecyclerView 闪烁、项目跳动、删除、再添加等等。我不知道为什么 RecyclerView 或其 ItemAnimator 表现出这种方式,看起来很难看而且有故障。更重要的是,当我滚动到列表末尾时,会获取新项并再次发生此类故障和跳动效果。

如果您能帮助我,我将非常感激,我已经花了三天时间,非常感谢。以下是我的代码片段:

@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
  }
}

BlogsViewmodel

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

->

政治片段Kotlin

      @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
    }
}

BlogsAdapter

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
        }
    }

}
}

NewsApiInterface

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排序的结果列表。顺便说一句,当应用程序脱机运行时,项目也会跳跃。

在在线刷新和加载视频中

在线加载和分页

在线加载和分页(2)

在离线刷新和加载视频中

离线加载和刷新

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

编辑 非常感谢@dlam,当我将pageSize设置为10时,跳跃消失了...然后我想起为什么一开始将pageSize设置为1...当我刷新时,加载3个页面大小的项,即使我覆盖了initialLoadSize = 10,它仍然在刷新后调用append 2次,我可能做错了什么,正确的方法只加载第一页,当我刷新时?


你的网络API返回的结果是否按照ID排序?如果可能的话,能否分享一段物品跳跃的视频,同时PagingSource和RemoteMediator加载的页面日志也会很有帮助。你可以将它们转储到gist中,因为它们可能会很长。 - dlam
@dlam,是的,我的网络响应返回按id排序的结果。我还附上了视频和gist链接到我的问题中。 - Moonshine
@dlam,太棒了,它起作用了)我将pageSize设置为10,非常感谢,跳跃已经消失了...然后我想起来为什么一开始要将pageSize设置为1...当我刷新时,即使我覆盖了initialLoadSize = 10,也会加载3xpageSize的项目,在刷新后调用append 2次,我可能做错了什么? - Moonshine
你能提交你的答案吗,这样我就可以接受它了吗? - Moonshine
1
如果您查看PagingConfig源代码,它会说参数pageSizeinitialLoadSize可以被忽略。因此,调整这两个参数并不是100%的解决方案。 注意:[initialLoadSize]用于通知[PagingSource.LoadParams.loadSize],但不是强制性的。[PagingSource]可能会完全忽略此值,仍然返回有效的初始[Page][PagingSource.LoadResult.Page]。 - Stan
显示剩余2条评论
3个回答

7

回应下面的评论:

pageSize = 10 设置即可解决该问题。

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

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


1
嗨,我仍然遇到了附加追加负载的问题。当我将initialLoadSize设置为小于页面大小时,应用程序会崩溃。当我将其设置为initialLoadSize = pageSize时,它仍然会追加2次。我尝试了不同的prefetchDistance值,但问题仍然存在,我该怎么办?感谢您的帮助。 - Moonshine
如果您将initialLoadSize设置为小于pageSize,则应用程序可能会崩溃,因为这是无效的PagingConfig,您是否阅读了抛出的异常消息? - dlam
1
你在PagingConfig中设置了哪些值?请注意,RV通常会绑定至少比可见项多1个项目,而且很难预测有多少项同时可见,因为Android手机有许多不同的屏幕尺寸。你真的不应该将initialLoadSize设置小于pageSize,并且应该使用比pageSize = 1大得多的值。我建议你尝试看看是否可以让你的网络API返回每页多于1个项目,因为这将导致大量的网络请求。 - dlam
@Moonshine,你能解决每次刷新时屏幕上方会显示4个项目的问题吗? - Anders Cheow
1
闪烁问题怎么样?我遇到了类似的问题,但只有在 adapter.refresh() 后的第一项上才会出现闪烁。 - Hayton Leung
显示剩余10条评论

4
config = PagingConfig(
                pageSize = PAGE_SIZE,
                enablePlaceholders = true,
                prefetchDistance = 3* PAGE_SIZE,
                initialLoadSize = 2*PAGE_SIZE,
            )

请确保enablePlaceholders设置为true,并将页面大小设置在10到20之间。


知道问题,但我迷失了!不知道最佳值。这是几乎任何屏幕上项目的最佳组合。 - Vikram Baliga

0

RecyclerView 闪烁是因为从 DAO 中获取的项与网络响应的顺序不同。我会建议您使用我的解决方案。我们将按主键、databaseid 的降序从数据库中获取项目。首先删除 autogenerated = true。我们将手动设置 databaseid,以与从网络获取的项目相同的顺序。

接下来让我们编辑 remoteMediator 的 load 函数。

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()
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接