개요
평소 당연한 듯 ViewPager와 RecyclerView를 사용해왔는데, 이번 기회에 내부 코드를 읽고 동작 원리를 파악하기 위해 포스팅을 남긴다.
1편은 우선 Recycler 클래스에 대해 알아보고, 2편에서는 RecyclerView의 Animation 처리에 대해 알아볼 예정이다.
그 다음은 ViewPager나 Proto datastore에 대해 포스팅하려 한다.
메인 키워드
- ViewHolder
- Recycler
- scrap/unscrap
- RecycledPool
View와 ViewHolder의 관계
RecyclerView는 화면에 보여지는 항목 개수만큼의 View와 ViewHolder를 생성한다.
이 때 각각의 View는 ViewHolder와 1:1의 관계를 가지게 되는데, 화면에 6개의 아이템이 보여진다면 View:ViewHolder=6:6, 8개의 아이템이 보여진다면 8:8로 생성된다.
(화면에 보여지는 아이템 개수만큼 onCreateViewHolder가 호출되고, 이후 재사용이 가능하다면 onBindViewHolder가 호출되는 방식으로 동작한다. 만약 viewType이 여러 개일 경우에는 추가적으로 onCreateViewHolder가 호출될 수 있다.)
사실 RecyclerView는 일반적으로 2~4개 정도의 여유분 ViewHolder를 추가로 생성하기 때문에 화면에 보여지는 아이템 개수 + a 만큼 생성된다고 알고 있으면 좋다.
이는 스크롤 상황을 고려하여 성능을 최적화시키기 위함이다.
scrapView/unscrapView
Scrap 상태의 ViewHolder는 RecyclerView에 연결되어 있지만, 현재 제거되었거나 재사용을 위해 scrap된 ViewHolder이다.
Scrap 상태의 ViewHolder들은 ArrayList 타입의 mAttachedScrap, mChangedScrap을 통해 관리되며,
Recycler 객체는 scrapView(), unscrapView(), clearScrap() 등의 메소드들을 통해 이를 수행한다.
(1) scrapView
ViewHolder를 scrap 상태로 설정하기 위해 사용되는 메소드이다.
파라미터로 받은 View의 ViewHolder를 getChildViewHolderInt 메소드를 통해 가져오고, ViewHolder의 상태에 따라 mAttachedScrap 또는 mChangedScrap에 추가한다.
void scrapView(View view) {
final ViewHolder holder = getChildViewHolderInt(view);
if (holder.hasAnyOfTheFlags(ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_INVALID)
|| !holder.isUpdated() || canReuseUpdatedViewHolder(holder)) {
if (holder.isInvalid() && !holder.isRemoved() && !mAdapter.hasStableIds()) {
throw new IllegalArgumentException("Called scrap view with an invalid view."
+ " Invalid views cannot be reused from scrap, they should rebound from"
+ " recycler pool." + exceptionLabel());
}
holder.setScrapContainer(this, false);
mAttachedScrap.add(holder);
} else {
if (mChangedScrap == null) {
mChangedScrap = new ArrayList<ViewHolder>();
}
holder.setScrapContainer(this, true);
mChangedScrap.add(holder);
}
}
해당 ViewHolder가
(1) REMOVED, INVALID 플래그 중 하나라도 가지고 있는가
(2) 업데이트되었는가
(3) 재사용이 가능한가
위 조건들 중 하나라도 만족하는 경우에 RecyclerView의 setScrapContainer 메소드를 통해 스크랩 컨테이너에 추가하고, false 플래그를 설정하는데, 이 때 해당 ViewHolder는 변경되지 않은 상태로 설정된다.
이후 mAttachedScrap에 ViewHolder를 추가한다.
만약 위 조건을 모두 만족하지 않는 경우에는 mChangedScrap을 초기화한 후 해당 ViewHolder를 스크랩 컨테이너에 추가하고 true 플래그를 설정하여 해당 ViewHolder를 변경된 상태로 설정한다.
이후 mChangedScrap에 ViewHolder를 추가한다.
(2) unscrapView
scarp 상태의 ViewHolder를 다시 화면에 표시되기 전에 unscrap하기(사용 가능 상태로 변경) 위해 사용하는 메소드이다.
이를 통해 mChangedScrap/mAttachedScrap에서 해당 ViewHolder를 제거한다.
void unscrapView(ViewHolder holder) {
if (holder.mInChangeScrap) {
mChangedScrap.remove(holder);
} else {
mAttachedScrap.remove(holder);
}
holder.mScrapContainer = null;
holder.mInChangeScrap = false;
holder.clearReturnedFromScrapFlag();
}
Recycler
RecyclerView는 불변 프로퍼티로 Recycler 객체를 가지며, 이 Recycler 객체를 통해 View의 재사용을 위해 더 이상 사용되지 않거나 사라진 View를 관리한다.
public class RecyclerView extends ViewGroup implements ScrollingView,
NestedScrollingChild2, NestedScrollingChild3 {
...
생략
...
final Recycler mRecycler = new Recycler();
...
생략
...
}
Recycler 클래스는 RecyclerView의 핵심 컴포넌트이므로, 내부 구조에 대해 명확히 짚고 넘어가야 동작 원리에 대해서도 이해하기가 수월하다.
public final class Recycler {
final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();
ArrayList<ViewHolder> mChangedScrap = null;
final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();
private final List<ViewHolder>
mUnmodifiableAttachedScrap = Collections.unmodifiableList(mAttachedScrap);
private int mRequestedCacheMax = DEFAULT_CACHE_SIZE;
int mViewCacheMax = DEFAULT_CACHE_SIZE;
RecycledViewPool mRecyclerPool;
private ViewCacheExtension mViewCacheExtension;
static final int DEFAULT_CACHE_SIZE = 2;
(1) mAttachedScrap
Recycler 클래스의 핵심 컴포넌트로, 화면에 표시되었지만 현재는 사용되지 않는 ViewHolder들을 저장하는 프로퍼티이다.
mAttachedScrap은 ArrayList 타입의 불변 프로퍼티로 선언되어 있으며, 컴파일 타임에 인스턴스화된다.
또한 backing property로 List 타입의 불변 프로퍼티인 mUnmodifiableAttachedScrap를 사용하여
mAttachedScrap을 외부에서 수정하지 못하도록 캡슐화함으로써 데이터 무결성을 유지한다.
(2) mChangedScrap
데이터가 변경되어 다시 바인딩이 필요한 ViewHolder들을 저장하는 컬렉션이다.
ArrayList 타입의 가변 프로퍼티로 선언시에 초기화되지 않으며, scrapView 메소드가 호출될 때 특정 조건을 충족할 경우에 초기화된다.
preLayout 단계에서 변경된 데이터를 추적하기 위해 사용되며, 이 프로퍼티를 통해
notifyItemChanged, notifyDataSetChanged 메소드들을 호출했을 때 LayoutManager가 변경된 데이터를 빠르게 찾고 변경 사항을 반영할 수 있다.(tryGetViewHolderForPositionByDeadLine 메소드 내부에서 가장 먼저 체크되는 프로퍼티이므로)
Java로 작성되어 있으며 초기값이 null이므로 사용할 때마다 초기화 여부를 확인하는(null 체크) 보일러플레이트 코드가 곳곳에 존재하는데,
Kotlin으로 작성했다면 backing property나 지연 초기화 사용해서 코드를 간결하고 가독성 높게 작성할 수 있었을 것 같다.
(3) mCachedViews
ArrayList 타입의 불변 프로퍼티로, 캐싱된 ViewHolder들을 저장한다.
화면에 표시되지 않는 ViewHolder들은 여기에 저장되어 추후 재사용될 수 있다.
mRequestedCacheMax, mViewCacheMax 프로퍼티를 통해 캐시할 ViewHolder의 최대 개수를 지정할 수 있다.
Recycler 클래스 내부의 메소드들을 보면, mAttachedScrap에서 ViewHolder가 재사용이 불가능할 경우에
mCachedViews에 캐싱된 ViewHolder를 가져와서 재사용하도록 구현되어 있다.
(recycleViewHolderInternal 메소드 내부에서 )
mCachedViews에 설정된 최대 값을 초과하면, 오래된 ViewHolder들을 제거하고 RecyclerPool로 이동시킨다.
(4) mRecyclerPool
RecycledViewPool 타입의 가변 프로퍼티로, ViewHolder를 재사용하기 위한 풀이다.
재사용이 가능한 ViewHolder들을 관리한다.
✔️ mAttachedScrap, mCachedViews, mRecyclerPool의 차이는?
이 셋의 역할은 비슷하지만, ViewHolder를 관리하는 주기에 차이가 있다.
mAttachedScrap - 단기적
mCachedViews - 중기적
mRecycledPool - 장기적
mAttacedScrap/mChangedScrap에서 제거된 ViewHolder들은 특정조건 하에 mCachedViews에 저장된다.
mCachedViews에서 제거된 ViewHolder들은 특정 조건 하에 mRecyclerPool으로 옮겨진다.
이 세 프로퍼티들은 Recycler 클래스의 메소드들에서
가장 빠르게 재사용이 가능한 mAttachedScrap부터 mCachedViews, mRecyclerPool 순으로 순차적으로 탐색된다.
ViewHolder를 재사용/생성하는 과정
이제 위 프로퍼티들을 어떻게 활용해서 ViewHolder를 관리하는지 살펴보자.
@NonNull
public View getViewForPosition(int position) {
return getViewForPosition(position, false);
}
View getViewForPosition(int position, boolean dryRun) {
return tryGetViewHolderForPositionByDeadline(position, dryRun, FOREVER_NS).itemView;
}
위 Recycler 클래스의 메소드는 주어진 position에 대한 View를 반환하는 메소드로,
tryGetViewHolderForPositionByDeadLine 메소드를 통해 ViewHolder를 가져오고, 해당 ViewHolder의 itemView를 반환한다.
실질적인 ViewHolder 재사용/생성 로직은 tryGetViewHolderForPositionByDeadLine에서 수행되기 때문에
RecyclerView 클래스에서 가장 중요한 메소드라고 생각한다.
여기서는 동작 방식만 가볍게 이해하고, 실제 코드를 뜯어가며 분석해보면 큰 도움이 될 것이다.
@Nullable
ViewHolder tryGetViewHolderForPositionByDeadline(int position,
boolean dryRun, long deadlineNs) {
if (position < 0 || position >= mState.getItemCount()) {
throw new IndexOutOfBoundsException("Invalid item position " + position
+ "(" + position + "). Item count:" + mState.getItemCount()
+ exceptionLabel());
}
boolean fromScrapOrHiddenOrCache = false;
ViewHolder holder = null;
// 0) If there is a changed scrap, try to find from there
if (mState.isPreLayout()) {
holder = getChangedScrapViewForPosition(position);
fromScrapOrHiddenOrCache = holder != null;
}
// 1) Find by position from scrap/hidden list/cache
if (holder == null) {
holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
if (holder != null) {
if (!validateViewHolderForOffsetPosition(holder)) {
// recycle holder (and unscrap if relevant) since it can't be used
if (!dryRun) {
// we would like to recycle this but need to make sure it is not used by
// animation logic etc.
holder.addFlags(ViewHolder.FLAG_INVALID);
if (holder.isScrap()) {
removeDetachedView(holder.itemView, false);
holder.unScrap();
} else if (holder.wasReturnedFromScrap()) {
holder.clearReturnedFromScrapFlag();
}
recycleViewHolderInternal(holder);
}
holder = null;
} else {
fromScrapOrHiddenOrCache = true;
}
}
}
if (holder == null) {
final int offsetPosition = mAdapterHelper.findPositionOffset(position);
if (offsetPosition < 0 || offsetPosition >= mAdapter.getItemCount()) {
throw new IndexOutOfBoundsException("Inconsistency detected. Invalid item "
+ "position " + position + "(offset:" + offsetPosition + ")."
+ "state:" + mState.getItemCount() + exceptionLabel());
}
final int type = mAdapter.getItemViewType(offsetPosition);
// 2) Find from scrap/cache via stable ids, if exists
if (mAdapter.hasStableIds()) {
holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition),
type, dryRun);
if (holder != null) {
// update position
holder.mPosition = offsetPosition;
fromScrapOrHiddenOrCache = true;
}
}
if (holder == null && mViewCacheExtension != null) {
// We are NOT sending the offsetPosition because LayoutManager does not
// know it.
final View view = mViewCacheExtension
.getViewForPositionAndType(this, position, type);
if (view != null) {
holder = getChildViewHolder(view);
if (holder == null) {
throw new IllegalArgumentException("getViewForPositionAndType returned"
+ " a view which does not have a ViewHolder"
+ exceptionLabel());
} else if (holder.shouldIgnore()) {
throw new IllegalArgumentException("getViewForPositionAndType returned"
+ " a view that is ignored. You must call stopIgnoring before"
+ " returning this view." + exceptionLabel());
}
}
}
if (holder == null) { // fallback to pool
if (DEBUG) {
Log.d(TAG, "tryGetViewHolderForPositionByDeadline("
+ position + ") fetching from shared pool");
}
holder = getRecycledViewPool().getRecycledView(type);
if (holder != null) {
holder.resetInternal();
if (FORCE_INVALIDATE_DISPLAY_LIST) {
invalidateDisplayListInt(holder);
}
}
}
if (holder == null) {
long start = getNanoTime();
if (deadlineNs != FOREVER_NS
&& !mRecyclerPool.willCreateInTime(type, start, deadlineNs)) {
// abort - we have a deadline we can't meet
return null;
}
holder = mAdapter.createViewHolder(RecyclerView.this, type);
if (ALLOW_THREAD_GAP_WORK) {
// only bother finding nested RV if prefetching
RecyclerView innerView = findNestedRecyclerView(holder.itemView);
if (innerView != null) {
holder.mNestedRecyclerView = new WeakReference<>(innerView);
}
}
long end = getNanoTime();
mRecyclerPool.factorInCreateTime(type, end - start);
if (DEBUG) {
Log.d(TAG, "tryGetViewHolderForPositionByDeadline created new ViewHolder");
}
}
}
// This is very ugly but the only place we can grab this information
// before the View is rebound and returned to the LayoutManager for post layout ops.
// We don't need this in pre-layout since the VH is not updated by the LM.
if (fromScrapOrHiddenOrCache && !mState.isPreLayout() && holder
.hasAnyOfTheFlags(ViewHolder.FLAG_BOUNCED_FROM_HIDDEN_LIST)) {
holder.setFlags(0, ViewHolder.FLAG_BOUNCED_FROM_HIDDEN_LIST);
if (mState.mRunSimpleAnimations) {
int changeFlags = ItemAnimator
.buildAdapterChangeFlagsForAnimations(holder);
changeFlags |= ItemAnimator.FLAG_APPEARED_IN_PRE_LAYOUT;
final ItemHolderInfo info = mItemAnimator.recordPreLayoutInformation(mState,
holder, changeFlags, holder.getUnmodifiedPayloads());
recordAnimationInfoIfBouncedHiddenView(holder, info);
}
}
boolean bound = false;
if (mState.isPreLayout() && holder.isBound()) {
// do not update unless we absolutely have to.
holder.mPreLayoutPosition = position;
} else if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) {
if (DEBUG && holder.isRemoved()) {
throw new IllegalStateException("Removed holder should be bound and it should"
+ " come here only in pre-layout. Holder: " + holder
+ exceptionLabel());
}
final int offsetPosition = mAdapterHelper.findPositionOffset(position);
bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);
}
final ViewGroup.LayoutParams lp = holder.itemView.getLayoutParams();
final LayoutParams rvLayoutParams;
if (lp == null) {
rvLayoutParams = (LayoutParams) generateDefaultLayoutParams();
holder.itemView.setLayoutParams(rvLayoutParams);
} else if (!checkLayoutParams(lp)) {
rvLayoutParams = (LayoutParams) generateLayoutParams(lp);
holder.itemView.setLayoutParams(rvLayoutParams);
} else {
rvLayoutParams = (LayoutParams) lp;
}
rvLayoutParams.mViewHolder = holder;
rvLayoutParams.mPendingInvalidate = fromScrapOrHiddenOrCache && bound;
return holder;
}
위 메소드의 내부 동작은 간단히 살명하자면 아래와 같다.
(1) mState가 preLayout 상태라면 mChangedScrap에서 ViewHolder를 찾는다
(2) mAttachedScrap, mCachedViews에서 ViewHolder를 찾는다
(3) mRecyclerPool에서 ViewHolder를 찾는다
(3) 없다면 새로운 ViewHolder를 생성한다
데이터가 변경된 ViewHolder를 mChagedScrap에서 최우선으로 탐색해본다.
이후 ViewHolder를 단기-중기-장기적으로 관리하기 위한 프로퍼티인 mAttachedScrap, mCachedViews, mRecyclerPool을 순차적으로 탐색하여 ViewHolder를 재사용하게 된다.
재사용 가능한 ViewHolder가 존재하지 않을 경우 adapter의 onCreateViewHolder를 호출하여 새로운 ViewHolder 객체를 생성하는 방식으로 동작한다.
RecyclerView 내부의 모든 코드를 해석할 수 있다면 좋겠지만, 정말 복잡하고 과투자라고 생각하기 때문에
전체적으로 어떤 원리로 동작하는지만 간단히 살펴보았다.
면접을 준비하면서 ScrapView와 DirtyView, RecyclerPool에 대해 고맥락으로만 공부했었는데,
그 과정에서 RecyclerView의 내부 구현이 궁금해서 뜯어보는 김에 포스팅하게 되었다.
틀린 설명이 있다면 댓글로 조언 부탁드립니다!
'개발 > Android' 카테고리의 다른 글
[안드로이드] Gson에서 Moshi로 마이그레이션하기 (0) | 2024.12.30 |
---|---|
[안드로이드] EXTRA_PICK_IMAGES_MAX 사용 시 주의할 점 (0) | 2023.09.14 |
[안드로이드] 클린 아키텍처 - (3) 멀티 모듈 패키지 구조 (0) | 2023.05.23 |
[안드로이드] 클린 아키텍처 - (2) 의존성 역전 (0) | 2023.05.16 |
[안드로이드] 클린 아키텍처 - (1) 개념 (0) | 2023.05.16 |