[Android] View 의 한 평생 살펴보기

H43RO·2021년 10월 15일
9

Android 와 친해지기

목록 보기
19/26
post-thumbnail
post-custom-banner

View

안드로이드는 UI 를 구성하기 위해 사용되는 녀석이다. 우리가 XML 상으로 구성했던 거의 모든 UI 요소들의 조상 객체는 바로 View 인 것이다. 아래 이미지와 같이, '어 혹시 걔도?' 싶은 애들은 모두 View 의 서브 클래스들이다.

View드로잉, 이벤트 처리를 담당하는 UI 구성요소의 기본 클래스이다. View 를 상속받아 구현하는 TextView, Button 등 어떤 특수 목적을 가지고 있는 View 를 위젯, 컴포넌트라고 부르기도 한다.

눈치 챈 사람도 있겠지만, 새로운 위젯을 만들기 위해선 View 를 반드시 상속하여 구현해야 한다. 그리고 그러한 위젯들을 담는 부모 뷰, 즉 Layout 역시 View 를 상속받는 ViewGroup 을 상속받아 구현한다.


View 는 이렇게 그려져요

안드로이드에서 사용자에게 보여지는 화면, 즉 사용자와 인터랙션하는 컴포넌트는 Activity 라고 다들 알고 있을 것이다. 액티비티는 포커스를 받게 되면 Android 에게 View Hierarchy 의 루트 노드를 제공하여 레이아웃을 그리게 된다.

onCreate() 내에서 setContentView() 를 통해 루트 노드 전달

레이아웃은 루트 노드부터 리프 노드까지 트리를 따라 순서대로 그려지게 된다. 부모가 먼저 그려지고 그 다음 자식들 순서대로 그려지는 형태이다. (Top-down 방식)

  • 부모 뷰 (ViewGroup, Layout) 은 자식 뷰들의 draw() 를 호출하여 화면에 지정된 형태로 자식 뷰들을 그려줄 것을 요청
  • 모든 각각의 뷰들은 지 알아서 스스로 그려질 책임이 있음 (마마보이 금지)

레이아웃을 그리는 과정은 Measure, Layout 이렇게 크게 두 단계를 거친다.

1. Measure 단계

  • measure() 메소드 호출을 통해 이루어짐
  • 모든 뷰들은 각각 자신의 크기 측정값을 저장함 (너비와 높이)
  • 뷰의 measure() 가 반환되면, getMeasuredWidth()getMeasuredHeight() 값을 자식 뷰들의 값과 함께 설정해야 함
  • 부모 뷰는 자식들에게 두 번 이상의 measure() 를 호출할 수도 있음
    (자식 뷰들의 크기 합이 너무 크거나, 너무 작을 때와 같은 상황)

이러한 측정 단계에서는 아래와 같은 두 클래스를 사용하곤 한다.

ViewGroup.LayoutParams

자식 뷰가 부모 뷰에게 '나 이렇게 측정해줭' 하고 알리는 수단이다.

  • 정확한 숫자값 : DP 값 등 (정해인 나오는 D.P 아님 ㅈㅅ)
  • MATCH_PARENT : 부모 뷰 크기에 꽉 맞추겠다
  • WRAP_CONTENT : 자신의 내용물 크기에 꽉 맞추겠다

ViewGroup.MeasureSpecs

부모 뷰가 자식 뷰의 크기 제한을 둘 때 사용한다.

  • UNSPECIFIED : 자식 뷰 크기 제한 X
  • EXACTLY : 자식 뷰의 정확한 사이즈 설정 (자식 뷰는 해당 사이즈 내에서 자신의 자식 뷰도 맞춰야 함)
  • AT_MOST : 자식 뷰의 최대 사이즈 설정

2. Layout 단계

  • layout() 메소드 호출을 통해 이루어짐
  • 부모 뷰는 Measure 단계에서 측정된 크기를 사용하여 모든 자식 뷰들의 위치를 배정함
  • 즉, Measure 때 모아놓은 크기 수치값을 기준으로 전체적인 레이아웃을 딱 그리는 과정

이렇듯 뷰는 그려지기 전부터 화면에 온전히 표시되기까지 생명주기가 존재한다. 안드로이드에서 CustomView 를 직접 만들거나, 화면상 Layout 이 어떻게 그려지게 되는지에 대한 이해를 위해선 View 의 생명주기에 대해 빠삭하게 이해할 필요가 있다.

앱을 개발하다보면 기본적으로 제공되는 위젯에서 더 나아가 특색있는 기능을 갖고 있는 뷰를 만들거나, 특이한 형태의 뷰가 계속하여 재사용될 때 생산성을 위해 CustomView 를 자주 만들게 된다. 그러나 View 의 생명주기도 모른채 마구잡이로 만들었다간 어떤 대참사가 발생할지 모른다.


View Lifecycle

부모 뷰가 addView() 를 호출하게 되면, 뷰의 생애는 본격적으로 시작된다.

그럼 위 라이프사이클대로 하나씩 따라가며 각각의 메소드가 어떤 역할을 수행하게 되는지에 대해 알아보도록 하자.

1. constructor()

  • 모든 뷰는 생성자에 의해 생명 주기가 시작됨 (AttributeSet 을 갖게 됨)
  • addView() 메소드를 갖게 됨

2. onAttachedToWindow()

  • 부모 뷰가 addView() 를 호출함으로써 View 가 윈도우에 붙을 때 호출된다 (말 그대로)
  • 고유 ID 를 통해 View 에 접근 가능해짐
  • 이 순간부터는 뷰를 그리기 위한 surface 를 가짐
    • 단, onDetachedFromWindow() 호출 이후에는 surface 가 없음
      액티비티 onDestroyed() 호출될 때, 혹은 부모 뷰에서 해당 뷰를 제거할 때 호출
  • 따라서 이 순간부터는 리소스 할당 및 리스너 설정 등이 가능해짐

3. onMeasure()

  • measure() 에서 호출하는 콜백 메소드 (View 의 크기를 측정하기 위해 호출됨)
    • 부모 뷰의 경우에는 모든 자식 뷰들의 measure() 를 호출한 뒤 자신의 크기 결정
    • setMeasuredDimenstion() 호출하여 명시적으로 너비와 높이 설정

글의 서두에서 다뤘던 내용을 다시 살펴보자.

ViewGroup.MeasureSpecs

onMeasure() 단계에서 부모 뷰가 자식 뷰의 크기 제한을 둘 때 사용한다.

  • UNSPECIFIED : 자식 뷰 크기 제한 X
  • EXACTLY : 자식 뷰의 정확한 사이즈 설정 (자식 뷰는 해당 사이즈 내에서 자신의 자식 뷰도 맞춰야 함)
  • AT_MOST : 자식 뷰의 최대 사이즈 설정

해당 MeasureSpecs 를 활용하여 자식 뷰들의 크기 제한을 명시한다.

아래는 실제 onMeasure() 코드인데, 파라미터 두 개가 각각

  • widthMeasureSpec : 부모 뷰에 의해 적용된 수평 공간 제약사항
  • heightMeasureSpec : 부모 뷰에 의해 적용된 수직 공간 제약사항

이렇게 정의된다. 코드를 잠시 살펴보자.

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)

    // 지정한 MeasureSpec 에 따라 Mode 를 가져옴
    val widthMode = MeasureSpec.getMode(widthMeasureSpec)
    val widthSize = MeasureSpec.getSize(widthMeasureSpec)
    val heightMode = MeasureSpec.getMode(heightMeasureSpec)
    val heightSize = MeasureSpec.getSize(heightMeasureSpec)

    // 가져온 Mode 를 체크하여 뷰의 크기를 적용함
    val width = when (widthMode) {
        MeasureSpec.EXACTLY -> widthSize
        MeasureSpec.AT_MOST -> (paddingLeft + paddingRight + suggestedMinimumWidth)
            .coerceAtMost(widthSize)
        else -> widthMeasureSpec
    }

    val height = when (heightMode) {
        MeasureSpec.EXACTLY -> heightSize
        MeasureSpec.AT_MOST -> (paddingTop + paddingBottom + suggestedMinimumHeight)
            .coerceAtMost(heightSize)
        else -> heightMeasureSpec
    }

    setMeasuredDimension(width, height)  // 명시적으로 너비와 높이 설정
}

마지막에는 어떤 값을 반환하는게 아닌, setMeasuredDimension() 를 호출함으로써 측정된 너비와 높이 값을 명시적으로 설정하는 모습을 확인해볼 수 있다.

4. onLayout()

  • layout() 에서 호출하는 콜백 메소드 (뷰의 크기와 위치 지정)
  • 즉, 뷰의 크기와 위치를 지정하여 화면에 배치한 후에 호출함 (주로 부모 뷰일 때 호출)
  • 아직 뷰가 그려지는 단계는 아님 (헷갈리지 말자!)

5. dispatchToDraw()

  • ViewGroup 에 속한 메소드
  • 뷰가 다시 그려져야 할 경우에 자식 뷰들도 싹 다 다시 그려지도록 함

6. onDraw()

  • 실제로 뷰를 그리는 단계
    • Canvas : 뷰의 모양을 그리는 객체
    • Paint : 뷰의 색상을 칠하는 객체
  • 크기와 위치는 이전에 계산되기 때문에 그것들을 기준으로 뷰를 그리게
  • 해당 콜백 메소드는 언제든 다시 호출될 수 있기 때문에, 이 안에서 객체 생성은 하면 안 됨
    • 스크롤, 스와이프 등 인터랙션이 발생하면 언제든 호출될 수 있음
override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)

    val width = measuredWidth + 0.0f
    val height = measuredHeight + 0.0f

    val circle = Paint()
    circle.color = this.lineColor
    circle.strokeWidth = 10f
    circle.isAntiAlias = false
    circle.style = Paint.Style.STROKE

    canvas?.drawArc(
        RectF(
            10f, 10f, width - 10f, height - 10f
        ), -90f,
        (this.curValue + 0.0f) / (this.maxValue + 0.0f) * 360, false, circle
    )

    val textp = Paint()
    textp.color = Color.BLACK
    textp.textSize = 30f
    textp.textAlign = Paint.Align.CENTER

    if (System.currentTimeMillis() / 1000 % 2 == 0L) {
        canvas?.drawText(
            "${this.curValue} / ${this.maxValue}",
            (width / 2),
            (height / 2),
            textp
        )
    }

    Observable.interval(1, TimeUnit.SECONDS)
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe({
            invalidate()
        }, {

        })
        .addTo(disposable)
}

6-1. invalidate()

  • 글자나 색상 등 크기 변화는 없이 단순히 뷰의 속성 등이 변경되어 다시 그려야하는 경우 View 를 다시 그리기 위해 호출하는 메소드

6-2. requestLayout()

  • 위에서 크기 변화 없이라고 했는데, 만약 뷰의 크기 변화가 발생할 경우 레이아웃의 배치도 달라질 수 있기 때문에 해당 메소드를 호출함으로써 뷰들의 크기 측정부터 다시하게 됨

참고자료

https://www.charlezz.com/?p=29013

profile
어려울수록 기본에 미치고 열광하라
post-custom-banner

1개의 댓글

comment-user-thumbnail
2022년 5월 26일

좋아요❤

답글 달기