와챠의 우당탕탕 코딩 일기장

[Android/Kotlin]Udemy 강의 정리: #12: 장바구니 RecyclerView, Room(완강^^v) 본문

코딩 일기장/Android(Kotlin)

[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이란? (공식문서)

테이블 생성: @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 <안드로이드 앱개발 부트캠프> 완강~^^

재맜는 여정이었다

 

넘 힘드니까 후기는 담에 쓸게

반응형
Comments