RecyclerView Prefetch – 스크롤을 부 드럽게 하기위한 시도

RecyclerView Prefetch를 번역한 글입니다.

내가 어렸을 때, 어머니께서 지금 방청소를 하면 나중에 할 필요가 없다며 버릇을 고치려고 하였습니다. 하지만 나는 그것에 속지 않았습니다. 나는 가능한 오랫동안 하지 않는 것이 항상 최선이라는 것을 알았습니다. 한 가지 생각해보면, 만약 청소를 지금 당장 하게 된다면 다시 더러워질 것이고 청소를 두 번 해야 됩니다. 만약에 더 오랫동안 미룬다면 어머니는 잊어버릴 수도 있습니다.

미루는 버릇은 항상 나의 일이었습니다. 하지만 내 친구 인 RecyclerView는 일관된 프레임 속도에 대한 문제가 없습니다.

 

문제점

스크롤이나 플링 연산중 RecyclerView는 화면에 새로운 아이템 정보를 표시해야 합니다. 이러한 아이템은 데이터와 바인딩합니다. (캐시에 항목이 없는 경우 새롭게 생성될 수 있습니다.) 그런 다음 배치되며 화면에 그려지게 됩니다. 이 모든 작업이 완료되면, 프레임에서 처리할 작업이 완료될 때까지 UI스레드는 멈춰있게 됩니다. 그러면 렌더링이 진행되고 스크롤은 스무스하게 움직일 수 있습니다… 다음 새로운 아이템이 나타날 때까지

일반적으로 RecyclerView에서 콘텐츠를 스크롤하는 동안 렌더링 하는 단계입니다 (롤리팝 이상에서). UI스레드에서 입력 이벤트, 이벤트 핸들링, 레이아웃을 수행하고 드로잉 연산 작업을 기록합니다. 그런 다음 렌더스레드가 명령을 GPU로 보냅니다.

스크롤하는 동안의 프레임은 처리할 새로운 내용이 없으므로 RecyclerView는 필요한 작업을 수행하는데 아무런 문제가 없습니다. 이 프레임 동안, UI스레드 입력을 처리하고, 애니메이션을 처리하며 레이아웃을 수행하고 드로잉 명령을 기록합니다. 그런 다음 드로잉 정보를 렌더스레드와 동기화합니다. (롤리팝 이상, 이전 버전은 UI 스레드에서 모든 작업을 수행함) 이러한 명령을 GPU로 보냅니다.

새로운 아이템 생성 시 뷰 생성, 바인딩 및 배치될 때 입력 단계가 가장 오래 걸리게 합니다. 결과적으로 프레임 경계 이후에 끝나기 때문에 렌더링 단계가 늦어져 프레임 누락의 원인이 됩니다.

새로운 아이템이 스크린에 나타나면 입력 단계에 더 많은 작업이 필요하며, 적합한 뷰를 생성할 수 있습니다. 이것은 렌더스레드의 후속 작업뿐만 아니라 나머지 UI 스레드를 늦추어 프레임 범위를 벗어나기 때문에 무의미한 프레임이 됩니다.

입력을 받는 동안 새로운 아이템을 보여주기 위해 생성하고 바인딩 하는데 사용할 수 있는 많은 시간을 할당 할 수 있습니다.

새로운 아이템이 나타날 때 입력 단계에서 호출 스택을 검사하면 많은 시간이 뷰 생성 및 바인딩에 소비된다는 것을 알 수 있습니다. 새로운 아이템을 준비 하는 동안 다른 작업을 지연하지 않는 방법이 있으면 좋지 않을까요?

뷰 생성 및 바인딩은 렌더링 되기 전에 완료되어야 하며 필요한 경우 처리하는 동안 UI스레드에서는 많은 시간을 소비합니다. 이전 프레임에서 많은 시간을 보내고 있으면 지연이 발생됩니다.

Chris Craik(안드로이드 UI 툴킷팀, 그래픽 엔지니어)가 RecyclerView의 스크롤을 관측하기 위해 Systraces를 만들었습니다. 특히, 그는 아이템이 필요할 때 준비하는 시간이 오래 걸린다는 것을 알았습니다. 그리고 프레임을 처리는 동안에 UI스레드가 잠자는데 많은 시간을 보내고 있었습니다. 왜냐하면 UI스레드는 작업을 일찍 끝마치기 때문입니다.

 

해결책

생성과 바인드 연산을 이전 프레임에서 허용하면 UI스레드와 렌더스레드가 병렬로 작업을 수행할 수 있게 되며, 렌더스레드의 작업이 완료되기 전 동기적으로 수행하여 나중에 처리할 필요가 없습니다.

분명히 시간을 가지고 놀 시간이 없습니다. 특히 Chris는 기본 RecyclerView 레이아웃에서 일어나는 일을 재배열하여 필요한 View의 아이템을 미리 가져옵니다. 유휴 상태에서 이 작업을 수행하여 결과를 기다리는 것을 피할 수 있습니다.

이제 이 작업은 기본적으로 무료로 가능합니다. 유휴시간을 사용하여 나중에 해야 될 작업을 수행하기 때문에 UI스레드는 프레임들 사이의 갭에서 어떤 일도 하지 않습니다. 어려운 일이 이미 완료되었기 때문에 훤씬 빠른 속도로 프레임을 만들어 갈 수 있습니다.

 

세부 설명

이 시스템은 RecyclerView가 스크롤링 작업을 시작할 때마다 Runnable을 스케쥴링함으로써 작동합니다. 이 Runnable은 레이아웃 매니저와 뷰가 스크롤되는 방향에 따라 즉시 나타나며 아이템의 프리페치를 수행합니다.

프리페치는 단일 아이템에 국한되지 않습니다. GridLayoutManager 아이템의 행이 화면에 오는 등 여러 아이템을 한 번에 검색할 수 있습니다. 버전 25.1에서 프리페치 연산은 개별 생성/바인드 연산으로 분리되어 아이템별 전체 그룹의 연산보다 UI스레드 갭이 더 쉽게 구분됩니다.

프리 페치 방식에 대한 흥미로운 점 중 하나는 시스템에서 작업에 소요되는 시간과 사용 가능한 갭 내에 들어갈 수 있는지 여부를 예측해야 한다는 것입니다. 결국, 프리 페치 작업이 타이밍을 찾지 못하고 놓쳐 프레임을 지연시키게 되면 프리 페치가 없는 것과 동일하게 무의미한 프레임이 될 수 있습니다. 시스템이 이 세부 사항을 처리하는 방식은 View 타입별 평균 생성 및 바인드 지속 기간을 추적하여 향후 생성 및 바인드 연산에 대해 합리적인 예측을 가능하게 합니다.

내부 RecyclerView를 바인딩해도 자식을 할당하지 않기 때문에 충첩된 RecyclerViews(RecyclerView 내부 컨테이너 아이템)에 대해 작업을 수행하는 것이 더 까다롭습니다. RecyclerView는 첨부 및 레이아웃 될 때 자식을 가져옵니다. 프리 페치 시스템은 여전히 RecyclerView 내부에서 자식들을 준비할 수 있지만 얼마나 많은지 알고 있어야 합니다. LinerLayoutMananger의 버전 25.1에 있는 새로운 API인 setInitialPrefetchItemCount()입니다. 이 API는 화면에서 스크롤할 때 RecyclerView를 채우기 위해 미리 가져올 항목의 수를 시스템에 알려줍니다.

 

주의사항

알고 있어야 할 몇 가지 중요한 주의사항:

  • 프리페칭은 필요로 하지 않는 작업을 할 수도 있습니다. View를 프리페칭하고 있기 때문에 적극적으로 일을 할 수 있으며, RecyclerView는 문제 되는 아이템에 도달하지 않을 수도 있습니다. 이것은 프리페치 작업이 낭비될 가능성이 있다는 것을 의미합니다. 동시에 발생했기 때문에 큰 문제는 없습니다. 필요하기 직전에 인출하고 있기 때문에 이것은 매우 드뭅니다. 스크롤이 두 프레임 간 정지나 역전하는 일은 거의 없습니다.
  • 렌더스레드: 렌더스레드는 롤리팝에서 성능 향상을 위해 도입된 기능으로 렌더링을 UI스레드에 영향을 받지 않도록 다른 스레드로 분리하여 불변의 애니메이션(ex. 잔물결 및 원형틀)을 실행하는 등 일부 개선을 허용합니다. 롤리팝 이전 버전은 병렬로 작업을 처리할 수 없기 때문에 최적화의 이점은 얻지 못합니다.

 

무엇을 원하며  — 어디서 얻을 수 있는가?

프리페치 최적화는 서포트 라이브러리 25 버전에 도입되었으며, 25.1.0 버전에서 더욱 향상된 기능이 추가되었습니다. 따라서 첫 번째 단계는 최신의 서포트 라이브러리 버전을 사용하는 것입니다.

RecyclerView와 함께 기본으로 제공되는 레이아웃 매니저를 사용하면 최적화가 자동으로 수행됩니다. 그러나 중첩된 RecyclerView를 사용하거나 직접 만든 레이아웃 매니저의 경우 기능을 활용하려면 코드 변경이 필요합니다.

중첩된 RecyclerView의 경우, 최상의 성능을 얻으려면 내부 레이아웃 매니저에서 LinearLayoutManager의 새로운 메소드인 setInitialPrefetchItemCount()를 호출하면 됩니다. 예를 들어 세로 목록의 행에 최소 3개 이상이 표시되면 setInitialPrefetchItemCount(4)를 호출하면 됩니다.

직접 LayoutManager를 구현한 경우, 프리페치를 활성화하기 위해 LayoutManager.collectAdjacentPrefetchPositions()를 구현해야합니다.
언제나 그렇듯이 가능한 작은 작업으로 생성과 바인드 단계를 최적화하는 것이 좋습니다. 가장 빠른 코드는 프레임워크가 프리 페치를 통해 수행된 작업을 병렬 처리할 수 있을 때 실행될 필요가 없으며, 여전히 시간이 걸리는 비싼 아이템 생성은 비용이 들 수 있습니다. 예를 들어, 뷰를 최소한으로 써가며 복잡한 구조를 만드는 것보다 항상 생성하고 바인딩하는 것이 더욱 저렴합니다. 바인딩은 기본적으로 setter를 호출하는 것처럼 간단하고 빠릅니다. 현재 코드로 프레임 제한 시간까지 잘 맞출 수 있어도 이를 최적화하면 저사양 기기에서 더 잘 작동할 가능성이 커지며 고사양 기기에서는 배터리 소모에 도움이 됩니다.

이미 생성과 바인딩을 빨리 할 수 있더라도 프리페칭은 프레임들 사이의 틈새에서 남은 시간을 당기는 것에 대해 도와주어야 합니다. LayoutManager.setItemPrefetchEnabled()를 토글 하여 결과를 바탕으로 최적화를 비교해볼 수 있습니다. 결과를 시각적으로 볼 수도 있습니다. 정말 중요하며, 특히 생성하고 바인딩하는데 상당한 시간이 걸리는 아이템 있습니다. 프리페치 사용 여부와 상관없이 실시간으로 모니터링하고 싶다면 Systrace를 실행하거나 GPU 프로파일링을 활성화하면 됩니다.

Systrace는 UI스레드의 유휴 시간동안 발생하는 프리페치를 보여줍니다.

 

마치며..

최신 서포트 라이브러리를 확인하고 RecyclerView의 새로운 프리페치를 사용해보세요. 한편, 나는 방을 청소하지 않고 돌아가겠습니다.

 

안드로이드 RecyclerView 성능 개선팁

RecyclerView는 제한된 화면에서 큰 데이터 세트를 제공하기 위한 유연한 View입니다. RecyclerView는 안드로이드 앱 개발에 있어서 가장 중요한 위젯 중 하나인 ListView를 좀 더 발전시킨 버전입니다. 뉴스 피드나 연락처 목록을 구현 시 사용자가 빠르게 스크롤할 때 성능 문제 또는 불필요한 지연을 방지하기 위해 ListView를 사용했습니다. RecyclerView는 ListView의 성능과 지연을 100% 방지 못하는 문제점을 해결한 버전입니다.

이 글은 RecyclerView 사용방법에 관한 글이 아닙니다. RecyclerView를 사용하면서 유용한 정보와 중요한 규칙, 절대 하지 말아야 할 것을 하나씩 살펴보겠습니다.

피할 수 있는 문제

1. 제대로된 View 재사용
: ViewHolder 내부에서 View 애니메이션을 절대 사용하면 안됩니다. (ex. itemView.animate()호출)
ItemAnimator는 View 애니메이션을 처리할 수 있는 유일한 구성 요소입니다. (ex. RecyclerView.setItemAnimator() )

ListView에서는 아이템 View 애니메이션 처리를 위해 getView()에서 작업을 하였습니다. 이는 View의 재사용 문제가 있으며 RecyclerView에서 동일한 패턴으로는 사용해서는 절대 안됩니다.

2. 세분화된 Adapter 업데이트
: 변경이 된 데이터에 대해서만 Adapter 업데이트를 하세요. (ex. notifyItemChanged(4))
NotifyItemRangeChanged()는 필요로 하지 않는 View를 업데이트하기 때문에 불필요하게 사용하지 마세요. (ex. notifyItemRangeChanged(0, getItemsCount() )

DiffUtil을 사용하여 Adapter의 성능을 측정 해 볼 수 있습니다.

3. onBindViewHolder position != final
: 절대 onBindViewHolder 내부에서 View.OnClickListener를 셋하지 마세요. onBindViewHolder는 데이터를 View에 바인딩하기 위해서만 사용해야 합니다.
아래와 같이 사용합니다.

이러한 간단한 규칙 3가지만으로도 RecyclerView의 성능은 보장됩니다.
데이터 변경에 따른 쉬운 애니메이션 처리방법.

  • 위에서 언급한 RecyclerView.setItemAnimator()를 직접 구현해도 되지만 notifyItemChanged(), notifyItemRangeChanged(), notifyItemInserted(), notifyItemMoved(), notifyItemRemoved()를 사용하기만 해도 기본적인 애니메이션이 적용됩니다.
  • getItemId(int position)와 함께 setHasStableIds(true)를 사용하면 RecyclerView.notifyDataSetChanged()로 모든 애니메이션을 자동으로 처리할 수 있습니다. (참고: http://wp.me/p4L8WU-MF)

 

 

성능 팁!

부드러운 스크롤을 원한다면 아래의 간단한 규칙을 지키면 됩니다.

  • 프레임 당 모든 작업을 수행하는데 16ms내로 작업하도록 해야 합니다. 개발자 옵션에서 프로필 GPU 렌더링 옵션을 사용하여 성능을 모니터링하세요.
  • 레이아웃 구조를 최적화 및 간단한 구조를 유지하세요.
  • 깊은 레이아웃 계층을 피하기 위해 HierarchyViewer를 사용하세요.
  • 오버 드로우 문제를 피하고 시스템 도구로 모니터링 하세요.
  • TextView에 긴 텍스트를 설정하지 마세요. 텍스트 줄을 계산하기위해 많은 연산이 필요하여 성능을 떨어집니다. 텍스트 끝 말줄임표나 최대 줄수를 설정 해두는 것도 하나의 방법입니다.
  • 렌더링 퍼포먼스를 향상하기위해 LayoutManager.setItemPrefetchEnabled()를 사용하세요.

RecyclerView는 안드로이드의 강력한 위젯이며 모든 결과를 얻을 수 있습니다. 하지만 잘못된 작은것 하나가 앱의 품질을 좌우 합니다.

참고: https://medium.com/master-of-code-global/recyclerview-tips-and-recipes-476410fa12fd#.enkpoi5ir

Stable Id를 이용한 RecyclerView 성능 향상법

RecyclerView는 ListView를 완전히 대체할 수 있을 만큼 기능과 성능이 크게 향상되었다. ListView에서도 어떻게 하면 끊김 없이 빠른 스크롤을 지원할까라는 고민을 해왔었고, RecyclerView를 사용하다 보면 똑같은 고민을 또 하게 될 것이다.

ViewHolder라는 패턴을 통해 ListView의 성능을 RecyclerView에서 크게 향상할 수 있었다. 재활용하는 뷰들의 클래스를 View 태그 또는 Array에 저장하고 필요할 때 바로 가져와서 사용하는 방법으로 성능을 크게 향상하였다. ListView에서 재활용되는 뷰를 해당 포지션에 맞게 가져오는 곳에서 성능을 향상할 수 있었다면, RecyclerView는 가져온 View에 데이터를 바인드 시 최적화할 수 있는 방법을 제공하고 있다.

HasStableIds사용을 통해 데이터 바인드 시 onBindViewHolder()를 최적화 되게 호출할 수 있다. 아래 2가지 중 하나만이라도 해당한다면 성능을 크게 향상할 수 있다.

  • 똑같은 데이터가 반복적으로 나타는 리스트이다.
  • notifyDataSetChanged를 자주 호출한다.

HasStableIds는 Adapter.setHasStableIds(boolean)을 통해 설정할 수 있으며, 사용하는 경우 어댑터의 getItemId(int)를 반드시 구현해야 작동한다.

getItemId(int)를 통해 해당 아이템은 고정된 상태로 설정된다. 예를 들어 아래와 값을 반환되게 구현했다면 어떤 성능적인 변화가 일어날까?

position
return
0
100
1
200
2
300
3
100
4
400
5
500

onBindViewHolder(view, int)는 포지션이 0, 1, 2, 4, 5 만 호출된다. 3번은 0번째 포지션에서 같은 고정된 ID를 반환했기 때문에 같은 데이터로 인식하여 onBindViewHolder(view, int)가 호출되지 않는다. 같은 데이터임을 알고 데이터 바인드를 할 필요가 없기 때문에 호출되지 않으며 그만큼 성능은 향상된다.

position
return
0
100
1
200
2
600
3
300
4
100
5
400
6
500

포지션 2번에 데이터를 추가하고 notifyDataSetChanged()를 호출하였다. 이때 onBindViewHolder(view, int)는 현재 보이고 있는 포지션이 모두 호출되지만 StableId를 사용하게 된다면 이미 호출된 고정된 ID를 제외한 위치가 호출된다. notifyDataSetChanged()를 하였음에도 변경되는 ID만을 골라 해당 포지션만 onBindViewHolder(view, int)를 호출하게 됨으로 그만큼 성능은 향상된다.

안드로이드 ListView에서 RecyclerView로 마이그레이션 하기

안드로이드 5.0(롤리팝)에서 발표된 RecyclerView는 ListView의 확장성과 유연성을 높이기위해 만들어진 고급위젯이다. 이런 유연성때문에 기존의 ListView의 기능들이 RecyclerView에서는 직접 구현해야되는 경우가 많아 졌다. 그렇다면 ListView에서 RecyclerView로 마이그레이션 하기위해 우리는 무엇을 고민해야 하는지 하나씩 설명해보겠다.

1. OnItemClickListener, OnItemLongClickListener

ListView에서 아이템을 클릭시 콜백 받을수 있는 리스너는 RecyclerView에서는 존재하지 않으며 그대신 OnItemTouchListner가 새롭게 생겼다. 터치 이벤트를 통해 사용자가 아이템을 클릭했는지, 롱클릭 했는지등을 직접 처리해야 한다. 아니면 ViewHolder에서 아이템별로 OnClickListener를 달아준다.

전자의 방법은 아래 코드와 같이 GestureDector를 사용해서 onLongPress(), onSingleTapConfirmed()의 이벤트를 분기하는 방법이다.

GestureDetectorCompat mGestureDetector = new GestureDetectorCompat(getActivity().getApplicationContext(), new GestureDetector.SimpleOnGestureListener() {

    @Override
    public boolean onSingleTapConfirmed(MotionEvent e) {
        
        View childView = getRecyclerView().findChildViewUnder(e.getX(), e.getY());
        int position = getRecyclerView().getChildPosition(childView);
        
        //ItemClick
        
        return super.onSingleTapConfirmed(e);
    }
    
    @Override
    public void onLongPress(MotionEvent e) {
    
        View childView = getRecyclerView().findChildViewUnder(e.getX(), e.getY());
        int position = getRecyclerView().getChildPosition(childView);
        
        //LongClick
        
        super.onLongPress(e);
    }

});


@Override
public boolean onInterceptTouchEvent(RecyclerView recyclerView, MotionEvent event) {
    mGestureDetector.onTouchEvent(event);

    return false;
}

하지만 실제로 사용시 문제 점이 발견되어 쉽게 처리가 불가능할것 같다. 이유는 스크롤시 사용자가 스크롤을 멈추기 위해 화면을 터치 하는 순간 onSingleTapConfirmed 이벤트가 발생하는 문제점이다. 이렇게 되면 사용자는 스크롤을 멈추는게 아닌 아이템클릭으로 인식 되는 오작동이 생기기 때문에 이런 문제를 해결하기위해 좀 더 추가적인 작업이 필요하다.

findChildViewUnder(e.getX(), e.getY()) : 터시이벤트로 해당아이템 뷰 가져오기

getChildPosition(childView) : 해당뷰의 아이템 포지션

 

후자의 방법은 Adapter에 OnClick이벤트를 념겨 ViewHolder의 클릭이벤트로 달아 주는 것이다.

class SimpleViewHolder extends ViewHolder {

    public ImageView mImage;
    public TextView mText;
    public OnClickListener mOnClickListener;

    public SimpleViewHolder(View itemView) {
        super(itemView);

        mImage = (ImageView) itemView.findViewById(R.id.img);
        mText = (TextView) itemView.findViewById(R.id.txt);

        itemView.setOnClickListener(mOnClickListener);

    }
}

 

2. setOnScrollListener

ListView의 setOnScrollListener는 RecyclerView에서도 있으나 콜백되는 데이터가 다르다.

  • ListView
    public void onScrollStateChanged(AbsListView view, int scrollState)
    public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount)
  • RecyclerView
    public void onScrollStateChanged(RecyclerView recyclerView, int newState)
    public void onScrolled(RecyclerView recyclerView, int dx, int dy)

ListView의 onScroll을 통해 리스트의 제일 하단 일 경우 더보기 기능을 구현해서 썼다면 RecyclerView onScrolled()로는 알 수 없으며 LayoutManager에서 별도의 메서드로 위치를 찾아야 한다.

public void onScrolled(RecyclerView recyclerView, int dx, int dy){
    super.onScrolled(recyclerView, dx, dy);

    int visibleItemCount = recyclerView.getChildCount();
    int totalItemCount = recyclerView.getLayoutManager().getItemCount();
    int firstVisibleItem = ((LinearLayoutManager) recyclerView.getLayoutManager()).findFirstVisibleItemPosition();
}

 

3. HeaderView, FooterView

ListView에서 제공되는 addHeaderView와 addFooterView는 더이상 제공되지 않으며 Adapter에서 ViewType를 통해 Header와 Footer 뷰를 제어 해야 한다. 아래와 같이 getItemCount와 getItemViewType을 통해 onCreateViewHolder와 onBindViewHolder에서 Header와 Footer뷰의를 생성하게끔한다. 이렇게 ListView에서 HeaderView와 FooterView를 지원하지 않게 된이유는 재활용되지 않는 문제점을 해결하고자 애초에 문제점가 발생 되지 않기 위해 삭제 해버린것 같다. 어쨌든 아래 코드를 통해 Header와 Footer뷰를 Adapter에서 구성할 수 있다.

public abstract class HeaderRecyclerViewAdapter extends RecyclerView.Adapter {
    private static final int TYPE_HEADER = Integer.MIN_VALUE;
    private static final int TYPE_FOOTER = Integer.MIN_VALUE + 1;
    private static final int TYPE_ADAPTEE_OFFSET = 2;

    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        if (viewType == TYPE_HEADER) {
            return onCreateHeaderViewHolder(parent, viewType);
        } else if (viewType == TYPE_FOOTER) {
            return onCreateFooterViewHolder(parent, viewType);
        }
        return onCreateBasicItemViewHolder(parent, viewType - TYPE_ADAPTEE_OFFSET);
    }

    @Override
    public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
        if (position == 0 && holder.getItemViewType() == TYPE_HEADER) {
            onBindHeaderView(holder, position);
        } else if (position == getBasicItemCount() && holder.getItemViewType() == TYPE_FOOTER) {
            onBindFooterView(holder, position);
        } else {
            onBindBasicItemView(holder, position - (useHeader() ? 1 : 0));
        }
    }

    @Override
    public int getItemCount() {
        int itemCount = getBasicItemCount();
        if (useHeader()) {
            itemCount += 1;
        }
        if (useFooter()) {
            itemCount += 1;
        }
        return itemCount;
    }

    @Override
    public int getItemViewType(int position) {
        if (position == 0 && useHeader()) {
            return TYPE_HEADER;
        }

        if (position == getBasicItemCount() && useFooter()) {
            return TYPE_FOOTER;
        }

        if (getBasicItemType(position) != Integer.MAX_VALUE - TYPE_ADAPTEE_OFFSET) {
            new IllegalStateException("HeaderRecyclerViewAdapter offsets your BasicItemType by " + TYPE_ADAPTEE_OFFSET + ".");
        }

        return getBasicItemType(position) + TYPE_ADAPTEE_OFFSET;
    }

    public abstract boolean useHeader();

    public abstract RecyclerView.ViewHolder onCreateHeaderViewHolder(ViewGroup parent, int viewType);

    public abstract void onBindHeaderView(RecyclerView.ViewHolder holder, int position);

    public abstract boolean useFooter();

    public abstract RecyclerView.ViewHolder onCreateFooterViewHolder(ViewGroup parent, int viewType);

    public abstract void onBindFooterView(RecyclerView.ViewHolder holder, int position);

    public abstract RecyclerView.ViewHolder onCreateBasicItemViewHolder(ViewGroup parent, int viewType);

    public abstract void onBindBasicItemView(RecyclerView.ViewHolder holder, int position);

    public abstract int getBasicItemCount();

    public abstract int getBasicItemType(int position);
}

참고: https://gist.github.com/sebnapi/fde648c17616d9d3bcde

 

4. ItemSelect

ListView에서 기본적으로 지원되던 아이템 선택모드는 RecyclerView에서는 지원되지 않는다. 솔찍히 ListView에서 제공되는 기본 선택 기능은 좀 복잡한 경우 커스텀으로 직접 구현해야되는 문제점을 갖고있고 하위 OS의 경우 기능이 구현되어 있지도 않다. 이렇게 좀더 확장해서 써라는 의미로 애초에 지원하지 않은것 같다. 직접 구현하는 방법은 Adapter에서 선택 모드시 선택된 해당 아이템을 List로 직접 관리 하면된다. 구현 한다면 다음과 같다. 참고로 SparseBooleanArray는 순서에 상관없는 Array이며 빠르기 때문에 사용하였다.

public class RecyclerViewDemoAdapter
        extends RecyclerView.Adapter
                 {

   // ...
   private SparseBooleanArray selectedItems;
   
   // ...

   public void toggleSelection(int pos) {
      if (selectedItems.get(pos, false)) {
         selectedItems.delete(pos);
      }
      else {
         selectedItems.put(pos, true);
      }
      notifyItemChanged(pos);
   }
   
   public void clearSelections() {
      selectedItems.clear();
      notifyDataSetChanged();
   }
   
   public int getSelectedItemCount() {
      return selectedItems.size();
   }
   
   public List getSelectedItems() {
      List items = 
            new ArrayList(selectedItems.size());
      for (int i = 0; i < selectedItems.size(); i++) {
         items.add(selectedItems.keyAt(i));
      }
      return items;
   }

   // ...

}

참고: http://www.grokkingandroid.com/statelistdrawables-for-recyclerview-selection/

 

5. Divider

ListView에서 지원되는 아이템간의 Divider는 RecyclerView에는 없다. 아이템별로 하단에 View를 넣어 선을 긋는 방법도 있겠지만 RecyclerView에서 지원하는 ItemDecoration을 이용하면 간단하게 처리 가능하다.

 

public class DividerItemDecoration extends RecyclerView.ItemDecoration {
 
    private static final int[] ATTRS = new int[]{
            android.R.attr.listDivider
    };
 
    public static final int HORIZONTAL_LIST = LinearLayoutManager.HORIZONTAL;
 
    public static final int VERTICAL_LIST = LinearLayoutManager.VERTICAL;
 
    private Drawable mDivider;
 
    private int mOrientation;
 
    public DividerItemDecoration(Context context, int orientation) {
        final TypedArray a = context.obtainStyledAttributes(ATTRS);
        mDivider = a.getDrawable(0);
        a.recycle();
        setOrientation(orientation);
    }
 
    public void setOrientation(int orientation) {
        if (orientation != HORIZONTAL_LIST && orientation != VERTICAL_LIST) {
            throw new IllegalArgumentException("invalid orientation");
        }
        mOrientation = orientation;
    }
 
    @Override
    public void onDraw(Canvas c, RecyclerView parent) {
        if (mOrientation == VERTICAL_LIST) {
            drawVertical(c, parent);
        } else {
            drawHorizontal(c, parent);
        }
    }
 
    public void drawVertical(Canvas c, RecyclerView parent) {
        final int left = parent.getPaddingLeft();
        final int right = parent.getWidth() - parent.getPaddingRight();
 
        final int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View child = parent.getChildAt(i);
            final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
                    .getLayoutParams();
            final int top = child.getBottom() + params.bottomMargin;
            final int bottom = top + mDivider.getIntrinsicHeight();
            mDivider.setBounds(left, top, right, bottom);
            mDivider.draw(c);
        }
    }
 
    public void drawHorizontal(Canvas c, RecyclerView parent) {
        final int top = parent.getPaddingTop();
        final int bottom = parent.getHeight() - parent.getPaddingBottom();
 
        final int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View child = parent.getChildAt(i);
            final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
                    .getLayoutParams();
            final int left = child.getRight() + params.rightMargin;
            final int right = left + mDivider.getIntrinsicHeight();
            mDivider.setBounds(left, top, right, bottom);
            mDivider.draw(c);
        }
    }
 
    @Override
    public void getItemOffsets(Rect outRect, int itemPosition, RecyclerView parent) {
        if (mOrientation == VERTICAL_LIST) {
            outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
        } else {
            outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
        }
    }
}

참고: https://gist.github.com/alexfu/0f464fc3742f134ccd1e

 

6. 그외

이외 아이템 레이아웃 배경에 selector에 android:state_pressed=”true”를 한다고 해도 pressed가 호출 되지 않아 아이템을 누르는 경우 배경이 변하지 않는다. RecyclerView가 이벤트를 childView로 넘기지 않아 발생되며 1번의 GestureDetector에서 onShowPress()를 통해 직접 해당뷰에 setPressed(true)를 호출하면된다.

ListView의 scrollToPosition()도 RecyclerView에서 관리 되는것이 아닌 recyclerView.getLayoutManager().scrollToPosition() 에서 관리된다.

 

이처럼 ListView에서 RecyclerView로 마이그레이션 하기에는 많은 기능을 직접 구현해야 되는 문제점이 있다. 하지만 복잡한 아이템이 많은 경우 ListView에 비해 RecyclerView가 성능이 우수하다.한번에 바꾸기는 다소 무리가 있으며 천천히 준비 해가면서 바꾸거나, 신규 앱개발시 복잡한 리스트인 경우라면 써볼만하다.

Android RecyclerView 사용하기

Android L 프리뷰 버전에서 등장한 RecyclerView는 이번 Android 5.0 프리뷰 버전과 함께 Support-Library-v7의 최신 버전에 정식으로 추가되었다. RecyclerView는 기존의 ListView보다 유연하고 성능이 향상된 고급 위젯이다. 기존의 ListView는 커스텀하기에는 구조적인 문제로 많은 제약이 따랐으며, 구조적인 문제로 인해 성능 문제가 있었다. RecyclerView는 이런 고질적인 문제를 해결하기 위해 좀 더 다양한 형태로 개발자가 커스텀할 수 있도록 유연하며 성능에 중점을 두어 만들어졌다.

ListView에 비해 RecyclerView의 가장 큰 변경 사항은 LayoutManager과 ViewHolder, Item에 대한 뷰의 변형이나 애니메이션 할 수 있는 개념이 추가되었다. 이들은 리스트의 아이템이 표시될 재활용 뷰를 관리하는 데 사용되며 아이템의 포지션에 따른 레이아웃의 배치를 결정하게 된다. 또한 불필요한 뷰의 생성을 피하기 위해 레이아웃을 관리하는 역할을 하게 된다.

 

주요 클래스

  • Adapter – 기존의 ListView에서 사용하는 Adapter와 같은 개념으로 데이터와 아이템에 대한 View생성
  • ViewHolder – 재활용 View에 대한 모든 서브 뷰를 보유
  • LayoutManager – 아이템의 항목을 배치
  • ItemDecoration – 아이템 항목에서 서브뷰에 대한 처리
  • ItemAnimation – 아이템 항목이 추가, 제거되거나 정렬될때 애니메이션 처리

 

LayoutManager

RecyclerView에서 가장 흥미로운 부분이다. RecyclerView를 생성 시 반드시 생성되어야 하며 이를 통해 모든 아이템의 뷰의 레이아웃을 관리한다. 수평/수직 배치뿐만 아니라 그리드 형태의 다양하게 레이아웃을 배치할 수 있다.

기본적으로 제공하는 LayoutManager

  • LinearLayoutManager – 수평/수직의 스크롤 리스트
  • GridLayoutManager – 그리드 리스트
  • StaggeredGridLayoutManage – 높이가 불구칙적인 형태의 그리드 리스트

 

이외에 개발자는 LayoutManager를 확장하여 다양한 형태를 만들 수 있다. 아래와 같이 사용한다.

LinearLayoutManager layoutManager = new LinearLayoutManager(context);
layoutManager.setOrientation(LinearLayoutManager.VERTICAL);
layoutManager.scrollToPosition(currPos);
recyclerView.setLayoutManager(layoutManager);

 

ViewHolder

ViewHolder는 기존의 ListView에서 많이 사용하고 구글 안드로이드 팀에서도 오랫동안 추천된 패턴이다. 하지만 이를 사용하는 것을 강제적으로 제한하지 않았지만 RecyclerView에서는 Adapter와 ViewHolder를 반듯이 같이 사용할 수밖에 없는 구조로 바뀌었다. ViewHolder패턴을 사용하지 않은 개발자는 약간의 훈련이 필요할 것이다.

public final static class ListItemViewHolder extends RecyclerView.ViewHolder {
   TextView label;
   TextView dateTime;
   
   public ListItemViewHolder(View itemView) {
      super(itemView);
      label = (TextView) itemView.findViewById(R.id.txt_label_item);
      dateTime = (TextView) itemView.findViewById(R.id.txt_date_time);
   }
}

사용법은 ViewHolder를 확장 후 서브 클래스를 findViewById()를 통해 저장해놓으면 된다. 이렇게 함으로 한번 생성한 클래스를 통해 서브 클래스(뷰)를 빠르게 다시 액세스 할 수 있다.

 

Adapter

ListView에서 Adpater와 동일한 형태의 구조로 해당 아이템의 데이터와 뷰간의 처리를 한다. 다음과 같은 3가지의 인터페이스를 구현해야 한다.

  • public ListItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType)
    • 제네릭 형식의 변수로 ViewHolder를 생성
  • public void onBindViewHolder(ListItemViewHolder holder, int position)
    • 만들어진 ViewHolder에 데이터를 넣는 작업, ListView의 getView()와 동일
  • public int getItemCount()
    • 데이터의 갯수

 

public class RecyclerViewDemoAdapter extends RecyclerView.Adapter {

    private List items;

    RecyclerViewDemoAdapter(List modelData) {
        if (modelData == null) {
            throw new IllegalArgumentException(
                  "modelData must not be null");
        }
        this.items = modelData;
    }

    @Override
    public ListItemViewHolder onCreateViewHolder(
            ViewGroup viewGroup, int viewType) {
        View itemView = LayoutInflater.
                from(viewGroup.getContext()).
                inflate(R.layout.item_demo_01, viewGroup, false);
        return new ListItemViewHolder(itemView, viewType);
    }

    @Override
    public void onBindViewHolder(
            ListItemViewHolder viewHolder, int position) {
        DemoModel model = items.get(position);
        viewHolder.label.setText(model.label);
        String dateStr = DateUtils.formatDateTime(
                viewHolder.label.getContext(),
                model.dateTime.getTime(),
                DateUtils.FORMAT_ABBREV_ALL);
        viewHolder.dateTime.setText(dateStr);
    }

    @Override
    public int getItemCount() {
        return items.size();
    }

    public final static class ListItemViewHolder 
           extends RecyclerView.ViewHolder {
        // ViewHolder
    }
}

기존의 ListView와 동일한 구조라서 쉽게 구현이 가능하며, Adapter의 기본 클래스를 확장한 형태(CursorAdapter, ArrayAdapter)는 없다.

 

ItemDecoration

각 아이템 항목별로 오프셋을 추가하거나 아이템을 꾸미는 작업을 하게 된다. 예를 들어 스크롤 시 콘텐츠의 내용에 따라 View의 높이가 달라져 레이아웃의 위치를 이동해야 하는 작업하는 경우 여기에서 처리하면 된다.

필요시 다음과 같은 3가지를 구현해야 한다.

  • public void onDraw(Canvas c, RecyclerView parent)
  • public void onDrawOver(Canvas c, RecyclerView parent)
  • public void getItemOffsets(Rect outRect, int itemPosition, RecyclerView parent)

 

LayoutManager에서 getItemOffsets()의 호출을 통해 아이템의 레이아웃의 크기를 측정 하기 때문에 위의 예시는 getItemOffsets()에서 작업하면된다.

 

ItemAnimtor

ListView에서 아이템별 애니메이션을 일으키기 위해 notifyDataSetChanged()를 호출해 모든 아이템 변경이 발생할 때 처리를 하였으나 notifyItemChanged(int position), notifyItemInserted(int position), notifyItemRemoved(int position)를 통해 ItemAnimator를 통해 특정 아이템에 대한 애니메이션을 발생할 수 있다.

  • public final void notifyItemInserted(int position)
  • public final void notifyItemRemoved(int position)
RecyclerView.ItemDecoration itemDecoration =
        new DividerItemDecoration(this, DividerItemDecoration.VERTICAL_LIST);
recyclerView.addItemDecoration(itemDecoration);

recyclerView.setItemAnimator(new CustomItemAnimator());


public class CustomItemAnimator extends ItemAnimator {
 
    public CustomItemAnimator() {
        setAddDuration(300);
        setRemoveDuration(300);
    }
 
    @Override
    protected boolean prepHolderForAnimateRemove(ViewHolder holder) {
        return true;
    }
 
    @Override
    protected ViewPropertyAnimatorCompat animateRemoveImpl(ViewHolder holder) {
        return ViewCompat.animate(holder.itemView)
                .rotationX(90)
                .translationY( - (holder.itemView.getMeasuredHeight() / 2));
    }
 
    @Override
    protected void onRemoveCanceled(ViewHolder holder) {
        ViewCompat.setRotationX(holder.itemView, 0);
        ViewCompat.setTranslationY(holder.itemView, 0);
    }
 
    @Override
    protected boolean prepHolderForAnimateAdd(ViewHolder holder) {
        ViewCompat.setRotationX(holder.itemView, 90);
        ViewCompat.setTranslationY(holder.itemView, - (holder.itemView.getMeasuredHeight() / 2));
        return true;
    }
 
    @Override
    protected ViewPropertyAnimatorCompat animateAddImpl(ViewHolder holder) {
        return ViewCompat.animate(holder.itemView)
                .rotationX(0)
                .translationY(0);
    }
 
    @Override
    protected void onAddCanceled(ViewHolder holder) {
        ViewCompat.setRotationX(holder.itemView, 0);
        ViewCompat.setTranslationY(holder.itemView, 0);
    }
}

그외

ListView OnItemClickListener, OnItemLongClickListener의 아이템 터치에 대한 리스너가 RecyclerView에서는 addOnItemTouchListener, setOnClickListener를 통해 처리하게 되었다. 이는 GestureDetecter를 통해 좀 더 다양한 이벤트를 식별할 수 있는 유연한 구조로 바뀌었다. 한편으로는 유연하지만 한편으로는 좀 더 어려워졌다고 볼 수 있다.

addHeaderView(), addFooterView()는 어디로?

없어졌다. 이를 위해 Adapter의 onCreateViewHolder()에서 itemType으로 header나 footer를 추가하는 방식으로 처리해야 한다.

마치며..

ListView에서 RecyclerView를 바로 사용하기는 개념상 달라진 점이 없기 때문에 무리가 없겠으나 ListView에 있는 몇몇 기능들이 RecyclerView에서는 지원하지 않아 직접 처리해야 하는 경우가 있어 보인다. 그리고 확실시 ListView에 다양하게 확장할 수 있도록 유연해졌다. 하지만 유연해진 만큼 손이 많이 가는 부분도 없지 않아 있다.

참고 코드:https://github.com/writtmeyer/recyclerviewdemo