Paging with Jetpack Compose cause infinite retrofit calls

Viewed 1579

I am trying to implement paging for the TMDB API using paging3 and paging-compose. Here the source of truth is database and api calls are handled by Remote-mediator.

Repository:

class Repository @Inject constructor(
    val database: Database,
    private val apiService: ApiService
){
    @ExperimentalPagingApi
    fun movies(): Flow<PagingData<Movie>> = Pager(
        config = PagingConfig(pageSize = 20),
        remoteMediator = MovieRemoteMediator(database,apiService),
    ){
       database.movieDao().pagedTopRated()
    }.flow
}

RemoteMediator:

@ExperimentalPagingApi
class MovieRemoteMediator(
    private val database: Database,
    private val networkService: ApiService
) : RemoteMediator<Int, Movie>() {
    override suspend fun load(loadType: LoadType, state: PagingState<Int, Movie>): MediatorResult {
        val page:Int = when(loadType){
            LoadType.REFRESH -> {
                val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
                remoteKeys?.nextKey?.minus(1) ?: 1
            }
            LoadType.PREPEND ->  {
                val remoteKeys = getRemoteKeyForFirstItem(state)
                val prevKey = remoteKeys?.prevKey
                    ?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
                prevKey
            }
            LoadType.APPEND -> {
                val remoteKeys = getRemoteKeyForLastItem(state)
                val nextKey = remoteKeys?.nextKey
                    ?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
                nextKey
            }
        }
        return try {
            val response
                    : MovieResponse = networkService.getTopRatedMovies(page)
            val toInsert: MutableList<Movie> = mutableListOf();
            for (i in response.results)
                toInsert.add(i.mapToMovie());
            val endOfPaginationReached = response.page + 1 > response.totalPages
            database.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    database.movieKeyDao().clearRemoteKeys()
                    database.movieDao().clearMovies()
                }
                val prevKey = if (page == 1) null else page - 1
                val nextKey = if (endOfPaginationReached) null else page + 1
                val keys = response.results.map {
                    MovieKey(movieId = it.id, prevKey = prevKey, nextKey = nextKey)
                }
                database.movieDao().insertMovies(toInsert)
                database.movieKeyDao().insertAll(keys)
            }
            MediatorResult.Success(
                endOfPaginationReached = endOfPaginationReached
            )
        } catch (e: IOException) {
            MediatorResult.Error(e)
        } catch (e: HttpException) {
            MediatorResult.Error(e)
        }
    }

    private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Movie>): MovieKey? {
        // Get the last page that was retrieved, that contained items.
        // From that last page, get the last item
        return state.pages.lastOrNull() { it.data.isNotEmpty() }?.data?.lastOrNull()
            ?.let { repo ->
                // Get the remote keys of the last item retrieved
                database.movieKeyDao().remoteKeysMovieId(repo.id)
            }
    }

    private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Movie>): MovieKey? {
        // Get the first page that was retrieved, that contained items.
        // From that first page, get the first item
        return state.pages.firstOrNull { it.data.isNotEmpty() }?.data?.firstOrNull()
            ?.let { repo ->
                // Get the remote keys of the first items retrieved
                database.movieKeyDao().remoteKeysMovieId(repo.id)
            }
    }

    private suspend fun getRemoteKeyClosestToCurrentPosition(
        state: PagingState<Int, Movie>
    ): MovieKey? {
        // The paging library is trying to load data after the anchor position
        // Get the item closest to the anchor position
        return state.anchorPosition?.let { position ->
            state.closestItemToPosition(position)?.id?.let { repoId ->
                database.movieKeyDao().remoteKeysMovieId(repoId)
            }
        }
    }

ViewModel:

@HiltViewModel
class MovieViewModel @Inject constructor(
    private val movieRepository: Repository
) : ViewModel() {

    @ExperimentalPagingApi
    fun getMovies() = movieRepository.movies().cachedIn(viewModelScope)

}

Ui Screen:

@ExperimentalCoilApi
@ExperimentalPagingApi
@Composable
fun MainScreen(){
    val viewModel: MovieViewModel = viewModel()
    val movieList = viewModel.getMovies().collectAsLazyPagingItems()
    LazyColumn{
        items(movieList){ movie ->
            if (movie != null) {
                Card(movie)
            }
        }
    }
}
@ExperimentalCoilApi
@ExperimentalPagingApi
@Composable
fun Main(){
    MainScreen()
}

MainActivty.kt

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    @ExperimentalCoilApi
    @ExperimentalPagingApi
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JetpackComposePagingTheme {
                // A surface container using the 'background' color from the theme
                Surface(color = MaterialTheme.colors.background) {
                    Main()
                }
            }
        }
    }
}

I am following the paging3 Codelab for writing the remoteMediator. On opening the app, it only loads the first 2 pages and then loops infinitely making infinite retrofit calls

2 Answers

You doesn't do exactly as The Codelab you're referencing suggests: they are creating paging Flow inside init and only update it when the query string changes.

On the other hand, you're calling viewModel.getMovies() on each recomposition which causes your problem. Check out how you should work with side effects in Compose in documentation.

In this particular case, as you don't have any parameters, you can simply create it once in the view model like this:

@HiltViewModel
class MovieViewModel @Inject constructor(
    private val movieRepository: Repository
) : ViewModel() {
    val moviesPagingFlow = movieRepository.movies().cachedIn(viewModelScope)
}

No idea why but in my case isolating the LazyPagingItems from LazyColumn worked.

Try the following:

 @Composable
 fun MainScreen(){
     val viewModel: MovieViewModel = viewModel()
     val movieList = viewModel.getMovies().collectAsLazyPagingItems()
     MainScreen(movies = movieList)
 }

 @Composable
 fun MainScreen(movies: LazyPagingItems<Movie>){
      LazyColumn{
         items(movies){ movie ->
             if (movie != null) {
                 Card(movie)
             }
         }
     }
 }
Related