참고)
Android Developer 도큐먼트 - RecyclerView
Android Developer 도큐먼트 - ScrollView
Android Developer 도큐먼트 - NestedScrollView
Android Developer 도큐먼트 - ConcatAdapter.Config
Android Developer medium - Concatenate adapters sequentially with ConcatAdapter
위 영상처럼 ScrollView안에 RecyclerView를 추가하고 스크롤하면 ScrollView가 아닌 RecyclerView에 포커스가 가면서 RecyclerView 영역만 스크롤 됨을 알 수 있습니다
이때 RecyclerView의 생명주기 로그를 보면, ViewHolder가 재활용되고 있기 때문에 RecyclerView 자체의 성능에는 영향을 미치지 않습니다.
ScrollView+RecyclerView 에서 자식 View인 RecyclerView에게 포커스가 가는걸 막기 위해서는 NestedScrollView를 사용하면 됩니다.
안드로이드 공식문서를 보면 NestedScrollView는 ScrollView와 비슷하지만 Android의 새 버전과 이전 버전 모두에서 부모와 중첩된 자식 스크롤을 모두 지원한다고 되어있습니다
NestedScrollView의 코드를 보면 View의 스크롤 X 또는 Y 위치가 변경될 때 호출되는 콜백에 대한 인터페이스인 OnScrollChangeListener
를 기본적으로 지원하기 때문에 스크롤 이벤트를 감지할 수 있는 것입니다. (ScrollView에는 OnScrollChangeListener가 없기 때문에 중첩 스크롤 이슈가 발생한 것입니다)
onScrollChange
메서드 1개만 가지고 있습니다. 매개변수로 NestedScrollView, 현재, 이전 스크롤 좌표값을 받습니다 private OnScrollChangeListener mOnScrollChangeListener;
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
if (mOnScrollChangeListener != null) {
mOnScrollChangeListener.onScrollChange(this, l, t, oldl, oldt);
}
}
안드로이드 5.0 롤리팝(API 21) 이전 버전과 호환되는 중첩 스크롤 하위 보기를 구현하기 위한 도우미 클래스입니다.
NestedScrollView안에 RecyclerView를 사용하면 '중첩 스크롤 문제'는 해결됩니다. 하지만 로그에 나타난 것처럼 RecyclerView의 onCreateViewHolder가 item의 개수만큼 한번에 호출되며, ViewHolder가 전혀 재활용되지 않는다는 것을 알 수 있습니다.
만약 item의 수가 몇개 없다면 큰 문제가 되지 않지만, 그렇지 않다면 메모리 효율을 높일 수 있는 RecyclerView의 장점을 전혀 활용하지 못하는 코드입니다
그렇다면 NestedScrollView의 안의 RecyclerView는 왜 재활용 매커니즘이 제대로 동작하지 않는걸까요? 이유를 알아보고자 NestedScrollView와 RecyclerView의 공식 문서를 읽어보고 NestedScrollView 관련 모든 인터넷 글을 찾아봤지만 명확한 이유를 알 수 없었습니다. 그래서 직접 테스트해보며 원인을 파악해보았습니다
(위 캡쳐 참고) 먼저 RecyclerView의 높이값을 고정 dp로 준 후 로그를 찍어보았을 때 onViewRecycled가 호출되며 ViewHolder가 재활용되지만, 중첩스크롤 문제는 해결되지 않았습니다
높이값을 wrap_content, match_parent로 두면 ViewHolder가 재활용되지 않지만, 중첩 스크롤 문제는 해결되었습니다
class RvActivity : AppCompatActivity() {
private lateinit var adapter: RecyclerViewAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_rv)
val rv = findViewById<RecyclerView>(R.id.rv)
val nsv = findViewById<NestedScrollView>(R.id.nsv)
nsv.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
change(nsv.height)
nsv.viewTreeObserver.removeOnGlobalLayoutListener(this)
}
})
}
fun change(height: Int) {
// RecyclerView 크기 조절
val rv = findViewById<RecyclerView>(R.id.rv)
val params = rv.getLayoutParams()
params.height = height
rv.setLayoutParams(params)
adapter = RecyclerViewAdapter()
rv.adapter = adapter
rv.layoutManager = LinearLayoutManager(this)
}
}
그래서 위 코드처럼 RecyclerView의 height을 wrap_content로 지정한 후 NestedScrollView에 viewTreeObserver로 addOnGlobalLayoutListener 를 호출해서 recyclerView를 감싸고 있는 NestedScrollView의 height값을 알아낸 후 동적으로 RecyclerView의 height값을 NestedScrollView의 height값으로 지정해 실행해보았습니다.
그 결과 ViewHolder가 재활용되지만 고정 dp를 주었을때처럼 중첩 스크롤 문제는 해결되지 않았습니다.
이를 통해 RecyclerView의 layout height가 그려지는 과정에서 height가 고정값이 아니라면 item을 한번에 그린다는 것을 확인할 수 있었습니다.
만약 RecyclerView가 가지고 있는 itemView가 너무 많아서 재활용되지 않으면 메모리 낭비가 심한 경우에는 2가지 방법을 사용할 수 있습니다.
import android.util.Log
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.ummaaack.myapplication.databinding.Item2Binding
import com.ummaaack.myapplication.databinding.ItemBinding
class RecyclerViewAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var count = -1
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
count += 1
return if (viewType == ITEM) {
ItemViewHolder(ItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
} else {
Item2ViewHolder(Item2Binding.inflate(LayoutInflater.from(parent.context), parent, false))
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val type = getItemViewType(position)
if (getItemViewType(position) == ITEM)
(holder as ItemViewHolder).bind()
else if (getItemViewType(position) == ITEM2) (holder as Item2ViewHolder).bind()
}
override fun getItemCount(): Int {
return 50
}
override fun getItemViewType(position: Int): Int {
return if (position % 2 == 0)
ITEM
else ITEM2
}
inner class ItemViewHolder(var binding: ItemBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind() {
binding.tv.text = "${adapterPosition}번째 홀더입니다"
}
}
inner class Item2ViewHolder(var binding: Item2Binding) : RecyclerView.ViewHolder(binding.root) {
fun bind() {
binding.tv.text = "${adapterPosition}번째 홀더입니다"
}
}
companion object {
private const val ITEM = 1
private const val ITEM2 = 2
}
}
위 코드처럼 RecyclerView의 override fun getItemViewType(position: Int): Int 함수를 오버라이딩 해서 각각의 아이템에 대하여 다른 Int 값을 리턴하고 ViewType에 맞게 ViewHolder를 생성하고 bind해주면 됩니다.
하지만 ViewType마다 다른 작업을 처리해주고 싶거나 ViewHolder마다 bind해주고 싶은 data타입이 다른 상황 등 ViewHodler에 따라 처리해야 하는 작업이 복잡한 경우가 있습니다. 이런 경우에는 ConcatAdapter를 사용할 수 있습니다
ViewHolder를 여러개 사용해서 다양한 View를 그리는 것 처럼 Adapter를 여러개 사용해서 View를 그릴 수 있도록 도와주는게 ConcatAdapter입니다.
ConcatAdapter는 RecyclerView.Adapter을 상속한 class이며 여러 Adapter들을 순차적으로 결합하여 하나의 RecycleView에 표시할 수 있게 해주는Adapter입니다.
이미지 출처 - Android Medium
val firstAdapter: FirstAdapter = …
val secondAdapter: SecondAdapter = …
val thirdAdapter: ThirdAdapter = …
val concatAdapter = ConcatAdapter(firstAdapter, secondAdapter,
thirdAdapter)
recyclerView.adapter = concatAdapter
ConcatAdapter을 잘 활용하기 위해선 ConcatAdapter.Config에 대한 이해가 선행되어야 합니다. ConcatAdapter.Config는 ConcatAdapter의 기본 구성요소입니다.
ConcatAdapter.Config는 3가지 필드를 가지고 있습니다. 이 중 가장 중요한건 isolateViewTypes 필드입니다.
public final boolean isolateViewTypes
: false인 경우 ConcatAdapter는 할당된 모든 어댑터가 ViewHolder Pool을 공유하여 동일한 ViewHolder를 사용한다고 가정합니다.
하지만 만약 모든 Adapter가 동일한 ViewHolder를 사용하고 있다면 ViewHolder Pool을 공유하게 하는 것이 더 효율적인 방법입니다. 이를 위해선 ConcatAdapter의 첫번째 파라미터로 setIsolateViewTypes(false)로 설정한 config객체를 전달하면 됩니다.
val config = ConcatAdapter.Config.Builder()
.setIsolateViewTypes(false)
.build()
...
val concatAdapter = ConcatAdapter(config,adapter, adapter2)
mergeAdapter = MergeAdapter(config, headerAdapter, postAdapter, footerAdapter)
ConcatAdapter을 사용 시 ViewHolder의 포지션을 찾을 때 주의해야할 점이 있습니다. 일반적인 Adapter에서 클릭된 item의 포지션을 알고싶을 때는 getAdapterPosition()
함수를 사용했습니다.
하지만 위 함수는 ConcatAdapter처럼 Adapter가 중첩되어 사용될 때 어느 Adapter인지 혼동을 야기할 수 있어 RecyclerVIew 1.2.0 라이브러리에서 Deprecated 되어 버리고 말았습니다. 대신 getBindingAdapterPosition()
,getAbsoluteAdapterPosition()
이라는 함수가 생겼습니다.
getBindingAdapterPosition()
는 Adapter내의 위치를 반환하고 getAbsoluteAdapterPosition()
는 RecyclerView에서의 위치를 반환합니다.
val concatAdapter = ConcatAdapter(adapter, adapter2)
NestedScrollView안에 RecyclerView를 사용하면 메모리 낭비가 발생하므로 MultiViewType, ConcatAdapter를 사용해야 한다는 것을 알 수 있었습니다. 그렇다면 이 두 방법 중 어떤 상황에서 어떤 방법을 사용하는게 좋을까요?
위와 같이 반복적인 ItemView가 규칙적으로 계속 나타낼 때는 ViewType을 분기하는게 좋을 수 있습니다. 하지만 이런 상황이 아니라면 ConcatAdapter를 사용하는 게 좋습니다.
ConcatAdapter의 장점
- Adapter마다 RecyclerView.Adapter나 ListAdapter를 각각 다르게 구현할 수 있습니다
- 다양한 타입의 ItemView가 사용된 RecyclerView라는 것을 직관적으로 알 수 있습니다
- ViewHolder가 bind하는 data타입이 다를 경우 인터페이스를 통한 추상화를 하지 않아도 됩니다.
- Adapter를 재활용할 수 있습니다
- Header Footer에 따라 Adapter를 분리하기 때문에, 객체지향 원칙에 맞게 한 클래스가 하나의 역할을 가지게 만들 수 있습니다
설명이 잘 되어있어서 많은 도움 됐어요 감사합니다!