안드로이드 리스트뷰 성능최적화 방법

안드로이드 애플리케이션에서 가장 흔하게 쓰이는 View중 하나가 ListView이다. 아이템 별로 많은 정보와 이미지 사용할때 스크롤이 매끄럽지 않는 경험을 한번쯤 해보았을 것이라 생각한다. 지금도 Play 스토어 앱의 상당수가 스크롤시 끊어지는 것을 볼 수 있다. ListView는 어떤 구조를 통해 아이템을 표현하는지와 기본적인 기능들을 사용해보고, 매끄럽게 작동하기위한 성능 최적화에 대해 알아보자.

10.1 작동원리

안드로이드의 ListView는 일반 View와는 다르게 확장성과 성능을 위해 설계된 View이다. 일반 View를 기반으로 스크롤을 하게 된다면 아이템의 데이터를 통해하나씩 보여줄때 View를 inflate하게 되는데, 이는 상당한 메모리와 성능상의 문제가 생기며 스크롤시 매끄럽게 빠른성능을 보여주지 못한다. 레이아웃 inflate는 xml의 블럭과 트리를 하나씩 읽어가며 각각의 뷰를 인스턴스화 하기에 값비싼 작업이다. 이러한 문제점을 해결하고자 ListView는 아이템의 크기만큼이 아닌 화면에 보여지는 아이템 갯수 만큼의 View를 그려놓은 후 재활용하는 구조로 설계되었다.


<그림 10.1.1> ListView 구조

실제 화면에 그려지는 아이템을 ScrapView라는 배열로 관리하는데, 이는 스크롤시 위치를 바꿔가며 사용자는 리스트를 보는것과 동일하게 하기위해 위치를 변경한다. 화면에 보여지는 만큼의 ScrapView를 생성하고 스크롤시 View를 재활용 하기때문에 성능면으로 우수한 구조이다. ListView의 재활용 View인 ScrapView는 Adapter의 getView()를 통해서 관리된다.

<코드 10.1.1>

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

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

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

  return convertView;
}

ListVIew는 화면에 새로운 아이템을 표시 할떄 마다 Adapter의 getView()를 호출하게된다. getView()를 통해 행의 위치 position, 재활용 뷰 convertView, 부모 뷰 parent로 3개의 인수를 사용한다. 여기서 convertView는 앞에서 설명한 재활용 View인 ScrapView이다. 그래서 ListView는 아이템 레이아웃을 재활용 할때는 null이 아닌값이 들어오게 되며, null인 경우에는 레이아웃을 View inflate한다. convertView가 null이 아닌경우에는 기존의 View를 재활용 하기때문에 새롭게 View를 inflate할 필요 없이 데이터만 바꾸는 작업을 하면된다.

10.2 ViewHolder Pattern

ListView에서 아이템이 보여질때마다 getView()가 호출 된다. 여기서 해당되는 데이터를 View에 표시하기위해 findViewById()를 통해 해당되는 View를 얻어온 후 데이터를 표시하는 것이 일반적인 방식이다. 생각 해보면 convertView는 재활용 되는 View이기때문에 아이템을 표시 할때마다 findViewById()를 호출해서 View를 얻어 올 필요는 없다. 또한 아이템 View의 구조가 매우 복잡할 경우 매번 findViewById()를 호출한다는 것은 매우 값비싼 작업이기때문에 매끄럽지 않은 스크롤이 될 가능성이 크다.

convertView의 tag에 모든View의 정보를 객체로 가지고 있있다가 최초에 한번 저장해두고 재활용시 불러와서 사용하는 구조를 만들면 된다.

<코드 10.2.1>

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

  ViewHolder holder;

  if (convertView == null) {
    convertView = mInflater.inflate(R.layout.list_item, 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;
  }

}

재활용 View가 null일때 View를 inflate한 후 findViewById를 통해 찾은 View들을 ViewHolder에 저장 후 convertView의 Tag에 저장해놓는다. 그 후 재활용 View를 사용할때는 convertView의 Tag에 있는 ViewHolder를 가져와서 사용하는 구조이다. 이런 구조를 가짐으로써 중복적인 findViewById()를 호출하지 않아 성능적으로 우수한 구조를 가질 수 있다.

<그림 10.2.1> ViewHolder 사용(상) / 비사용(하) 여부에 따른 스크롤 프레임 속도

 

ViewHolder Class는 아이템 View가 달라질때 마다 새롭게 생성해야 한다. 즉 Adapter마다 각각의 ViewHolder를 가지고 있어야 한다. 아이템 View가 바뀌게 되면 ViewHolder도 같이 수정되어야 하기때문에 유지/보수 면에서는 좋은 구조는 아니다. 좀더 유연하게 하기위해서는 ViewHolder를 정적이아닌 동적으로 변경이 가능한 구조로 하꿔야 한다.

<코드 10.2.2>

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

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

  TextView text = ViewHolderHelper.get(convertView, R.id.text);
  text.setText(“position " + position);
  return convertView;

}

public class ViewHolderHelper {
  public static <T extends View> T get(View convertView, int id) {
    SparseArray<View> viewHolder = (SparseArray<View>) convertView.getTag();
    if (viewHolder == null) {
      viewHolder = new SparseArray<View>();
      convertView.setTag(viewHolder);
    }

    View childView = viewHolder.get(id);

    if (childView == null) {
      childView = convertView.findViewById(id);
      viewHolder.put(id, childView);
    }

    return (T) childView;
  }
}

ViewHolderHelper를 통해 어떤 Adapter에서 유동적으로 사용가능하도록 하기위해 동적인 SparseArray<View>를 Tag에 만들어 놓은 후 View의 SparseArray<View>에 id가 없으면 findViewById를 해서 put을 하고 id가 있으면 get을 통해 View를 가져온다.

이렇게 ViewHolderHelper를 통해서 getView()에서 좀 더 깔끔한 코드와 ViewHolder를 유연하게 사용 할 수 있다.

10.3 빈화면 표시

ListView는 네트워크를 통해 데이터를 전달받은 후 화면에 표시하는 경우 또는 로컬에 있는 정보를 효율적으로 표시해주는 View이다. 만약 네트워크 상태가 좋지 않거나, 로컬에서 특정 정보를 호출했을시 리스트가 비어있을 경우 사용자에게 재시도를 할 수 있는 버튼을 넣는 다거나, 리스트가 비어있으니 다른 작업을 해야된다는 정보를 UI적으로 표현해야한다. ListView에는 이런 빈리스트에 대한 UI를 보여주는 EmptyView를 설정 할 수 있으며 이를 자동적으로 관리해준다.

빈화면을 처리하기위해서 Activity를 상속받는 것이 아닌 ListActivity를 상속 받으면 쉽게 구현이 가능하다. 그리고 ListView 레이아웃 구성시 빈화면 UI를 표시할 View의 ID를 android에 정의된 id인 empty(@android:id/empty)로 설정 해주면 된다. 내부적으로 Adapter의 데이터가 존재하면 empty id로 정의된 View를 숨김 처리하고, 데이터가 비게되면 보여준다.

<코드 10.3.1>

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="fill_parent"
  android:layout_height="fill_parent" >

  <ListView
    android:id="@android:id/list"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent" />
 
  <TextView
    android:id="@android:id/empty"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="center"
    android:text="List Empty!" />

</FrameLayout>

ListActivity 또는 ListFragment가 아닌 Activity나 Fragment를 상속 받는 경우에는 setEmptyView(View) 메서드를 호출 하여 빈화면 View를 설정 할 수 있다. 직접 구현 하는 경우 안드로이드 내부에 정의된 id인 empty를 사용할 필요는 없기 때문에 상황에 맞는 빈화면을 구성할 수 있다. 예를 들어 네트워크상태가 좋지 않을때 재시도 UI를 구성하거나 목록이 비어 있는 알림성 UI또는 데이터 로딩 UI를 상황에 맞도록 구성을 할 수 있다.

<코드 10.3.2>

protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);

  setContentView(R.layout.activity_emptylistview);

  mListView = (ListView) findViewById(android.R.id.list);
  mListView.setEmptyView(findViewById(android.R.id.empty));
  mAdapter = new ItemsAdapter(ITEMS);
  mListView.setAdapter(mAdapter);

}

빈화면 View가 단순히 텍스트가 아닌 여러가지 애니메이션 또는 이미지와 버튼들로 이루어 지게된다면 Activity가 실행 되는 매순간 사용하지 않을 수도 있는데 내부적으로 초기화를 하게 되어 성능상 좋지않을 것이다. 그럼 빈화면 View가 보여질때 초기화를 하도록 하기위해서 ViewStub을 이용해서 레이아웃을 구성 하면된다.

<코드 10.3.4>

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="fill_parent"
  android:layout_height="0dp"
  android:layout_weight="1" >

  <ListView
    android:id="@android:id/list"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent" />

  <ViewStub
    android:id="@android:id/empty"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="center"
    android:layout="@layout/empty" />

</FrameLayout>

res/layout/empty.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:layout_gravity="center"
  android:gravity="center"
  android:orientation="vertical"
  android:padding="20dp" >


  <ImageView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:src="@drawable/ic_launcher" />
 
  <TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginTop="8dp"
    android:gravity="center"
    android:text="데이터를 가져오지 못했습니다." />

  <Button
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginTop="20dp"
    android:text="재시도" />

</LinearLayout>

ListView는 데이터가 없을 경우 설정된 빈화면 View를 보이게 되고, ViewStub은 View가 보이게되면 inflate하는 구조이기 때문에 실제 빈화면 View가 필요한 시점에 초기화한다. 그러므로 빈화면 레이아웃이 복잡한 형태라면 ViewStub를 사용하는 것이 Activity 실행시 조금이나마 빠른 로딩에 도움이 된다.

10.4 ListView layout_height

레이아웃 ListView의 layout_height값은 반드시 고정된 높이값이나 match_parent로 주자. wrap_content를 주게되면 ListView내부에서는 고정된 높이값을 모르기때문에 높이값을 재계산 하기위해 Adapter의 getView()가 여러번 중복 호출되어 불필요한 작업은 물론 성능상 문제가 된다.

<코드 10.4.1>

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_height="fill_parent"
  android:layout_width="fill_parent">

  <ListView android:id="@android:id/list"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"/>

</LinearLayout>

특별한 경우가 아니라면 ListView가 높이를 재계산 하지않도록 레이아웃의 ListView layout_height는 고정된 수치나 match_parent값을 사용하자. 또는 layout_height값을 0dp으로 고정하고 layout_weight 값을 1로 주면된다.

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;
}

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