통합검색은 원래 하나의 레이아웃을 재사용하는 구조로 설계되어 있었다. 다른 카테고리들은 하나의 리싸이클러뷰로 구성이 되어 있는 반면 통합검색은 구조가 다르게 구성되어 있다는 것이다. 추가적인 카테고리가 생기게 되면 레이아웃에 재사용뷰 추가, 더보기 클릭 리스너 추가 등 확장하기 위한 작업들이 추가적으로 필요하다. 그래서, 다른 카테고리와 통일된 구조를 만들고 확장에 유리한 구조를 설계하기 위해 하나의 리싸이클러뷰로 구조를 수정했다.
하나의 리싸이클러뷰에 여러 어댑터를 연결할 수 있는 ConcatAdapter라는 것을 멘토님을 통해 알게되어서 이를 활용하여 통합검색 화면을 완성하였지만, 문제가 발생했다. 카드 보기로 변경 시 기존의 구조에서는 아이템의 spanSize를 변경시키기만 하면 되었지만, 현재 구조에서는 헤더, 푸터가 모두 Adapter에 포함되어있어 아이템만 spanSize를 변경시키도록 구현해야 하는 것이다.
viewType에 따라 spanSize를 다르게 지정하면 아이템만 spanSize를 변경시키도록 할 수 있다. 그래서 spanSizeLookup 의 속성을 지정해 원하는 동작을 구현할 수 있다.
view.layoutManager= when (state ?: return) {
LayoutState.LINEAR-> LinearLayoutManager(view.context)
LayoutState.GRID-> GridLayoutManager(view.context,GRID_SPAN_COUNT).apply{
spanSizeLookup= object : SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
val type = view.adapter?.getItemViewType(position)
return (type) {
HEADER -> GRID_SPAN_COUNT
FOOTER -> GRID_SPAN_COUNT
ITEM -> 1
}
}
}
}
}
하지만, 또 문제가 발생했다. ConcatAdapter는 여러 어댑터를 가지고 있기 때문에 getItemViewType
속성으로 얻는 type이 카테고리 Adapter의 viewType과 다른 것이다. 확인해보니 카테고리 Adapter의 Type은 총 5가지만 존재하지만 ConcatAdapter의 viewType은 4배인 20개의 viewType이 존재하는 것이다.
각 adapter의 viewType이 mapping 되어 20가지의 viewType을 반환하는 것이다.
그렇기에 규칙을 찾기 위해서 내부 코드를 살펴보았다.
public int getItemViewType(int globalPosition) {
WrapperAndLocalPosition wrapperAndPos = findWrapperAndLocalPosition(globalPosition);
int itemViewType = wrapperAndPos.mWrapper.getItemViewType(wrapperAndPos.mLocalPosition);
releaseWrapperAndLocalPosition(wrapperAndPos);
return itemViewType;
}
concat adapter의 getItemViewType 메서드이다. 인자로 globalPosition을 전달받는다. 이는 getSpanSize 메서드에서 position이 전체 아이템의 위치를 나타내기 때문에 해당 position을 전달하면 된다.
findWrapperAndLocalPosition 함수를 먼저 살펴보겠다.
@NonNull
private WrapperAndLocalPosition findWrapperAndLocalPosition(
int globalPosition
) {
WrapperAndLocalPosition result;
if (mReusableHolder.mInUse) {
result = new WrapperAndLocalPosition();
} else {
mReusableHolder.mInUse = true;
result = mReusableHolder;
}
int localPosition = globalPosition;
for (NestedAdapterWrapper wrapper : mWrappers) {
if (wrapper.getCachedItemCount() > localPosition) {
result.mWrapper = wrapper;
result.mLocalPosition = localPosition;
break;
}
localPosition -= wrapper.getCachedItemCount();
}
if (result.mWrapper == null) {
throw new IllegalArgumentException("Cannot find wrapper for " + globalPosition);
}
return result;
}
전달받은 position을 이용해 특정 범위에 있는 wrapper 객체와 position을 반환한다. 이때 전달받은 global position은 wrapper를 위한 local position의 값으로 만들어준다.
이때 Wrapper은 Adapter를 포함하고 있을 뿐만 아니라 viewType을 가진 Lookup 테이블도 존재한다.
class NestedAdapterWrapper {
@NonNull
private final ViewTypeStorage.ViewTypeLookup mViewTypeLookup;
@NonNull
private final StableIdStorage.StableIdLookup mStableIdLookup;
public final Adapter<ViewHolder> adapter;
...
}
int getItemViewType(int localPosition) {
return mViewTypeLookup.localToGlobal(adapter.getItemViewType(localPosition));
}
wrapper의 viewType을 반환하는 메서드는 위와 같이 정의되어 있다. adapter의 viewType을 반환하게되면 원하는 type을 얻을 수 있지만, 이를 local type에서 global type으로 변환하는 작업을 수행한다.
@Override
public int localToGlobal(int localType) {
int index = mLocalToGlobalMapping.indexOfKey(localType);
if (index > -1) {
return mLocalToGlobalMapping.valueAt(index);
}
// get a new key.
int globalType = obtainViewType(mWrapper);
mLocalToGlobalMapping.put(localType, globalType);
mGlobalToLocalMapping.put(globalType, localType);
return globalType;
}
localType을 이용하여 globalType을 반환하는 역할을 하며 존재하지 않을 때 새로운 키를 만들게 된다. 이때 mLocalToGlobalMapping과 mGlobalToLocalMapping은 SparseArray로 정의되어 있다.
이와 같은 구조는 HashMap을 사용할 수 있을텐데 왜 SparseArray를 사용했는지 궁금했다. 둘의 차이는 무엇일까? 이들을 비교할 때 HashMap과 SparseArray 뿐만 아니라 ArrayMap이라는 것도 존재한다. 이 세 가지 자료구조에 대해 알아보자.
먼저 해시맵의 동작 원리부터 알아보자. 먼저 키를 해시 함수를 통해 해시 값으로 매핑하여 해시 값을 테이블의 인덱스로 활용하는 것이 HashMap이다. 이때 해시 값은 int 형으로 되어있기 때문에 2^32의 배열을 가지고 있어야 한다. 그렇기에 불필요한 메모리 공간이 낭비될 수 있다는 단점이 있다.
HashMap과 다르게 두 개의 배열을 사용한다. 저장할 데이터가 많아지면 배열의 크기를 늘리고, 데이터가 삭제되면 배열의 크기를 줄이는 방식으로 메모리를 관리하게 된다. 이는 그만큼의 오버헤드가 발생하게 되고 키를 찾기 위한 이분 탐색, 데이터 추가, 삭제 비용이 든다.
ArrayMap과 동일한 구조로 키는 제네릭이 아닌 기본형을 사용한다. 그렇기에 오토박싱이 발생하지 않아 불필요한 메모리를 잡아먹지 않는다.
여기서 오토박싱이란 기본형의 데이터를 Wrapper 클래스로 변경해주는 것을 의미한다. Wrapper 객체를 추가적으로 할당하는 것은 기본형보다 메모리를 더 잡아먹게 된다. 그렇기에 기존 HashMap은 키에 기본형 데이터를 넣어줄 때마다 오토박싱이 발생해 불필요한 메모리를 잡아먹게 되는 것이다.
그렇다면 언제 ArrayMap과 SparseArray를 사용하는 것이 좋을까?
결론은 SparseArray를 사용한 이유는 아무래도 많은 Adapter를 연결하여 사용하지는 않다보니 아이템 개수가 1000 미만을 유지하여 속도 측면에서 유의미한 데이터가 아니기 때문에, 속도는 느리지만 메모리적 이점을 얻기 위해서 사용한 것이다.
어쨌든, 이와 같은 작업을 하기 때문에 viewType이 다르게 나온 것이었다.
interface ViewTypeLookup {
int localToGlobal(int localType);
int globalToLocal(int globalType);
void dispose();
}
localToGlobal 메서드는 인터페이스의 메서드로 정의되어 있으며, 위의 동작과 다르게 수행할 수 있는 동작이 존재한다.
class IsolatedViewTypeStorage implements ViewTypeStorage
위의 동작이 해당 클래스에 있는 동작이었고,
class SharedIdRangeViewTypeStorage implements ViewTypeStorage
다른 동작을 가진 클래스도 존재한다.
이름에서 볼 수 있듯이 분리된 뷰 타입이 아닌 공유 뷰타입 저장소라는 클래스명을 가진다.
@Override
public int localToGlobal(int localType) {
// register it first
List<NestedAdapterWrapper> wrappers = mGlobalTypeToWrapper.get(
localType);
if (wrappers == null) {
wrappers = new ArrayList<>();
mGlobalTypeToWrapper.put(localType, wrappers);
}
if (!wrappers.contains(mWrapper)) {
wrappers.add(mWrapper);
}
return localType;
}
이 클래스의 메서드의 반환되는 값은 localType이다. 즉, 여러 어댑터가 모두 같은 뷰타입을 공유하고 있다는 것이다. 그래서 위의 클래스 메서드를 활용하면 원하는 동작을 수행할 수 있지만, 문제가 있다.
모두 같은 뷰홀더를 사용하고 있을 때 주로 활용하는 방법으로 현재 구조에서는 각 카테고리마다 아이템의 뷰홀더가 다르기 때문에 만약 위의 방식을 이용하려면 각각의 카테고리의 아이템을 다른 뷰타입으로 생성해야한다.
이러한 구조는 오히려 확장에 더 불리한 구조를 가져가는 것이라 생각했고 위의 방식을 선택하지 않았다.
val adapterList = mutableListOf<Pair<PostAdapter, Int>>()
(view.adapter as ConcatAdapter).adapters.forEach { adapter ->
val size = adapterList.size
repeat(adapter.itemCount) {
adapterList.add(adapter as PostAdapter to size)
}
}
val (adapter, start) = adapterList[position]
adapter.getItemViewType(position - start)
그래서, ConcatAdapter의 어댑터들을 배열에 담아두고 global position에 해당하는 adapter를 찾아 해당 adapter의 viewType을 얻는 방식으로 문제를 해결할 수 있었다.