Today
-
Yesterday
-
Total
-
  • 리사이클러뷰 해부하기(2)
    안드로이드 개발

     

    ※ 본 포스팅은 이 링크의 내용을 번역한 글입니다.

     

    Anatomy of RecyclerView: a Search for a ViewHolder (continued)

    We continue our discussion of the way RecyclerView searches for a View at given position, which was started here…

    medium.com

     

     

    리사이클러뷰가 주어진 position 을 기준으로 뷰홀더를 탐색하는 방법에 대해 이전 글(리사이클러뷰 해부하기(1))에 이어 논의해보겠습니다. 

     

    참고하기 쉽도록 리사이클러뷰가 뷰홀더를 탐색하는 과정을 다시 한 번 나열해보겠습니다.

    1. chaged scrap 탐색

    2. attached scrap 탐색

    3. removed 되지 않은 hidden view 탐색

    4. view cache 탐색

    5. 어댑터가 stable ids 를 가질 경우 - 주어진 id 를 기준으로 attched scrap 과 view cache 재탐색

    6. ViewCacheExtension 탐색

    7. RecycledViewPool 탐색

    우리는 앞에서 RecycledViewPool 과 view cache 에 대해 다루었습니다.

     


    ViewCacheExtension

    ViewCacheExtension 은 내가 원하는 대로 동작하는 캐시입니다. 명시적으로 생성하기 전에는 존재하지 않으며,  setViewCacheExtension() 을 통해 생성하고 다음 메서드를 구현하면 사용할 수 있습니다.

    View getViewForPositionAndType(Recycler recycler, int position, int type);

    심플하고 매력적이지 않나요? 그러나 자세히 들여다보면 그렇게 매력적이지도, 심플하지도 않습니다.

     

    그 이유는 첫 째로, 여기서 리턴할 수 있는 뷰는 뷰홀더에 속해 있는 뷰여야만 합니다. 뷰홀더가 생성될 때 뷰의 LayoutParams 내에 뷰홀더의 참조를 저장합니다. 만약 RecyclerView 가 뷰에서 뷰홀더를 찾을 수 없으면 crash 가 발생합니다. 실제로는 뷰홀더가 필요한데 왜 뷰를 리턴해야 하는지 잘 이해가 가지 않는 부분입니다.

     

    그런데 이보다 더 큰 문제가 있습니다. 뷰홀더는 리사이클러뷰에 의해 변경되는 상태 값(position, flags 등)을 가집니다. 만약 여러분이 뷰홀더에 연결된 뷰를 리턴했는데, 이 뷰홀더가 가진 position 값과 파라미터로 주어진 position 값이 서로 다르면 리사이클러뷰와 LayoutManage 는 혼란에 빠지고 버그를 발생시킵니다.

     

    이 position 과 관련된 문제는 우리가 컨트롤 할 수 있는 문제가 아닌 것 같아 보입니다. position 이 어디서부터 왔는지 더 자세히 들여다보기 위해 어떤 아이템들을 추가하거나 삭제하는 상황을 생각해봅시다. 변경 사항을 처리하는 과정에서 LayoutManager 가 작업을 시작하기 전에, AdapterHelper 가 먼저 마지막 레이아웃 이후에 발생한 변경 사항을 처리하도록 요청을 받습니다. AdapterHelper 는 뷰홀더 position 의 offset 값을 계산하여 리사이클러뷰에게 돌려주고, 리사이클러뷰는 현재 화면에 보이는 뷰홀더들에 대해 루프를 돌면서 offset 값을 적용시킵니다. 그러나 리사이클러뷰는 우리가 어딘가에 캐싱해둔 뷰홀더에 대해서는 알지 못하기 때문에 우리가 사용할 뷰홀더는 offset 을 적용받지 못하며, 우리가 직접 position 을 변경해주고 싶어도 package-local 이기 때문에 불가능합니다.

     

    이런 제약 때문에 ViewCacheExtension 을 활용할 수 있는 곳은 매우 제한적입니다.

     

    ViewCacheExtension Example

    ViewCacheExtension 은 다음과 같은 특성을 가진 "특별한" 아이템에 사용할 수 있습니다. 

    1. position 이 고정되어 있다. (예를 들어, 항상 같은 position 에 위치하는 광고 배너)

    2. 시각적 요소가 변하지 않는다.

    3. 모든 뷰들을 메모리에 저장해도 괜찮은 만큼의 용량을 가진다.

    우리가 궁극적으로 원하는 것은 이 아이템들에 대해서 rebinding 을 피하는 것입니다. 이런 "특별한" 아이템이 리스트 내에 3개가 있고, 서로 멀리 떨어져있다고 가정해보겠습니다. 앞 뒤로 스크롤을 하면 하나의 뷰홀더에 3개의 아이템이 번갈아가며 바인딩되어 화면에 보여질 것입니다. 그러나 바인딩하는 작업은 비용이 많이 들고 부드러운 스크롤에 방해가 됩니다. 우리는 이 3개의 뷰를 모두 메모리에 유지하고 싶습니다. Pool 은 나중에 뷰홀더를 꺼내 쓸 때 데이터를 다시 바인딩해주어야 하기 때문에 적절하지 않아 보입니다. View cache 는 view type 에 대한 정보를 들고 있지 않기 때문에 마찬가지로 적절하지 않습니다. 이 때 사용할 수 있는 것이 바로 ViewCacheExtension 입니다.

     

    ViewCacheExtension 를 사용하는 코드는 다음과 같이 작성할 수 있습니다. 

    SparseArray<View> specials = new SparseArray<>();
    
    …
    
    recyclerView.getRecycledViewPool().setMaxRecycledViews(SPECIAL, 0);
    
    recyclerView.setViewCacheExtension(new RecyclerView.ViewCacheExtension() {
        @Override
        public View getViewForPositionAndType(RecyclerView.Recycler recycler, int position, int type) {
            return type == SPECIAL ? specials.get(position) : null;
        }
    });
    
    …
    
    class SpecialViewHolder extends RecyclerView.ViewHolder {
        …	
        
        public void bindTo(int position) {
            …
            specials.put(position, itemView);
        }
    }

    우리의 의도대로 special 아이템은 각각의 뷰홀더가 한 번씩만 생성되고 바인딩되는 것을 확인할 수 있을 것입니다.

     

    그러나 이 아이템들의 앞, 뒤에서 다른 아이템의 추가 또는 삭제가 발생해서 position 이 바뀐다면 모든 것이 무너져버립니다. SparseArray 의 키를 다시 계산해주면 되지 않을까 생각할 수도 있겠지만, 그것만으로는 해결되지 않습니다. 앞서 문제점을 언급했다시피, 뷰홀더는 바뀌기 전의 position 을 그대로 가지고 있으며, position 은 우리가 컨트롤 할 수 있는 값이 아닙니다.

     


    Hidden Views

    다음으로, 리사이클러뷰가 뷰홀더를 탐색하는 과정 중 3번째 "remove 되지 않은 hidden view" 가 무엇인지 알아보겠습니다.

     

    스크롤 또는 새로운 아이템 추가 등으로 인해 리사이클러뷰의 범위 밖으로 벗어나는 뷰는 LayoutManager 의 관점에서는 사라진 뷰이지만, 애니메이션을 위해 잠시동안 리사이클러뷰의 child 로 유지됩니다. 이러한 뷰를 Hidden View 라고 합니다. Hidden View 는 곧 사라질 뷰이기 때문에 LayoutManager 가 수행하는 어떠한 연산에도 포함되어서는 안됩니다. "연산에 포함되지 않는다"는 것이 무슨 의미냐면, 예를 들어 우리가 어떤 아이템을 삭제해서 해당 뷰가 화면에서 사라지고 있을 때 LayoutManager 의 getChildAt() 메서드를 호출한다고 생각해봅시다. 당연하게도, 이 호출을 처리하는 연산 과정에 이미 사라지고 있는 뷰는 포함되지 않습니다.

     

    LayoutManager 의 getChildAt(), getChildCount(), addView() 등의 메서드를 호출하면 리사이클러뷰의 실제 child 리스트에 적용되기 전에 먼저 ChildHelper 를 거치게 됩니다. ChildHelper 클래스는 Hidden View 가 아닌 child 리스트와 Hidden View 를 포함한 모든 child 리스트를 비교하여 인덱스를 새로 계산하는 역할을 해줍니다.

     

    이러한 Hidden View 가 뷰홀더를 탐색하는데 사용된다는 것은 미스터리해 보입니다. 왜냐하면 뷰홀더를 찾는 것은 그 결과를 LayoutManager 에게 돌려주기 위해서인데, LayoutManager 는 Hidden View 를 모르기 때문입니다.

     

    실제로 LayoutManager 의 관점에서 Hidden View 는 이미 사라진 뷰이며 해당 뷰의 존재를 몰라야 하는 것이 맞습니다. 그러나 리사이클러뷰는 알고 있으며, 아주 특별한 경우 이 뷰를 다시 LayoutManager 에게 돌려주게 됩니다. Hidden View 에게 일어나는 이러한 "도돌이표" 같은 메커니즘은 다음과 같은 상황에서 필요합니다.

     

    어떤 아이템(c)을 추가하고, 추가되는 애니메이션이 끝나기 전에 그 아이템을 다시 삭제한다고 생각해봅시다.

    이미지 출처 : 원문(https://medium.com/android-news/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-continued-d81c631a2b91)

    우리가 원하는 애니메이션은 c 가 추가되면서 b 가 화면 밖으로 밀려나고있던 중에 c 가 삭제되면, b 는 다시 위로 올라와서 원래의 자리로 돌아오는 것입니다. 화면 밖으로 밀려나고 있던 b 가 바로 Hidden View 입니다. 만약 그 순간 b 를 그냥 무시해버리면, 밖으로 밀려나고 있는 b 아래에 또 하나의 b 가 위로 올라오는 애니메이션이 서로 겹쳐서 보여질 것입니다. 이러한 버그를 피하기 위해 리사이클러뷰는 뷰홀더를 찾을 때 먼저 ChildHelper 에게 지금 찾고 있는 position 과 view type 에 맞는 Hidden View 가 존재하는지 물어봅니다. 

     

    만약 조건에 맞는 Hidden View 를 찾으면, 리사이클러뷰는 해당 뷰를 LayoutManager 에게 리턴해주고 LayoutManager 는 pre-layout 을 그릴 때 해당 뷰의 애니메이션이 어느위치에서 시작되어야하는지를 표시해줍니다 ( recordAnimationInfoIfBounceHiddenView()  참고). 만약 지금까지의 내용을 잘 따라오셨다면, 매우 혼란스러운 기분이 들었을 것입니다. pre/post-layout 에 무언가를 추가하는 것은 LayoutManager 의 영역인데, 리사이클러뷰가 직접 pre-layout 에 뷰를 추가하다니? 의아하고 엉망으로 보이겠지만, 그렇기 때문에 우리는 이 매커니즘을 잘 이해할 필요가 있습니다.

     


    Scrap

    Scrap 리스트는 리사이클러뷰가 뷰홀더를 찾을 때 가장 먼저 탐색하는 곳입니다. Pool 과 View Cache 와는 꽤 다른 특성을 가집니다.

     

    Scrap 은 pre/post-layout 을 그리는 동안을 제외하고는 빈 리스트로 존재합니다. 다시 말해, Scrap 에는 layout 을 그리는 동안에만 뷰가 들어있습니다. LayoutManager 가 layout 을 그리기 시작할 때, layout 에 추가된 뷰는 모두 scrap 에 추가됩니다 ( LinearLayoutManager#onLayoutChildren() 에서 호출하는  detachAndScrapAttachedViews() 메서드 참고). 그 후 LayoutManager 는 뷰를 하나씩 검색하고, 뷰에 문제가 없으면 Scrap 에서 다시 꺼내옵니다.

     

    아이템 a, b, c, d 중에 a, b, c 가 화면에 보이는 상태에서 b 를 삭제하는 것을 예로 들어보겠습니다. 

    이미지 출처 : 원문(https://medium.com/android-news/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-continued-d81c631a2b91)

    b 를 삭제함에 따라, re-layout 이 발생하여 a, b, c 가 Scrap 에 추가되었다가 a, c 만 Scrap 에서 다시 꺼내집니다.

     

    b 에게는 무슨 일이 일어날까요? layout 을 그리는 과정의 마지막 단계에서 리사이클러뷰는 b 가 post-layout 에 추가되지 않은 것을 발견하고 Scrap 에서 꺼내 Hidden View 로 만들고, 사라지는 애니메이션을 시작합니다. 애니메이션이 끝나면 b 는 pool 로 보내집니다.

     

    왜 LayoutManager 는 child 뷰들을 바로 사용하지 않고 Scrap 에 보냈다가 다시 꺼내서 쓰는걸까요? 그 이유는, Scrap 이 존재하는 이유가 LayoutManager 와 RecyclerView.Recycler 의 역할을 분리시키기 위함이기 때문입니다. LayoutManager 는 특정 child 를 유지하는 것이 좋은지, pool 이나 다른 어떤 곳으로 보내야하는지 신경 쓰지 않아야 합니다. 그것은 Recycler 가 판단할 일이니까요.

     

    layout 을 그리는 동안에만 비어있지 않다는 것 외에, Scrap 의 또 다른 특징은 Scrap 에 추가된 뷰들은 모두 리사이클러뷰에서 detach 된 뷰라는 것입니다. ViewGroup 의 attachView()/ detachView() addView() / removeView() 와 비슷하지만, layout request 나 invalidation 이 발생하지 않는다는 차이점이 있습니다. 뷰가 detach 될 때는 단지 ViewGroup 에서 해당 child 를 지우고, child 의 parent 를 null 로 바꾸기만 하면 됩니다. Detach 상태는 일시적인 것으로, 이후 attach 나 remove 가 발생해야 합니다. 이미 많은 child 뷰를 추가해놓은 상태에서 새 레이아웃을 계산할 때, 먼저 모든 자식들을 detach 하는 것이 편리할 것입니다. 그 작업을 리사이클러뷰가 해주는 것입니다.

     

    Attached vs Changed scrap

    Recycler 의 코드를 보면 mAttachedScrap mChangedScrap 두 개의 컨테이너가 존재하는 것을 알 수 있습니다. 왜 두개가 필요할까요?

     

    두 컨테이너 중 하나가 선택되는 과정을 먼저 살펴봅시다. 아이템이 변경되어 notifyItemChanged() 또는 notifyItemRangeChanged() 가 호출되고, ItemAnimation 에게 "이 뷰홀더를 재사용해도 되는지" 물어봤는데(canReuseUpdatedViewHolder()) "NO" 라는 답변을 받으면 해당 뷰홀더는 Changed scrap 으로 보내집니다. 나머지의 경우는 모두 Attached scrap 으로 보내집니다. "NO" 의 의미는 뷰 자체가 변경되었으니 cross-fade 와 같이 변경되는 애니메이션이 필요하다는 의미이며, "YES" 는 해당 뷰 내부의 어떤 뷰에만 변경 애니메이션을 주면 된다는 것을 의미합니다.

     

    Changed scrap 과 Attached scrap 의 차이점은 한가지입니다. Attached scrap 은 pre/post-layout 두 곳에 모두 쓰이는 반면, Changed scrap 은 pre-layout 에만 쓰인다는 것입니다. 변경된 아이템에 대해 post-layout 에서는 새로운 뷰홀더로 바뀌어야 하기 때문에 changed scrap 은 post-layout 에 사용되지 않는 것이 당연합니다. 우리의 예상대로, 변경되는 애니메이션이 끝나면 changed scrap 은 pool 로 보내집니다.

     

    디폴트로 사용되는 ItemAnimator 는 다음 3 가지 경우에 변경된 뷰홀더를 재사용합니다.

    1. setSupportsChangeAnimations(false) 를 호출했을 때

    2. notifyItemChanged() 또는 notifyItemRangeChanged() 대신 notifyDataSetChanged() 를 호출했을 때

    3. adapter.notifyItemChanged(index, anyObject) 와 같이 change payload 를 넘겨주었을 때 

    마지막 3번은 아이템 내부에서 변경사항이 일어났을 때, 새로운 뷰홀더가 생성되거나 바인딩 되는 것을 방지하는 좋은 방법입니다.

     


    Stable Ids

    Stable ids 의 가장 중요한 특성은 notifyDataSetChanged() 가 호출된 이후의 리사이클러뷰의 행동에만 영향을 준다는 것입니다.

     

    위에서 Scrap 에 대해 다룰 때, layout 작업이 시작되면 모든 child 뷰들이 Scrap 으로 보내진다는 것을 배웠습니다. 사실 거기에는 한 가지 예외가 있습니다. 만약 notifyDataSetChanged() 를 호출했는데 stable ids 가 없다면, 리사이클러뷰는 무슨 일이 일어났는지, 무엇이 변경되었는지 알 수가 없기 때문에 모든 것이 변경되었다고 가정합니다. 모든 뷰홀더가 invalid 되고 Scrap 대신 Pool 로 보내집니다. 따라서 아래와 같은 그림이 됩니다.

    이미지 출처 : 원문(https://medium.com/android-news/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-continued-d81c631a2b91)

    만약 stable ids 를 가진다면, 그림은 달라집니다. 

    이미지 출처 : 원문(https://medium.com/android-news/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-continued-d81c631a2b91)

    뷰홀더는 Pool 이 아닌 Scrap 으로 보내집니다. 그리고 Scrap 에서는 position 대신 어댑터의 getItemId() 를 통해 얻은 id 를 기준으로 뷰홀더를 검색하게 됩니다.

     

    여기서 얻을 수 있는 이점은 무엇일까요? 첫 째로, Pool 이 꽉 차서 새로운 뷰홀더가 생성되는 일이 발생하지 않습니다. 다만, id 가 바뀌지 않았다는 것이 content 가 바뀌지 않았다는 의미는 아니기 때문에 데이터가 새로 바인딩되는 것은 피할 수 없습니다.

     

    그보다 더 좋은 점은, 애니메이션을 얻을 수 있다는 것입니다. 아이템 4~6번에 move 애니메이션을 주기 위해서는 원래 notifyItemMoved(4, 6) 을 호출해야 하지만, stable ids 를 가진다면 notifyDataSetChanged() 를 호출하는 것으로 같은 효과를 얻을 수 있습니다. 리사이클러뷰는 특정 id 를 가진 뷰에게 어떤 일이 있어났는지(위치가 변경되었는지, 삭제되었는지 등) 감지할 수 있게 됩니다.

     

    그러나 한 가지 주목할 점은, 이 방식으로는 예측(predictive) 애니메이션이 아닌 '단순한' 애니메이션밖에 얻을 수 없다는 것입니다. 예측 애니메이션이 여기에서도 가능할지 한 번 생각해보겠습니다. 만약 새로 그려진 레이아웃에 이전에 없던 새로운 id 가 있을 때, 리사이클러뷰는 그것이 새로 추가된 아이템인지(inserted) 자리가 바뀐건지(moved) 알 수 있을까요? 만약 자리가 바뀐거라면 어느 위치에서 온 아이템인지 알 수 있을까요? 이 질문에 대한 답은 pre-layout 에서 찾을 수 있습니다. 그러나 pre-layout 은 리사이클러뷰의 범위를 넘어선, 확장된 컴포넌트의 영역이며, 리사이클러뷰는 id 만을 가지고 어댑터에서 일어난 변화가 정확히 무엇인지 알 수 없습니다.

     

    전반적으로 보았을 때, stable ids 에 대한 유용성은 제한적인 것 같습니다. 그러나 ListView 를 RecyclerView 로 마이그레이션하는 중에 notifyDataSetChanged() 를 모두 특정한 변경을 알리는 메서드로 변경하려고 하면 골치가 아플 수 있습니다. 이 때, Stable ids 를 사용하면 단순한 애니메이션을 쉽게 얻을 수 있습니다. 그 다음 단계로, DiffUtil 을 사용하기를 권해드립니다.

     

    이것으로 리사이클러뷰의 내부 동작 방식에 대한 여정을 마치겠습니다.

    댓글

Designed by black7375.