[Kotlin Side Project] RemoteMediator 키 관리

2021. 11. 17. 16:08Kotlin/Kotlin Side Project

class LocalDataSource(val localMovieDatabase: LocalMovieDatabase) :
    PagingSource<Int, PopularMovie>() {
    override fun getRefreshKey(state: PagingState<Int, PopularMovie>): Int? {
        Log.i("KMJTEST", "[LocalDataSource] getRefreshKey ${state.anchorPosition}")
        return state.anchorPosition?.let { pos ->
            state.closestPageToPosition(pos)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(pos)?.nextKey?.minus(1)
        }
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, PopularMovie> {
        Log.i("KMJTEST", "[LocalDataSource] ${params.key} key, ${params.loadSize} loadsize")
        val page = params.key ?: 1
        return try {
            localMovieDatabase.withTransaction {
                lateinit var popularMovieLocalList: List<PopularMovie>
                //localMovieDatabase.popularMovieDao().getPopularMoviesFlowWithPage(
                //      page = page,
                //      loadsize = 10
                //  ).collect { value ->
                //      popularMovieLocalList = value.map { it.toEntity() }
                //  } // TODO : check collect() is right

                popularMovieLocalList =  localMovieDatabase.popularMovieDao().getPopularMovies(page, 20).map { it.toEntity() }

                popularMovieLocalList.forEach {
                    Log.i("KMJTEST", "[LocalDataSource] id ${it.id}")
                }
                val lastId = popularMovieLocalList.lastOrNull()?.id
                val nextPage = localMovieDatabase.popularMovieDao().getNextPage(lastId)
                val nextPageList = localMovieDatabase.popularMovieDao().getNextPageAll();

                Log.i("KMJTEST", "[LocalDataSource] popularList ${popularMovieLocalList.size}")
                Log.i("KMJTEST", "[LocalDataSource] lastId ${lastId}")
                Log.i("KMJTEST", "[LocalDataSource] nextPage ${nextPage}")
                nextPageList.forEach {
                    Log.i("KMJTEST", "[LocalDataSource] movieId ${it.movieId} nextPage ${it.nextPage}")
                }

                LoadResult.Page(
                    data = popularMovieLocalList,
                    prevKey = if (page == 1) null else page - 1,
                    nextKey =  if (popularMovieLocalList.isEmpty()&&page!=1) null else page + 1
                )
            }

        } catch (e: Exception) {
            return LoadResult.Error(e)
        }
    }

    suspend fun store(flow: Flow<PagingData<PopularMovie>>) {
        //localMovieDatabase.popularMovieDao().insertPopularMovies(*popularMovieLocal) // TODO : check vararg and *
        Log.i("KMJTEST","[LocalDataSource] store() $flow")
        flow.collect {
            Log.i("KMJTEST","[LocalDataSource] store() #1 $it")
            it.map {
                Log.i("KMJTEST","[LocalDataSource] store() #2 $it")
                localMovieDatabase.popularMovieDao().insertPopularMovie(
                    PopularMovieLocal(
                        it.poster_path,
                        it.adult,
                        it.overview,
                        it.release_date,
                        it.genre_ids,
                        it.id,
                        it.original_title,
                        it.original_language,
                        it.title,
                        it.backdrop_path,
                        it.popularity,
                        it.vote_count,
                        it.video,
                        it.vote_average
                    )

                )
            }
        }
    }

    suspend fun delete() {
        localMovieDatabase.popularMovieDao().deletePopularMovieAll()
    }
class PopularMovieMediator(
    private val db: LocalMovieDatabase,
    private val api: TMDBService,
    private val refreshControl: RefreshControl) : RemoteMediator<Int, PopularMovie>() {

    override suspend fun initialize(): InitializeAction {
        Log.i("KMJTEST","[PopularMovieMediator] isExpired() ${refreshControl.isExpired()}")
        return if(refreshControl.isExpired()) InitializeAction.LAUNCH_INITIAL_REFRESH
        else InitializeAction.SKIP_INITIAL_REFRESH
    }

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, PopularMovie>
    ): MediatorResult {
        return try {
            var lastItemId: Int?

            Log.i("KMJTEST","[PopularMovieMediator] LoadType $loadType state $state")
            val loadKey = when (loadType) {
                LoadType.REFRESH -> null // 리프레시의 경우 무조건 첫 페이지부터 로드
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
                LoadType.APPEND -> { // 추가적인 로드가 필요한 경우
                    lastItemId = state.lastItemOrNull()?.id // 마지막으로 로드된 아이템의 id를 확인해서

                    val nextPage = db.withTransaction { // db에서 다음 페이지가 있는지 확인
                        db.popularMovieDao().getNextPage(lastItemId)
                    } ?: return MediatorResult.Success(endOfPaginationReached = true) // 다음 페이지가 null인 경우 마지막 페이지

                    nextPage // 로드할 페이지가 남아있는 경우(네트워크 상의 데이터가 db에는 아직 로드되지 않은 경우)
                }
            }

            Log.i("KMJTEST", "[PopularMovieMediator] loadKey is $loadKey")
            val response =
                api.getPopularMovies(App.context().getString(R.string.api_key), loadKey ?: 1) // 위에서 얻은 loadKey에 따라 api 실행


            val nextPage = (if (response.page == response.total_pages) null else response.page + 1) // 네트워크 상 페이지가 막페이지면 다음 페이지는 null 아니면 현재 페이지+1

            Log.i("KMJTEST", "[PopularMovieMediator] nextpage is $nextPage")

            response.results.forEach {
                Log.i("KMJTEST", "[PopularMovieMediator] movieId ${it.id}")
            }
            db.withTransaction {
                Log.i("KMJTEST", "[PopularMovieMediator] count before delete (${db.popularMovieDao().getCountMovie()} / ${db.popularMovieDao().getCountMoviePage()})")
                if (loadType == LoadType.REFRESH) { // 로드 타입이 리프레시인 경우
                    db.popularMovieDao().deletePopularMoviePageAll() // db에 캐시된 페이지 정보 지우기
                    db.popularMovieDao().deletePopularMovieAll() // db에 캐시된 데이터 지우기
                }

                Log.i("KMJTEST", "[PopularMovieMediator] count after delete (${db.popularMovieDao().getCountMovie()} / ${db.popularMovieDao().getCountMoviePage()})")

                Log.i("KMJTEST", "[PopularMovieMediator] ${response.results.last().id}")

                db.popularMovieDao().insertPopularMoviePage( // db에 페이지 정보 캐시
                    PopularMoviePage(response.results.last().id, nextPage)
                )

                db.popularMovieDao().insertPopularMovies(response.results.map { it.toRoomEntity() }) // db에 데이터 캐시

                Log.i("KMJTEST", "[PopularMovieMediator] count after insert (${db.popularMovieDao().getCountMovie()} / ${db.popularMovieDao().getCountMoviePage()})")

            }

            MediatorResult.Success(endOfPaginationReached = nextPage == null) // 다음 페이지 여부에 따라 마지막에 도달했는지 반환
        }catch (e : IOException){
            e.printStackTrace()
            MediatorResult.Error(e)
        }catch (e : HttpException){
            e.printStackTrace()
            MediatorResult.Error(e)
        }
    }
}

[이슈] 

RemoteMediator에서는 별도의 키를 제공하지 않아 필요 시 별도로 테이블을 생성해서 관리해야함. CleanArchitecture를위해 Entity를 따로 구성했으므로 LocalDataSource를 구현함(Dao에서 바로 PagingSource를 return 타입으로 지정해서 구성하지 않음). LocalDataSource를 단일 데이터 저장소로 지정하고, 해당 데이터 저장소에서 key값이 null이면 RemoteMediator가 작동하는데, initialize()에서 Refresh 상태를 실행할 것인지 아닌지를 구별해 load()를 수행. LocalDataSource에서 로드된 가장 마지막 id를 기준으로 key 테이블을 조회해도 계속 null을 반환. 

 

[원인]

네트워크에서 받아온 데이터 중 가장 마지막 데이터를 기준으로 key를 관리했으나, 실제적으로 db에는 id 순으로 정렬됨. 따라서 db상의 마지막 데이터의 id는 key 테이블에서 관리되고 있지 않아 key 테이블 내에 데이터가 있어도 null을 반환.

 

[해결]

movieId가 아닌 별도의 primary key로 autoGenerate한 id를 사용

 

@Entity(tableName = "popular_movie")
data class PopularMovieLocal(
    @PrimaryKey(autoGenerate = true) @ColumnInfo(name="id") val id :Int,
    @ColumnInfo(name = "poster_path") val posterPath: String?,
    @ColumnInfo(name = "adult") val adult: Boolean,
    @ColumnInfo(name = "overview") val overview: String,
    @ColumnInfo(name = "release_date") val releaseDate: String,
    @ColumnInfo(name = "genre_ids") val genreIds: List<Int>,
    @ColumnInfo(name = "movie_id") val movieId: Int,
    @ColumnInfo(name = "original_title") val originalTitle: String,
    @ColumnInfo(name = "original_language") val originalLanguage: String,
    @ColumnInfo(name = "title") val title: String,
    @ColumnInfo(name = "backdrop_path") val backdropPath: String?,
    @ColumnInfo(name = "popularity") val popularity: Float,
    @ColumnInfo(name = "vote_count") val voteCount: Int,
    @ColumnInfo(name = "video") val video: Boolean,
    @ColumnInfo(name = "vote_average") val voteAverage: Float
) : EntityMapper<PopularMovie> {
    override fun toEntity(): PopularMovie = PopularMovie(
        id,
        posterPath,
        adult,
        overview,
        releaseDate,
        genreIds,
        movieId,
        originalTitle,
        originalLanguage,
        title,
        backdropPath,
        popularity,
        voteCount,
        video,
        voteAverage
    )
}