서론
오늘은 2편에서 알아본 DiffUtil을 사용해 프로젝트를 마이그레이션 해보겠습니다. 모든 작업이 완료된 시점에서 글을 작성하다 보니 결론부터 말하자면 DiffUtil을 사용함으로써 성능은 끌어올렸겠지만 UI/UX 측면에서 큰 이점을 끌어올리진 못했습니다. 그 이유는 DiffUtil의 특성상 데이터가 변경된 뷰홀더를 재바인딩 하면서 화면을 다시 그리게 됩니다. 이 과정에서 일반 사용자가 보기엔 화면이 깜빡이는 현상이 일어났습니다. 그래서 오늘은 DiffUtil 어떻게 적용했는지부터 시작해 왜 이런 현상이 발생하는지 알아보겠습니다.
1. 필요한 데이터 클래스를 만들자
가장 먼저 할 일은 ListAdapter를 쉽게 사용할 수 있도록 RecyclerView.Adapter를 상속하고 있던 Adapter Class를 ListAdapter를 상속하도록 변경해 줍니다. 이때, 내부 구현 코드를 살펴보면 제네릭 파라미터로 두 가지의 타입을 지정해줘야 합니다.
- T: 리스트에 표시할 아이템의 데이터 타입입니다.
- VH: RecyclerView.ViewHolder를 상속하는 뷰홀더 클래스입니다.
ViewHolder는 RecyclerView.ViewHolder를 상속하는 클래스를 전달하면 됩니다. 하지만 저는 총 6개의 ViewHolder를 사용했으며 아래 그림처럼 각각의 필요한 데이터가 모두 달랐습니다.
각각의 화면이 사용할 데이터는 모두 다르지만 ListAdapter가 받을 수 있는 데이터 타입은 단 하나입니다. 그렇기 때문에 저는 ListAdapter에 하나의 타입으로 넘겨줄 sealed Class를 선언하고 각각의 ViewHolder가 사용할 데이터를 선언해주었습니다.
이때, DiffUtil.ItemCallback의 areItemsTheSame 메소드에서 사용될 인스턴스의 고유한 값이 필요해 UUID를 사용했습니다.
📌 UUID(Universally Unique Identifier) 란?
128-bit의 고유 식별자로 크기가 작고 다른 고유 식별자에 비해 정렬, 차수, 해싱 등 다양한 알고리즘에 사용하기 쉽습니다.
UUID는 이를 직접 상속하고 있는 클래스에 선언하는 것보다 부모 클래스의 주생성자 프로퍼티로 선언해 주는 것이 맞다고 판단해 부모 클래스에 선언해 주었습니다.
sealed class ListSearchUIModel(val uuid: UUID = UUID.randomUUID())
data class CategoryModel(
val optionState: MutableMap<DisabilityType, Int>
) : ListSearchUIModel()
data class AreaModel(
val areas: List<String>,
) : ListSearchUIModel()
data class SigunguModel(
val sigungus: List<String>,
val selectedSigungu: String
) : ListSearchUIModel()
data class SortModel(
val totalItemCount: Int,
): ListSearchUIModel()
data class NoPlaceModel(
val msg: String = "검색 결과가 없어요\n다시 검색 해주세요"
) : ListSearchUIModel()
data class PlaceModel(
val placeName: String = "",
val placeAddr: String = "",
val placeId: String = "",
val placeImg: String = "",
val disability: List<String> = emptyList(),
val itemCount: Int = 0
) : ListSearchUIModel()
이렇게 부모 클래스를 타입으로 받는 곳에 자식 타입을 사용할 수 있는 이유는 SOLID 원칙 중 리스코프 치환 원칙 때문입니다. 리스코프 치환 원칙이란 서브 타입은 기반 타입으로 대체 가능해야 한다 는 이론입니다. 즉, 상위 타입을 하위 타입으로 대체할 수 있다는 원칙으로 기반 클래스의 객체를 사용하는 코드는 서브 클래스의 객체로 대체하더라도 정상적으로 동작해야 하고, 할 수 있습니다.
2. Adapter를 만들자
class ListSearchAdapter(
private val uiScope: CoroutineScope,
private val onClickPhysicalDisability: (PhysicalDisability) -> Unit,
private val onClickVisualImpairment: (VisualImpairment) -> Unit,
private val onClickHearingDisability: (HearingImpairment) -> Unit,
private val onClickInfantFamily: (InfantFamily) -> Unit,
private val onClickElderlyPeople: (ElderlyPeople) -> Unit,
private val onSelectArea: (String) -> Unit,
private val onSelectSigungu: (String) -> Unit
) : ListAdapter<ListSearchUIModel, RecyclerView.ViewHolder>(DIFF_CALLBACK) {
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is CategoryModel -> R.layout.item_list_search_category
is PlaceModel -> R.layout.item_place_high
is AreaModel -> R.layout.item_list_search_area
is SigunguModel -> R.layout.item_list_search_sigungu
is SortModel -> R.layout.item_list_search_sort
is NoPlaceModel -> R.layout.item_no_place
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val v = layoutInflater.inflate(viewType, parent, false)
return when (viewType) {
R.layout.item_list_search_category -> CategoryViewHolder(
ItemListSearchCategoryBinding.bind(v),
uiScope,
onClickPhysicalDisability,
onClickVisualImpairment,
onClickHearingDisability,
onClickInfantFamily,
onClickElderlyPeople
)
R.layout.item_place_high -> PlaceHighViewHolder(
ItemPlaceHighBinding.bind(v)
)
R.layout.item_list_search_area -> AreaViewHolder(
ItemListSearchAreaBinding.bind(v),
onSelectArea,
)
R.layout.item_list_search_sort -> ItemCountViewHolder(
ItemListSearchSortBinding.bind(v)
)
R.layout.item_list_search_sigungu -> SigunguViewHolder(
ItemListSearchSigunguBinding.bind(v),
onSelectSigungu
)
R.layout.item_no_place -> NoPlaceViewHolder(ItemNoPlaceBinding.bind(v))
else -> throw IllegalArgumentException("Unknown View Type")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = getItem(position)) {
is CategoryModel -> {
(holder as CategoryViewHolder).bind(item)
}
is AreaModel -> {
(holder as AreaViewHolder).bind(item)
}
is SigunguModel -> {
(holder as SigunguViewHolder).bind(item)
}
is PlaceModel -> {
(holder as PlaceHighViewHolder).bind(item)
}
is SortModel -> {
(holder as ItemCountViewHolder).bind(item)
}
is NoPlaceModel -> {
(holder as NoPlaceViewHolder).bind(item.msg)
}
}
}
companion object {
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<ListSearchUIModel>() {
override fun areItemsTheSame(oldItem: ListSearchUIModel, newItem: ListSearchUIModel): Boolean {
return oldItem.uuid == newItem.uuid
}
override fun areContentsTheSame(oldItem: ListSearchUIModel, newItem: ListSearchUIModel): Boolean {
return oldItem == newItem
}
}
}
}
ListAdapter Class에 대한 코드입니다. 각각의 메서드의 역할에 대해 알아보겠습니다.
- getItemViewType(position: Int)
- 각 아이템의 위치(position)에 따라 어떤 뷰 타입을 사용할지 결정합니다.
- 뷰 타입은 onCreateViewHolder에서 어떤 뷰홀더를 생성할지 결정하는 데 사용됩니다.
- 코드에서는 when 문을 사용하여 getItem(position)으로 얻은 아이템의 타입에 따라 다른 레이아웃 ID를 반환하고 있습니다.
- onCreateViewHolder(parent: ViewGroup, viewType: Int)
- getItemViewType에서 반환된 뷰 타입에 따라 뷰홀더를 생성합니다.
- LayoutInflater를 사용하여 레이아웃을 inflate 하고, 뷰 타입에 따라 적절한 뷰홀더 클래스를 생성하여 반환합니다.
- 각 뷰홀더는 해당하는 레이아웃 파일과 필요한 데이터, 콜백 함수 등을 전달받아 초기화됩니다.
- onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int)
- 뷰홀더에 데이터를 바인딩합니다.
- getItem(position)으로 아이템 데이터를 가져와서 뷰홀더의 bind 메서드를 호출하여 데이터를 표시합니다.
- 현재 코드에서는 when 문을 사용하여 뷰홀더의 타입에 따라 다른 bind 메서드를 호출하고 있습니다.
- DIFF_CALLBACK
- DiffUtil.ItemCallback 객체로, ListAdapter가 리스트의 변경 사항을 감지하는 데 사용합니다.
- areItemsTheSame 메서드는 두 아이템이 같은 아이템인지 판별하고, areContentsTheSame 메서드는 두 아이템의 내용이 같은지 판별합니다.
- 현재 코드에서는 uuid를 사용하여 아이템의 고유성을 판별하고 있습니다.
3. ViewModel을 설계하자
@HiltViewModel
class SearchListViewModel @Inject constructor(
private val areaCodeRepository: AreaCodeRepository,
private val sigunguCodeRepository: SigunguCodeRepository,
private val placeRepository: PlaceRepository,
) : ViewModel() {
@Inject
lateinit var networkErrorDelegate: NetworkErrorDelegate
init {
viewModelScope.launch {
loadPlaces()
}
}
private val _uiState: MutableStateFlow<List<ListSearchUIModel>> = MutableStateFlow(
listOf(
CategoryModel(mutableMapOf()),
AreaModel(emptyList()),
SortModel(0)
)
)
val uiState: StateFlow<List<ListSearchUIModel>> = _uiState.asStateFlow()
}
리사이클러뷰에 전달할 리스트를 담을 uiState라는 이름의 StateFlow를 선언해 주었습니다. 이때 중요한 것은 리사이클러뷰는 전달받은 리스트의 데이터 순서대로 화면을 그려주기 때문에 반드시 그리고자 하는 순서대로 리스트를 담아주어야 합니다. 저는 가장 상단부터 CategoryModel, AreaModel, SortModel 순으로 그려줘야 하기 때문에 리스트를 초기화할 때 위 코드와 같이 초기화 해주었습니다.
private suspend fun loadPlaces() {
listOptionState.collect { listOption ->
placeRepository.getSearchPlaceResultByList(listOption.toDomainModel())
.onSuccess { result ->
_uiState.update {
val currentUiState = it.toMutableList()
val sortModelIndex = currentUiState.indexOfFirst { it is SortModel }
currentUiState[sortModelIndex] = SortModel(result.itemSize)
val noPlaceModelIndex = currentUiState.indexOfFirst { it is NoPlaceModel }
if (result.itemSize == 0 && noPlaceModelIndex == -1) {
currentUiState.add(NoPlaceModel())
currentUiState
}else{
if (noPlaceModelIndex != -1) currentUiState.removeAt(noPlaceModelIndex)
val newPlaceModels = result.toUiModel()
currentUiState.addAll(newPlaceModels)
currentUiState
}
}
if (result.isLastPage) _isLastPage.update { true }
}
.onError { e ->
networkErrorDelegate.handleNetworkError(e)
}
}
}
리스트를 초기화가 완료됐다면 4번에 해당하는 관광지 검색 결과 추가해야 합니다. listOptionState는 1번에 있는 버튼들을 클릭했을 때 선택한 옵션들입니다. 이 옵션을 가지고 서버에 검색 요청 후 결과를 UI에서 사용할 모델로 변환해 리스트에 저장합니다. 이때, 옵션에 따라 검색 결과가 없다면 검색 결과가 없음을 사용자에게 알리기 위한 NoPlaceModel을 추가해 줍니다.
또한 기존에 사용자에게 검색 indexOfFirst 메소드를 사용해 NoPlaceModel이 이미 존재하는지를 확인합니다. 이는 이전 검색 결과에서 결과가 없다면 이미 NoPlaceModel을 보여주고 있기 때문에 NoPlaceModel을 또 추가하면 화면에 두 개가 생기기 때문에 이를 방지하기 위해 기존에 존재하지 않을 때 만 추가하도록 해주었습니다.
suspend fun onSelectedArea(areaName: String){
val sigunguList = sigunguCodeRepository.getAllSigunguCode(areaCode)
_uiState.update { uiState ->
val hasSigunguModel = uiState.any { it is SigunguModel }
if (hasSigunguModel) {
uiState.map { uiModel ->
if (uiModel is SigunguModel) {
uiModel.copy(
sigungus = sigunguList.getSigunguName(),
selectedSigungu = "시/군/구"
)
} else {
uiModel
}
}
} else {
val newUiState = uiState.toMutableList()
val sortModelIndex = newUiState.indexOfFirst { it is SortModel }
val insertIndex = if (sortModelIndex > 0) sortModelIndex else 0
newUiState.add(insertIndex, SigunguModel(sigunguList.getSigunguName(),"시/군/구"))
newUiState
}
}
}
다음은 그림과 같이 지역을 선택했을 때 지역에 따라 시군구를 선택하는 AutoCompleteText를 보여줘야 했습니다. 이 부분 또한 별도의 ViewHolder로 분리했기 때문에 리스트에 추가해야 합니다. 앞서 말한 것처럼 리사이클러뷰는 전달받은 리스트의 순서대로 화면을 그려줍니다. 그렇기 때문에 위 그림에서 3번에 해당하는 위치에 객체를 넣어줘야 합니다. 이를 위해 기존에 3번에 해당하는 위치를 계산하고 계산된 위치에 시군구를 보여주기 위한 뷰홀더를 추가했습니다.
4. 결과를 리뷰해 보자
서론에서도 짧게 언급했지만 결과적으로 UX는 더 나빠졌습니다. 이유는 DiffUtil이 뷰홀더를 다시 바인딩하는 과정에서 사용자가 보기엔 마치 화면이 깜빡이는 것처럼 보입니다. DiffUtil을 적용함으로써 성능면에서는 개선을 했지만 가장 중요한 UX는 더 떨어졌는데요... 🥲 제가 생각하기엔 DiffUtil을 사용하는 구조라면 애초에 이런 동작을 하기 때문에 어쩔 수 없는 것 같습니다.
다만 GPU 렌더링을 사용해 프레임을 측정해본 결과 맨 처음 개선하려 했던 스크롤 시 버벅거림 현상도 꽤나 개선이 된 것 같습니다. 글에는 적진 않았지만 제가 기존에 만들었던 XML은 다수의 LinearLayout이 중첩되어 있었습니다. 안드로이드는 뷰가 중첩되면 중첩될 수록 렌더링하는데 오랜 시간과 비용이 들기 때문에 최대한 얕은 레이아웃 계층을 만들어야 합니다. 그래서 ConstraintLayout을 사용해 레이아웃들을 평탄화 해주니 많이 개선되었습니다. 개선을 하기전에 기록을 남겨놓지 않아 비교하지 못하는게 많이 아쉬운데요, 이전에는 그래프가 아주 고냥 핸드폰을 뚫고 나올 기세였습니다. 물론 아직도 개선해야할게 산더미 같지만 계속해서 공부해야겠습니다.
데이터 로딩시 프로그래스바를 보여준다던가 스켈레톤 UI를 적용한다던가 등의 대안을 생각했지만 이 부분도 근본적인 해결책이 아니라 말 그대로 "대안"이라 컴포즈로의 마이그레이션을 고민 중에 있습니다. 컴포즈를 사용하는 것도 좋겠지만 여기서 냅다 포기하는건 용납이 안되서 계속해서 연구해보겠습니다.
이번 내용과 연관된 GitHub PR 링크입니다.
'Android' 카테고리의 다른 글
안드로이드 접근성 개선기 (0) | 2024.08.26 |
---|---|
안드로이드 리사이클러뷰 성능 개선 일지 4편 (0) | 2024.08.11 |
안드로이드 리사이클러뷰 성능 개선 일지 2편(부제 : DiffUtil Deep Dive) (0) | 2024.08.04 |
안드로이드 리사이클러뷰 성능 개선 일지 1편(부제: Recyclerview Deep Dive) (0) | 2024.08.03 |
안드로이드 클린아키텍처 에러 핸들링 (부제: 상속을 지양하자) (2) | 2024.07.28 |