안드로이드 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가 성능이 우수하다.한번에 바꾸기는 다소 무리가 있으며 천천히 준비 해가면서 바꾸거나, 신규 앱개발시 복잡한 리스트인 경우라면 써볼만하다.




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


안드로이드 애플리케이션에서 가장 흔하게 쓰이는 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 vs. iOS

안드로이드 개발자로써 iOS 개발에 전혀 관심이 없지만 IOS를 쓰다보면 안드로이드에서 찾아 볼 수 없는 흥미로운 UI들이 있다. 리스트뷰에서 스크롤을 하다가 제일 위로 가고싶은 경우에안드로이드의 경우에는 플랫폼적으로 지원하지 않지만,  iOS는 플랫폼적으로 이런 기능을 제공하고 있다. 

위의 그림처안 iOS는 시간이 표시되는 작업표시줄영역을 터치시 시스템적으로 리스트의 제일 상단으로 자동 스크롤링 되는 것을 볼 수 있다. 안드로이드 플랫폼은 ListView, GridView, ScrollView, WebView, MapView등의 Scroll Containers가 모두 재각각이기 때문에 시스템에서 모든 View들에 대한 Scroll-To-Top기능을 시스템적으로 제공하기가 거의 불가능 했던 걸로 보인다. 

제조사에서도 이런 기능을 제공하기위해 시도했던걸로 보이나 글로벌하게 사용하게끔 만들지는 못했던것 같다. 삼성 갤럭시 시리즈에 Scroll-To-Top기능을 일부 화면에서 제한적으로 제공한다. 물론 iOS와 같이 작업표시줄이 아닌 물리적인 힘으로 폰을 장구치긋이 두번 두들겨 줘야 한다.

안드로이드에서는 이런 문제점을 FastScroll을 약간 만회 할 수 있지만 사용자 측면에서는 대단히 불편 할 뿐만 아니라, 시도조차 해보지 않는 UI적인 요소가 문제점이다. 이런 문제점을 앱내에서 직접 구현 하는 방법을 소개 해보겠다. 

상단으로 스크롤 하기 위한 조건

1) 항상 상단위로 스크롤하기 UI를 표현 할 필요는 없다. 

2) 사용자가 위로 가기위한 액션을 하고 있을때 UI적으로 표현한다.  

상단으로 가기위한 ListView의 Method들

1) setSelection(int position): 지정된 위치로 스크롤 된다. 

2) smoothScrollPosition(int position): API 8 부터 제공되며, smooth하게 스크롤 된다.

3) smoothScrollToPositionFromTop(int position, int offset): API 11부터 제공되며, smooth Scroll시 pixel offset을 지정 할 수 있다.  

API 버전이 높아 질 수록 제공되는 파라미터가 복잡하다. 이런 복잡성을 자동으로 해주는 AutoScrollListView의 requestPositionToScreen(int position, boolean smoothScroll)을 사용하면 된다 .

사용자가 상단으로 스크롤하기위한 액션을 알아내기위한 스크롤시 가속도 측정

a(가속도) = v – v0(이동거리) / t(시간) 아래의 공식과 ListView의 onScrollListener를 통해서 가속도를 계산 할 수 있다.

코드는 아래와 같다. 

public class VelocityListView extends AutoScrollListView {

    /**
     * A callback to be notified the velocity has changed.
     * 
     * @author Cyril Mottier
     */
    public interface OnVelocityListViewListener {
        void onVelocityChanged(int velocity);
    }

    private static final long INVALID_TIME = -1;

    /**
     * This value is really necessary to avoid weird velocity values. Indeed, in
     * fly-wheel mode, onScroll is called twice per-frame which results in
     * having a delta divided by a value close to zero. onScroll is usually
     * being called 60 times per seconds (i.e. every 16ms) so 10ms is a good
     * threshold.
     */
    private static final long MINIMUM_TIME_DELTA = 10L;

    private final ForwardingOnScrollListener mForwardingOnScrollListener = new ForwardingOnScrollListener();

    private OnVelocityListViewListener mOnVelocityListViewListener;

    private long mTime = INVALID_TIME;
    private int mVelocity;

    private int mFirstVisiblePosition;
    private int mFirstVisibleViewTop;
    private int mLastVisiblePosition;
    private int mLastVisibleViewTop;

    public VelocityListView(Context context) {
        super(context);
        init();
    }

    public VelocityListView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public VelocityListView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        init();
    }

    private void init() {
        super.setOnScrollListener(mForwardingOnScrollListener);
        mForwardingOnScrollListener.selfListener = mOnScrollListener;
    }

    @Override
    public void setOnScrollListener(OnScrollListener l) {
        mForwardingOnScrollListener.clientListener = l;
    }

    public void setOnVelocityListener(OnVelocityListViewListener l) {
        mOnVelocityListViewListener = l;
    }

    /**
     * Return an approximative value of the ListView's current velocity on the
     * Y-axis. A negative value indicates the ListView is currently being
     * scrolled towards the bottom (i.e items are moving from bottom to top)
     * while a positive value indicates it is currently being scrolled towards
     * the top (i.e. items are moving from top to bottom).
     * 
     * @return An approximative value of the ListView's velocity on the Y-axis
     */
    public int getVelocity() {
        return mVelocity;
    }

    private void setVelocity(int velocity) {
        if (mVelocity != velocity) {
            mVelocity = velocity;
            if (mOnVelocityListViewListener != null) {
                mOnVelocityListViewListener.onVelocityChanged(velocity);
            }
        }
    }

    /**
     * @author Cyril Mottier
     */
    private static class ForwardingOnScrollListener implements OnScrollListener {

        private OnScrollListener selfListener;
        private OnScrollListener clientListener;

        @Override
        public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
            if (selfListener != null) {
                selfListener.onScroll(view, firstVisibleItem, visibleItemCount, totalItemCount);
            }
            if (clientListener != null) {
                clientListener.onScroll(view, firstVisibleItem, visibleItemCount, totalItemCount);
            }
        }

        @Override
        public void onScrollStateChanged(AbsListView view, int scrollState) {
            if (selfListener != null) {
                selfListener.onScrollStateChanged(view, scrollState);
            }
            if (clientListener != null) {
                clientListener.onScrollStateChanged(view, scrollState);
            }
        }
    }

    private OnScrollListener mOnScrollListener = new OnScrollListener() {
        @Override
        public void onScrollStateChanged(AbsListView view, int scrollState) {
            switch (scrollState) {
                case SCROLL_STATE_IDLE:
                    mTime = INVALID_TIME;
                    setVelocity(0);
                    break;

                default:
                    break;
            }
        }

        @Override
        public void onScroll(AbsListView view, int firstVisiblePosition, int visibleItemCount, int totalItemCount) {

            final long now = AnimationUtils.currentAnimationTimeMillis();
            final int lastVisiblePosition = firstVisiblePosition + visibleItemCount - 1;

            if (mTime != INVALID_TIME) {

                final long delta = now - mTime;
                if (now - mTime > MINIMUM_TIME_DELTA) {
                    int distance = 0;
                    //@formatter:off
                    if (mFirstVisiblePosition >= firstVisiblePosition 
                            && mFirstVisiblePosition <= lastVisiblePosition) {
                        distance = getChildAt(mFirstVisiblePosition - firstVisiblePosition).getTop() - mFirstVisibleViewTop;

                    } else if (mLastVisiblePosition >= firstVisiblePosition 
                            && mLastVisiblePosition <= lastVisiblePosition) {
                        distance = getChildAt(mLastVisiblePosition - firstVisiblePosition).getTop() - mLastVisibleViewTop;
                    //@formatter:on
                    } else {
                        // We're in a case were the item we were previously
                        // referencing has moved out of the visible window.
                        // Let's compute an approximative distance
                        int heightSum = 0;
                        for (int i = 0; i < visibleItemCount; i++) {
                            heightSum += getChildAt(i).getHeight();
                        }

                        distance = heightSum / visibleItemCount * (mFirstVisiblePosition - firstVisiblePosition);
                    }

                    setVelocity((int) (1000d * distance / delta));
                }
            }

            mFirstVisiblePosition = firstVisiblePosition;
            mFirstVisibleViewTop = getChildAt(0).getTop();
            mLastVisiblePosition = lastVisiblePosition;
            mLastVisibleViewTop = getChildAt(visibleItemCount - 1).getTop();

            mTime = now;
        }
    };

}

이렇게 ListView의 setOnVelocityListener를 통해 속도 변화를 감지 할 수 있다. 스크롤시 특정 속도변화가 생기면 버튼을 노출 하도록 레이아웃을 구성 하면 된다.

setOnVelocityListener를 통해 속도변화가 생기면 버튼을 보이고, 아니면 숨기도록 작업을 하면 된다.

public class MainActivity extends Activity {

    private static final int VELOCITY_ABSOLUTE_THRESHOLD = 5500;

    private static final int BIT_VISIBILITY = 0x01;
    private static final int BIT_ANIMATION = 0x02;

    private static final int SCROLL_TO_TOP_HIDDEN = 0;
    private static final int SCROLL_TO_TOP_HIDING = BIT_ANIMATION;
    private static final int SCROLL_TO_TOP_SHOWN = BIT_VISIBILITY;
    private static final int SCROLL_TO_TOP_SHOWING = BIT_ANIMATION | BIT_VISIBILITY;

    private VelocityListView mListView;
    private Button mScrollToTopButton;

    private ViewPropertyAnimator mAnimator;

    private int mVelocityAbsoluteThreshold;
    private int mScrollToTopState = SCROLL_TO_TOP_HIDDEN;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        mVelocityAbsoluteThreshold = (int) (VELOCITY_ABSOLUTE_THRESHOLD * getResources().getDisplayMetrics().density + 0.5f);

        setContentView(R.layout.main_activity);
        
        mScrollToTopButton = (Button) findViewById(R.id.btn_scroll_to_top);
        mScrollToTopButton.setOnClickListener(mOnClickListener);

        mAnimator = mScrollToTopButton.animate();

        mListView = (VelocityListView) findViewById(android.R.id.list);
        mListView.setAdapter(new CheesesAdapter());
        mListView.setOnVelocityListener(mOnVelocityListener);
    }

    private OnClickListener mOnClickListener = new OnClickListener() {
        @Override
        public void onClick(View v) {
            mListView.requestPositionToScreen(0, true);
        }
    };

    private OnVelocityListViewListener mOnVelocityListener = new OnVelocityListViewListener() {
        @Override
        public void onVelocityChanged(int velocity) {
            if (velocity > 0) {
                if (Math.abs(velocity) > mVelocityAbsoluteThreshold) {
                    if ((mScrollToTopState & BIT_VISIBILITY) == 0) {
                        mAnimator.translationY(0).setListener(mOnShownListener);
                        mScrollToTopState = SCROLL_TO_TOP_SHOWING;
                    }
                }
            } else {
                if ((mScrollToTopState & BIT_VISIBILITY) == BIT_VISIBILITY) {
                    mAnimator.translationY(-mScrollToTopButton.getHeight()).setListener(mOnHiddenListener);
                    mScrollToTopState = SCROLL_TO_TOP_HIDING;
                }
            }
        }
    };

    private final AnimatorListener mOnHiddenListener = new AnimatorListenerAdapter() {
        public void onAnimationEnd(Animator animation) {
            mScrollToTopState = SCROLL_TO_TOP_HIDDEN;
        };
    };

    private final AnimatorListener mOnShownListener = new AnimatorListenerAdapter() {
        public void onAnimationEnd(Animator animation) {
            mScrollToTopState = SCROLL_TO_TOP_SHOWN;
        };
    };
    
}

소스코드:

cfile7.uf.27189145524E532F347739.zip

이런식의 구현 방식을 오픈소스화 해서 공개한 StickyScrollViewItems도 있으니 참고 하자.

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를 구현 할 수 있을 것이다.