RecyclerView과 ListView의 큰 차이점 중 하나는 필요한 아이템만 골라서 갱신이 가능하다는 것이다.
사실 이론적으로는 매우 좋은 기능이었지만, 실무에서 아이템별로 골라서 갱신하는 것은 쉬운 일이 아니었다. 잘못 사용하면 크래시도 발생하였고, 어떤 아이템이 갱신이 필요한지/아닌지에 대한 처리가 추가적으로 필요했기 때문에 그냥 기존의 notifyDataSetChanged를 이용하기 일쑤였다.
그러던 와중에 support library 24.2.0에서 DiffUtil이라는 클래스가 추가되었다. 이름 그대로 변경된 부분을 찾아 갱신하는데 도움이 되는 기능들로 구성되어 있는데 이를 이용하여 효율적인 갱신 처리를 쉽게 처리가 가능해졌다.
DiffUtil 클래스에 대해
DiffUtil은 앞에서도 말했다시피 support library 24.2.0에서 추가된 클래스이다. 기존에 불편했던 RecyclerView의 효율적인 갱신 처리를 편리하게 다룰 수 있도록 제공하는 util 클래스이다.
DiffUtil은 구글이 밝히길, 내부적으로 Eugene W. Myers가 제안한 diff algorithm을 사용한다. 이 알고리즘은 공간에 최적화 되어있어 아이템이 N개 있을 때, 공간복잡도는 O(N)이다.
대신 시간복잡도는 일반적인 경우 old/new 두 리스트의 합인 N개의 아이템과, old가 new로 변환되기 위해 필요한 최소 작업갯수(==edit script) D가 있을 때 O(N + D^2)이다.
여기에다가 아이템 이동이 있는 경우 Myers의 알고리즘 이후 2nd pass를 타는데, 추가되거나 삭제된 아이템의 총 갯수를 N’라 할 때 O(N’^2)만큼의 시간을 더 사용하게 된다. 만약에 아이템이 이미 정렬된 상태라 이동될 상황이 없다면 이 처리를 생략할 수 있다.
아무튼 시간복잡도가 꽤 높은 알고리즘이므로 대량의 데이터를 다룰 경우 worker thread에서 수행을 해야한다. 넥서스 5X 기준으로 수행시간은 아래와 같다.
보다시피 전체 아이템 갯수와 이동/변경되는 아이템 갯수가 많으면 수행시간이 상당히 커지는 것을 알 수 있다.
DiffUtil은 구현상 제약사항으로 인해 최대 사이즈는 2^26(67,108,864)개까지 지원한다.
사용법
먼저 Diff처리에 사용할 모델을 구현한다. DiffUtil에서 비교작업을 해야하므로 미리 equals 구현을 하도록 한다. (꼭 equals일 필요는 없지만)
public class PhoneNumberItem { private int id; private String phoneNumber; public int getId() { return id; } public void setId(int id) { this.id = id; } public String getPhoneNumber() { return phoneNumber; } public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; } @Override public boolean equals(Object obj) { if (obj instanceof PhoneNumberItem) { PhoneNumberItem another = (PhoneNumberItem) obj; return TextUtils.equals(this.phoneNumber, another.phoneNumber); } return false; } }
다음은 DiffUtil.Callback을 상속받아 구현한다. 이 클래스는 추상 클래스로 4개의 필수 구현 메서드와, 1개의 선택적 구현 메서드가 존재한다.
getOldListSize() : 변경 전 리스트 크기.
getNewListSize() : 변경 후 리스트 크기.
areItemsTheSame() : 아이템이 동일한가의 여부. 예를 들면 아이템들이 고유한 ID값을 가질 때, 해당 메서드를 구현해 비교처리 하도록 한다.
areContentsTheSame() : 아이템의 내용이 동일한가 여부. areItemsTheSame이 true로 리턴되었을 때만 수행된다. DiffUtil이 equal 비교를 할 때 사용한다.
getChangePayload() : areItemsTheSame() && !areContentsTheSame()인 경우 호출되는 메서드이다. RecyclerView에서 payload 처리가 필요한 경우에만 사용하기 때문에 이 메서드는 선택적으로 구현할 수 있으며 디폴트는 null을 리턴한다.
public class ListDiffCallback extends DiffUtil.Callback { private final List<PhoneNumberItem> oldList; private final List<PhoneNumberItem> newList; public ListDiffCallback(List<PhoneNumberItem> oldList, List<PhoneNumberItem> newList) { this.oldList = oldList; this.newList = newList; } @Override public int getOldListSize() { return oldList.size(); } @Override public int getNewListSize() { return newList.size(); } @Override public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { return oldList.get(oldItemPosition).getId() == newList.get(newItemPosition).getId(); } @Override public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { PhoneNumberItem oldItem = oldList.get(oldItemPosition); PhoneNumberItem newItem = newList.get(newItemPosition); return oldItem.equals(newItem); } }
콜백 정의가 끝이나면 아래와 같이 어댑터 갱신 코드를 구현한다. 여기서는 맛만 보기위해서 대충 구현되었다. 중요한 점은 worker thread에서 DiffUtil 계산을 처리하는 것이다.
public class PhoneNumberAdapter extends RecyclerView.Adapter<PhoneNumberViewHolder> { private List<PhoneNumberItem> items; private Context context; // (생략)... @Override public void onBindViewHolder(final PhoneNumberViewHolder vh, int position) { PhoneNumberItem item = items.get(position); vh.phoneNumberText.setText(item.getPhoneNumber()); vh.phoneNumberText.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { final int adapterPosition = vh.getAdapterPosition(); Toast.makeText(context, items.get(adapterPosition).getPhoneNumber(), Toast.LENGTH_SHORT).show(); new Thread() { @Override public void run() { final List<PhoneNumberItem> newList = new ArrayList<>(items); newList.remove(adapterPosition); updateList(newList); } }.start(); } }); } private void updateList(List<PhoneNumberItem> newList) { ListDiffCallback callback = new ListDiffCallback(this.items, newList); final DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(callback); this.items.clear(); this.items.addAll(newList); new Handler(Looper.getMainLooper()).post(new Runnable() { @Override public void run() { diffResult.dispatchUpdatesTo(PhoneNumberAdapter.this); } }); } // (생략)... }
해당 소스를 실행하면 아이템을 클릭할 때 마다 애니메이션 처리되면서 삭제되는 모습을 볼 수 있다.
앞에서 이동에 대한 처리를 생략 할 수 있다고 말하였다. 이동에 대한 정보가 필요 없는 경우에는 calculateDiff 메서드의 두 번째 인수로 false 값을 주면 된다.
2018년 2월 6일 at 2:43 오후
ㅎㅎ 유용하게 잘 보고 갑니다! 감사합니다