ConcatAdapter Deep Dive
ConcatAdapter에 대해 알아봅니다.
ConcatAdapter
ConcatAdapter
는 recyclerview:1.2.0-alpha02
버전에서 등장한 여러 개의 어댑터를 하나의 RecyclerView에 추가할 수 있는 어댑터입니다. 기존에는 모양이 다른 각각의 뷰를 리스트 형태로 보여주기 위해서는 RecyclerView.Adapter
에서 뷰타입을 각각의 뷰마다 나눠주어야 했습니다. 그러다 보니 하나의 어댑터에 여러 가지 ViewHolder
가 존재하게 되었고, 여러 로직이 결합되어 있었습니다. ConcatAdapter는 여러 가지 Adapter를 하나의 Adapter에 추가하여 하나의 RecyclerView에 추가할 수 있도록 구현되어 캡슐화와 재사용의 이점을 챙길 수 있습니다.
ConcatAdapter의 Component
Config
ConcatAdapter의 생성자에 ConcatAdapter.Config
객체를 넘겨서 기본적인 옵션을 지정할 수 있습니다.
Config
의 isolateViewTypes
와 stableIdMode
를 제어할 수 있는데, 각각 옵션에 대한 설명은 아래와 같습니다.
isolateViewTypes
:false
인 경우 ConcatAdapter는 자신에게 할당된 모든 어댑터가 동일한 뷰 타입을 사용하여 동일한 뷰홀더를 참조하도록 글로벌한 뷰타입 풀을 공유한다고 가정하게 됩니다. 이 옵션을false
로 지정하면 중첩된 어댑터가 뷰홀더를 공유할 수 있게 되지만, 서로 다른 뷰홀더에 대해 동일한 뷰 타입을 리턴하는 충돌하는 뷰 타입을 가지지 않아야 함을 의미합니다. 기본적으로true
로 지정되어, 어댑터 간에 뷰타입을 분리하여 동일한 뷰홀더를 사용하지 않도록 합니다.stableIdMode
:StableIdMode
는 3가지가 존재합니다.
NO_STABLE_IDS
: 기본값으로 지정되는 값이며,concatAdapter
에 추가되는 어댑터의 stable id를 무시합니다. stable id를 사용하는 adapter를 이 모드일때 추가할 경우 경고가 발생합니다.ISOLATED_STABLE_IDS
: 이 모드에서 concatAdapter의hasStableIds()
는true
를 리턴하며, stable id가 지정된 adapter를 요구합니다. 서로 다른 두 어댑터가 서로 인지하지 못하고 동일한 stable id를 리턴할 수도 있으므로ConcatAdapter
는 각 어댑터의 id 풀을 서로 분리하여 리턴된 stable id를 덮어쓰도록 한 후에RecyclerView
에게 다시 알립니다. 이 모드에서는RecyclerView.Adapter.getItemId(int)
의 값과RecyclerView.ViewHolder.getItemId()
의 값이 다를 수 있습니다. 또한 stable id가 지정되지 않은 adapter를 추가할 경우IllegalArgumentException
이 발생합니다.SHARED_STABLE_IDS
:ISOLATED_STABLE_IDS
와 동일하게hasStableIds()
는true
를 리턴하고 stable id가 지정된 adapter를 요구합니다. 하지만ISOLATED_STABLE_IDS
와는 달리 리턴된 stable id를 재정의하지 않습니다. 이 모드에서 하위 어댑터는 서로를 인지해야 하며 어댑터 간에 아이템을 이동하지 않는 한 동일한 id를 리턴해서는 안 됩니다. stable id가 지정되지 않은 adapter를 추가할 경우IllegalArgumentException
이 발생합니다.
ConcatAdapter의 생성자에 Config
를 넘기지 않고 생성하면 Config.DEFAULT
로 지정되며, isolateViewTypes
는 true
, StableIdMode
는 NO_STABLE_IDS
로 지정됩니다.
NestedAdapterWrapper
ConcatAdapter의 대부분의 함수는 ConcatAdapterController
에게 동작을 위임하고 있습니다. 그리고 ConcatAdapterController
는 NestedAdapterWrapper.Callback
을 구현하고 있는데, 먼저 NestedAdapterWrapper
에 대해 살펴보겠습니다.
NestedAdapterWrapper
는 우리가 추가한 Adapter를 래핑한 클래스입니다. Callback
의 생김새는 RecyclerView.AdapterDataObserver
와 동일한데, 우리가 addAdapter
메소드를 통해 Adapter를 추가할 때, NestedAdapterWrapper
의 생성자의 파라미터에 추가하려는 Adapter와 ConcatAdapterController
가 넘겨지고, NestedAdapterWrapper
이 넘겨받은 Callback(ConcatAdapterController)
은 내부의 필드로 가지고 있는 AdapterDataObserver
에서 각각의 메소드에 매핑되어 호출됩니다.
위 코드에서 NestedAdapterWrapper
는 ViewTypeStorage.ViewTypeLookup
, StableIdStorage.StableIdLookup
이라는 객체를 필드로 보유하고 있음을 알 수 있습니다. 이 필드들을 통해 우리가 ConcatAdapter에 특정 아이템의 id나 뷰타입을 조회할 경우 리턴되는 값이 결정됩니다.
ViewTypeStorage
ViewTypeStorage
는 뷰타입을 저장 및 관리하는 스토리지 인터페이스입니다. ConcatAdapterController
에서 필드로 보유하고 있습니다.
getItemViewType
우리가 getItemViewType
함수를 호출하면 결국 NestedAdapterWrapper
의 getItemViewType
에 의해 뷰타입이 리턴되는데, 필드로 보유하고 있는 ViewTypeStorage.ViewTypeLookup
인터페이스 구현체에 의해 이 값이 결정됩니다.
그리고 ViewTypeStorage
는 ConcatAdapterController
의 생성자에서 생성되는데, isolateViewTypes
의 값에 따라 생성되는 구현체가 2가지로 나뉘게 됩니다.
IsolatedViewTypeStorage
, SharedIdRangeViewTypeStorage
는 각각 ViewTypeStorage.ViewTypeLookup
인터페이스의 구현체 클래스인 WrapperViewTypeLookup
이 내부에 선언되어 있는데, 이 구현체 클래스가 해당하는 Storage가 관리하는 뷰타입을 참조하여 뷰타입을 리턴합니다.
SharedIdRangeViewTypeStorage
:getItemViewType
시NestedAdapterWrapper
에 연결된 Adapter의 뷰타입을 그대로 리턴합니다.IsolatedViewTypeStorage
:getItemViewType
시 이미 저장되어 있는 뷰타입이라면 이를 리턴하고, 그렇지 않다면 유니크한 뷰타입을 생성하여 리턴합니다. (똑같은 뷰타입을 지정한 Adapter를 N개 추가해도,getItemViewType
시 각각 다른 유니크한 뷰타입이 리턴됨)
StableIdStorage
StableIdStorage
는 Stable id를 저장하는 스토리지 인터페이스입니다. 마찬가지로 ConcatAdapterController
에서 필드로 보유하고 있습니다.
getItemId
마찬가지로 NestedAdapterWrapper
에서 StableIdLookup
을 필드로 보유하고 있으며 getItemId
를 호출할 경우 이 필드를 통해 id
가 리턴됩니다.
StableIdLookup
의 구현체는 StableIdStorage
인터페이스의 3가지 구현체 내부에 선언되어 있으며 각각 이렇게 동작합니다.
NoStableIdStorage
: 무조건RecyclerView.NO_ID
를 리턴 (기본값)SharedPoolStableIdStorage
: Adapter의getItemId
값을 그대로 리턴IsolatedStableIdStorage
: 파라미터로 받은 adapter의getItemId
값이 이미 저장되어 있다면 그id
를 리턴하거나 고유한id
를 생성하여 리턴
Under the hood
addAdapter
우리가 adapter를 하나 추가하기 위해 addAdapter
를 호출하면 내부적으로 어떤 동작이 발생할까요? ConcatAdapter는 ConcatAdapterController
의 addAdapter
를 호출하게 되고 ConcatAdapterController
는 아래 프로세스를 따라 addAdapter
를 수행합니다.
Controller — addAdapter
- index 검사 : 우리가
addAdapter
에adapter
와index
를 함께 넘겨주어 추가하려고 하면 우선 이index
가 0에서 현재 어댑터들의 갯수 사이의 값인지 검사합니다. 만약 이 범위를 벗어나는index
값을 넘겨서 추가하려고 하면IndexOutOfBoundsException
가 발생합니다.index
를 넘기지 않고addAdapter
를 호출할 경우는 내부적으로 맨 마지막 순서로adapter
를 추가하게 됩니다. - Stable id 검사 : 현재 ConcatAdapter의
StableIdMode
가NO_STABLE_IDS
가 아니고, 추가하려는adapter
의hasStableIds
가true
일 경우 익셉션을 발생시킵니다. 그게 아니라StableIdMode
가NO_STABLE_IDS
이고 추가하려는adapter
의hasStableIds
가true
라면 경고를 로그로 출력합니다. - 이미 추가된 adapter인지 검사 : ConcatAdapter에 이미 추가된
adapter
와 추가하려는adapter
가 일치할 경우 adapter를 추가하지 않고 그대로 리턴합니다. - 이 과정을 모두 통과하면,
NestedAdapterWrapper
를 생성한 후 추가하려는 adapter를 연결하고 이wrapper
를 캐싱합니다. 그리고 ConcatAdapter에 연결된 RecyclerView에onAttachedToRecyclerView
메소드를 통해서 attach를 알리고, 만약 추가하려는 adapter에 이미 아이템이 존재한다면notifyItemRangeInserted
를 호출하여 리스트를 갱신합니다. - 그리고 마지막으로
StateRestorationPolicy
를 갱신합니다.
getItemViewType & getItemId
우리는 특정 위치 아이템의 뷰타입을 알아내기 위해서 ConcatAdapter의 getItemViewType
을 호출할 수 있습니다. 그러면 ConcatAdapter는 내부적으로 ConcatAdapterController
의 getItemViewType
을 호출합니다.
또한 특정 위치 아이템의 id를 알아내기 위해서 getItemId
를 호출할 수 있습니다. 마찬가지로 ConcatAdapterController
의 getItemId
를 호출합니다.
wrapper
에서 호출하는 getItemViewType
과 getItemId
는 위에서 어떤 동작을 통해 값을 가져오는지 살펴봤으니, findWrapperAndLocalPosition
과 releaseWrapperAndLocalPosition
에 대해 살펴보겠습니다.
findWrapperAndLocalPosition
findWrapperAndLocalPosition
은 파라미터로 받는 globalPosition
을 사용하여 정확한 position을 계산하는 함수입니다.
이 때 WrapperAndLocalPosition
클래스를 사용하는데, 이 클래스는 ConcatAdapterController
내부에 정의되어 있습니다.
findWrapperAndLocalPosition
메소드는 아래와 같은 절차로 정확한 position
과 해당하는 Wrapper
를 보유한 WrapperAndLocalPosition
객체를 리턴합니다.
- Controller가 보유한
mReusableHolder
필드의mInUse
플래그 값을 이용하여 캐싱한mReusableHolder
가 사용되지 않았다면 새로운WrapperAndLocalPosition
객체를 생성합니다. - 캐싱되어 있는
List<Wrapper>
필드를 반복문을 통해globalposition
을 캐싱된 wrapper의 item 갯수만큼 빼는 작업을 진행합니다. - 만약
globalPosition
보다 wrapper의 item 갯수가 크다면 생성한WrapperAndLocalPosition
객체의wrapper
와position
을 갱신하고 이를 리턴합니다.
releaseWrapperAndLocalPosition
getItemId
, getItemViewType
등 어떤 행위로 인해 mReusableHolder
가 사용되었다면 releaseWrapperAndLocalPosition
함수가 마지막에 호출됩니다. 이는 가능하면 새로운 할당 없이 wrapper와 position을 리턴할 수 있도록 하기 위함입니다.
마치며
여러 타입의 데이터를 하나의 어댑터로 표현하며, 로직을 캡슐화 할 수 있는 ConcatAdapter를 사용하는 것은 좋은 방안일 수 있습니다. 하지만 Config
와 내부 동작에 대해 자세히 이해하고 사용하지 않으면 의도치않은 동작을 마주하게 될 수 있을 것입니다.