관리 메뉴

나만을 위한 블로그

[Android] View의 생명주기 본문

Android

[Android] View의 생명주기

참깨빵위에참깨빵_ 2025. 5. 12. 22:05
728x90
반응형

액티비티, 프래그먼트가 생명주기를 갖고 있듯이 뷰도 생명주기를 갖고 있다.

이게 중요할까? 당연히 중요하다. 하나의 UI를 만들기 위해 여러 커스텀 뷰를 추가하거나 리사이클러뷰 어댑터로 뷰를 인플레이트할 때 특정 경우엔 뷰를 동적으로 수정해서 고쳐 그려야 하는 경우가 생길 수 있다. 이 때 뷰의 생명주기를 활용해서 로직을 짜야 하는 경우가 생길 수 있다.

뷰의 생명주기는 크게 아래와 같다. 오른쪽이 뷰의 생명주기고 왼쪽의 액티비티 생명주기에 따른 호출 단계가 포함된 그림이다. 혹시 몰라 말해두지만 onPause 이전에 onMeasure, measure()가 있다고 해서 두 함수가 onPause 전에 호출된다는 뜻은 아니다.

 

https://stackoverflow.com/a/60395115

 

뷰의 생명주기는 유저에게 보여지는 Visible to User를 제외하면 크게 10단계로 나눠진다.

 

  • Constructors
  • onAttachedToWindow()
  • measure()
  • onMeasure()
  • layout()
  • onLayout()
  • dispatchToDraw()
  • draw()
  • onDraw()
  • onDetachedFromWindow()

 

아래는 생명주기를 다루기 위한 예시 코드다.

 

import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.util.Log
import android.view.View
import android.widget.FrameLayout

class TestView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {

    private val TAG = this::class.simpleName
    private val paint = Paint().apply {
        color = Color.BLUE
        style = Paint.Style.FILL
    }

    // 메인 액티비티에서 전달받은 width, height
    private var customWidth = 0
    private var customHeight = 0

    init {
        Log.d(TAG, "## [view] 생성자 호출 -> 뷰 초기화 중...")
        setBackgroundColor(paint.color)
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        Log.d(TAG, "## [view] onAttachedToWindow 호출 -> 뷰가 window에 붙음")
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        /**
         * resolveSize() : 뷰가 요청하는 크기, 부모의 measure 제약 조건을 고려해서 적절한 크기(타협점)를 반환
         *
         * 뷰는 200dp를 원하는데 부모 뷰는 최대 100dp만 허용하는 경우 100dp를 리턴함
         */
        val width = resolveSize(customWidth, widthMeasureSpec)
        val height = resolveSize(customHeight, heightMeasureSpec)

        /**
         * setMeasuredDimension() : onMeasure()에서 반드시 호출해야 하는 함수
         *
         * 이 함수로 설정된 width, height는 이후 onLayout()에서 뷰의 실제 위치, 크기 결정 시 쓰임
         */
        setMeasuredDimension(width, height)
        Log.d(TAG, "## [view] onMeasure 호출 - 픽셀 크기 : $width x $height")
    }

    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        super.onLayout(changed, left, top, right, bottom)
        Log.d(TAG, "## [view] onLayout 호출 -> 뷰 위치 배치 중...")
        Log.d(TAG, "## [view] changed : $changed, left : $left, top : $top, right : $right, bottom : $bottom")
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        Log.d(TAG, "## [view] onDraw 호출 -> 뷰 그리는 중...")

        // 뷰 전체를 paint 색상으로 채움
        canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
        Log.d(TAG, "## [view] onDraw 호출 - 색상 : ${paint.color}")
    }

    override fun onDetachedFromWindow() {
        Log.d(TAG, "## [view] onDetachedFromWindow 호출 -> 뷰가 window에서 분리됨")
        super.onDetachedFromWindow()
    }

    // 뷰 크기가 변경될 때 호출됨
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        Log.d(TAG, "## [view] onSizeChanged() 호출 -> 뷰 크기 변경됨) width : $oldw, height : $oldh → 변경된 width : $w, 변경된 height : $h")
    }

    // 뷰의 가시성이 변경될 때 호출됨
    override fun onVisibilityChanged(changedView: View, visibility: Int) {
        super.onVisibilityChanged(changedView, visibility)
        Log.d(TAG, "## [view] onVisibilityChanged() 호출 -> 가시성 변경 : $visibility")
    }

    // 뷰에 포커스가 변경될 때 호출됨
    override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: android.graphics.Rect?) {
        super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
        Log.d(TAG, "## [view] onFocusChanged() 호출 -> 포커스 변경 : $gainFocus")
    }

    fun changeView(
        width: Int,
        height: Int,
        color: Int,
    ) {
        paint.color = color

        customWidth = dpToPx(width)
        customHeight = dpToPx(height)

        post {
            setBackgroundColor(color)

            val params = layoutParams
            params.width = width
            params.height = height
            layoutParams = params

            // 뷰 크기가 변경되었으므로 requestLayout() 호출
            Log.d(TAG, "## [view] requestLayout() 호출 -> 뷰 레이아웃 다시 측정 요청. width : $width, height : $height")
            requestLayout()
        }
    }

    private fun dpToPx(dp: Int): Int = (dp * resources.displayMetrics.density).toInt()

}

 

아래는 메인 액티비티의 코드다. TestView와 메인 액티비티에서의 import문은 적절하게 수정해 준다.

 

<?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:id="@+id/main"
    android:layout_marginHorizontal="14dp"
    android:background="@color/white"
    tools:context=".presentation.MainActivity">

    <TextView
        android:id="@+id/tvFirstViewTitle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="아래의 뷰는 2초 뒤 색깔, 크기가 변한다"
        android:textSize="20dp"
        android:textColor="@color/black"
        android:layout_marginTop="40dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

    <com.example.regacyviewexample.presentation.TestView
        android:id="@+id/tvFirst"
        android:layout_width="150dp"
        android:layout_height="150dp"
        android:layout_marginTop="60dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/tvFirstViewTitle"/>

</androidx.constraintlayout.widget.ConstraintLayout>
import android.os.Bundle
import android.util.Log
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.lifecycleScope
import com.example.regacyviewexample.R
import com.example.regacyviewexample.databinding.ActivityMainBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    private val TAG = this::class.simpleName
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }

        initView()
    }

    private fun initView() {
        binding.apply {
            lifecycleScope.launch {
                Log.e(TAG, "## [main] 메인 액티비티에서 changeView() 호출!!")
                Log.e(TAG, "## [main] 2초 대기...")
                delay(2000L)
                Log.e(TAG, "## [main] !! 2초 대기 끝 !!")
                tvFirst.changeView(
                    width = 300,
                    height = 300,
                    color = ContextCompat.getColor(this@MainActivity, R.color.purple200)
                )
            }
        }
    }

}

 

이걸 실행하면 아래처럼 작동한다.

 

 

 

로그를 확인하면 먼저 아래 로그들이 표시된다.

 

## [view] 생성자 호출 -> 뷰 초기화 중...
## [main] 메인 액티비티에서 changeView() 호출!!
## [main] 2초 대기...
## [view] onAttachedToWindow 호출 -> 뷰가 window에 붙음
## [view] onVisibilityChanged() 호출 -> 가시성 변경: 0
## [view] onMeasure 호출 - 픽셀 크기: 525 x 525
## [view] onMeasure 호출 - 픽셀 크기: 525 x 525
## [view] onSizeChanged() 호출 -> 뷰 크기 변경됨) width : 0, height : 0 → 변경된 width : 525, 변경된 height : 525
## [view] onLayout 호출 -> 뷰 위치 배치 중...
## [view] changed : true, left : 458, top : 555, right : 983, bottom : 1080
## [view] onDraw 호출 -> 뷰 그리는 중...
## [view] onDraw 호출 - 색상: -16776961

 

이후 2초가 지나면 아래 로그들이 표시된다.

 

## [main] !! 2초 대기 끝 !!
## [view] requestLayout() 호출 -> 뷰 레이아웃 다시 측정 요청. width : 300, height : 300
## [view] onMeasure 호출 - 픽셀 크기: 300 x 300
## [view] onSizeChanged() 호출 -> 뷰 크기 변경됨) width : 525, height : 525 → 변경된 width : 300, 변경된 height : 300
## [view] onLayout 호출 -> 뷰 위치 배치 중...
## [view] changed : true, left : 570, top : 555, right : 870, bottom : 855
## [view] onDraw 호출 -> 뷰 그리는 중...
## [view] onDraw 호출 - 색상: -2640134
## [view] onDraw 호출 -> 뷰 그리는 중...
## [view] onDraw 호출 - 색상: -2640134

 

onMeasure()에서 크기가 525x525로 잡히는 이유는 안드로이드 시스템이 xml에서 설정한 150dp를 자동으로 픽셀로 변환하기 때문에 150dp가 525px로 변환된다. 참고로 아래는 dp / px 변환 사이트에서 확인한 150dp를 px로 바꾼 값이다.

 

 

테스트에 사용한 갤럭시 S24+는 밀도 배율이 3.5x인 것인지 150에 3.5를 곱한 525가 표시된다. 720x1280 크기의 에뮬레이터(안드로이드 스튜디오 기준 Small Phone 선택)에서 확인하면 2배율이기 때문에 300x300으로 표시된다. 또한 바뀌는 크기도 300x300이기 때문에 색깔만 바뀐다.

 

## [view] 생성자 호출 -> 뷰 초기화 중...
## [main] 메인 액티비티에서 changeView() 호출!!
## [main] 2초 대기...
## [view] onAttachedToWindow 호출 -> 뷰가 window에 붙음
## [view] onVisibilityChanged() 호출 -> 가시성 변경 : 0
## [view] onMeasure 호출 - 픽셀 크기 : 300 x 300
## [view] onMeasure 호출 - 픽셀 크기 : 300 x 300
## [view] onSizeChanged() 호출 -> 뷰 크기 변경됨) width : 0, height : 0 → 변경된 width : 300, 변경된 height : 300
## [view] onLayout 호출 -> 뷰 위치 배치 중...
## [view] changed : true, left : 210, top : 307, right : 510, bottom : 607
## [view] onDraw 호출 -> 뷰 그리는 중...
## [view] onDraw 호출 - 색상 : -16776961
## [main] !! 2초 대기 끝 !!
## [view] requestLayout() 호출 -> 뷰 레이아웃 다시 측정 요청. width : 300, height : 300
## [view] onMeasure 호출 - 픽셀 크기 : 300 x 300
## [view] onLayout 호출 -> 뷰 위치 배치 중...
## [view] changed : false, left : 210, top : 307, right : 510, bottom : 607
## [view] onDraw 호출 -> 뷰 그리는 중...
## [view] onDraw 호출 - 색상 : -2640134

 

 

이제 TestView를 바탕으로 뷰의 생명주기가 어떤 순서로 어떻게 작동하는지 확인한다.

 

  • 생성자 호출 ~ init : 생성자 호출은 당연히 액티비티의 onCreate()가 호출된 후에 호출된다. 액티비티가 없는데 뷰의 생성자가 먼저 호출될 수는 없다. TestView의 부 생성자(constructor)를 통해 3가지 매개변수를 전달받고 FrameLayout을 상속받은 다음 TAG, paint 프로퍼티를 초기화한다. 이후 init에서 로그를 출력하고 paint 프로퍼티에 기본 값을 설정한다
  • XML에 선언한 TestView가 inflate : XML은 onCreate의 setContentView()에서 파싱되는데, 이 때 뷰 계층 구조가 생성되며 TestView의 생성자가 호출되고 초기화된다. 이 영향으로 TestView의 onAttachedToWindow()가 호출되고, 메인 액티비티의 binding.apply {} 도 호출되서 lifecycleScope.launch {} 안의 로그 2개가 호출된다. 이후 delay(2000L)이 그 다음에 호출돼 2초 간 대기한다. 이 때 대기하는 것은 changeView()를 호출하기 전에 잠깐 대기하는 것이다
  • onAttachedWindow() : TestView가 윈도우, 즉 부모 뷰에 연결된다. 예시에선 로그만 출력하지만 이 함수는 액티비티의 onResume() 이후 호출되기 때문에 포커스 설정, 리스너 등록이나 LiveData, Flow 등 데이터 스트림을 관찰하는 로직을 추가할 수 있다
  • onMeasure() : XML의 layout_width, layout_height 값을 기반으로 그려질 뷰의 width, height를 결정한다. resolveSize로 뷰가 요청하는 크기와 부모 뷰의 제한을 고려해서 적절한 width, height를 뽑아낸 다음 setMeasuredDimension()에 넘겨서 뽑아낸 width, height로 뷰의 크기를 설정한다
  • onLayout() : 부모 뷰의 위치를 기반으로 자식 뷰가 어느 위치에 그려질지 결정한다. changed 매개변수는 뷰의 새 크기 또는 위치가 이전과 비교해서 변경됐는지 여부다. TestView에선 크기를 바꾸기 때문에 changed는 true인 것을 로그에서 확인할 수 있다. left, top, right, bottom은 각각 부모 뷰의 왼쪽 / 위 / 오른쪽 / 밑 가장자리에서 뷰가 어느 위치에 떨어져 표시돼야 하는지를 의미한다.
    left(458)는 부모 뷰의 왼쪽에서 TestView의 왼쪽 가장자리까지의 거리(px) = x축 시작점 좌표
    top(555)은 부모 뷰의 상단 가장자리에서 TestView의 상단 가장자리까지의 거리(px) = y축 시작점 좌표
    right(983)는 부모 뷰의 오른쪽 가장자리에서 TestView의 오른쪽 가장자리까지의 거리(px) = x축 끝점 좌표
    bottom(1080)은 부모 뷰의 하단 가장자리에서 TestView의 하단 가장자리까지의 거리(px) = y축 끝점 좌표를 의미하기 때문에 right - left = 뷰의 너비, bottom - top = 뷰의 높이로 볼 수 있다
    계산하면 983-458 = 525, 1080-555 = 525기 때문에 onMeasure()의 결과로 리턴되는 뷰의 크기는 525x525가 된다. 로그에서도 픽셀 크기는 525x525라고 찍히는 걸 볼 수 있다
  • onDraw() : Canvas 매개변수를 받아서 뷰 전체에 색을 칠한다. 이 때 color는 TestView의 전역 프로퍼티로 선언한 Color.BLUE기 때문에 Color.BLUE의 값인 -16776961이 로그에 찍히는 걸 볼 수 있다. BLUE를 cmd + 클릭하면 이 값이 정의된 걸 볼 수 있으니 궁금하면 확인해 본다
  • delay(2000L) 끝 : 메인 액티비티에서 설정한 2초 대기가 끝났다. 이후 changeView()가 호출되면서 매개변수로 300, 300, purple_200을 받아 TestView의 크기, 색을 바꾼 뒤 requestLayout()이 호출된다
  • requestLayout() : 뷰 크기, 위치가 바뀌어서 레이아웃 계산을 새로 하라고 안드로이드 시스템에 알리는 함수다. onMeasure -> onSizeChanged -> onLayout -> onDraw 순으로 함수들을 호출한다.
    뷰의 속성이 바뀌기 때문에 부모 뷰도 바뀐 TestView를 다시 그리기 위해 다시 측정(measure)하고 배치(layout)하는 과정을 거치는 것이다. 그래야 바뀐 뷰가 유저에게 표시될 수 있다
  • onSizeChanged() : 뷰 크기가 바뀌었을 때 호출된다. 호출 시점은 onLayout 이후, onDraw 이전이다.
    첫 호출 시 oldw, oldh는 모두 0이고 w, h는 525다. 2초 뒤에 다시 호출되면 oldw, oldh는 525고 w, h는 새로 받은 값인 300으로 설정된다. 처음 호출될 때는 oldw, oldh는 반드시 0이기 때문에 신경쓸 것 없다
    onMeasure()는 뷰가 자기 크기를 결정하기 위해 호출되는 함수고 onSizeChanged()는 뷰 크기가 실제로 할당된 후에 호출되는 함수라는 차이가 있다
  • onVisibilityChanged() : 뷰 가시성이 바뀔 때 호출된다. 0은 visible, 4는 invisible, 8은 gone이라고 이해하면 되며 처음부터 visible이기 때문에 로그캣에는 0으로 표시된다. 홈 버튼을 누르거나 앱 실행 목록 버튼을 누르면 invisible로 변경된다
  • onFocusChanged() : 뷰의 포커스가 바뀌었을 때 호출되는 함수다. 크기, 색만 바뀌기 때문에 호출되지 않지만 체크박스, editText 같이 포커스가 잡혔냐 아니냐에 따른 처리가 필요한  뷰를 쓸 경우 이 함수를 활용할 수 있다

 

requestLayout()은 편해 보이는 함수지만 편해 보이는 함수일수록 조심해 써야 한다. 위에 썼듯 requestLayout()은 바뀐 자식 뷰를 위해 부모 뷰까지 일 시키는 함수다. 이 말은 전체 뷰 구조에 measure - layout - draw하는 과정을 다시 시작하라고 명령하는 것이기 때문에 빈번하게 호출하면 UI 성능 저하로 이어질 수 있다.

그래서 단순히 외관(=위치, 크기는 안 변하고 뷰가 그려진 결과, 내용)만 바뀐다면 invalidate()를 호출하는 것도 방법일 수 있다. invalidate()는 onMeasure, onLayout을 생략하고 바로 onDraw()를 호출하는 함수다.

실제로 위 예시 코드에서 requestLayout() 대신 invalidate()를 호출하도록 바꿔도 작동은 한다. 그러나 invalidate()는 뷰 모양이 바뀔 때 호출하고 requestLayout()은 크기, 위치가 바뀔 때 호출하기 때문에 굳이 따지면 requestLayout()을 써야 하는 게 맞다. 이 부분은 본인이 구현하려는 게 무엇인지에 맞춰서 적절히 사용하면 된다.

추가로 invalidate()는 메인 쓰레드에서 호출해야 한다. 메인 쓰레드 이외의 쓰레드에서 호출하려면 postInvalidate()를 사용한다.

 

이렇게 뷰의 생명주기 함수들을 간단하게 확인했지만 이외에도 여러 함수들이 있다. 그러나 정말 특별한 경우가 아니라면 이 함수들만 써도 충분히 원하는 커스텀 뷰를 구현할 수 있다고 생각된다.

반응형
Comments