[Kotlin Side Project] Paging3 적용(Offline-first)

2022. 3. 4. 18:01Kotlin/Kotlin Side Project

  • DB+Network로 단일 저장소로 구현하기 위해 RemoteMediator를 사용
  • RemoteMediator의 구현 방법에 따라 item-keyed, page-keyed로 나뉠 수 있음.
    • RemoteMediator는 키를 관리할 수 있는 별도의 기능을 제공하지 않으므로, db와 network간 싱크를 맞추기 위해 별도로 key를 저장하는 형태가 되어야한다.
  • RemoteMediator 작동 방식
    • Pager는 UI에서 새로운 데이터가 필요할 경우, PagingSource의 load()를 호출해 PagingData라는 스트림을 생성(viewModel에 캐싱했었음)
    • RemoteMediator를 사용할 때도 마찬가지로 PagingSource의 데이터 스트림을 생성하게끔 호출하지만, PagingSource의 데이터가 모두 소진되면 RemoteMediator가 trigger되고, 네트워크로부터 새로운 데이터를 받아오면 로컬 db에 저장하기 때문에 viewModel의 in-memory 캐싱이 필요 없어지고, PagingSource는 자신을 무효화하고, Pager가 새로운 데이터를 받아오면 새로운 인스턴스를 생성해 새로운 데이터를 로드한다.
  • Data Layer
@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
    )
}

/** network와 싱크를 맞추기 위한 key 관리 테이블
*
**/
@Entity(tableName = "popular_movie_page")
data class PopularMoviePage(
    @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id : Int,
    @ColumnInfo(name = "movieId") val movieId: Int,
    @ColumnInfo(name = "nextPage") val nextPage: Int?
)
@Dao
interface PopularMovieDao {
    /** RemoteMediator을 구현하기 위한 쿼리
     *
     */
    @Query("select * from popular_movie")
    fun getPopularMoviesPagingSource() : PagingSource<Int, PopularMovieLocal>

    @Query("select * from popular_movie_page order by rowid desc limit 1")
    fun getLastNextPage() : PopularMoviePage?

    @Query("select nextPage from popular_movie_page where movieId=:movieId")
    fun getNextPage(movieId : Int?) : Int?

    @Query("select count(id) from popular_movie")
    suspend fun getCountMovie() : Int

    @Query("select count(movieId) from popular_movie_page")
    suspend fun getCountMoviePage() : Int
}
/** 단일 데이터 저장소 역할을 돕는 클래스
 * DB+Network 구조에서 Offline-first 구현한 형태
 */
@OptIn(ExperimentalPagingApi::class)
class PopularMovieMediator(
    private val db: LocalMovieDatabase,
    private val api: TMDBService,
    private val refreshControl: RefreshControl) : RemoteMediator<Int, PopularMovieLocal>() {

    private lateinit var mode : InitializeAction

    override suspend fun initialize(): InitializeAction {
        return if(refreshControl.isExpired()){
            refreshControl.refresh();
            mode = InitializeAction.LAUNCH_INITIAL_REFRESH
            InitializeAction.LAUNCH_INITIAL_REFRESH }
        else{
            mode = InitializeAction.SKIP_INITIAL_REFRESH
            InitializeAction.SKIP_INITIAL_REFRESH
        }

    }

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

            Log.i("KMJTEST","[PopularMovieMediator] LoadType $loadType state $state")
            
            // Network에서 로드할 기준 키를 탐색
            val loadKey = when (loadType) {
                LoadType.REFRESH -> null // 리프레시의 경우 무조건 첫 페이지부터 로드
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
                LoadType.APPEND -> { // 추가적인 로드가 필요한 경우
                    Log.i("KMJTEST","[PopularMovieMediator] Append")

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


                    Log.i("KMJTEST","[PopularMovieMediator] Append ${nextPage}")
                    // 로드할 페이지가 남아있는 경우(네트워크 상의 데이터가 db에는 아직 로드되지 않은 경우)
                    nextPage 
                }
            }

            
            // 탐색한 로드키로 Network 데이터 로드(loadkey가 null이면 첫 페이지부터 로드)
            val response =
                api.getPopularMovies(App.context().getString(R.string.api_key), loadKey ?: 1) 
            

			// page-keyed로 구성된 데이터 로드 시 마지막 페이지 여부 체크
            // 네트워크 상 페이지가 막페이지면 다음 페이지는 null 아니면 현재 페이지+1
            val nextPage : Int? = (if (response.page == response.totalPages) null else response.page + 1) 


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

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

            }

			// 다음 페이지 여부에 따라 마지막에 도달했는지 반환
            MediatorResult.Success(endOfPaginationReached = nextPage == null ) 
        }catch (e : IOException){
            e.printStackTrace()
            MediatorResult.Error(e)
        }catch (e : HttpException){
            e.printStackTrace()
            MediatorResult.Error(e)
        }
    }
}
class PopularMovieRepositoryImpl(
    private val localDataSource: LocalDataSource,
    private val remoteDataSource: RemoteDataSource,
    private val db : LocalMovieDatabase,
    private val api : TMDBService
) : PopularMovieRepository {

    @ExperimentalPagingApi
    override suspend fun getAll(): Flow<PagingData<PopularMovie>> {
        return Pager(
            pagingSourceFactory = {
                db.popularMovieDao().getPopularMoviesPagingSource()
                },
            remoteMediator = PopularMovieMediator(db, api, RefreshControl(TimeUnit.MINUTES.toMillis(5))),
            config = PagingConfig(pageSize = 20, initialLoadSize = 20, prefetchDistance = 3)
        ).flow.map {
            it.map {
                it.toEntity()
            }
        }

    }

}

 

  • Domain Layer
class GetPopularMovies(private val repository: PopularMovieRepository) :  UseCase<PopularMovie>() {
    override suspend fun buildUseCase(): Flow<PagingData<PopularMovie>> {
        return repository.getAll();
    }
}

 

  • Presentation Layer
class MainViewModel(val getPopularMovies: GetPopularMovies) : ViewModel() {
    suspend fun getContent() : Flow<PagingData<PopularMovie>> {
        Log.i("KMJTEST","[MainViewModel] getContent()")
        return getPopularMovies.buildUseCase()
    }

}

class MainViewModelFactory(val getPopularMovies: GetPopularMovies) : ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(p0: Class<T>): T {
        if(p0.isAssignableFrom(MainViewModel::class.java)) {
            return MainViewModel(getPopularMovies) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}
class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding
    private val viewModel by viewModels<MainViewModel> {
        MainViewModelFactory(
            GetPopularMovies(
                PopularMovieRepositoryImpl(
                    LocalDataSource(
                        LocalMovieDatabase.getLocalMovieDatabase(
                            App.context(),
                            lifecycleScope
                        )
                    ), RemoteDataSource(RetrofitClient.getTMDBService())
                , LocalMovieDatabase.getLocalMovieDatabase(App.context(), lifecycleScope)
                , RetrofitClient.getTMDBService()
                )
            )
        )
    }
    private val adapter = PagingAdapter()


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.recyclerView.layoutManager = LinearLayoutManager(this)
        binding.recyclerView.adapter = adapter

        lifecycleScope.launch {
             viewModel.getContent()?.collectLatest {
                 adapter.submitData(it)
             }
        }

    }


}
class PagingAdapter :
    PagingDataAdapter<PopularMovie, PagingViewHolder>(diffCallback) {

    companion object {
        private val diffCallback = object : DiffUtil.ItemCallback<PopularMovie>() {
            override fun areItemsTheSame(oldItem: PopularMovie, newItem: PopularMovie): Boolean {
                return oldItem.id == newItem.id
            }

            override fun areContentsTheSame(oldItem: PopularMovie, newItem: PopularMovie): Boolean {
                return oldItem == newItem
            }
        }
    }

    override fun getItemCount(): Int {
        //Log.i("KMJTEST","[PagingAdapter] item Count : "+super.getItemCount())
        return super.getItemCount()
    }

    override fun onBindViewHolder(holder: PagingViewHolder, position: Int) {
        val popularMovie = getItem(position)
        if (popularMovie != null) {
            holder.bind(popularMovie)
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PagingViewHolder {
        val layoutInflater = LayoutInflater.from(parent.context)
        return PagingViewHolder(
            DataBindingUtil.inflate(
                layoutInflater,
                R.layout.item_popular,
                parent,
                false
            )
        )
    }
}

class PagingViewHolder(private val binding: ItemPopularBinding) :
    RecyclerView.ViewHolder(binding.root) {
    fun bind(popularMovie: PopularMovie) {
        var imgUrl = App.context().getString(R.string.img_url)
        imgUrl = imgUrl.substring(0,imgUrl.length-1) +"/w500"+ popularMovie.posterPath
        Glide.with(binding.imageView)
            .load(imgUrl)
            .circleCrop()
            .into(binding.imageView)

        binding.title.text = popularMovie.title
        binding.voteDesc.text = "${popularMovie.voteAverage.toString()} / voted by ${popularMovie.voteCount.toString()}"

    }
}