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)를 호출하게 됨으로 그만큼 성능은 향상된다.




Android 유연성 있는 ViewHolder Pattern


얼마전 ListView 포퍼먼스 팁에 관한 블로그 포스팅을 한적이 있다.  Adapter에서 View의 재활용과 함께 ViewHolder Pattern으로 findViewById를 View생성 시점에 setTag()를 하여 재활용에 대해 언급 했다. 

이 방법은 각 ListView의 ViewItem별로 각각의 ViewHolder를 가지고 있어야 한다.  

ListView의 아이템별로 서로 다른 디자인이 필요하기에 View의 종류가 달라 질 수 밖에 없기때문에 ViewHolder도 각각 존재 할 수 밖에 없다. 이렇게 static하게 ViewHolder을 가지고 있는것 보다 유연하게 ViewHolder를 생성 할 수 있는 코드를 생성하는 방법에 대해서 알아보자.

아래처럼 Adapter에 static class로 만들어 ViewHolder를 사용하는것이 일반적이다.  ViewHolder는 같은 기능을 하는데 단순히 View의 종류가 달라져 각각 생성 할수 밖에 없는 상황이다. 

private static class ViewHolder {

    public final ImageView bananaView;
    public final TextView phoneView;

    public ViewHolder(ImageView bananaView, TextView phoneView) {
        this.bananaView = bananaView;
        this.phoneView = phoneView;
    }
}

@Override
public View getView(int position, View convertView, ViewGroup parent) {

    ImageView bananaView;
    TextView phoneView;

    if (convertView == null) {
        convertView = LayoutInflater.from(context).inflate(R.layout.banana_phone, parent, false);
        bananaView = (ImageView) convertView.findViewById(R.id.banana);
        phoneView = (TextView) convertView.findViewById(R.id.phone);
        convertView.setTag(new ViewHolder(bananaView, phoneView));
    } else {
        ViewHolder viewHolder = (ViewHolder) convertView.getTag();
        bananaView = viewHolder.bananaView;
        phoneView = viewHolder.phoneView;
    }

    BananaPhone bananaPhone = getItem(position);
    phoneView.setText(bananaPhone.getPhone());
    bananaView.setImageResource(bananaPhone.getBanana());

    return convertView;
}

해결책으로 아래와 같이 ViewGroup의 View들에 대해 동적으로 생성하는 코드를 만들 수 있다.

public class ViewHolder {
    @SuppressWarnings("unchecked")
    public static <T extends View> T get(View view, int id) {
        SparseArray<View> viewHolder = (SparseArray<View>) view.getTag();
        if (viewHolder == null) {
            viewHolder = new SparseArray<View>();
            view.setTag(viewHolder);
        }
        View childView = viewHolder.get(id);
        if (childView == null) {
            childView = view.findViewById(id);
            viewHolder.put(id, childView);
        }
        return (T) childView;
    }
}

모든 View들은 View를 상속 받고 있기 때문에  SparseArray를 통해서  ChildView의 Id가 없으면 findViewById()를 통해 put해주고 있으면 get으로 가져오는 방식이다. 

사용하는 간단한 예를 알아보면:

@Override
public View getView(int position, View convertView, ViewGroup parent) {

    if (convertView == null) {
        convertView = LayoutInflater.from(context).inflate(R.layout.banana_phone, parent, false);
    }

    ImageView bananaView = ViewHolder.get(convertView, R.id.banana);
    TextView phoneView = ViewHolder.get(convertView, R.id.phone);

    BananaPhone bananaPhone = getItem(position);
    phoneView.setText(bananaPhone.getPhone());
    bananaView.setImageResource(bananaPhone.getBanana());

    return convertView;
}

기존의 방법보다 더 간결화되고 심플해진 것을 볼 수 있다.

리스트뷰 퍼포먼스 팁

안드로이드의 ListView는 확장성및 성능을 위해 설계된 뷰이다. 일반 View를 기반으로 스크롤처리를 하면 뷰를 inflate할때 상당한 시간과 메모리부족등의 문제가 발생될 것이다. 이러한 문제점을 해결하기위해 ListView를 제공하고 있다.

요즘도 흔하지만 안드로이드 초기 시절 ListView를 스크롤 할때 상당히 끊김이 심한 경우를 볼 수 있었다. (국내 포털 N사앱에서 스크롤시 ANR발생되는 등) 이는 ListView의 구조를 정확하게 이해하지 않고 개발을 했기 때문이다.

그래서 ListView가 어떤 원리로 작동되는지와 어떻게 하면 성능을 최적화해서 좀 더 자연스러운 스크롤을 할 수 있는지에대해 써볼까 한다.

 

ListView의 작동 원리

ListView는 View를 재활용한다. 화면에 보여질 뷰를 inflate 한다음 스크롤시 재활용 하는 방식이다.  View inflate는 많은 비용이 드는 작업이며, 화면에 보이지도 않은 View를 미리그려 메모리의 문제가 생길 수 있기 때문에 한번 생성된 뷰를 재활용해서 데이터만 바꾸는 구조이다.

ListView내부 코드를 보면 ScrapView라는 화면에 보여질 View 배열이 존재 한다. ListView의 포지션에 따라 이 ScrapView의 위치가 바뀌게되는 구조로 재활용 하게 된다.

 

ConvertView 재활용

ListView의 Adapter에는 getView()라는 메소드가존재 한다. 해당 포지션이 보이면 이 getView()가 호출된다. 다들 아는것 처럼 인수로 View convertView, int position, ViewGroup parent 가 들어 온다.

convertView는 위에서 언급한 재활용 되는 ScrapView[]이다.재활용 하기때문에 최초 한번만 convertView는 NULL값이 들어 오며 그후에 호출 되면 이미 생성된 View가 들어 오게 된다.

그렇기 때문에 아래 처럼 convertView가  NULL일 때만 inflate해주면 된다.

public View getView(int position, View convertView, ViewGroup parent) {
    if (convertView == null) {
        convertView = mInflater.inflate(R.layout.your_layout, null);
    }

    TextView text = (TextView) convertView.findViewById(R.id.text);
    text.setText("Position " + position);

    return convertView;
}

 

ViewHolder Pattern

inflate된 View를 findViewById()메소드를 통해 찾는 것이 일반적이다. 레이아웃 구조가 복잡하고, 많은 데이터를 View에 설정 할때 이또한 많은 비용이 발생하게 된다. 이렇게 많은 비용이 발생하게 된다면 스크롤시 자연스러움을 보장 할 수 없게된다.

이미 찾은 View를 ViewHolder Pattern을이용해서 convertView의 Tag값이 저장해두는 방식을 쓰면 매번 findViewById()할 필요가 없다. convertView가 최초로 호출 될때 findViewById()로 찾은 View를 Tag에 저장하며, 그 이후로 호출되면 Tag에 저장된 View를 불러오는 방식이다.

public View getView(int position, View convertView, ViewGroup parent) {
    ViewHolder holder;

    if (convertView == null) {
        convertView = mInflater.inflate(R.layout.your_layout, null);

        holder = new ViewHolder();
        holder.text = (TextView) convertView.findViewById(R.id.text);

        convertView.setTag(holder);
    } else {
        holder = convertView.getTag();
    }

    holder.text.setText("Position " + position);

    return convertView;
}

private static class ViewHolder {
    public TextView text;
}

비동기 처리

getView()는 UI Thread에서 작동 되기때문에 인터넷이나 로컬에서 사진을 불러오는등 동적인 작업시 전혀다른 Thread를 통해서 비동기 처리해야 된다. 그렇지 않을경우 스크롤시 동기화처리되어 아주 많이 끊기게 된다. 안드로이드에서 비동기 처리방법으로 AsyncTask를 사용하면된다.

public View getView(int position, View convertView,
        ViewGroup parent) {
    ViewHolder holder;

    ...

    holder.position = position;

    new ThumbnailTask(position, holder)
            .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, null);

    return convertView;
}

private static class ThumbnailTask extends AsyncTask {
    private int mPosition;
    private ViewHolder mHolder;

    public ThumbnailTask(int position, ViewHolder holder) {
        mPosition = position;
        mHolder = holder;
    }

    @Override
    protected Cursor doInBackground(Void... arg0) {
        // Download bitmap here
    }

    @Override
    protected void onPostExecute(Bitmap bitmap) {
        if (mHolder.position == mPosition) {
            mHolder.thumbnail.setImageBitmap(bitmap);
        }
    }
}

private static class ViewHolder {
    public ImageView thumbnail;
    public int position;
}

그외 사항

스크롤시 빠르게 지나가는 경우 삭제될 데이터들에 대한 자원을 낭비 하게 된다. 이런 경우를 대비해서 스크롤 중일때는 기본데이터만 가져오고 스크롤이 멈추면 모든데이터를 가져오는 방식도 추천할만하다. ListView의 onScrollChangeListener을 통해 스롤되는중이거나 스크롤이 끝났음을 Callback 받을수 있으며 Adapter에서 flag를 만들어 getView에서 모든데이터를 가져올때와 기본데이터만 보여줄것을 구현 하면된다.

이처럼 위의 사항을 잘 지킨다면 아무리 많고 복잡한 데이터일지라도 끊김 없는 아주 자연스러운  ListView를 구현 할 수 있을 것이다.