Android에서 ConstraintLayout은 왜 사용하는 걸까

DoDoBest

·

2024. 3. 3. 14:45

https://www.youtube.com/watch?v=dB3_vgS-Uqo

 

 이것은 Android가 view를 그리는 과정과 관련된 것으로, Android는 measure pass, layout pass 두 과정을 통해 UI를 설정한다. measure pass는 view가 자신의 크기를 계산하고 설정하는 과정이고, layout pass는 view가 속한 ViewGroup이 view가 측정한 값에 따라 View의 위치를 설정 하는 과정이다.

보통은 이 과정이 짧은 시간 내에 완료되지만, 런타임에 View를 추가, 삭제한다던가(대표적인 예로 RecyclerView), View의 텍스트가 변경되어 크기를 다시 측정해야 하는 경우 비교적 오래 걸린다. 하지만 이것 만으로는 ANR이 발생할만큼 문제가 되지는 않는다.

 

공식문서에서 추천하는 Layout

 

ViewGroup인 Layout이 다른 Layout 내부에 중첩되어 존재할 경우, layout stage 과정이 길어진다. Layout에 속한 View의 크기가 바뀌면 이로 인해 크기가 바뀌는 parent도 pass 과정을 다시 해야 하는데, Layout이 여러 겹으로 중첩되어 있으면 영향을 받는 View가 많을 확률이 크다. 이것은 Double taxation이라고 불리는 것으로, 다음 section에서 살펴본다.

그래서 LinearLayout 대신 단일 계층 구조로 되어 있는 ConstraintLayout의 사용을 권장하는 것이다. 더 간단한 layout에 대해서는 ConstraintLayout보다 더 효율적인 FrameLayout 사용이 권장된다. 

However, for simple layouts that can be achieved using FrameLayout, we recommend using FrameLayout.

 

RelativeLayout 대신 ConstraintLayout을 사용해야 하는 이유는, RelativeLayout의 기능 뿐만 아니라 새로운 기능을 제공하는 상위 호환의 Layout이기 때문이다. 또한 더 효율적인 layout이다.

 

Double taxation

 

보통은 measure, layout pass가 단 한 번 이루어진다. 복잡한 Layout의 경우, Layout에 속한 View의 위치를 계산하기 위해 중첩된 Layout을 여러 번 iterate 하면서 pass를 더 많이 수행한다. measure pass, layout pass를 두 번 이상 수행해야 하는 것을 double taxation이라고 부른다. 단일 RelatvieLayout에서 Double taxation이 발생하는 과정은 다음과 같다.

 

1. Layout에 속한 View 들을 방문하며, 위치와 크기를 계산한다. ( measure pass )

2. Layout은 각 View가 측정한 값에 따라 View의 적절한 위치를 계산하고, boundary를 조정하다. ( layout pass )

3. 각 View의 위치와 크기를 다시 계산한다. (measure pass)

4. Layout은 각 View가 측정한 값에 따라 View의 적절한 위치를 계산하고, boundary를 조정하며 배치한다. ( layout pass )

 

Layout이 중첩되면, double taxation이 곱연산으로 늘어난다.

https://www.youtube.com/watch?v=dB3_vgS-Uqo

 

LinearLayout을 horizontal로 설정할 경우 Double taxation이 발생할 수 있다. vertical로 설정하더라도, measureWithLargestChild 속성을 true로 설정하면 Double taxation이 발생할 수 있다.

GrideLayout은 child View들 사이의 위치적 관계를 pre-processing 함으로써 double taxation을 피한다. 하지만 GridLayout에서 weight를 사용하거나 Gravity class를 사용하면 pre-processing의 이점이 사라지며, RelativeLayout 내부에 존재하면 pass를 여러 번 수행해야 할 수 있다.

 

layout pass와 measure pass를 여러 번 수행하는 것이 무조건 성능에 부담이 되는 영향을 주는 것은 아니다. 다음과 같은 경우에 조심해야 한다.

 

  • It's a root element in your view hierarchy.
  • It has a deep view hierarchy beneath it.
  • There are many instances of it populating the screen, similar to children in a ListView object.

 

Relatvie Layout에는 없는 ConstraintLayout의 기능

 

Weight

Relative Layout의 child View는 layout_weight property를 사용할 수 없다. 그래서 Relative Layout 내부에 Linear layout을 중첩해서 사용했다.

Constraint Layout에서는 다음 2개의 layout_weight을 제공한다.

 

  • layout_constraintHorizontal_weight
  • layout_constraintVertical_weight

 

layout_weight은 horizontal 혹은 verital에 남은 공간을 뷰들이 얼마나 나눠가질지를 나타내는 값이다. 이 값의 비율에 따라 남은 공간을 나눠 갖는다.

 

Chain

 

ConstraintLayout의 chian 기능을 이용해서, LinearLayout처럼 관련된 view들을 수직 혹은 수평으로 나열할 수 있다.

https://developer.android.com/develop/ui/views/layout/constraint-layout#constrain-chain

 

Visibility behavior - layout_goneMargin

 

View의 Visibility가 View.GONE으로 변경되면 해당 View와 연결 된 다른 View의 위치가 변경된다.

 

 

 

layout_goneMarginXXX를 사용해서 연결된 View가 View.GONE으로 변경되어도 이전과 동일한 위치를 유지할 수 있다.

 

B 버튼의 원래 위치는 왼쪽으로부터 180dp다. 버튼 B에 layout_goneMarginStart 값으로 180dp를 주면 버튼 A가 사라져도 버튼 B의 위치가 유지된다.

 

 

View.GONE, View.INVISIBLE

 

View.GONE은 위 예시에서 나온 것처럼 View가 차지하는 크기를 0으로 처리한다.

View.INVISIBLE은 View가 사용자에게 보이지는 않지만, 보일 때와 동일하게 크기를 차지하도록 처리한다.

따라서 위 A, B 버튼의 예시에서, A 버튼이 View.INVISIBLE로 변경되어도 B 버튼의 위치는 바뀌지 않는다. Invisible 상태에서는 layout_goneMarginStart가 적용되지 않기 때문에 Gone 상태로 변경될 때도 View의 위치를 유지시키기 위해 해당 property를 줘도 된다.

 

 

 

 

Double Taxation 실제로 확인해보기

 

완전히 동일한 UI로 비교한 것은 아니나, 최대한 비슷하게 LinearLayout과 ConstraintLayout으로 구현한 후 비교해봤다.

ViewModel에서 0.1초에 한 번씩 LiveData text에 " append"를 추가했으며, 100번 추가할 때마다 text를 ""로 초기화했다. Activity에서는 LiveData를 Observe해서 UI가 업데이트 되도록 구현했다.

 

class MyViewModel : ViewModel() {

    private val _text =  MutableLiveData("")
    val text: LiveData<String>
        get() = _text

    init {

        viewModelScope.launch {
            var cnt = 0
            while (true) {
                _text.postValue(_text.value + " append")
                cnt++
                if (cnt % 100 == 0) {
                    _text.postValue("")
                }
                delay(100)
            }
        }
    }
}

 

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private val viewModel: MyViewModel by viewModels()

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


        viewModel.text.observe(this) {
            binding.targetTextview.text = it
        }
    }
}

 

 

UI가 단순해서 그런지, 두 Layout의 시간 차이가 크지는 않았다.

 

LinearLayout

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:layoutMode="opticalBounds"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:background="@drawable/insert_drawable"
            android:text="1번 텍스트뷰" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:background="@drawable/insert_drawable"
            android:text="2번 텍스트뷰" />

        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:orientation="vertical">

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_margin="10dp"
                android:background="@drawable/insert_drawable"
                android:text="3번 텍스트 뷰" />

            <TextView
                android:id="@+id/target_textview"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_margin="10dp"
                android:background="@drawable/insert_drawable"
                tools:text="target textview " />
        </LinearLayout>


    </LinearLayout>

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:orientation="vertical">

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_margin="10dp"
                android:background="@drawable/insert_drawable"
                android:text="더미뷰 더미뷰 더미뷰 " />

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_margin="10dp"
                android:background="@drawable/insert_drawable"
                android:text="더미뷰 더미뷰 더미뷰 " />
        </LinearLayout>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:background="@drawable/insert_drawable"
            android:text="더미뷰 더미뷰 " />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:background="@drawable/insert_drawable"
            android:text="더미뷰 더미뷰 더미뷰 더미뷰 " />


    </LinearLayout>


</LinearLayout>

 

ConstraintLayout

 

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:layoutMode="opticalBounds"
    android:orientation="vertical"
    tools:context=".MainActivity">


    <TextView
        android:id="@+id/first_text_view"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:background="@drawable/insert_drawable"
        android:text="1번 텍스트 뷰"
        app:layout_constraintEnd_toStartOf="@id/second_text_view"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/second_text_view"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:background="@drawable/insert_drawable"
        android:text="2번 텍스트 뷰"
        app:layout_constraintEnd_toStartOf="@id/third_text_view"
        app:layout_constraintStart_toEndOf="@id/first_text_view"
        app:layout_constraintTop_toTopOf="parent" />


    <TextView
        android:id="@+id/third_text_view"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_margin="10dp"
        android:background="@drawable/insert_drawable"
        android:text="3번 텍스트 뷰"
        app:layout_constraintBottom_toTopOf="@id/target_textview"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@id/second_text_view"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/target_textview"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_margin="10dp"
        android:background="@drawable/insert_drawable"
        tools:text="target 텍스트뷰 target 텍스트뷰 target 텍스트뷰"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@id/second_text_view"
        app:layout_constraintTop_toBottomOf="@id/third_text_view" />


    <TextView
        android:id="@+id/dummy_view"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:background="@drawable/insert_drawable"
        android:text="더미 뷰 더미 뷰 더미 뷰 더미 뷰 더미 뷰 더미 뷰 "
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@id/dummy_view2"
        app:layout_constraintTop_toBottomOf="@id/target_textview" />

    <TextView
        android:id="@+id/dummy_view2"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:background="@drawable/insert_drawable"
        android:text="더미 뷰 더미 뷰 더미 뷰 더미 뷰 더미 뷰 더미 뷰 "
        app:layout_constraintEnd_toStartOf="@id/dummy_view"
        app:layout_constraintStart_toEndOf="@id/dummy_view3"
        app:layout_constraintTop_toTopOf="@id/dummy_view" />


    <TextView
        android:id="@+id/dummy_view3"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_margin="10dp"
        android:background="@drawable/insert_drawable"
        android:text="더미 뷰 더미 뷰 더미 뷰 더미 뷰 더미 뷰 더미 뷰 "
        app:layout_constraintEnd_toStartOf="@id/dummy_view2"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="@id/dummy_view2" />

    <TextView
        android:id="@+id/dummy_view4"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="10dp"
        android:background="@drawable/insert_drawable"
        android:text="더미 뷰 더미 뷰 더미 뷰 더미 뷰 더미 뷰 "
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/dummy_view3" />


</androidx.constraintlayout.widget.ConstraintLayout>

 

 

 

 

참고자료

https://developer.android.com/topic/performance/rendering/optimizing-view-hierarchies

 

성능 및 뷰 계층 구조  |  App quality  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. 성능 및 뷰 계층 구조 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. View 객체의 계층 구조를 관리하

developer.android.com

https://www.youtube.com/watch?v=dB3_vgS-Uqo