오랜만에 안드로이드 글을 작성하는 것 같습니다.
적당한 퍼포먼스를 가지는 리싸이클러뷰를 만들어 볼 예정입니다.
완성된 소스코드는 맨 아래 링크에 있습니다.
이번 포스팅은 Architecture 등은 제외하고, Recyclerview 구현에만 관심이 있습니다.
디펜던시는 프로젝트 생성시 추가되어 있는 라이브러리 + Third party (Glide, Recyclerview) 입니다
네트워크에서 가져온다면 (Gson 등이 필요합니다)
먼저 보여줄 Data class 를 명세해줍니다.
@Parcelize 어노테이션을 이용하여, 다른 Activity, Fragment 에 아이템을 전달할 일이 있을 때, 쉽게 처리할 수 있도록 추가하였습니다.
@Parcelize
data class Music(
@SerializedName("id") val id: Int,
@SerializedName("title") val title: String,
@SerializedName("albumUrl") val albumUrl: String,
@SerializedName("writer") val writer: String,
@SerializedName("category") val category: String,
) : Parcelable
다음으로 뷰타입을 정의합니다
저는 Header, Content, Footer, Ads(광고) 로 총 4개로 구분하였고, 구분자로 layout id 값을 사용하였습니다.
companion object {
private const val VIEW_TYPE_HEADER = R.layout.item_music_content_header
private const val VIEW_TYPE_CONTENT = R.layout.item_music_content
private const val VIEW_TYPE_FOOTER = R.layout.item_music_content_footer
private const val VIEW_TYPE_ADS = R.layout.item_music_ads
}
뷰타입들에 보여줄 데이터를 정의합니다.
이전에 저는 Enum 으로 타입을 정의해두고, 사용하는 방식을 선호했었는데, sealed class 로 정의하는 것이 Single instance 가 아닌 객체를 여러개 생성할 수 있다는 점에서 현재는 sealed class 를 사용하는 방식을 선호하고 있습니다
또한 when 문에서 구현하지 않은 case 가 있는 경우 컴파일 타임에 오류를 던져줘서 실수를 방지할 수도 있습니다(대수타입 활용)
그래서 when 문에서 else 같은 처리를 할 필요가 없게 됩니다
sealed class Item {
data class Header(val title: String, val subTitle: String, val music: Music) : Item() {
override fun toString() = "${music.title}-${music.category}"
}
data class Content(val music: Music) : Item() {
override fun toString() = "${music.title}-${music.category}"
}
data class Footer(val title: String, val music: Music) : Item() {
override fun toString() = "${music.title}-${music.category}"
}
data class Ads(val id: Int, val advertisementUrl: String) : Item()
}
이제 본격적으로 Adapter 를 만들어줄 때입니다.
DiffUtil 를 사용할 것 이기 때문에, ListAdapter 를 상속받아서 아답터를 구성하겠습니다.
이전에 뷰타입에서 보여줄 데이터에 대한 정의가 끝났으니, DiffUtil.ItemCallback 를 구현해줍니다.
areItemsTheSame : 같은 아이템인지 확인하는 절차로 보통은 서버에서 내려주는 유니크 아이디 같은 것들을 활용하지만, 예제에서는 객체에 할당된 참조값을 가지고 비교해도 문제가 없기 때문에 참조값으로 비교하였습니다
areContentsTheSame : areItemsTheSame 가 true 를 반환하는 경우 호출되며 이때는 equals 비교를 합니다. 현재는 Item 클래스의 equals 을 건드리지 않았기 때문에, 기본 hasCode 를 사용하고 있습니다. 혹시나 Item 클래스의 Equals 메서드를 재정의하는 경우는 HashCode 를 꼭 재정의 해줘야합니다.
Object hashCode 정의(논리적(Equals 에서 비교한 변수들)으로 같은 객체는 같은 hashCode 값을 가져야한다) - 해당 내용은 effective java 참고하시면 됩니다
private val DiffUtilItemCallback = object : DiffUtil.ItemCallback<Item>() {
override fun areItemsTheSame(
oldItem: Item,
newItem: Item
) = oldItem == newItem
override fun areContentsTheSame(
oldItem: Item,
newItem: Item
) = oldItem.hashCode() == newItem.hashCode()
}
간단한 Recyclerview 동작은 아이템이 여러개라는 가정하에
재활용된 뷰홀더("scrapped" view)를 가져와서 타입을 확인하여(getItemViewType)이라면 같은 타입이라면 onBindViewHolder 가 호출되어 데이터만 바인딩해주고,
같은 타입이 아니라면, onCreateViewHolder 가 호출되어 해당 뷰 타입을 만들고 onBindViewHolder가 호출되어 데이터를 바인딩해주게 된다.
그래서 우리는 getItemViewType 를 재정의(override) 를 해줘야 합니다.
Item 이 sealed class 이기 때문에, 이후에 다른 뷰타입이 추가되고, 깜박하고 이를 구현하지 않아도 컴파일 타임에 알려주기 때문에 실수할 여지는 없어집니다.
override fun getItemViewType(position: Int) = when (getItem(position)) {
is Item.Header -> VIEW_TYPE_HEADER
is Item.Content -> VIEW_TYPE_CONTENT
is Item.Footer -> VIEW_TYPE_FOOTER
is Item.Ads -> VIEW_TYPE_ADS
}
getItemViewType 함수 재정의가 끝났으면, 이제는 onCreateViewHolder 차례입니다.
아쉽게도 viewType 은 강제로 Int 형이기 때문에 else 문이 꼭들어가게 됩니다(단 한번도 호출되지 않겠지만..ㅠㅠ)
이런 비효율적인 코딩을 하지 않기 위해, enum 및 sealed 를 이용하는 것이 좋습니다.(예전에는 성능이슈가 있었지만 이제는 가볍게 넘어갑시다)
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): MusicViewHolder = when (viewType) {
VIEW_TYPE_HEADER -> {
MusicViewHolder(binding<ItemMusicContentHeaderBinding>(parent, VIEW_TYPE_HEADER))
}
VIEW_TYPE_CONTENT -> {
MusicViewHolder(binding<ItemMusicContentBinding>(parent, VIEW_TYPE_CONTENT))
}
VIEW_TYPE_FOOTER -> {
MusicViewHolder(binding<ItemMusicContentFooterBinding>(parent, VIEW_TYPE_FOOTER))
}
VIEW_TYPE_ADS -> {
MusicViewHolder(binding<ItemMusicAdsBinding>(parent, VIEW_TYPE_ADS))
}
else -> error("invalid viewType")
}
binding 함수
private fun <T : ViewDataBinding> binding(parent: ViewGroup, viewType: Int) =
DataBindingUtil.inflate<T>(LayoutInflater.from(parent.context), viewType, parent, false)
뷰홀더도 정의해줬습니다.
Databinding 을 사용할 것 이기 때문에 생성자에 넘겨줍니다.
class MusicViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root)
이전에는 이런 방식으로도 많이 구현했었습니다 하지만 Databinding 활용하기 시작하면서는 잘 사용하지 않습니다.
abstract class MusicViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root) {
abstract fun bind()
}
class MusicHeaderViewHolder(binding: ViewDataBinding) : MusicViewHolder(binding) {
override fun bind() {
}
// TODO()
}
class MusicContentViewHolder(binding: ViewDataBinding) : MusicViewHolder(binding) {
override fun bind() {
}
// TODO()
}
이제 onBindViewHolder 차례입니다.
이번코드에서 가장 실수할 수 있는 여지가 많은 부분 인 것 같습니다. 이 부분 코드 구조는 앞으로도 개선이 되어야할 부분이라고 생각합니다.
convert 에서 묻지마 캐스팅이라던지 CleanCode, Effective java 등에서 지양하는 스킬들이 많이 사용이 되었습니다
Binding 클래스가 추가되면 컴파일에서 알려주지 않기 때문에, 항상 신경써서 개발자가 추가해줘야합니다.
override fun onBindViewHolder(holder: MusicViewHolder, position: Int) {
when (val binding = holder.binding) {
is ItemMusicContentHeaderBinding -> {
val item = convertType<Item.Header>(position)
binding.header = item
holder.itemView.setOnClickListener { onHeaderClicked(item) }
}
is ItemMusicContentBinding -> {
val item = convertType<Item.Content>(position)
binding.content = convertType<Item.Content>(position)
holder.itemView.setOnClickListener { onContentClicked(item) }
}
is ItemMusicContentFooterBinding -> {
val item = convertType<Item.Footer>(position)
binding.footer = convertType<Item.Footer>(position)
holder.itemView.setOnClickListener { onFooterClicked(item) }
}
is ItemMusicAdsBinding -> {
binding.ads = convertType<Item.Ads>(position)
holder.itemView.setOnClickListener { onAdsClicked() }
}
}
}
convert 함수
// 확실한 타입일 때 사용
@Suppress("UNCHECKED_CAST")
private fun <T> convertType(position: Int) = getItem(position) as T
아마 여기까지 오시면 binding.header, binding.content, binding.footer , binding.ads 에 빨간 줄이 보일텐데 일단은 무시합니다.
거의 다왔습니다.
여기서 약간의 성능 개선(같은 데이터가 여러번 보일 수 있는 경우) & notifyDataSetChanged 같은 메서드 호출에서 변경되지 않는 아이템에 한해서 화면 깜박이는 현상 등을 위해
setHasStableIds 를 아답터 내에서 true 로 선언해줍니다
init {
setHasStableIds(true)
}
setHasStableIds 이름과 같이 해당 기능을 사용하게되면, 유니크한 아이디(DB auto generator id, 서버에서 제공하는 유니크 아이디 등)를 제공해줘야합니다.
이때는 getItemId 를 재정의 해줍니다.
역시나 대수타입으로 else 문은 필요하지 않습니다.
override fun getItemId(position: Int) = when (val item = getItem(position)) {
is Item.Header -> item.music.id.toLong()
is Item.Content -> item.music.id.toLong()
is Item.Footer -> item.music.id.toLong()
is Item.Ads -> item.id.toLong()
}
이제 마지막으로 위에서 선언해준 layout 정의만 해주고, BindingAdapter 함수만 구현해주면 됩니다.
private const val VIEW_TYPE_HEADER = R.layout.item_music_content_header
private const val VIEW_TYPE_CONTENT = R.layout.item_music_content
private const val VIEW_TYPE_FOOTER = R.layout.item_music_content_footer
private const val VIEW_TYPE_ADS = R.layout.item_music_ads
여기서는 item_music_content_header 하나만 예로 보겠습니다.
<?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.nanamare.sample.Item.Header" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/iv_profile"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintBottom_toTopOf="@id/ll_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:loadImageUrl="@{header.music.albumUrl}"
tools:layout_height="360dp"
tools:src="@android:drawable/ic_menu_gallery" />
<!-- 생략.. -->
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
이런식으로 구성하게되면 아까 binding.header 에서 빨간 줄이던 에러도 사라집니다.
마지막으로 loadImageUrl BindingAdapter 함수를 구현해줍니다.
@BindingAdapter(value = ["loadImageUrl"])
fun ImageView.loadImageUrl(url: String?) {
url?.let {
Glide.with(this)
.load(it)
.error(android.R.drawable.stat_notify_error)
.placeholder(android.R.drawable.ic_menu_gallery)
.into(this)
}
}
이것으로 여러개의 뷰타입을 가지는 리싸이클러 뷰 구현을 마칩니다.
Tips
혹시나 뷰홀더에서 애니메이션을 이용하면, onViewRecycled 에서 애니메이션을 제거하는 것이 좋습니다.
그 이유는 onViewRecycled 가 뷰홀더가 재활용 되기 전 호출되는데, 이때도 애니메이션이 동작하고 있으면 이후 재활용되어 쓰일 때 문제가 생깁니다 - StackOverFlow 링크로 대체
override fun onViewRecycled(holder: MusicViewHolder) {
super.onViewRecycled(holder)
// animation 등 제거
}
소스코드
혹시나 질문이나 이슈는 코멘트 남겨주세요.
'Android > Development Tips' 카테고리의 다른 글
DialogFragment 를 상속하는 다이얼로그에서 dismiss 할 때 Tips (0) | 2021.01.10 |
---|---|
java.lang.IllegalStateException: Software rendering doesn't support hardware bitmaps 에러 수정하기 (1) | 2021.01.10 |
Clean Architecture (무비 앱) (0) | 2020.04.26 |
KOIN FragmentFactory 사용하기 (3) | 2020.02.29 |
Custom view 에서 Koin 사용해 ViewModel 주입할 때 주의 할점 (0) | 2019.08.05 |