티스토리 뷰

버그와 함께 알아보는 RecyclerView 에서 wrap_content 사용을 조심해야 하는 이유

안녕하세요. G마켓에서의 첫 돌이 지난 Mobile Application 팀 전계원입니다.

작년 여름, 지마켓 내부코드 리팩토링 과정에서 버그를 만났었습니다.

버그의 원인을 이해하기 위해 분석하고 공부했던 내용들을 이번 글을 통해 공유해보고자 합니다.



0. 프롤로그 - 버그 발견

G마켓 Mobile Application 팀에서는 원활한 유지보수와 Android 시장의 개발 트렌드를 따라가기 위해 리팩토링 작업을 매우 권장하고 있습니다.

당시 신규 입사자였던 저도 G마켓의 코드에 빠르게 적용하기 위해 코드를 분석하며 간단한 화면 리팩토링 프로젝트를 진행했었습니다.

그리고 개발한 내용을 홀로 테스트하던 중 이상한 경험을 마주하게 됩니다.

 

(0.5배속입니다)

위 화면에서 이상한 점을 느끼셨나요?



1 2 3

현재 검색어 리스트가 나열되어 있는 상태에서 "자동저장 끄기" 버튼을 누르면 현재의 ViewHolder 가 "자동저장 켜기" 버튼이 있는 새로운 ViewHolder 로 교체되는 애니메이션이 동작하고 있습니다.

그런데 위 애니메이션을 프레임 단위로 끊어서 확인해 보니 이전 ViewHolder 의 일부 영역이 잠깐 소실되고 애니메이션이 실행되는 버그를 확인할 수 있습니다.



1. 버그 해결 방법

버그의 원인은 무엇이었고, 어떻게 하면 해결할 수 있을까요?

정답은 RecyclerView 의 layout_height 이 wrap_content 로 되어있는 것을 match_parent 로 바꾸면 해결할 수 있습니다.

그런데 왜 match_parent 로 바꾸면 해결되는 것일까요? 그리고 이를 해결할 수 있는 다른 방법은 없을까요?

View 가 그려지는 과정을 천천히 분석해 보며, 위 질문들에 대한 답을 알아보겠습니다.



2. 분석 - View 가 그려지는 과정

버그가 발생하는 원인을 이해하기 위해서는 View 가 그려지는 과정에 대한 이해가 필요합니다.



2-1. View 가 그려지는 과정

Android 를 공부할 때 View 는 onMeasure(), onLayout(), onDraw() 를 거치며 각각 측량, 배치, 그리기 과정의 Lifecycle 을 지나며 그려진다는 점을 공부했었습니다.

그런데 이러한 함수들은 누가 실행시키는 걸까요?



2-2. 분석을 위한 샘플 코드

샘플 코드와 함께 View 가 그려지는 과정을 조금 더 깊게 이해해보겠습니다.



실행화면은 위와 같습니다. 여기서 "CHANGE!" 버튼을 누르면 "item view 1" 이 "item view 2" 로 변경됩니다.

그리고 다시 "CHANGE!" 버튼을 누르면 "item view 2" 가 "item view 1" 으로 변경됩니다

소스코드 내용은 아래와 같습니다.

샘플코드

// MainActivity.kt
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val myAdapter = MyAdapter()
        updateAdapter(myAdapter, false)
        findViewById<RecyclerView>(R.id.recyclerView).apply {
            adapter = myAdapter
        }

        var buttonState = false
        findViewById<Button>(R.id.button).setOnClickListener {
            buttonState = !buttonState
            updateAdapter(myAdapter, buttonState)
        }
    }

    private fun updateAdapter(adapter: MyAdapter, buttonState: Boolean) {
        adapter.submitList(
            if (buttonState) listOf(ViewHolderType(2))
            else listOf(ViewHolderType(1))
        )
    }
}
// MyAdpater.kt
class MyAdapter : ListAdapter<ViewHolderType, MyAdapter.MyViewHolder>(
    object : DiffUtil.ItemCallback<ViewHolderType>() {
        override fun areItemsTheSame(oldItem: ViewHolderType, newItem: ViewHolderType): Boolean = oldItem.typeNum == newItem.typeNum
        override fun areContentsTheSame(oldItem: ViewHolderType, newItem: ViewHolderType): Boolean = oldItem == newItem
    }
){
    sealed class MyViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
        class MyViewHolder1(itemView: View): MyViewHolder(itemView)
        class MyViewHolder2(itemView: View): MyViewHolder(itemView)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder = when(viewType) {
        1 -> MyViewHolder.MyViewHolder1(
            LayoutInflater.from(parent.context).inflate(R.layout.item_view_1, parent, false)
        )
        else -> MyViewHolder.MyViewHolder2(
            LayoutInflater.from(parent.context).inflate(R.layout.item_view_2, parent, false)
        )
    }

    override fun getItemViewType(position: Int): Int = getItem(position).typeNum

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {}
}

data class ViewHolderType(
    val typeNum: Int
)
<!-- activity_main.xml -->
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <androidx.appcompat.widget.AppCompatButton
        android:id="@+id/button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/white"
        android:text="Change!" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        app:layout_constraintTop_toBottomOf="@id/button" />

</androidx.appcompat.widget.LinearLayoutCompat>
<!-- item_view_1.xml -->
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <androidx.appcompat.widget.AppCompatTextView
        android:layout_width="match_parent"
        android:layout_height="120dp"
        android:background="@color/teal_200"
        android:gravity="center"
        android:text="Item View 1" />
</androidx.appcompat.widget.LinearLayoutCompat>
<!-- item_view_2.xml -->
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <androidx.appcompat.widget.AppCompatTextView
        android:layout_width="match_parent"
        android:layout_height="300dp"
        android:background="@color/purple_200"
        android:gravity="center"
        android:text="Item View 2" />
</androidx.appcompat.widget.LinearLayoutCompat>

위 샘플 코드를 기준으로 보면, 최상단 View는 LinearLayout 인 것 같습니다.

만약 MainActivity 를 기준으로 본다면 최상단 View는 LinearLayout 이 맞습니다.

하지만 View 계층 전체의 기준에서 최상단 View 는 따로 존재합니다.



2-3. 최상단 View 와 ViewParent (DecorView, ViewRootImpl) 그리고 Choreographer

(출처 :&nbsp;https://youtu.be/zdQRIYOST64?t=1178)

바로 DecorView 입니다. 그리고 DecorView 의 ViewParent 는 ViewRootImpl 이라는 class 입니다.

결국 View 계층에서의 최상위 ViewParent는 ViewRootImpl 인 것을 알 수 있습니다.

// ViewRootImpl (android API 32)
public final class ViewRootImpl implements ViewParent,
        View.AttachInfo.Callbacks, ThreadedRenderer.DrawCallbacks,
        AttachedSurfaceControl {

     ...

     final class TraversalRunnable implements Runnable {
        @Override
        public void run() {
            doTraversal();
        }
    }
    final TraversalRunnable mTraversalRunnable = new TraversalRunnable();

    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
    void scheduleTraversals() {
        if (!mTraversalScheduled) {
            mTraversalScheduled = true;
            mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
            mChoreographer.postCallback(
                    Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
            notifyRendererOfFramePending();
            pokeDrawLockIfNeeded();
        }
    }  

ViewRootImpl 에서는 TraversalRunnable 라는 클래스가 존재합니다.

ViewRootImpl 은 우리가 화면을 새로 그려주어야 할 경우 해당 Runnable class 를 scheduleTraversals() 함수를 통해 Choreographer 에 등록합니다.

Choreographer 는 내부에서 Handler 를 통해 이와 같은 이벤트들을 스케쥴링하고 있다가 화면을 그려줘야 하는 시점에 TraversalRunnable.run() 을 실행시키고, 그로 인하여 ViewRootImpl.doTraversal() 이라는 코드가 실행됩니다.

* Choreographer : 애니메이션 및 View 렌더링 타이밍을 스케쥴링하는 클래스



2-4. View 계층의 단계별 Traversal 과정

(주의 : 설명을 위해 대략적으로 정리되었습니다. 실제 구현은 이보다 더욱 복잡하게 구현되어 있습니다)

doTraversal() 함수가 실행되고 나면 View Tree 의 최상단인 ViewRootImpl 에서부터 상황에 맞게 performMeasure(), performLayout(), performDraw() 가 실행되고, 이들은 다시 각각 measure(), layout(), draw() 함수를 실행합니다.

그리고 각각의 측정/배치/그리기 작업이 필요할 땐 각 View 에서 오버라이딩하여 개발한 onMeasure(), onLayout(), onDraw() 작업을 실행합니다. (이미 작업이 되었거나, 변화가 없는 등의 이유로 작업이 필요 없는 경우 onMeasure(), onLayout(), onDraw() 작업은 실행되지 않기도 합니다.)

이를 정리하면 아래와 같습니다.

  1. ViewRootImpl 에서 scheduleTraversals() 함수를 통해 Choreographer 에 Traversal 작업등록
  2. Choreographer 에서 스케쥴링된 작업에 따라 doTraversal() 함수가 실행
  3. 상황에 맞춰 performMeasure() 및 View Tree 전체적으로 measure() 실행, 그려질 View 의 크기 측정이 필요한 경우 onMeasure() 함수 실행
  4. 상황에 맞춰 performLayout() 및 View Tree 전체적으로 layout() 실행, View 가 그려질 위치를 배치해야 할 경우 onLayout() 함수 실행
  5. 상황에 맞춰 performDraw() 및 View Tree 전체적으로 draw() 실행, View 를 새로 그려주어야 하는 경우 onDraw() 실행



2-5. ViewHolder 변환 애니메이션은 어느 시점에?

// RecyclerView.java
public class RecyclerView extends ViewGroup implements ScrollingView,
        NestedScrollingChild2, NestedScrollingChild3 {

    ...

    ItemAnimator mItemAnimator = new DefaultItemAnimator();

RecyclerView 에는 ViewHolder 가 생성/제거/변환될 때의 애니메이션을 담당하는 ItemAnimator 가 있습니다.

그리고 ItemAnimator 의 Default 값은 DefaultItemAnimator 입니다.

DefaultItemAnimator 은 item 이 변경되었을 때 기존 ViewHolder 가 fade-out 으로 사라지고 새로운 ViewHolder 가 fade-in 으로 생성되도록 구현되어 있습니다.

지금은 default itemAnimator 가 설정이 되어있으니, View 의 Traversal 과정에서 ItemAnimator 가 어느 시점에 실행되는지도 알아봐야겠습니다.



브레이크포인트를 통해 RecyclerView 에서 itemAnimator 를 바탕으로 애니메이션 작업을 트리거하는 작업은 RecyclerView.onLayout() 시점에 이루어지며,

정확히는 dispatchLayout() 에서 실행되는 것을 확인할 수 있었습니다.



2-6. RecyclerView Draw 와 ViewHolder 변환 과정

이제 위와 같은 정보들을 바탕으로 샘플 코드의 주요 부분들에 브레이크 포인트를 설정하여, 실행순서를 정리해 보겠습니다.



2-6-1. 샘플 코드의 View 계층

샘플 코드의 ViewTree 구조는 위와 같습니다.



2-6-2. 처음 View 를 그리는 과정

초기 View 렌더링 시점에서는 performMeasure(), performLayout(), performDraw() 가 실행됩니다.

performMeasure() 단계에서는 자식 View 들에 대해 크기를 측정합니다.

이때 RecyclerView 는 measure() 과정에서 자식 View 들에 대해 onLayout() 을 실행합니다. (정확한 RecyclerView 의 크기 측정을 위해 자식 ViewHolder 들에 대해 배치단계까지 진행한 후 크기 반환)

peformLayout() 단계에서는 자식 View 들을 배치합니다. 이때 ViewHolder1 의 상황처럼 이미 이전단계에서 onLayout() 이 진행되었으면 onLayout() 과정은 생략됩니다.

그 이후 performDraw() 단계를 통해 새로 그려주어야 하는 View 들을 Canvas 에 그려줍니다.



2-6-3. 버튼을 누르고 나서 ViewHolder 가 변경되는 과정

버튼을 누르면, RecyclerView Adapter 의 List 가 ViewHolder1 에서 ViewHolder2 로 변경되고, 이에 따라 화면을 다시 그려주기 위해 performTraversal() 이 시작됩니다.

performMeasure() 이 실행됩니다. 이때 Button 은 변동사항이 없기 때문에 onMeasure() 이 실행되지 않으며, 이후 onLayout() 도 실행되지 않습니다.

그리고 performLayout() 의 RecyclerView.onLayout() 시점에서 ViewHolder 가 변경한 것에 대해 Animation 작업을 트리거합니다. (기존 ViewHolder 가 사라지는 애니메이션과 새로운 ViewHolder 가 나타나는 애니메이션이 트리거 됩니다)

마지막으로 performDraw() 를 실행하면서 새로운 ViewHolder 가 포함된 RecyclerView 를 다시 그려주고

그려준 값을 이용하여 ViewHolder1 이 제거되고 ViewHolder2 가 생성되는 애니메이션이 실행됩니다.

이때 애니메이션의 실행은 Choreographer 가 여러 번의 performDraw() 실행을 통해 변하는 화면을 계속 업데이트하는 방법으로 진행됩니다.



3. 분석 - 버그가 발생하는 이유

이제 분석한 내용을 버그 상황에 대입을 해보며, 왜 버그가 발생한 것인지 분석을 해보겠습니다.

레이아웃이 그려지는 단계는 결국 performDraw() 입니다. 그래서 performDraw() 를 브레이크포인트로 설정하여 분석해보겠습니다.



버튼을 누르면 새로운 ViewHolder 가 포함된 RecyclerView 를 그립니다.

그리고 그려진 RecylcerView 의 height 값은 "Item View 1" 으로 변경되면서 300px 로 바뀌었고, 첫 performDraw() 에서는 RecyclerView 의 변화된 높이 값이 반영됩니다.



그리고 다음 performDraw() 부터는 이전에 onLayout() 단계에서 애니메이션 트리거 했던 내용에 따라 기존 ViewHolder 가 사라지는 애니메이션이 그려집니다.



그리고 새로운 ViewHolder 를 생성하는 애니메이션이 다시 그려집니다.

결국 오류의 원인은 RecyclerView 의 높이값이 유동적으로 변할 수 있는 wrap_content 이면서, itemAnimator 로 인하여 변경되는 애니메이션이 등록되어 있기 때문에

위 과정을 실행하면서 첫 performDraw() 때 짧은 순간 750px의 View 가 300px 까지만큼만 보이면서 버그 상황과 같이 일부영역이 잠깐 소실되는 버그가 나타나는 것이었습니다.



4. 정리 - TL;DR

 RecyclerView 를 wrap_content 로 설정한 상태에서 itemAnimator 을 적용하면, RecyclerView 의 height 값이 작아지는 방향으로 ViewHolder 가 변경되었을 때 애니메이션 초반에 ViewHolder 의 일부 화면이 소실되는 버그가 발생합니다.

 이는 itemAnimator 가 적용되면 애니메이션이 실행 전에 Layout 의 가로와 세로값이 재조정된 후 애니메이션이 실행되기 때문에 발생합니다.

 해결방법은 RecyclerView 를 match_parent 혹은 고정값으로 설정하면 해결할 수 있으며, itemAnimator 를 사용하지 않는다면 itemAnimator 를 null 로 설정하여 문제를 해결할 수 있습니다.



5. 참고자료

- Drawn out: How Android renders (Google I/O '18) : https://www.youtube.com/watch?v=zdQRIYOST64

=> Android System 이 View 를 그리는 과정

 

- Android Performance Patterns: Android UI and the GPU : https://www.youtube.com/watch?v=WH9AFhgwmDw

=> CPU, GPU, UI 의 동작 관계에 대한 이해

 

- Performance: Using sampling profiling with Systrace - MAD Skills : https://www.youtube.com/watch?v=21lY_MMiD2g

=> Android Profile 과 함께하는 시스템 동적분석

댓글