[안드로이드 공식문서 파헤치기] ScrollView, NestedScrollView, ConcatAdapter의 모든 것!

dada·2022년 8월 5일
9
post-thumbnail

참고)
Android Developer 도큐먼트 - RecyclerView
Android Developer 도큐먼트 - ScrollView
Android Developer 도큐먼트 - NestedScrollView
Android Developer 도큐먼트 - ConcatAdapter.Config
Android Developer medium - Concatenate adapters sequentially with ConcatAdapter

✅공부배경

  • ScrollView안에 RecyclerView를 사용하면 반드시 한번쯤 겪게 되는 문제가 위 영상과 같은 중첩 스크롤 문제입니다. 이에 대한 해결방법으로 ScrollView 대신 NestedScrollView를 사용하면 된다고 알려져 있고, 저 또한 아무 고민없이 해당 방법을 사용했었습니다🤣 하지만 프로젝트가 끝나고 ScrollView, NestedScrollView의 차이, RecyclerView에 대해 깊게 공부하면서 NestedScrollView 의 문제점을 알게되었습니다. 그래서 NestedScrollView을 제대로! 적용하고자 프로젝트 코드를 리팩토링하기로 했습니다.

✅ScrollView+RecyclerView

  • 위 영상처럼 ScrollView안에 RecyclerView를 추가하고 스크롤하면 ScrollView가 아닌 RecyclerView에 포커스가 가면서 RecyclerView 영역만 스크롤 됨을 알 수 있습니다

  • 이때 RecyclerView의 생명주기 로그를 보면, ViewHolder가 재활용되고 있기 때문에 RecyclerView 자체의 성능에는 영향을 미치지 않습니다.

✅NestedScrollView+RecyclerView

  • ScrollView+RecyclerView 에서 자식 View인 RecyclerView에게 포커스가 가는걸 막기 위해서는 NestedScrollView를 사용하면 됩니다.

  • 안드로이드 공식문서를 보면 NestedScrollView는 ScrollView와 비슷하지만 Android의 새 버전과 이전 버전 모두에서 부모와 중첩된 자식 스크롤을 모두 지원한다고 되어있습니다

  • NestedScrollView의 코드를 보면 View의 스크롤 X 또는 Y 위치가 변경될 때 호출되는 콜백에 대한 인터페이스인 OnScrollChangeListener를 기본적으로 지원하기 때문에 스크롤 이벤트를 감지할 수 있는 것입니다. (ScrollView에는 OnScrollChangeListener가 없기 때문에 중첩 스크롤 이슈가 발생한 것입니다)

✅NestedScrolliew가 중첩스크롤 문제를 해결한 원리

1. OnScrollChangeListener 인터페이스

  • OnScrollChangeListener은 NestedScrollView의 내부 인터페이스이고 onScrollChange 메서드 1개만 가지고 있습니다. 매개변수로 NestedScrollView, 현재, 이전 스크롤 좌표값을 받습니다
    • 뷰의 스크롤 위치가 변경되면 호출
    • 스크롤 포지션에 대한 콜백을 받아서 이에 맞는 이벤트를 작성할 수 있음
    • ScrollView에는 OnScrollChangeListener인터페이스가 구현되어 있지 않음

2. onScrollChanged메서드: OnScrollChangeListener타입 변수가 사용됨

  • OnScrollChangeListener타입의 변수가 사용되는 onScrollChanged를 보았더니
   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);
        }
    }
  • onScrollChanged 멤버함수를 정의하고 있는데 내부적으로 OnScrollChangeListener타입의 변수mOnScrollChangeListener가 null이 아니라면 OnScrollChangeListener의 onScrollChange을 호출

3.onScrollChange가 호출되면: onTouchEvent

  • NestedScrollView의 onTouchEvent 메서드를 분석해봤는데 onScrollChange가 호출됨에 따라 내부적으로 NestedScrollingChildHelper , NestedScrollingParentHelper클래스를 이용해 스크롤을 start하고 stop하는 메서드들이 정의되어있습니다
  • NestedScrollingChildHelper는 중첩 스크롤 하위 보기를 구현하기 위한 헬퍼 클래스

4. NestedScrollingChildHelper

안드로이드 5.0 롤리팝(API 21) 이전 버전과 호환되는 중첩 스크롤 하위 보기를 구현하기 위한 도우미 클래스입니다.

  • 중첩된 스크롤 작업의 한 단계를 현재 중첩된 스크롤 상위 항목으로 디스패치합니다.
    위임 메서드입니다. 표준 정책을 구현하기 위해 동일한 서명을 가진 View 하위 클래스 메서드/NestedScrollingChild 인터페이스 메서드에서 호출합니다.

✅NestedScrollView의 문제점

  • NestedScrollView안에 RecyclerView를 사용하면 '중첩 스크롤 문제'는 해결됩니다. 하지만 로그에 나타난 것처럼 RecyclerView의 onCreateViewHolder가 item의 개수만큼 한번에 호출되며, ViewHolder가 전혀 재활용되지 않는다는 것을 알 수 있습니다.

  • 만약 item의 수가 몇개 없다면 큰 문제가 되지 않지만, 그렇지 않다면 메모리 효율을 높일 수 있는 RecyclerView의 장점을 전혀 활용하지 못하는 코드입니다

  • 그렇다면 NestedScrollView의 안의 RecyclerView는 왜 재활용 매커니즘이 제대로 동작하지 않는걸까요? 이유를 알아보고자 NestedScrollView와 RecyclerView의 공식 문서를 읽어보고 NestedScrollView 관련 모든 인터넷 글을 찾아봤지만 명확한 이유를 알 수 없었습니다. 그래서 직접 테스트해보며 원인을 파악해보았습니다

✅RecyclerView의 ViewHolder가 재활용되지 않는 이유

  • (위 캡쳐 참고) 먼저 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을 한번에 그린다는 것을 확인할 수 있었습니다.

✅해결방법

  • ScrollView+RecyclerView => 중첩스크롤 X
  • NestedScrollView+RecyclerView => ViewHolder 재활용 X

만약 RecyclerView가 가지고 있는 itemView가 너무 많아서 재활용되지 않으면 메모리 낭비가 심한 경우에는 2가지 방법을 사용할 수 있습니다.

1. ViewHolder 분리

  • 위 캡쳐처럼 보라색 영역을 RecyclerView의 하나의 ViewType으로두고 ViewHolder를 분리해서 RecyclerView를 구성하면 됩니다(RecyclerView의 Adapter가 ScrollView에 포함하고 싶었던 View를 하나의 ViewHolder로 만들어서 그려주는 방식이라고 생각하시면 됩니다)

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를 사용할 수 있습니다

2. 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
  • 예를 들어 위와같은 스크롤 되는 뷰가 있다고 했을 때 위에서부터 Header(First), Post(Second), Footer(Third) 의 역할을 하는 View들을 FirstAdapter, SecondAdapter, ThirdAdapter로 나눈 후 ConcatAdapter로 병합하는 것입니다. 이를 코드로 나타내면 위와 같습니다

✔ConcatAdapter.Config

  • ConcatAdapter을 잘 활용하기 위해선 ConcatAdapter.Config에 대한 이해가 선행되어야 합니다. ConcatAdapter.Config는 ConcatAdapter의 기본 구성요소입니다.

  • ConcatAdapter.Config는 3가지 필드를 가지고 있습니다. 이 중 가장 중요한건 isolateViewTypes 필드입니다.

  • public final boolean isolateViewTypes: false인 경우 ConcatAdapter는 할당된 모든 어댑터가 ViewHolder Pool을 공유하여 동일한 ViewHolder를 사용한다고 가정합니다.

    • 기본적으로 Adapter들은 Adapter끼리 ViewHolder는 공유하지 않고 자신이 생성한 ViewHolder만 재사용합니다. 즉ConcatAdapter의 Adapter들은 각자의 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에서의 위치를 반환합니다.

  • 하나의 Adapter만 사용해서 RecyclerView에 연결할 경우 로그에서 보이는 것처럼 두 메소드는 같은 position값을 반환합니다.

        val concatAdapter = ConcatAdapter(adapter, adapter2)
  • 하지만 ConCatAdapter로 여러 Adapter을 이어 하나의 RecyclerView에 표시하면 두 값은 다르게 표시됩니다. getBindingAdapterPosition()는 RecyclerView 전체에서 몇번째 position에 있는지를 나타냅니다. 즉 초록색으로 표시한 숫자처럼 어디에 속한 Adapter이냐에 상관없이 RecyclerView의 관점에서 몇번째 position에 있는지를 알 수 있습니다
  • getAbsoluteAdapterPosition()는 해당 ViewHolder가 Adapter내에 몇번째 position에 있는지 나타냅니다.

✅MultyViewType, ConcatAdapter중 어떤걸 사용해야할까?

  • NestedScrollView안에 RecyclerView를 사용하면 메모리 낭비가 발생하므로 MultiViewType, ConcatAdapter를 사용해야 한다는 것을 알 수 있었습니다. 그렇다면 이 두 방법 중 어떤 상황에서 어떤 방법을 사용하는게 좋을까요?

  • 위와 같이 반복적인 ItemView가 규칙적으로 계속 나타낼 때는 ViewType을 분기하는게 좋을 수 있습니다. 하지만 이런 상황이 아니라면 ConcatAdapter를 사용하는 게 좋습니다.

ConcatAdapter의 장점

  • Adapter마다 RecyclerView.Adapter나 ListAdapter를 각각 다르게 구현할 수 있습니다
  • 다양한 타입의 ItemView가 사용된 RecyclerView라는 것을 직관적으로 알 수 있습니다
  • ViewHolder가 bind하는 data타입이 다를 경우 인터페이스를 통한 추상화를 하지 않아도 됩니다.
  • Adapter를 재활용할 수 있습니다
  • Header Footer에 따라 Adapter를 분리하기 때문에, 객체지향 원칙에 맞게 한 클래스가 하나의 역할을 가지게 만들 수 있습니다
profile
'왜?'라는 물음을 해결하며 마지막 개념까지 공부합니다✍

2개의 댓글

comment-user-thumbnail
2022년 9월 22일

설명이 잘 되어있어서 많은 도움 됐어요 감사합니다!

1개의 답글