我正在构建一个电影应用程序,它使用 Paging3 使用 Remote Mediator 同时从网络和本地数据库进行寻呼。
从 TMDB api 获取数据
并将它们保存到房间数据库中。
但是我在回收器视图中遇到一些闪烁或闪烁
当我向下滚动时,某些项目会改变其位置并上下跳跃。
这是有关该问题的视频:https://youtu.be/TzV9Mf85uzk
仅使用 api 或数据库的分页源即可正常工作。
但是当使用远程中介时,将任何页面数据从 api 插入到数据库后就会发生闪烁。
我不知道是什么原因造成的,希望我能找到解决方案。
这是我的一些代码片段:
远程中介器
class MovieRemoteMediator(
private val query: String ="",
private val repository: MovieRepository
) :
RemoteMediator<Int, Movie>() {
companion object {
private const val STARTING_PAGE_INDEX = 1
}
private val searchQuery = query.ifEmpty { "DEFAULT_QUERY" }
override suspend fun initialize(): InitializeAction {
// Require that remote REFRESH is launched on initial load and succeeds before launching
// remote PREPEND / APPEND.
return InitializeAction.LAUNCH_INITIAL_REFRESH
}
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, Movie>
): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> STARTING_PAGE_INDEX
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> {
val remoteKey = getRemoteKeyForLastItem(state)
val nextPage = remoteKey?.nextPage
?: return MediatorResult.Success(endOfPaginationReached = remoteKey != null)
nextPage
}
}
val response = repository.getMoviesFromApi(page)
if (response is NetworkResult.Success) {
val movies = response.data.results ?: emptyList()
val nextPage: Int? =
if (response.data.page < response.data.totalPages) response.data.page + 1 else null
val remoteKeys: List<MovieRemoteKey> = movies.map { movie ->
MovieRemoteKey(searchQuery, movie.id, nextPage)
}
repository.insertAndDeleteMoviesAndRemoteKeysToDB(
searchQuery,
movies,
remoteKeys,
loadType
)
return MediatorResult.Success(
endOfPaginationReached = nextPage == null
)
} else {
val error = (response as NetworkResult.Error).errorMessage
return MediatorResult.Error(Exception(error))
}
}
private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Movie>): MovieRemoteKey? {
return state.pages.lastOrNull() { it.data.isNotEmpty() }?.data?.lastOrNull()
?.let { movie ->
repository.getMovieRemoteKey(movie.id.toInt(), searchQuery)
}
}
}
存储库
class MovieRepository @Inject constructor(
private val apiClient: ApiService,
private val movieDao: MovieDao,
private val movieRemoteKeyDao: MovieRemoteKeyDao
) {
companion object {
private const val PAGE_SIZE =20
val config = PagingConfig(pageSize = PAGE_SIZE,
enablePlaceholders = false)
}
@OptIn(ExperimentalPagingApi::class)
fun getPagingMovies() = Pager(config,
remoteMediator = MovieRemoteMediator(repository = this)
) {
getPagedMoviesFromDB(SortType.DEFAULT, "")
}.flow
suspend fun insertAndDeleteMoviesAndRemoteKeysToDB(
query: String,
movies: List<Movie>,
remoteKeys: List<MovieRemoteKey>,
loadType: LoadType
)= withContext(Dispatchers.IO) {
movieRemoteKeyDao.insertAndDeleteMoviesAndRemoteKeys(query,movies, remoteKeys, loadType)
}
suspend fun getMovieRemoteKey(itemId:Int,query:String):MovieRemoteKey? {
return movieRemoteKeyDao.getRemoteKey(itemId,query)
}
电影道
fun getSortedMovies(sortType: SortType, searchQuery: String) : Flow<List<Movie>> =
when(sortType){
SortType.ASC -> getSortedMoviesASC(searchQuery)
SortType.DESC -> getSortedMoviesDESC(searchQuery)
SortType.DEFAULT -> getMovies()
}
fun getPagedMovies(sortType: SortType, searchQuery: String) : PagingSource<Int,Movie> =
when(sortType){
SortType.ASC -> getPagedSortedMoviesASC(searchQuery)
SortType.DESC -> getPagedSortedMoviesDESC(searchQuery)
SortType.DEFAULT -> getDefaultPagedMovies(searchQuery.ifEmpty { "DEFAULT_QUERY" })
}
@Query("SELECT * FROM movies ORDER BY popularity DESC")
fun getMovies(): Flow<List<Movie>>
@Query("SELECT * FROM movies WHERE title LIKE '%' || :search || '%'" +
" OR originalTitle LIKE :search" +
" ORDER BY title ASC")
fun getSortedMoviesASC(search:String): Flow<List<Movie>>
@Query("SELECT * FROM movies WHERE title LIKE '%' || :search || '%'" +
" OR originalTitle LIKE :search" +
" ORDER BY title DESC")
fun getSortedMoviesDESC(search:String): Flow<List<Movie>>
@Transaction
@Query("SELECT * FROM movies" +
" INNER JOIN movie_remote_key_table on movies.id = movie_remote_key_table.movieId" +
" WHERE searchQuery = :search" +
" ORDER BY movie_remote_key_table.id")
fun getDefaultPagedMovies(search:String): PagingSource<Int,Movie>
@Query("SELECT * FROM movies WHERE title LIKE '%' || :search || '%'" +
" OR originalTitle LIKE :search" +
" ORDER BY title ASC")
fun getPagedSortedMoviesASC(search:String): PagingSource<Int,Movie>
@Query("SELECT * FROM movies WHERE title LIKE '%' || :search || '%'" +
" OR originalTitle LIKE :search" +
" ORDER BY title DESC")
fun getPagedSortedMoviesDESC(search:String): PagingSource<Int,Movie>
@Query("SELECT * FROM movies WHERE id = :id")
fun getMovieById(id: Int): Flow<Movie>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertMovie(movie: Movie)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertMovies(movies: List<Movie>)
@Query("DELETE FROM movies")
fun deleteAllMovies()
@Query("DELETE FROM movies WHERE id = :id")
fun deleteMovieById(id: Int)
}
RemoteKeyDao
@Dao
interface MovieRemoteKeyDao {
@Query("SELECT * FROM movie_remote_key_table WHERE movieId = :movieId AND searchQuery = :query LIMIT 1")
suspend fun getRemoteKey(movieId: Int, query: String): MovieRemoteKey?
@Query("DELETE FROM movie_remote_key_table WHERE searchQuery = :query")
suspend fun deleteRemoteKeys(query: String)
@Transaction
@Query("DELETE FROM movies WHERE id IN ( SELECT movieId FROM movie_remote_key_table WHERE searchQuery = :query)")
suspend fun deleteMoviesByRemoteKeys(query: String)
@Transaction
suspend fun insertAndDeleteMoviesAndRemoteKeys(
query: String,
movies: List<Movie>,
remoteKeys: List<MovieRemoteKey>,
loadType: LoadType
) {
if (loadType == LoadType.REFRESH) {
Timber.d("REMOTE SOURCE DELETING:")
deleteMoviesByRemoteKeys(query)
deleteRemoteKeys(query)
}
Timber.d("REMOTE SOURCE INSERTING ${movies.size} Movies and ${remoteKeys.size} RemoteKeys :")
insertMovies(movies)
insertRemoteKey(remoteKeys)
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRemoteKey(movieRemoteKey: List<MovieRemoteKey>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertMovies(movies: List<Movie>)
}
电影视图模型
@HiltViewModel
class MoviesViewModel @Inject constructor(
private val repository: MovieRepository, private val preferenceManger: PreferenceManger
) : ViewModel() {
private val searchFlow = MutableStateFlow("")
private val sortFlow = preferenceManger.preferencesFlow
val movies = repository.getPagingMovies().cachedIn(viewModelScope)
//
// val movies: StateFlow<Resource<List<Movie>>> = sortFlow.combine(searchFlow) { sort, search ->
// Pair(sort, search)
// } //For having timeouts for search query so not overload the server
// .debounce(600)
// .distinctUntilChanged()
// .flatMapLatest { (sort, search) ->
// repository.getMovies(sort, search)
// }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Resource.Loading())
//
fun setSearchQuery(query: String) {
searchFlow.value = query
}
fun saveSortType(type: SortType) {
viewModelScope.launch {
preferenceManger.saveSortType(type)
}
}
private val _currentMovie = MutableLiveData<Movie?>()
val currentMovie: LiveData<Movie?>
get() = _currentMovie
fun setMovie(movie: Movie?) {
_currentMovie.value = movie
}
}
电影片段
@AndroidEntryPoint
class MoviesFragment : Fragment(), MovieClickListener {
private lateinit var moviesBinding: FragmentMoviesBinding
private lateinit var pagingMovieAdapter: PagingMovieAdapter
private val viewModel: MoviesViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
moviesBinding = FragmentMoviesBinding.inflate(inflater, container, false)
return moviesBinding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupUI()
getMovies()
}
private fun setupUI() {
pagingMovieAdapter = PagingMovieAdapter(this)
moviesBinding.moviesRv.layoutManager = GridLayoutManager(context, 3)
moviesBinding.moviesRv.adapter = pagingMovieAdapter
moviesBinding.moviesRv.setHasFixedSize(true)
setHasOptionsMenu(true)
}
private fun getMovies() {
// repeatOnLifeCycle(pagingMovieAdapter.loadStateFlow) { loadStates ->
// val state = loadStates.refresh
// moviesBinding.loadingView.isVisible = state is LoadState.Loading
//
// if (state is LoadState.Error) {
// val errorMsg = state.error.message
// Toast.makeText(context, errorMsg, Toast.LENGTH_LONG).show()
// }
//
// }
lifecycleScope.launchWhenCreated{
viewModel.movies.collectLatest { pagingMovieAdapter.submitData(it) }
}
// repeatOnLifeCycle(viewModel.movies,pagingMovieAdapter::submitData)
// //scroll to top after updating the adapter
// repeatOnLifeCycle(pagingMovieAdapter.loadStateFlow
// .distinctUntilChangedBy { it.refresh }
// .filter { it.refresh is LoadState.NotLoading }
// ) {
// moviesBinding.moviesRv.scrollToPosition(0)
// }
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.main, menu)
val searchView = menu.findItem(R.id.action_search).actionView as SearchView
searchView.onQueryTextChanged() { query ->
viewModel.setSearchQuery(query)
}
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_sort_asc -> {
viewModel.saveSortType(SortType.ASC)
true
}
R.id.action_sort_desc -> {
viewModel.saveSortType(SortType.DESC)
true
}
R.id.action_sort_default -> {
viewModel.saveSortType(SortType.DEFAULT)
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onMovieClickListener(movie: Movie?) {
Toast.makeText(context, movie?.title, Toast.LENGTH_SHORT).show()
viewModel.setMovie(movie)
movie?.id?.let {
val action = MoviesFragmentDirections.actionMoviesFragmentToDetailsFragment2(it)
findNavController().navigate(action)
}
}
}
分页电影适配器
class PagingMovieAdapter(private val movieClickListener: MovieClickListener)
: PagingDataAdapter<Movie, PagingMovieAdapter.PagingMovieViewHolder>(diffUtil) {
companion object{
val diffUtil = object : DiffUtil.ItemCallback<Movie>() {
override fun areItemsTheSame(oldItem: Movie, newItem: Movie): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Movie, newItem: Movie): Boolean {
return oldItem == newItem
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PagingMovieViewHolder {
return PagingMovieViewHolder.from(parent,movieClickListener)
}
override fun onBindViewHolder(holder: PagingMovieViewHolder, position: Int) {
val movie = getItem(position)
holder.bind(movie)
}
class PagingMovieViewHolder(private val movieBinding: ItemMovieBinding,private val movieClickListener: MovieClickListener) :
RecyclerView.ViewHolder(movieBinding.root) , View.OnClickListener{
init {
movieBinding.root.setOnClickListener(this)
}
fun bind(movie: Movie?) {
movie.let { movieBinding.movie = movie }
}
companion object {
fun from(parent: ViewGroup, movieClickListener: MovieClickListener): PagingMovieViewHolder {
val inflater = LayoutInflater.from(parent.context)
val movieBinding = ItemMovieBinding.inflate(inflater, parent, false)
return PagingMovieViewHolder(movieBinding,movieClickListener)
}
}
override fun onClick(p0: View?) {
val movie = movieBinding.movie
movieClickListener.onMovieClickListener(movie)
}
}
}
谢谢。
对于任何可能面临此问题的人,
闪烁是因为我的
diffUtil
回调在 areContentTheSame
上返回 false,因为我的数据模型类上有一个长数组参数
和 kotlin 数据类 equals 方法根据它们的引用而不是值来比较数组,所以我必须手动重写 equals 方法。
对于在其位置移动的项目,这是因为我禁用了占位符 在分页配置上 这使得分页库在更新数据库后返回错误的偏移量 所以制作
enablePlaceholders = false
解决了这个问题。
来自 api 的数据顺序与来自数据库的数据顺序不同也可能会导致此问题。
谢谢