와챠의 우당탕탕 코딩 일기장
[Android/Kotlin]Udemy 강의 정리: #12: 장바구니 RecyclerView, Room(완강^^v) 본문
[Android/Kotlin]Udemy 강의 정리: #12: 장바구니 RecyclerView, Room(완강^^v)
minWachya 2022. 6. 24. 20:28
요구사항 1
장바구니 RecyclerView에 들어갈 layout은
- 상품 브랜드명 layout: item_cart_brand.xml
- 상품 상세 layout: item_cart_product.xml
으로 총 2가지임. 이 2가지 layout을 한 RecyclerView에 설정하려고 함,
= 2가지 타입의 ViewHolder만들어야 함
요구사항 2
brand명에 따른 product를 RecyclerView에 보여줘야 함
요구사항 3
로컬 데이터 베이스에 저장해야함: Room 라이브러리 사용
RecyclerView에 들어갈 layout은 다음과 같음
item_cart_brand.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="header"
type="com.example.shoppi.model.CartHeader" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<CheckBox
android:id="@+id/cb_item_cart_brand"
android:layout_width="20dp"
android:layout_height="20dp"
android:background="@drawable/selector_cart_checkbox"
android:button="@null"
android:checked="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_item_cart_brand"
style="@style/TextSubtitle1.Bold"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@{header.brandName}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/cb_item_cart_brand"
app:layout_constraintTop_toTopOf="parent"
tools:text="twg. official" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
item_cart_product.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="product"
type="com.example.shoppi.model.CartProduct" />
</data>
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="@color/shoppi_white"
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<CheckBox
android:id="@+id/cb_item_cart_product"
android:layout_width="20dp"
android:layout_height="20dp"
android:background="@drawable/selector_cart_checkbox"
android:button="@null"
android:checked="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/iv_item_cart_product"
imageUrl="@{product.thumbnailImageUrl}"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:contentDescription="@string/description_image_item_product_thumbnail"
app:layout_constraintStart_toEndOf="@id/cb_item_cart_product"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/Circle" />
<TextView
android:id="@+id/tv_item_cart_product_title"
style="@style/TextCaption1.Grey01"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:text="@{product.label}"
app:layout_constraintEnd_toStartOf="@id/btn_item_cart_product_delete"
app:layout_constraintStart_toEndOf="@id/iv_item_cart_product"
app:layout_constraintTop_toTopOf="@id/iv_item_cart_product"
tools:text="캐시미어 100% 터틀넥 스웨터" />
<ImageButton
android:id="@+id/btn_item_cart_product_delete"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/ic_delete"
android:contentDescription="@string/description_btn_delete"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_item_cart_product_option"
style="@style/TextCaption1.Grey01"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@{product.type}"
app:layout_constraintEnd_toEndOf="@id/tv_item_cart_product_title"
app:layout_constraintStart_toStartOf="@id/tv_item_cart_product_title"
app:layout_constraintTop_toBottomOf="@id/tv_item_cart_product_title"
tools:text="옵션: Free" />
<ImageButton
android:id="@+id/iv_item_cart_product_minus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/ic_minus"
android:contentDescription="@string/description_btn_minus"
app:layout_constraintStart_toStartOf="@id/tv_item_cart_product_option"
app:layout_constraintTop_toBottomOf="@id/tv_item_cart_product_option" />
<TextView
android:id="@+id/tv_item_cart_product_count"
style="@style/TextSubtitle2.Black02.Bold"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:text="@{product.count}"
app:layout_constraintBottom_toBottomOf="@id/iv_item_cart_product_minus"
app:layout_constraintEnd_toStartOf="@id/iv_item_cart_product_plus"
app:layout_constraintStart_toEndOf="@id/iv_item_cart_product_minus"
app:layout_constraintTop_toTopOf="@id/iv_item_cart_product_minus"
tools:text="1" />
<ImageButton
android:id="@+id/iv_item_cart_product_plus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:background="@drawable/ic_plus"
android:contentDescription="@string/description_btn_plus"
app:layout_constraintStart_toEndOf="@id/tv_item_cart_product_count"
app:layout_constraintTop_toTopOf="@id/iv_item_cart_product_minus" />
<TextView
android:id="@+id/tv_item_cart_product_price"
style="@style/TextSubtitle1.Bold"
priceAmount="@{product.price}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/iv_item_cart_product_plus"
tools:text="81,000원" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</layout>
Room 라이브러리 사용 위해 아래 코드 추가
// Room Database
implementation("androidx.room:room-runtime:$roomVersion")
kapt("androidx.room:room-compiler:$roomVersion
implementation("androidx.room:room-ktx:$roomVersion")
+ Room이란? (공식문서)
- 데이터베이스 클래스: 데이터베이스를 보유, 앱의 영구 데이터와의 기본 연결을 위한 기본 액세스 포인트 역할
- 데이터 항목: 앱 데이터베이스의 Table
- 데이터 액세스 객체(DAO): 앱이 데이터베이스의 데이터를 쿼리, 업데이트, 삽입, 삭제하는 데 사용할 수 있는 메서드를 제공
테이블 생성: @Entity
기본키: @PrimaryKey
만약 변수 명이 달라진다면 @ColumnInfo 사용
@Entity
data class User(
@PrimaryKey val uid: Int,
@ColumnInfo(name = "first_name") val firstName: String?,
@ColumnInfo(name = "last_name") val lastName: String?
)
통신하기: @Dao
@Query, @Insert, @Delete 요청 하기
@Dao
interface UserDao {
@Query("SELECT * FROM user")
fun getAll(): List<User>
@Query("SELECT * FROM user WHERE uid IN (:userIds)")
fun loadAllByIds(userIds: IntArray): List<User>
@Query("SELECT * FROM user WHERE first_name LIKE :first AND " +
"last_name LIKE :last LIMIT 1")
fun findByName(first: String, last: String): User
@Insert
fun insertAll(vararg users: User)
@Delete
fun delete(user: User)
}
데이터 베이스 생성하기
@Database 추가 + 생성할 Table 배열로 선언 + 위에서 작성한 Dao에 접근할 메서드 추가
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
1. 브랜드명(CartHeader)과 상품 상세 데이터(CartProduct)를 함께 관리할 data class Cart Data생성
이때 CartProduct를 db에 저장할 것이므로 @Entity, @PrimaryKey 써줌
package com.example.shoppi.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
sealed class CartData
data class CartHeader(
val brandName: String
): CartData()
@Entity(
tableName = "cart_product"
)
data class CartProduct(
@PrimaryKey @ColumnInfo(name = "product_id") val productId: String,
val label: String,
val price: Int,
@ColumnInfo(name = "brand_name") val brandName: String,
@ColumnInfo(name = "thumbnail_image_url") val thumbnailImageUrl: String,
val type: String,
val count: Int
): CartData()
2. database 패키지 생성 후 AppDatabase + CartProductDao 생성: 로컬 db에 데이터 저장하기 위함
@Database(entities = [CartProduct::class], version = 1)
abstract class AppDatabase: RoomDatabase() {
// Dao에 접근하는 메서드
abstract fun cartProductDao(): CartProductDao
}
@Dao
interface CartProductDao {
// 데이터 추가
@Insert(onConflict = OnConflictStrategy.REPLACE) // onConflict: 이미 있는 데이터 중 일부만 바뀔 때
suspend fun insert(cartProduct: CartProduct)
// 데이터 요청
@Query("SELECT * FROM cart_product")
suspend fun load(): List<CartProduct>
}
3. CartDataSorce / CartLocalDataSource / CartRepository 생성
- CartDataSorce: CartData 가져오는 함수 가지는 interface
- CartLocalDataSource: 위 interface를 구현한 객체,
- CartRepository: 위 객체 생성하고 함수 사용해 실제로 CartData 얻고 반환함
interface CartDataSource {
// 데이터 가져오는 시간 오래 걸리기 때문에 메인 스레드에서 작업하면 X
suspend fun getCartProduct(): List<CartProduct>
// 데이터 추가
suspend fun addCartProduct(cartProduct: CartProduct)
}
// ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
class CartLocalDataSource(private val dao: CartProductDao): CartDataSource {
// 데이터 베이스와 통신
override suspend fun getCartProduct(): List<CartProduct> {
return dao.load()
}
// 데이터 추가
override suspend fun addCartProduct(cartProduct: CartProduct) {
dao.insert(cartProduct)
}
}
// ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
class CartRepository(
private val localDataSource: CartLocalDataSource,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
suspend fun addCartProduct(product: Product) {
withContext(ioDispatcher) {
val cartProduct = CartProduct(
productId = product.productId,
label = product.label,
price = product.price,
brandName = product.brandName ?: "",
thumbnailImageUrl = product.thumbnailImageUrl ?: "",
type = product.type ?: "",
count = 1
)
localDataSource.addCartProduct(cartProduct)
}
}
suspend fun getCartProduct(): List<CartProduct> {
return withContext(ioDispatcher) {
localDataSource.getCartProduct()
}
}
}
4. CartViewModel 생성
class CartViewModel(private val cartRepository: CartRepository): ViewModel() {
private val _items = MutableLiveData<List<CartProduct>>()
val items: LiveData<List<CartProduct>> = _items
init {
loadCartProduct()
}
private fun loadCartProduct() {
viewModelScope.launch {
_items.value =cartRepository.getCartProduct()
}
}
}
+ ProductDetail 화면에서 <장바구니 담기> 버튼 통해 장바구니 담는 거니깐은,,,
fragment_product_detail.xml의 해당 버튼에 onClick 설정
android:onClick="@{() -> viewModel.addCart(product)}"
+ ProductDetailViewModel에도 addCart 함수 만들기 + 버튼의 상태를 저장하는 addCartEvent도 생성
class ProductDetailViewModel(
private val productDetailRepository: ProductDetailRepository,
private val cartRepository: CartRepository
) : ViewModel() {
private val _productDetail = MutableLiveData<Product>()
val productDetail: LiveData<Product> = _productDetail
// 버튼의 이벤트 상태 저장함
private val _addCartEvent = MutableLiveData<Event<Unit>>()
val addCartEvent: LiveData<Event<Unit>> = _addCartEvent
fun loadProductDetail(productId: String) {
viewModelScope.launch {
val productDetail = productDetailRepository.getProductDetail(productId)
_productDetail.value = productDetail
}
}
fun addCart(product: Product) {
viewModelScope.launch {
cartRepository.addCartProduct(product)
_addCartEvent.value = Event(Unit)
}
}
}
+ Event: 데이터 한 번만 사용되게 보장해줌
package com.example.shoppi.ui.common
import androidx.lifecycle.Observer
// 데이터 한 번 사용되면 더이상 못 쓰게
class Event<T>(private val content: T) {
// 데이터 소비 여부
private var hasBeenHandled = false
fun getContentIfNotHandled(): T? {
return if (hasBeenHandled) {
null
} else {
hasBeenHandled = true
content
}
}
}
// LiveData의 데이처가 변경되었는지 확인
// null 아닐 때만 데이터 전달
class EventObserver<T>(private val onEventUnhandledContent: (T) -> Unit): Observer<Event<T>> {
override fun onChanged(event: Event<T>?) {
event?.getContentIfNotHandled()?.let {
onEventUnhandledContent(it)
}
}
}
5. 장바구니 아이템 담길 RecyclerView 관리할 Adapter 생성
package com.example.shoppi.ui.cart
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.example.shoppi.databinding.ItemCartBrandBinding
import com.example.shoppi.databinding.ItemCartProductBinding
import com.example.shoppi.model.CartData
import com.example.shoppi.model.CartHeader
import com.example.shoppi.model.CartProduct
import retrofit2.http.Header
private const val VIEW_TYPE_HEADER = 0
private const val VIEW_TYPE_PRODUCT = 1
class CartAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder>() {
// 관리할 데이터
private val cartData = mutableListOf<CartData>()
// 2가지 ViewHolder 생성: viewType 구분 위해 getItemViewType 오버라이드 하기
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when(viewType) {
VIEW_TYPE_HEADER -> HeaderViewHolder(ItemCartBrandBinding.inflate(inflater, parent, false))
else -> ProductViewHolder(ItemCartProductBinding.inflate(inflater, parent, false)) // VIEW_TYPE_PRODUCT
}
}
// 2가지 ViewHolder item 전달
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when(holder) {
is HeaderViewHolder -> {
val item = cartData[position] as CartHeader
holder.bind(item)
}
is ProductViewHolder -> {
val item = cartData[position] as CartProduct
holder.bind(item)
}
}
}
override fun getItemCount(): Int = cartData.size
// ViewType 구분
override fun getItemViewType(position: Int): Int {
return when(cartData[position]) {
is CartHeader -> VIEW_TYPE_HEADER
is CartProduct -> VIEW_TYPE_PRODUCT
}
}
// cartData에 데이터 넣기
fun submitHeaderAndProductList(items: List<CartProduct>) {
val itemGroups = items.groupBy { it.brandName } // 상품 상세 데이터를 브랜드명으로 구분
val data = mutableListOf<CartData>()
itemGroups.entries.forEach { entry ->
val header = CartHeader(entry.key)
data.add(header)
data.addAll(entry.value)
}
cartData.addAll(data)
notifyItemRangeInserted(cartData.size, data.size) // 아이템이 추가되는 position, 추가된 아이템 갯수
}
class HeaderViewHolder(private val binding: ItemCartBrandBinding): RecyclerView.ViewHolder(binding.root) {
fun bind(cartHeader: CartHeader) {
binding.header = cartHeader
binding.executePendingBindings()
}
}
class ProductViewHolder(private val binding: ItemCartProductBinding): RecyclerView.ViewHolder(binding.root) {
fun bind(cartProduct: CartProduct) {
binding.product = cartProduct
binding.executePendingBindings()
}
}
}
6. ViewModelFactory에 CartViewModel 생성법 추가
class ViewModelFactory(private val context: Context): ViewModelProvider.Factory {
// ViewModel 생성하고 반환하기
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when {
// ...
modelClass.isAssignableFrom(ProductDetailViewModel::class.java) -> {
val repository = ProductDetailRepository(ProductDetailRemoteDataSource(ServiceLoader.provideApiClient()))
ProductDetailViewModel(repository, ServiceLoader.provideCartRepository(context)) as T
}
modelClass.isAssignableFrom(CartViewModel::class.java) -> {
CartViewModel(ServiceLoader.provideCartRepository(context)) as T
}
else -> {
throw IllegalArgumentException("Failed to create ViewModel: ${modelClass.name}")
}
}
}
}
+ 참고: CartRepository, Database를 싱글톤으로 생성하고 반환하는 코드
package com.example.shoppi
import android.content.Context
import androidx.room.Room
import com.example.shoppi.database.AppDatabase
import com.example.shoppi.network.ApiClient
import com.example.shoppi.repository.cart.CartLocalDataSource
import com.example.shoppi.repository.cart.CartRepository
object ServiceLoader {
private var apiClient: ApiClient? = null
private var database: AppDatabase?= null
private var cartRepository: CartRepository ?= null
fun provideApiClient(): ApiClient {
return apiClient ?: kotlin.run {
ApiClient.create().also {
apiClient = it
}
}
}
private fun provideDatabase(applicationContext: Context): AppDatabase {
return database ?: run {
Room.databaseBuilder(
applicationContext,
AppDatabase::class.java,
"shpooi-local" // db 이름
).build().also {
database = it
}
}
}
fun provideCartRepository(context: Context): CartRepository {
return cartRepository ?: run {
val dao = provideDatabase(context.applicationContext).cartProductDao()
CartRepository(CartLocalDataSource(dao)).also {
cartRepository = it
}
}
}
}
7. CartFragment에 viewModel 생성 + Adapter 연결
class CartFragment: Fragment() {
private val viewModel: CartViewModel by viewModels { ViewModelFactory(requireContext()) }
private lateinit var binding: FragmentCartBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentCartBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
setListAdapter()
}
private fun setListAdapter() {
val cartAdapter = CartAdapter()
binding.rvCart.adapter = cartAdapter
viewModel.items.observe(viewLifecycleOwner) { cartProduct ->
cartAdapter.submitHeaderAndProductList(cartProduct)
}
}
}
dk 피곤타
암튼 Udemy <안드로이드 앱개발 부트캠프> 완강~^^
재맜는 여정이었다
넘 힘드니까 후기는 담에 쓸게