1. Clean Architecture 구조 문제
이전의 Planfit 코드와 다르게 이번에는 Repository를 만들었습니다.
이전에는 Repository를 따로 만들지 않고 UseCase만으로 구현했기 때문에, 구조에 대한 깊은 고민을 하지 않았습니다. 하지만 이번에 Repository를 구현하고 UseCase의 인터페이스를 분리한 후, 그 구현체를 Data Layer에 두고 사용하다 보니 보일러플레이트 코드가 생기고, 이런 방식에 대해 의문이 들기 시작했습니다. 그래서 Clean Architecture에 대해 다시 찾아보면서, 제가 구조를 잘못 만들었다는 것을 깨달았습니다.
Clean Architecture에서는 Repository가 여러 데이터 소스(API, DB 등)를 관리하고, UseCase는 해당 비즈니스 로직을 처리합니다. 중요한 원칙 중 하나는 Domain Layer가 Data Layer나 Presentation Layer를 참조할 수 없다는 것입니다. 하지만 비즈니스 로직을 수행하려면 Domain Layer에서 데이터를 사용할 방법이 필요합니다. 이를 해결하기 위해 Domain Layer는 Data Layer를 참조하지 않고, 대신 데이터 소스를 관리하는 Repository 인터페이스만 정의합니다. 그런 다음 Data Layer에서는 이 Repository 인터페이스를 구현하고, 의존성 주입(DI)을 통해 UseCase에 연결합니다. UseCase는 Repository를 생성자 주입 방식으로 받아 사용하게 됩니다.
이전에는 Repository를 구현하지 않았기 때문에, UseCase를 인터페이스로 만들고 그 구현체를 Data Layer에 두었는데, 이는 비즈니스 로직을 Data Layer에 둠으로써 각 레이어의 책임이 명확히 분리되지 못하는 문제를 일으켰습니다.
해결
2. ViewPager 이슈: 조작시 화면 축소
원본 코드에서 ViewPager를 통해 배너를 보여주는 부분을 리팩토링하여, 기존에 Handler를 사용하던 방식을 Coroutine으로 변경하고, ViewPager에 PageChangeCallback을 추가하여 무한 스크롤을 구현했습니다. 이를 통해 마지막 화면에 도달해도 다음에 첫 번째 아이템이 나오도록, 반대로 첫 번째 아이템에서 이전으로 가면 마지막 아이템이 나오도록 했습니다. 자동으로 페이지를 넘기는 부분은 문제없이 동작했지만, 수동으로 페이지를 넘길 때 화면이 작아지거나 비율이 이상해지는 현상이 발생했습니다.
해결
이 문제는 ViewPager의 아이템 수가 3개 미만일 경우, position이 잘못 잡혀 화면 위치가 이상하게 설정되는 것이 원인이었습니다. 이를 해결하기 위해 ViewPager의 ItemList에 중복된 아이템을 추가하여 리스트의 크기를 늘리면, 해당 이슈를 해결할 수 있었습니다.
3. CleanArchitecture의 모델
데이터 레이어에서 서버에서 가져온 데이터를 사용할 때 해당 데이터를 저장하는 모델을 Data Layer에 정의하면 Domain Layer는 Data Layer를 참조할 수 없기 때문에 문제가 발생합니다. 물론 Domain Layer에 모델을 정의할 수 있지만, Room이나 Retrofit과 같은 라이브러리를 사용할 경우 해당 라이브러리가 요구하는 형태로 모델을 작성해야 하고, 이를 위해서는 그 라이브러리의 의존성을 gradle에 추가해야 합니다. 또한 Presentation Layer에서도 Parcelable이나 Serializable을 사용해 Activity 간에 데이터를 주고받거나, 번들에 데이터를 담아야 하는 경우가 있어, 모든 모델을 Domain Layer에 두는 것은 적합하지 않습니다. 이번 프로젝트에서도 Parcelable이나 Firebase를 사용할 때 모델이 필요했고, 이 부분을 고려하여 구현해야 했습니다.
해결
각 레이어에 모델을 따로 정의하고, Data Layer와 Presentation Layer에서는 Mapper를 사용해 Domain Layer로 데이터를 변환하여 전달합니다. 이렇게 하면 각 레이어에 맞는 모델을 사용할 수 있고, 상황에 맞게 적절한 형태로 데이터를 처리할 수 있습니다.
4. ListAdapter vs RecyclerView.Adapter
원본 소스에서는 RecyclerView.Adapter와 ViewPager.Adapter가 모두 RecyclerView.Adapter로 구현되었습니다. RecyclerView.Adapter는 변경 사항을 명확하게 처리할 수 있다는 장점이 있습니다. 데이터의 일부가 변경되면 notifyItemChanged를 호출하거나, 전체적으로 변경이 있을 때는 notifyDataSetChanged를 호출하여 RecyclerView에 변경 사항을 알릴 수 있습니다. 특정 항목만 변경되었을 때나 전체 데이터의 변경이 명확한 경우에는 RecyclerView.Adapter가 좋은 선택이 될 수 있습니다.
하지만, 매번 최신화되는 뉴스 같은 데이터를 다룰 때는 적합하지 않을 수 있습니다. 데이터가 요청될 때마다 어떤 데이터가 동일한지 알 수 없고, 변경 사항을 확인하는 데 시간이 걸린다면 ListAdapter가 RecyclerView.Adapter의 대안이 될 수 있습니다. ListAdapter는 DiffUtil.ItemCallback을 구현하여 아이템이 동일한지, 변경이 있는지를 쉽게 파악할 수 있으며, RecyclerView.Adapter와 달리 Adapter 내부에서 직접 리스트를 관리할 필요가 없습니다.
이러한 이유로 많은 개발자가 ListAdapter를 선호하지만, 하나의 항목만 토글되거나 변경 사항을 명확히 알고 있는 경우에는 RecyclerView.Adapter가 더 나은 선택일 수 있습니다. 이번 프로젝트에서도 좌석 선택, 날짜 선택, 시간 선택 등을 RecyclerView에서 처리해야 하는 경우가 있었고, 그때 상황에 맞는 적절한 Adapter를 선택할 수 있었습니다.
5. RecyclerView.Adapter ViewModel 연동
원본 코드에서는 MVVM 구조를 따르지 않고 대부분의 데이터가 Activity나 심지어 Adapter에 저장되어 있습니다. MVVM에서는 Activity나 UI에서 데이터를 유지해야 하는 경우 ViewModel에서 이를 관리하는 것이 바람직합니다. 구성 변경(Configuration Change)으로 인해 화면이 초기화되고 onCreate가 다시 호출되면 기존의 데이터가 사라지기 때문에, ViewModel에 데이터를 유지하고, Flow나 LiveData 같은 관찰 가능한 StateHolder를 이용해 데이터 변경 시 Callback이나 함수를 Adapter에 전달하여, ViewModel에서 값이 변경되면 이를 Adapter에 알려주는 방식으로 처리해야 합니다.
class TimeAdapter(private val timeSlots: List<String>) :
RecyclerView.Adapter<TimeAdapter.TimeViewholder>() {
private var selectedPosition = -1
private var lastSelectedPosition = -1
inner class TimeViewholder(private val binding: ItemTimeBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(time: String) {
binding.TextViewTime.text = time
if (selectedPosition == position) {
binding.TextViewTime.setBackgroundResource(R.drawable.white_bg)
binding.TextViewTime.setTextColor(
ContextCompat.getColor(
itemView.context,
R.color.black
)
)
} else {
binding.TextViewTime.setBackgroundResource(R.drawable.light_black_bg)
binding.TextViewTime.setTextColor(
ContextCompat.getColor(
itemView.context,
R.color.white
)
)
}
binding.root.setOnClickListener {
val position = position
if (position != RecyclerView.NO_POSITION) {
lastSelectedPosition = selectedPosition
selectedPosition = position
notifyItemChanged(lastSelectedPosition)
notifyItemChanged(selectedPosition)
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TimeAdapter.TimeViewholder {
return TimeViewholder(
ItemTimeBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: TimeAdapter.TimeViewholder, position: Int) {
holder.bind(timeSlots[position])
}
override fun getItemCount(): Int = timeSlots.size
}
위와 같은 경우에도 Adapter에서 관리하는 데이터를 ViewModel로 넘기고, Adapter는 ViewModel에서 업데이트를 받아 처리하는 것이 더 좋습니다.
해결
class TimeAdapter(
private val onItemClick: (Int) -> Unit,
) : RecyclerView.Adapter<TimeAdapter.TimeViewHolder>() {
private val timeSlots = mutableListOf<String>()
private var selectedPosition = RecyclerView.NO_POSITION
private var lastSelectedPosition = RecyclerView.NO_POSITION
fun submitList(newList: List<String>) {
timeSlots.clear()
timeSlots.addAll(newList)
selectedPosition = 0
lastSelectedPosition = 0
notifyDataSetChanged()
}
fun updateSelected(lastPosition: Int, selectPosition: Int) {
lastSelectedPosition = lastPosition
selectedPosition = selectPosition
if (lastSelectedPosition !in timeSlots.indices || selectedPosition !in timeSlots.indices) return
notifyItemChanged(lastSelectedPosition)
notifyItemChanged(selectedPosition)
}
inner class TimeViewHolder(private val binding: ViewholderTimeBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(time: String, isSelected: Boolean) {
binding.TextViewTime.text = time
if (isSelected) {
binding.TextViewTime.setBackgroundResource(R.drawable.ic_white)
binding.TextViewTime.setTextColor(
ContextCompat.getColor(
itemView.context,
R.color.black
)
)
} else {
binding.TextViewTime.setBackgroundResource(R.drawable.ic_light_black)
binding.TextViewTime.setTextColor(
ContextCompat.getColor(
itemView.context,
R.color.white
)
)
}
binding.root.setOnClickListener {
if (!isSelected) {
onItemClick(adapterPosition)
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TimeAdapter.TimeViewHolder {
return TimeViewHolder(
ViewholderTimeBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: TimeAdapter.TimeViewHolder, position: Int) {
holder.bind(timeSlots[position], position == selectedPosition)
}
override fun getItemCount(): Int = timeSlots.size
}
데이터를 Adapter에서 관리하는 대신, ViewModel에서 관리하고 LiveData 또는 Flow로 Adapter에 변경 사항을 알려주는 방식으로 개선할 수 있습니다. 이렇게 하면 구성 변경 시에도 데이터가 유지되고, MVVM 구조에 맞는 데이터 흐름을 구현할 수 있습니다.
6. 중복제거 공통화
class TimeAdapter(
private val onItemClick: (Int) -> Unit,
) : RecyclerView.Adapter<TimeAdapter.TimeViewHolder>() {
private val timeSlots = mutableListOf<String>()
private var selectedPosition = RecyclerView.NO_POSITION
private var lastSelectedPosition = RecyclerView.NO_POSITION
fun submitList(newList: List<String>) {
timeSlots.clear()
timeSlots.addAll(newList)
selectedPosition = 0
lastSelectedPosition = 0
notifyDataSetChanged()
}
fun updateSelected(lastPosition: Int, selectPosition: Int) {
lastSelectedPosition = lastPosition
selectedPosition = selectPosition
if (lastSelectedPosition !in timeSlots.indices || selectedPosition !in timeSlots.indices) return
notifyItemChanged(lastSelectedPosition)
notifyItemChanged(selectedPosition)
}
inner class TimeViewHolder(private val binding: ViewholderTimeBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(time: String, isSelected: Boolean) {
binding.TextViewTime.text = time
if (isSelected) {
binding.TextViewTime.setBackgroundResource(R.drawable.ic_white)
binding.TextViewTime.setTextColor(
ContextCompat.getColor(
itemView.context,
R.color.black
)
)
} else {
binding.TextViewTime.setBackgroundResource(R.drawable.ic_light_black)
binding.TextViewTime.setTextColor(
ContextCompat.getColor(
itemView.context,
R.color.white
)
)
}
binding.root.setOnClickListener {
if (!isSelected) {
onItemClick(adapterPosition)
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TimeAdapter.TimeViewHolder {
return TimeViewHolder(
ViewholderTimeBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: TimeAdapter.TimeViewHolder, position: Int) {
holder.bind(timeSlots[position], position == selectedPosition)
}
override fun getItemCount(): Int = timeSlots.size
}
class DateAdapter(
private val onItemClick: (Int) -> Unit,
) : RecyclerView.Adapter<DateAdapter.DateViewHolder>() {
private val dateSlots = mutableListOf<String>()
private var selectedPosition = RecyclerView.NO_POSITION
private var lastSelectedPosition = RecyclerView.NO_POSITION
fun submitList(newList: List<String>) {
dateSlots.clear()
dateSlots.addAll(newList)
selectedPosition = 0
lastSelectedPosition = 0
notifyDataSetChanged()
}
fun updateSelected(lastPosition: Int, selectPosition: Int) {
lastSelectedPosition = lastPosition
selectedPosition = selectPosition
if (lastSelectedPosition !in dateSlots.indices || selectedPosition !in dateSlots.indices) return
notifyItemChanged(lastSelectedPosition)
notifyItemChanged(selectedPosition)
}
inner class DateViewHolder(private val binding: ViewholderDateBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(date: String, isSelected: Boolean) {
val dateParts = date.split("/")
if (dateParts.size == 3) {
binding.dayTxt.text = dateParts[0]
binding.datMonthTxt.text = dateParts[1] + " " + dateParts[2]
if (isSelected) {
binding.mailLayout.setBackgroundResource(R.drawable.ic_white)
binding.dayTxt.setTextColor(
ContextCompat.getColor(
itemView.context,
R.color.black
)
)
binding.datMonthTxt.setTextColor(
ContextCompat.getColor(
itemView.context,
R.color.black
)
)
} else {
binding.mailLayout.setBackgroundResource(R.drawable.ic_light_black)
binding.dayTxt.setTextColor(
ContextCompat.getColor(
itemView.context,
R.color.white
)
)
binding.datMonthTxt.setTextColor(
ContextCompat.getColor(
itemView.context,
R.color.white
)
)
}
binding.root.setOnClickListener {
onItemClick(adapterPosition)
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DateAdapter.DateViewHolder {
return DateViewHolder(
ViewholderDateBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: DateAdapter.DateViewHolder, position: Int) {
holder.bind(dateSlots[position], position == selectedPosition)
}
override fun getItemCount(): Int = dateSlots.size
}
이 2가지 Adapter는 유사한부분이 많습니다. 코드 유지보수를 위해 중복코드를 처리할 필요가 있습니다.
해결
abstract class BaseAdapter<T>(
protected val onItemClick: (Int) -> Unit,
) : RecyclerView.Adapter<BaseAdapter.BaseViewHolder<T>>() {
protected val items = mutableListOf<T>()
protected var selectedPosition = RecyclerView.NO_POSITION
private var lastSelectedPosition = RecyclerView.NO_POSITION
fun submitList(newList: List<T>) {
items.clear()
items.addAll(newList)
selectedPosition = 0
lastSelectedPosition = 0
notifyDataSetChanged()
}
fun updateSelected(lastPosition: Int, selectPosition: Int) {
lastSelectedPosition = lastPosition
selectedPosition = selectPosition
notifyItemChanged(lastSelectedPosition)
notifyItemChanged(selectedPosition)
}
override fun getItemCount(): Int = items.size
abstract class BaseViewHolder<T>(bindingRoot: View) : RecyclerView.ViewHolder(bindingRoot) {
abstract fun bind(item: T, isSelected: Boolean)
}
}
class DateAdapter(
onItemClick: (Int) -> Unit,
) : BaseAdapter<String>(onItemClick) {
inner class DateViewHolder(private val binding: ViewholderDateBinding) :
BaseViewHolder<String>(binding.root) {
override fun bind(date: String, isSelected: Boolean) {
val dateParts = date.split("/")
if (dateParts.size == 3) {
binding.dayTxt.text = dateParts[0]
binding.datMonthTxt.text = "${dateParts[1]} ${dateParts[2]}"
if (isSelected) {
binding.mailLayout.setBackgroundResource(R.drawable.ic_white)
val textColor = ContextCompat.getColor(binding.root.context, R.color.black)
binding.dayTxt.setTextColor(textColor)
binding.datMonthTxt.setTextColor(textColor)
} else {
binding.mailLayout.setBackgroundResource(R.drawable.ic_light_black)
val textColor = ContextCompat.getColor(binding.root.context, R.color.white)
binding.dayTxt.setTextColor(textColor)
binding.datMonthTxt.setTextColor(textColor)
}
binding.root.setOnClickListener {
onItemClick(adapterPosition)
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DateAdapter.DateViewHolder {
return DateViewHolder(
ViewholderDateBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: BaseViewHolder<String>, position: Int) {
holder.bind(items[position], position == selectedPosition)
}
}
class TimeAdapter(
onItemClick: (Int) -> Unit,
) : BaseAdapter<String>(onItemClick) {
inner class TimeViewHolder(private val binding: ViewholderTimeBinding) :
BaseViewHolder<String>(binding.root) {
override fun bind(time: String, isSelected: Boolean) {
binding.TextViewTime.text = time
if (isSelected) {
binding.TextViewTime.setBackgroundResource(R.drawable.ic_white)
val textColor = ContextCompat.getColor(itemView.context, R.color.black)
binding.TextViewTime.setTextColor(textColor)
} else {
binding.TextViewTime.setBackgroundResource(R.drawable.ic_light_black)
val textColor = ContextCompat.getColor(itemView.context, R.color.white)
binding.TextViewTime.setTextColor(textColor)
}
binding.root.setOnClickListener {
if (!isSelected) {
onItemClick(adapterPosition)
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TimeAdapter.TimeViewHolder {
return TimeViewHolder(
ViewholderTimeBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: BaseViewHolder<String>, position: Int) {
holder.bind(items[position], position == selectedPosition)
}
}
'안드로이드 > 안드로이드' 카테고리의 다른 글
CIP(Cat-Image-Provider) 프로젝트하면서 생긴 이슈 및 해결 ,기술 정리 (1) | 2024.09.27 |
---|---|
CIP(Cat Image Provider) 개발 회고 (0) | 2024.09.26 |
Ticket Booking app - UiLover Android 클론코딩 회고 (4) | 2024.09.21 |
Planfit 프로젝트하면서 생긴 이슈 및 해결 ,기술 정리 (2) | 2024.09.14 |
Planfit onBoarding 페이지 클론 코딩 회고 (1) | 2024.09.13 |