[Kotlin Side Project] Paging3 적용(Offline-first)
2022. 3. 4. 18:01ㆍKotlin/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()}"
}
}
'Kotlin > Kotlin Side Project' 카테고리의 다른 글
[Kotlin Side Project] Hilt 적용 (0) | 2022.03.21 |
---|---|
[Kotlin Side Project] Dagger2 적용 (1) | 2022.03.21 |
[Kotlin Side Project] Clean Architecture 구현 (0) | 2022.03.04 |
[Kotlin Side Project] 구현 목표 (1) | 2022.03.04 |
[Kotlin Side Project] RemoteMediator 무한 로딩 (0) | 2021.11.18 |