안드로이드/앱개발(Android)

KaKao Open Api 책 검색 앱 - 2

김염인 2022. 9. 15. 14:34

KaKao Open Api 책 검색 앱 - 2

paging

위의 이미지와 같이 ListItem을 재활용 해주는 형식으로 View를 띄울 수 있다. 항상 구현해 왔듯이 ListItem과 RecyclerViewAdapter를 이용해 recyclerview를 구현해준다. 하지만 여기서 Paging 또한 구현해준다. Paging이란 무엇일까 ?

 

Api를 이용하려면 데이터가 터져야 한다. 데이터가 터지지 않는 상황에서는 Api호출이 불가능하다.

와이파이와 데이터 모두꺼져 있는 상황에서 APi를 불러와 보았다.

 

Paging 설정을 해주면 위와같이 데이터가 안터지기 때문에 RecyclerView 마지막에 Error occured가 뜨게 설정해주었다. 데이터가 터지지 않으면 Retry버튼도 통하지 않는다.

 

Paging이란?

 페이징이란 데이터를 가져올 때 한 번에 모든 데이터를 가져오는 것이 아니라 일정한 덩어리로 나눠서 가져오는 것을 뜻합니다. 예를 들어, 구글에서 어떤 키워드로 검색하게 되면 모든 데이터를 한 번에 가져오는 것이 아니라 10페이지씩 데이터를 가져오게 됩니다. 페이징을 사용하면 성능, 메모리, 비용 측면에서 굉장히 효율적입니다.

 

Paging의 장점

  • 페이징 된 데이터의 메모리 내 캐싱. 이렇게 하면 앱이 페이징 데이터로 작업하는 동안 시스템 리소스를 효율적으로 사용할 수 있습니다.
  • 요청 중복 제거 기능이 기본으로 제공되어 앱에서 네트워크 대역폭과 시스템 리소스를 효율적으로 사용할 수 있습니다.
  • 사용자가 로드된 데이터의 끝까지 스크롤할 때 구성 가능한 RecyclerView 어댑터가 자동으로 데이터를 요청합니다.
  • Kotlin 코루틴 및 Flow뿐만 아니라 LiveData 및 RxJava를 최고 수준으로 지원합니다.
  • 새로고침 및 재시도 기능을 포함하여 오류 처리를 기본으로 지원합니다.

Paging3 아키텍처

Paging3 라이브러리는 총 3개의 layer로 구성됩니다.

  1. Repository layer
  2. ViewModel layer
  3. UI layer

 

 

Paging Source 만들기

class BookSearchPagingSource(
    private val api: BookSearchApi,
    private val query: String,
    private val sort: String,
) : PagingSource<Int, Book>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Book> {
        return try {
            val pageNumber = params.key ?: STARTING_PAGE_INDEX

            val response = api.searchBooks(query, sort, pageNumber, params.loadSize)
            val endOfPaginationReached = response.body()?.meta?.isEnd!!

            val data = response.body()?.documents!!
            val prevKey = if (pageNumber == STARTING_PAGE_INDEX) null else pageNumber - 1
            val nextKey = if (endOfPaginationReached) {
                null
            } else {
                pageNumber + (params.loadSize / PAGING_SIZE)
            }
            LoadResult.Page(
                data = data,
                prevKey = prevKey,
                nextKey = nextKey,
            )
        } catch (exception: IOException) {
            LoadResult.Error(exception)
        } catch (exception: HttpException) {
            LoadResult.Error(exception)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Book>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
        }
    }

    companion object {
        const val STARTING_PAGE_INDEX = 1
    }
}

ㅁ 15개의 PaigingSize 만큼 가져오게 하는 PagingSource를 작성해준다.

 

 

item_load_state.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="8dp">

    <TextView
        android:id="@+id/tv_error"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Error message"
        android:textColor="@color/design_default_color_error"
        android:textSize="16sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <ProgressBar
        android:id="@+id/progress_bar"
        style="?android:attr/progressBarStyle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/btn_retry"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Retry"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

BookSearchViewHolder.kt

class BookSearchViewHolder(private val binding: ItemBookPreviewBinding) :
    RecyclerView.ViewHolder(binding.root) {

    fun bind(book: Book) {
        val author = book.authors.toString().removeSurrounding("[", "]") // List 형식이기 때문에 좌우 꺽쇠를 삭제
        val publisher = book.publisher
        val data = if (book.datetime!!.isNotEmpty()) book.datetime.substring(
            0,
            10
        ) else "" // 데이터는 Null 인경우도 있기 때문에 !

        itemView.apply {
            binding.ivArticleImage.load(book.thumbnail)
            binding.tvTitle.text = book.title
            binding.tvAuthor.text = "$author | $publisher"
            binding.tvDatetime.text = data
        }
    }
}

 

PagingDataAdapter.kt

class BookSearchPagingAdapter : PagingDataAdapter<Book, BookSearchViewHolder>(BookDiffCallback) {
    override fun onBindViewHolder(holder: BookSearchViewHolder, position: Int) {
        val pageBook = getItem(position)
        pageBook?.let { book ->
            holder.bind(book)
            holder.itemView.setOnClickListener {
                onItemClickListener?.let {
                    it(book)
                }
            }
        }
    }

    private var onItemClickListener: ((Book) -> Unit)? = null

    fun setOnItemClickListener(listener: (Book) -> Unit) {
        onItemClickListener = listener
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookSearchViewHolder {
        return BookSearchViewHolder(
            ItemBookPreviewBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        )
    }

    companion object {
        private val BookDiffCallback = object : DiffUtil.ItemCallback<Book>() {
            override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean {
                return oldItem.isbn == newItem.isbn
            }

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

        }
    }
}

 

 

BookSearchLoadStateAdpater.kt

class BookSearchLoadStateAdapter(
    private val retry: () -> Unit
) : LoadStateAdapter<BookSearchLoadStateViewHolder>() {

    override fun onBindViewHolder(holder: BookSearchLoadStateViewHolder, loadState: LoadState) {
        holder.bind(loadState)
    }

    override fun onCreateViewHolder(
        parent: ViewGroup,
        loadState: LoadState
    ): BookSearchLoadStateViewHolder {
        return BookSearchLoadStateViewHolder(
            ItemLoadStateBinding.inflate(LayoutInflater.from(parent.context), parent, false),
            retry
        )
    }
}

LoadStateAdapter  RecyclerView.Adapter 를 상속받은 클래스이다.

내부에는 LoadState 처리를 위한 코드가 들어있다.

 

onCreateViewHolder, onBindViewHolder 코드만 작성하면 된다.

 

 

BookSearchLoadStateViewHolder.kt

class BookSearchLoadStateViewHolder(
    private val binding: ItemLoadStateBinding,
    retry: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {

    init {
        binding.btnRetry.setOnClickListener {
            retry.invoke()
        }
    }

    fun bind(loadState: LoadState) {
        if (loadState is LoadState.Error) {
            binding.tvError.text = "Error occurred"
        }
        binding.progressBar.isVisible = loadState is LoadState.Loading
        binding.btnRetry.isVisible = loadState is LoadState.Error
        binding.tvError.isVisible = loadState is LoadState.Error
    }
}

Paging의 로딩 상태는 Loading, NotLoading, Error 세 가지로 나뉜다.
Error 일 때는 error 를 가져다 사용할 수 있다.

 

endOfPaginationReached 값은 로딩 가능 여부를 나타낸다. 해당 값이 true 이면 더 이상 로딩을 못하는 상태이다.

 

recyclerView의 Holder와 위에서 구현한 LoadState(데이터가 꺼졌을때 뜨는 하단 Alert)의 Holder를 구현해주어 각각에 알맞는 binding처리를 해준다.

 

adapter에 연결하기

private fun setupRecyclerView() {
    bookSearchAdapter = BookSearchPagingAdapter()
    binding.rvSearchResult.apply {
        setHasFixedSize(true)
        layoutManager =
            LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
        addItemDecoration(
            DividerItemDecoration(
                requireContext(),
                DividerItemDecoration.VERTICAL
            )
        )
        adapter = bookSearchAdapter.withLoadStateFooter(
            footer = BookSearchLoadStateAdapter(bookSearchAdapter::retry)
        )
    }
    bookSearchAdapter.setOnItemClickListener {
        val action = SearchFragmentDirections.actionFragmentSearchToFragmentBook(it)
        findNavController().navigate(action) // 페이지 이동!
    }
}

 

 

에러 상황도 확인하기 위해 일정 확률로 Exception 도 던졌다.

 

 

 

 DataStore

 

DataStore

Jetpack DataStore는 프로토콜 버퍼를 사용해서 키밸류 쌍 또는 유형이 지정된 객체를 저장할 수 있는 데이터 저장소 솔루션이다. DataStore는 코루틴 및 flow를 써서 비동기적이고 일관된 트랜잭션 방식으로 데이터를 저장한다. 현재 쉐어드 프리퍼런스를 써서 데이터를 저장하고 있다면 대신 DataStore로 이전하는 게 좋다
복잡한 대규모 데이터 세트, 부분 업데이트, 참조 무결성을 지원해야 할 경우 Room을 쓰는 게 좋다. DataStore는 소규모 단순 데이터 세트에 적합하며 부분 업데이트나 참조 무결성은 지원하지 않는다

DataStore는 Preferences Store, Proto DataStore 두 구현을 제공한다
- Preferences DataStore : 키를 써서 데이터를 저장하고 데이터에 접근한다. 유형 안전성을 제공하지 않으며 사전 정의된 스키마가 필요하지 않다
- Proto DataStore : 맞춤 데이터 유형의 인스턴스로 데이터 저장. 유형 안전성을 제공하며 프로토콜 버퍼를 써서 스키마를 정의해야 한다

 

BooksearchRepository에 두개의 함수추가

// DataStore
suspend fun saveSortMode(mode: String)

suspend fun getSortMode(): Flow<String>

 

private object PreferenceKeys {
    val SORT_MODE = stringPreferencesKey("sort_mode")
    val CACHE_DELETE_MODE = booleanPreferencesKey("cache_delete_mode")
}

override suspend fun saveSortMode(mode: String) {
    dataStore.edit { prefs -> //전달 받은 모드값을 edit 에서 접근
        prefs[SORT_MODE] = mode
    }
}

 

sharedPreference에 비해 에러핸들링, 데이터 콘시스턴시, 마이그래이션 또한 제공되기 때문에 더욱 좋은 효율을 가진다. DataStore는 코루틴과 Flow의 기능을 사용해서 데이터 검색 및 저장을 위한 완전 비동기식 API를 제공해 UI 쓰레드를 차단할 위협을 줄인다. 

 

 

 WorkManager

 

WorkManager란❓

WorkManager는 개발자를 대신하여 비동기로 백그라운드 작업을 처리합니다. 앱이 종료되거나 기기가 다시 시작되어도 안정적으로 실행이 되는 지연 가능한 비동기 작업을 처리하는데 적합합니다.

WorkManager에서 제공하는 백그라운드 스레드에서 작업 수행합니다. 참고로, WorkManager의 작업은 내부적으로 관리되는 SQLite 데이터베이스에 저장이 됩니다. WorkManager는 이 작업이 지속되고 기기 재부팅시 일정이 재조정되도록 합니다.

작업의 정확한 실행시간은 하나의 코드로 API Level 마다 비슷한 동작을 보장합니다. WorkManager는 API의 버전에 맞게 AlarmManager 또는 JobScheduler를 사용하고, FirebaseDispathcer의 의존성이 추가되었다면 이를 적극 이용합니다.

 

override suspend fun saveCacheDeleteMode(mode: Boolean) {
    dataStore.edit { prefs ->
        prefs[CACHE_DELETE_MODE] = mode
    }
}

override suspend fun getCacheDeleteMode(): Flow<Boolean> {
    return dataStore.data
        .catch { exception ->
            if (exception is IOException) {
                exception.printStackTrace()
                emit(emptyPreferences())
            } else {
                throw exception
            }
        }
        .map { prefs ->
            prefs[CACHE_DELETE_MODE] ?: false
        }
}

 

SettingViewmodel.kt

// WorkManager
fun setWork() {
    val constraints = Constraints.Builder()
        .setRequiresCharging(true)
        .setRequiresBatteryNotLow(true)
        .build()

    val workRequest = PeriodicWorkRequestBuilder<CacheDeleteWorker>(15, TimeUnit.MINUTES)
        .setConstraints(constraints)
        .build()

    workManager.enqueueUniquePeriodicWork(
        WORKER_KEY, ExistingPeriodicWorkPolicy.REPLACE, workRequest
    )
}

fun deleteWork() = workManager.cancelUniqueWork(WORKER_KEY)

fun getWorkStatus(): LiveData<MutableList<WorkInfo>> =
    workManager.getWorkInfosForUniqueWorkLiveData(WORKER_KEY)

companion object {
    private const val WORKER_KEY = "cache_worker"
}

 

 

정리

여러 Android 아키텍처와 라이브러리를 이용하여 더욱더 효율적 메모리 관리를 하는 BookSearchApp을 실습해보았는데 이 중 중요한 것은 팀워크가 중요시 되는 개발자 특성상 코드를 가시성 좋게 만드는게 중요하다고 생각한다. 그래서 중요하게 다루어야 할것은 usecase, hilt같은 재사용이 덜되게 만드는 코드 관리 기법이다. 이렇게 하나둘씩 실습해보니 더욱 기술효율성에 대해 생각해 보게 됐고 다른 앱에 지속적으로 적용가능 할것 같다.