ConcatAdapter Deep Dive

hongbeom
hongbeomi dev
Published in
12 min readSep 11, 2022

--

ConcatAdapter에 대해 알아봅니다.

Photo by KAL VISUALS on Unsplash

ConcatAdapter

ConcatAdapterrecyclerview:1.2.0-alpha02 버전에서 등장한 여러 개의 어댑터를 하나의 RecyclerView에 추가할 수 있는 어댑터입니다. 기존에는 모양이 다른 각각의 뷰를 리스트 형태로 보여주기 위해서는 RecyclerView.Adapter에서 뷰타입을 각각의 뷰마다 나눠주어야 했습니다. 그러다 보니 하나의 어댑터에 여러 가지 ViewHolder가 존재하게 되었고, 여러 로직이 결합되어 있었습니다. ConcatAdapter는 여러 가지 Adapter를 하나의 Adapter에 추가하여 하나의 RecyclerView에 추가할 수 있도록 구현되어 캡슐화와 재사용의 이점을 챙길 수 있습니다.

ConcatAdapter의 Component

Config

ConcatAdapter의 생성자에 ConcatAdapter.Config 객체를 넘겨서 기본적인 옵션을 지정할 수 있습니다.

ConfigisolateViewTypesstableIdMode를 제어할 수 있는데, 각각 옵션에 대한 설명은 아래와 같습니다.

  • isolateViewTypes : false인 경우 ConcatAdapter는 자신에게 할당된 모든 어댑터가 동일한 뷰 타입을 사용하여 동일한 뷰홀더를 참조하도록 글로벌한 뷰타입 풀을 공유한다고 가정하게 됩니다. 이 옵션을 false로 지정하면 중첩된 어댑터가 뷰홀더를 공유할 수 있게 되지만, 서로 다른 뷰홀더에 대해 동일한 뷰 타입을 리턴하는 충돌하는 뷰 타입을 가지지 않아야 함을 의미합니다. 기본적으로 true로 지정되어, 어댑터 간에 뷰타입을 분리하여 동일한 뷰홀더를 사용하지 않도록 합니다.
  • stableIdMode : StableIdMode는 3가지가 존재합니다.
  1. NO_STABLE_IDS : 기본값으로 지정되는 값이며, concatAdapter에 추가되는 어댑터의 stable id를 무시합니다. stable id를 사용하는 adapter를 이 모드일때 추가할 경우 경고가 발생합니다.
  2. 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이 발생합니다.
  3. 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로 지정되며, isolateViewTypestrue, StableIdModeNO_STABLE_IDS로 지정됩니다.

NestedAdapterWrapper

ConcatAdapter의 대부분의 함수는 ConcatAdapterController에게 동작을 위임하고 있습니다. 그리고 ConcatAdapterControllerNestedAdapterWrapper.Callback을 구현하고 있는데, 먼저 NestedAdapterWrapper 에 대해 살펴보겠습니다.

NestedAdapterWrapper는 우리가 추가한 Adapter를 래핑한 클래스입니다. Callback의 생김새는 RecyclerView.AdapterDataObserver와 동일한데, 우리가 addAdapter 메소드를 통해 Adapter를 추가할 때, NestedAdapterWrapper의 생성자의 파라미터에 추가하려는 Adapter와 ConcatAdapterController가 넘겨지고, NestedAdapterWrapper 이 넘겨받은 Callback(ConcatAdapterController) 은 내부의 필드로 가지고 있는 AdapterDataObserver 에서 각각의 메소드에 매핑되어 호출됩니다.

위 코드에서 NestedAdapterWrapperViewTypeStorage.ViewTypeLookup, StableIdStorage.StableIdLookup이라는 객체를 필드로 보유하고 있음을 알 수 있습니다. 이 필드들을 통해 우리가 ConcatAdapter에 특정 아이템의 id나 뷰타입을 조회할 경우 리턴되는 값이 결정됩니다.

ViewTypeStorage

ViewTypeStorage는 뷰타입을 저장 및 관리하는 스토리지 인터페이스입니다. ConcatAdapterController에서 필드로 보유하고 있습니다.

ConcatAdapterController는 ViewTypeStorage를 보유

getItemViewType

우리가 getItemViewType 함수를 호출하면 결국 NestedAdapterWrappergetItemViewType에 의해 뷰타입이 리턴되는데, 필드로 보유하고 있는 ViewTypeStorage.ViewTypeLookup 인터페이스 구현체에 의해 이 값이 결정됩니다.

NestedAdapterWrapper에서 ViewTypeLookup 필드의 값이 생성

그리고 ViewTypeStorageConcatAdapterController의 생성자에서 생성되는데, isolateViewTypes의 값에 따라 생성되는 구현체가 2가지로 나뉘게 됩니다.

IsolatedViewTypeStorage, SharedIdRangeViewTypeStorage는 각각 ViewTypeStorage.ViewTypeLookup 인터페이스의 구현체 클래스인 WrapperViewTypeLookup이 내부에 선언되어 있는데, 이 구현체 클래스가 해당하는 Storage가 관리하는 뷰타입을 참조하여 뷰타입을 리턴합니다.

  • SharedIdRangeViewTypeStorage : getItemViewTypeNestedAdapterWrapper에 연결된 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는 ConcatAdapterControlleraddAdapter를 호출하게 되고 ConcatAdapterController는 아래 프로세스를 따라 addAdapter를 수행합니다.

Controller — addAdapter

  1. index 검사 : 우리가 addAdapteradapterindex를 함께 넘겨주어 추가하려고 하면 우선 이 index가 0에서 현재 어댑터들의 갯수 사이의 값인지 검사합니다. 만약 이 범위를 벗어나는 index값을 넘겨서 추가하려고 하면 IndexOutOfBoundsException가 발생합니다. index를 넘기지 않고 addAdapter를 호출할 경우는 내부적으로 맨 마지막 순서로 adapter를 추가하게 됩니다.
  2. Stable id 검사 : 현재 ConcatAdapter의 StableIdModeNO_STABLE_IDS 가 아니고, 추가하려는 adapterhasStableIdstrue일 경우 익셉션을 발생시킵니다. 그게 아니라 StableIdModeNO_STABLE_IDS이고 추가하려는 adapterhasStableIdstrue라면 경고를 로그로 출력합니다.
  3. 이미 추가된 adapter인지 검사 : ConcatAdapter에 이미 추가된 adapter와 추가하려는 adapter가 일치할 경우 adapter를 추가하지 않고 그대로 리턴합니다.
  4. 이 과정을 모두 통과하면, NestedAdapterWrapper를 생성한 후 추가하려는 adapter를 연결하고 이 wrapper를 캐싱합니다. 그리고 ConcatAdapter에 연결된 RecyclerView에 onAttachedToRecyclerView 메소드를 통해서 attach를 알리고, 만약 추가하려는 adapter에 이미 아이템이 존재한다면 notifyItemRangeInserted를 호출하여 리스트를 갱신합니다.
  5. 그리고 마지막으로 StateRestorationPolicy를 갱신합니다.

getItemViewType & getItemId

우리는 특정 위치 아이템의 뷰타입을 알아내기 위해서 ConcatAdapter의 getItemViewType을 호출할 수 있습니다. 그러면 ConcatAdapter는 내부적으로 ConcatAdapterControllergetItemViewType을 호출합니다.

또한 특정 위치 아이템의 id를 알아내기 위해서 getItemId를 호출할 수 있습니다. 마찬가지로 ConcatAdapterControllergetItemId를 호출합니다.

wrapper에서 호출하는 getItemViewTypegetItemId는 위에서 어떤 동작을 통해 값을 가져오는지 살펴봤으니, findWrapperAndLocalPositionreleaseWrapperAndLocalPosition에 대해 살펴보겠습니다.

findWrapperAndLocalPosition

findWrapperAndLocalPosition은 파라미터로 받는 globalPosition을 사용하여 정확한 position을 계산하는 함수입니다.

이 때 WrapperAndLocalPosition 클래스를 사용하는데, 이 클래스는 ConcatAdapterController 내부에 정의되어 있습니다.

findWrapperAndLocalPosition 메소드는 아래와 같은 절차로 정확한 position과 해당하는 Wrapper를 보유한 WrapperAndLocalPosition 객체를 리턴합니다.

  1. Controller가 보유한 mReusableHolder 필드의 mInUse 플래그 값을 이용하여 캐싱한 mReusableHolder가 사용되지 않았다면 새로운 WrapperAndLocalPosition 객체를 생성합니다.
  2. 캐싱되어 있는 List<Wrapper> 필드를 반복문을 통해 globalposition을 캐싱된 wrapper의 item 갯수만큼 빼는 작업을 진행합니다.
  3. 만약 globalPosition 보다 wrapper의 item 갯수가 크다면 생성한 WrapperAndLocalPosition 객체의 wrapperposition을 갱신하고 이를 리턴합니다.

releaseWrapperAndLocalPosition

getItemId, getItemViewType 등 어떤 행위로 인해 mReusableHolder 가 사용되었다면 releaseWrapperAndLocalPosition 함수가 마지막에 호출됩니다. 이는 가능하면 새로운 할당 없이 wrapper와 position을 리턴할 수 있도록 하기 위함입니다.

마치며

여러 타입의 데이터를 하나의 어댑터로 표현하며, 로직을 캡슐화 할 수 있는 ConcatAdapter를 사용하는 것은 좋은 방안일 수 있습니다. 하지만 Config와 내부 동작에 대해 자세히 이해하고 사용하지 않으면 의도치않은 동작을 마주하게 될 수 있을 것입니다.

참고

--

--