본문 바로가기

내일배움캠프 안드로이드 3기

[TIL] 24.04.25 RecyclerView 아이템 내의 Switch 일관성 문제

1. RecyclerView 아이템 내의 Switch 일관성 문제

 

 RecyclerView를 사용하다 보면, View의 재활용 과정에서 의도하지 않은 속성값 찌꺼기가 남아있는 일들이 생긴다. 물론 일반적인 TextView의 text 등의 경우, 직접 상태값을 바인드하게 되니까 별 문제가 발생하지 않지만, 문제는 여러 상태 값을 가진 객체나 리스너 등을 이용하는 경우다.

 

 이번에 챌린지반 과제를 하면서, 아래처럼 Switch 위젯을 이용해 북마크 토글을 만들었는데 여기서 문제가 생겼다.

제멋대로 동작해버리는 Switch를 볼 수 있었다

 

 로직은 심플한데, 일단 각각의 Switch에 대해 OnCheckedChangeListener를 달아서 Switch의 상태 값이 변경되면 어댑터에서 받아온 콜백을 부른다. 이 콜백은 Fragment에서 주입 받은 것으로 ViewModel에 작성되어 있는 update(item: 아이템 객체) 메소드를 호출하게 된다. 이제 ViewModel에서는 인자로 받은 아이템을 조회해 업데이트 할 것이고, 리스트가 업데이트 된 것을 Observe 하고 있던 Fragment에서 어댑터(ListAdapter 상속)에 이를 전달해 내용물을 갱신하게 된다.

 

 처음에는 각 아이템 View가 재활용 되는 과정에서, OnCheckedChangeListener가 남아 있는 채로 바인드 되는 값이 변경되면서 문제가 된다고 생각했다. 유저에 의한 것은 아니지만, Switch의 상태 값이 변경될 것이고 이로 인해서 리스너가 호출된다고 추측했다. 

 

 그래서 처음 시도해 본 방법은 리스너를 클릭 리스너로 변경하는 방법이다. 

 

 이제 값의 갱신이 유저에 의해서만 발생하기 때문에 더 이상 위와 같은 문제는 발생하지 않았지만, 이는 미봉책에 가까웠다. 왜나하면 Switch 같은 경우에는 클릭 뿐만 아니라 드래그로도 상태를 변경할 수 있기 때문이다. 이렇게 둔다면 드래그 이벤트 발생 시에, UI만 업데이트 되고 실제 데이터는 업데이트 되지 않을 것이다. 그래서 드래그 자체를 비활성화 시킬까 하다가 이는 바람직한 방법이 아닌 것 같아 폐기했다.

 

 다음으로 시도해 본 방법은 View가 재활용 가능 풀에 들어가는 시점에 리스너를 해제하는 것이다. 

 

 ViewHolder 내에 리스너를 제거할 수 있는 메소드를 만들어 두고, 어댑터에서 onViewRecycled()를 오버라이드해서 해당 메소드를 호출하게 했다. 하지만 예상과 다르게 변화가 없어, View가 detach 되는 시점에 해제해야하나 싶어서 onViewDetachedFromWindow()를 오버라이드 하기도 했지만 역시 변화가 없었다. 이쯤에서 단순 잔여 리스너의 문제가 아니라고 생각했다.

 

 그래서 ViewHolder 에 아이템이 바인드 되는 시점과 ViewModel의 update 메소드가 불리는 시점에 Log를 찍어서 확인했는데, 의아한 동작이 생기고 있었다. 

Switch 활성화 시
Switch 비활성화 시

 

  Switch가 비활성화 될 때와 다르게 Switch가 활성화 될 때는 변경 로직이 세번이나 호출되고 있었다. 보니까 아이템 내의 북마크 값이 true가 되었다가 false가 되었다가 다시 true가 되고 있었다. 좀처럼 원인이 떠오르지 않았다.

 이것도 추측이긴 하지만, Switch에 리스너를 먼저 설정하고 그 이후에 스위치의 isCheck 값을 아이템 값을 참조해 변경하는데 이것 때문에 기존에 남아있는 Switch 상태와 바인드되는 북마크 값으로의 변화를 리스너가 감지해버리고 다시금 update를 트리거 해버리는 것 같았다.

 아니면 디버깅 하면서 보게 됐는데, Switch가 활성화 되는 과정이 세 단계에 걸쳐 나타나고 있었다.

 저 동그란 버튼이 왼쪽에서 활성화 되기 시작하고, 중앙, 오른쪽으로 차례대로 이동하고 있었는데, 이게 문제의 원인인지 결과인지 모르겠지만 혹여 원인일 가능성도 있었다.

 

 하지만 생각해보면 어느 쪽이든 해결할 수 있는 방법은 같았다. 

 

 위처럼 리스너를 먼저 설정하던 로직을, isChecked 값 할당 이후에 리스너를 설정하도록 바꾸는 것이다. 추측한 문제의 원인은 데이터를 바인딩 하는 과정에서(그러니까 어떻게 보면 초기화 값) 그걸 리스너가 감지해버려서 생기는 것이기 때문이었으니까. 이렇게 해서 되지 않는다면, 잔여 리스너 문제도 있는 것으로 생각하고 바인딩의 시작지점에 리스너를 null로 셋팅할 요량이었다.

 

 

 하지만 다행히 위처럼 의도한 대로 동작했다. 

 

 활성화 시, 로직 또한 한 번만 발생하는 것을 확인할 수 있었다. 사실 간단하게 작성했지만, 꽤 오래 고민하게 된 문제였는데, 그래도 많은 공부가 된 것 같다.

 

 

 

...

 저 때 끝난 줄 알았는데, 불필요한 코드들을 지우고 다시 실행하니 또 의도대로 동작하지 않는 부분이 생겨서 데이터를 아이템에 바인드 해줄 때에 먼저 리스너를 null로 설정했다가 값 할당 이후에 리스너를 붙여주니 완전히 해결됐다. 역시 잔여 리스너 문제도 있는 것이었다.

 그런데 이왕 제거할 거, onViewRecycled 시점에 제거하면 메모리 측면에서 조금 더 이득인 것 같아서 그 때 제거하기로 했다.

 

시행착오 중 폐기했다가 결국 부활한 코드

 

 이젠 정말로 무결하다😂