Uing? Uing!!

[내 맘대로 정리한 안드로이드] findViewById의 사용을 최소화해야 하는 이유와 대체 방법(ViewBinding) 본문

Android

[내 맘대로 정리한 안드로이드] findViewById의 사용을 최소화해야 하는 이유와 대체 방법(ViewBinding)

Uing!! 2021. 7. 16. 22:34
반응형

처음 안드로이드를 접하면, 화면에서 원하는 뷰에 접근하기 위해 findViewById 메소드를 사용하게 된다.

아, 요즘은 기초를 배울 때 코틀린 extensions를 사용하는 방식이 더 보편적일지도 모르겠다.

코틀린 익스텐션의 방식 역시 각 뷰에 캐싱을 걸어서 반복작업을 조금 줄여줄 뿐, 내부적으로는 이 findViewById 메소드를 사용하고 있다.

코틀린 extensions에 대해서도 이야기할 부분이 많지만, 우선은 findViewById 메소드를 들여다보고자 한다.

 

findViewById의 동작 방식

기본적으로 findViewById 메소드는 id값을 이용해 특정 뷰를 받아와주는 메소드로, 액티비티, 프래그먼트, 뷰홀더 등에서 다양하게 사용이 되곤 했다.

이 메소드를 이용해 텍스트뷰를 가져와 데이터를 바꿔 넣는다면 아래와 같이 작성할 수 있다. 

val textView = findViewById<TextView>(R.id.text_view)
textView.text = "Some Text"

이 메소드가 실제로 어떻게 동작하는지 타고 들어가 보자.

액티비티에서든, 프래그먼트에서든, findViewById 메소드가 호출되면 몇 단계를 타고 들어가 View.findViwById를 호출하게 된다.

@Nullable
public final <T extends View> T findViewById(@IdRes int id) {
    if (id == NO_ID) {
        return null;
    }
    return findViewTraversal(id);
}

 

이때 호출되는 View.findViewTraversal은 이렇게 생겼다.

protected <T extends View> T findViewTraversal(@IdRes int id) {
    if (id == mID) {
        return (T) this;
    }
    return null;
}

어? 뭔가 이상하다.

이 코드대로라면 액티비티는 루트 뷰에 대해서 findViewById를 호출하고, 이 findViewById는 다시 루트 뷰에 대해서 findViewTraversal을 수행하는데, findViewTraversal은 '원하는 뷰의 id값이 현재 뷰의 id값과 같은가?'만을 확인하고 끝난다.

이렇게 작동한다면 루트뷰가 아닌 하위 뷰, 그러니까 텍스트뷰와 같은 뷰들은 아예 받아올 수가 없다.

 

하지만 실제로 우리는 하위 뷰들을 잘 받아와서 사용할 수 있다.

이는 액티비티에서 위의 View.findViewByTraversal의 바디가 실행되지 않기 때문이다.

이유인즉슨, 액티비티의 루트뷰는 View를 상속받은 ViewGroup이고, ViewGroup은 findViewTraversal을 오버라이딩하고 있기 때문이다.

아래는 View.findViewTraversal을 상속받은 ViewGroup.findViewTraversal이다.

@Override
protected <T extends View> T findViewTraversal(@IdRes int id) {
    if (id == mID) {
        return (T) this;
    }

    final View[] where = mChildren;
    final int len = mChildrenCount;

    for (int i = 0; i < len; i++) {
        View v = where[i];

        if ((v.mPrivateFlags & PFLAG_IS_ROOT_NAMESPACE) == 0) {
            v = v.findViewById(id); // 하위 뷰에 대해서 다시 findViewById가 호출됨

            if (v != null) {
                return (T) v;
            }
        }
    }

    return null;
}

이 메소드 내용을 보면, ViewGroup의 findViewTraversal은 그 뷰그룹이 가지고 있는 하위 뷰들에 대해서 각각 v.findViewById를 호출한다. 만일 이 하위 뷰(v)가 뷰그룹이라면 다시 ViewGroup.findViewTraversal이 호출될 것이고, 그러면 또 그 하위 뷰의... 

결국 이 메소드는 ViewGroup 밑에 있는 모든 뷰들을 전부 한 번씩 순회하며 id값을 비교한다.

 

findViewById의 단점

findViewById는 기본적으로... 못생겼다.

하지만 못생긴 건 제쳐 두고 더 큰 단점이 두 가지 있다.

첫 번째는 느리다는 것이고, 두 번째는 null-safe하지 못하다는 것이다.

1) 느리다.

위에서 언급한,  

결국 이 메소드는 ViewGroup 밑에 있는 모든 뷰들을 전부 한 번씩 순회하며 id값을 비교한다.

는 관찰 대상인 ViewGroup이 복잡하면 복잡할수록, 타고 내려갈 View가 많으므로 작업량이 많아진다.

액티비티에서 TextView 하나만을 받아오는 작업이라면 별 문제가 되지 않지만, findViewById로 받아와야 할 뷰의 수가 많아질수록 액티비티의 onCreate는 무거워질 것이다.

 

실제로 이를 한번 테스트해 보기 위해 LinearLayout 안에 TextView가 하나 있는 화면을 구성했다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        android:id="@+id/text_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
</LinearLayout>

그리고 극단적인 케이스로, 

1) findViewById로 TextView 찾기 x 100번

2) 이미 알고 있는 TextView 조회하기 x 100번

이 두 가지를 10번씩 수행하여 그 평균을 비교해 보았다.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val textView = findViewById<TextView>(R.id.text_view)

        val timesWithFindView = mutableListOf<Long>()
        val timesWithoutFindView = mutableListOf<Long>()
        for (count in 0 until 10) {
            // findViewById로 텍스트뷰를 찾는 경우
            val timeWithFindView = measureNanoTime {
                for (i in 0 until 100) {
                    findViewById<TextView>(R.id.text_view)
                }
            }
            timesWithFindView.add(timeWithFindView)

            // 이미 생성된 textView를 조회만 하는 경우
            val timeWithoutFindView = measureNanoTime {
                for (i in 0 until 100) {
                    textView
                }
            }
            timesWithoutFindView.add(timeWithoutFindView)

            println("$timeWithFindView $timeWithoutFindView")
        }
        println("result ${timesWithFindView.average()} ${timesWithoutFindView.average()}")
    }
}

 

결과적으로 둘 다 많은 시간이 소요되지는 않았지만, 419310/15170 = 약 27.6배의 속도 차이가 나는 것을 볼 수 있었다.

390500 15600
394300 15300
381800 15200
390100 15200
380000 15100
392000 15200
382900 15200
425300 15100
405500 15200
650700 14600
result 419310.0 15170.0

2) null-safe하지 못하다.

findViewById는 파라미터로 뷰에 정의된 id값을 받는다.

여기서 문제는, 이 id값이 '현재 보고 있는 레이아웃의 id'라는 조건이 없다는 점이다.

 

가령 activity_main.xml과 activity_another.xml이라는 두 개의 액티비티 레이아웃이 있다고 하자.

another_activity 레이아웃에는 text_in_another_layout이라는 텍스트뷰가 정의되어 있다.

 

MainActivity에서는 실제로 어떤 레이아웃을 화면에 띄웠든 간에, R.id.text_in_another_layout를 사용할 수 있다.

따라서 아래 코드는 컴파일러 에러 없이 정상적으로 동작한다.

그렇다면 이 코드는 어떤 값을 출력할까?

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
         val textView = findViewById<TextView>(R.id.text_in_another_layout)
        println("found textView is: $textView")
    }
}
found textView is: null

textView 변수에 null이 할당된 것을 확인할 수 있다.

 

실제 애플리케이션을 작성하다 보면 비슷하지만 다른 id를 갖는 뷰들이 많아진다.

이런 상황에서 findViewById는 개발자가 의도하지 않은 실수로 null 값이 할당되는 상황을 발생시킬 가능성이 있다.

 

ViewBinding: 빠르고 안전한 findViewById의 대체재

ViewBinding(뷰 결합)이란, xml 레이아웃 파일 각각을 컴파일 시에 하나의 Binding 클래스로 미리 변환해 두는 기능이다.

뷰바인딩을 사용하게 되면, findViewById의 두 가지 문제가 동시에 해결된다.

 

장점을 언급하기 전에 실제 사용 방식을 간단하게 살펴보자면, 우선 app단의 build.gradle에서 viewBinding기능을 켜 주어야 한다.

android {
    ...
    viewBinding {
        enabled = true
    }
    ...
}

그리고 MainActivity의 onCreate에서 레이아웃 파일을 화면에 불러오는 방식이 조금 바뀐다.

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater) // binding 객체 생성
        val view = binding.root
        setContentView(view)
    }
}

위 코드에서처럼, activity_main.xml 파일은 xml파일 이름에 맞추어 ActivityMainBinding이라는 클래스를 만든다.

ActivityMainBinding 클래스는 이렇게 생겼다.

 

activity_main.xml에 딱 맞는 ActivityMainBinding 객체가 만들어지면, 이 객체를 통해 하위 뷰들에 접근할 수 있다.

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        binding.textView.text = "Text" // 바인딩_객체.뷰의_ID로 직접 접근
    }
}

이 코드만 보아도 앞서 findViewById에서 언급된 두 가지 문제가 해결된다는 것을 쉽게 유추할 수가 있다.

 

1) Binding 클래스에 이미 뷰들이 정의되어 있기 때문에 모든 뷰를 순회하면서 찾을 필요가 없어 속도가 빠르고,

2) ActivityMainBinding에서는 activity_main.xml과 관련된 뷰들만 접근할 수 있으므로 null에 대한 위험성이 줄어든다.

 

정말 findViewById에 비해 빠른지 테스트를 해 보자.

아까 100번씩 뷰를 조회하며 테스트했던 코드를 가져와서 binding 방식의 테스트를 추가했다.

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        val textView = findViewById<TextView>(R.id.text_view)

        val timesWithFindView = mutableListOf<Long>()
        val timesWithoutFindView = mutableListOf<Long>()
        val timesWithViewBinding = mutableListOf<Long>()
        for (count in 0 until 10) {
            // findViewById로 텍스트뷰를 찾는 경우
            val timeWithFindView = measureNanoTime {
                for (i in 0 until 100) {
                    findViewById<TextView>(R.id.text_view)
                }
            }
            timesWithFindView.add(timeWithFindView)

            // 이미 생성된 textView를 조회만 하는 경우
            val timeWithoutFindView = measureNanoTime {
                for (i in 0 until 100) {
                    textView
                }
            }
            timesWithoutFindView.add(timeWithoutFindView)

            // ViewBinding으로 TextView를 조회하는 경우
            val timeWithViewBinding = measureNanoTime {
                for (i in 0 until 100) {
                    binding.textView
                }
            }
            timesWithViewBinding.add(timeWithViewBinding)

            println("$timeWithFindView $timeWithoutFindView $timeWithViewBinding")
        }
        println("result ${timesWithFindView.average()} ${timesWithoutFindView.average()} ${timesWithViewBinding.average()}")
    }
}

 

259200 11100 16000
252800 11000 12300
252600 10900 12300
310300 11000 18600
316600 11000 15000
409000 14100 12900
383000 13800 12600
545400 21500 15400
447000 16400 18300
465700 19900 22100
result 364160.0 14070.0 15550.0

ViewBinding을 통해 텍스트뷰에 접근하는 방식이 사실상 이미 정의된 텍스트뷰에 접근하는 것과 똑같고,

findViewById 방식에 비해서 월등하게 빠르다는 것을 확인할 수 있다.

 

반응형
Comments