Overview
안드로이드 앱을 개발하다 보면 일반적인 뷰로는 내가 원하는 결과를 만들 수 없는 경우가 발생합니다. 이런 경우 직접 ‘커스텀 뷰’를 만들어야 합니다. 오늘은 커스텀 뷰에 대해 정리해보겠습니다.
View
앱 실행 화면을 구성하는 요소의 통칭
위 그림에서 최상단에 위치하고 있는 뷰는 사용자 인터페이스를 구축하고 유저의 모든 입력 이벤트를 처리하는 기본적인 클래스입니다. 스크린의 직사각형 영역을 차지하며 해당 자식 요소들과 함께 측정, 배치, 그리는 역할을 합니다. ViewGroup은 하위(자식) 뷰를 포함하고 자체 레이아웃 속성을 정의할 수 있습니다.
커스텀 뷰는 아래와 같을 때 도움이 될 수 있습니다.
- 현재 일반적인 안드로이드 구성 요소로는 원하는 작용이나 애니메이션 또는 UI를 만들 수 없을 때
- 코드 재사용성을 위해
- nested view 등으로 성능 저하가 예상될 때
View가 그려지는 과정
커스텀 뷰의 핵심은 onMeasure, onDraw, onLayout 입니다. 도화지 크기를 선택하고(onMeasure), 어느 위치에(onLayout) 어떤 그림을 그릴지(onDraw) 설정해주면 커스텀 뷰는 완성됩니다.
우리가 화면에서 보는 View들도 특정한 과정을 거쳐서 화면에 그려지게 되는데 아래와 같이 그려지게 됩니다.
크게 보면 아래 3단계를 통해서 그려집니다.
크기 알아내자! (onMeasure) -> 위치 알아내자! (onLayout) -> 그리자! (onDraw)
보면 아래와 같이 나타나는데 on이 붙지 않은 메서드는 반드시 실행이 되어야 하는 메서드이고, on이 붙은 메서드는 override해서 재활용할 수 있는 메서드라고 생각하시면 됩니다. 그래서 보통 커스텀 뷰나 뷰그룹을 만들때에는 onXXX()메서드를 확장하여 만듭니다.
또한, View는 포커스를 얻게 되면 레이아웃의 루트 노드에서 시작하여 전위 순회로 그려집니다. 따라서 부모가 자식들보다 먼저 그려지고, 형제들은 트리에 나타난 순서대로 그려집니다.
1. Constructor
뷰는 최대 4개의 생성자를 가집니다.
1. View(Context context) :이 생성자는 프로그래밍적으로 뷰를 생성할 때 호출됩니다. 즉, XML 레이아웃을 사용하지 않고, 코드에서 직접 뷰를 생성할 때 사용됩니다.
val myCustomView = MyCustomView(context)
2. View(Context context, AttributeSet attrs) : XML 레이아웃 파일에서 커스텀 뷰를 정의할 때 호출됩니다. 이 생성자는 XML에 정의된 속성들을 처리하기 위해 사용됩니다.
<com.example.MyCustomView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:customAttribute="Some Value" />
3. View(Context context, AttributeSet attrs, int defStyleAttr) : XML에서 스타일을 지정했을 때 호출됩니다. 즉, style 속성을 사용하여 기본 스타일을 정의한 경우 이 생성자가 호출됩니다.
<com.example.MyCustomView
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/MyCustomViewStyle" />
4. View(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) : API 21 이상에서 스타일 리소스를 지정할 때 호출됩니다. defStyleAttr로 스타일 속성이 전달되고, defStyleRes는 해당 스타일 리소스의 ID를 전달받습니다.
<com.example.MyCustomView
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/MyCustomViewStyle" />
JvmOverloads
코틀린에서는 @JvmOverloads 어노테이션을 통해 생성자를 간편하게 선언할 수 있습니다.
class CustomTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
...
}
JvmOverloads를 이용하면 1개의 파라미터를 가지는 Constructor부터 n-parameter 생성자까지 만들어줍니다. API<21의 디바이스에서 JvmOverloads 어노테이션으로 4-parameter 생성자를 만들게 되면 StackOverflow를 야기할 수 있습니다.
2. onAttachedToWindow()
View가 Window에 붙었을때 호출되는 콜백 입니다.
- 부모 뷰가 addView() 를 호출함으로써 View 가 윈도우에 붙을 때 호출된다 (말 그대로)
- 고유 ID 를 통해 View 에 접근 가능해짐
- 이 순간부터는 뷰를 그리기 위한 surface 를 가짐
- 단, onDetachedFromWindow() 호출 이후에는 surface 가 없음
- 따라서 이 순간부터는 리소스 할당 및 리스너 설정 등이 가능해짐
3. onMeasure
- measure() 에서 호출하는 콜백 메소드 (View 의 크기를 측정하기 위해 호출됨)
- 부모 뷰의 경우에는 모든 자식 뷰들의 measure() 를 호출한 뒤 자신의 크기 결정
- setMeasuredDimenstion() 호출하여 명시적으로 너비와 높이 설정
이 메소드에서는 해당 커스텀 뷰의 사이즈를 지정해줘야 합니다. xml에서 유저가 설정한 width, height의 정보가 파라미터로 넘어옵니다. 우리는 MeasureSpec.getMode(~)를 통해 MATCH_PARENT, WRAP_CONTENT 또는 100dp와 같이 지정된 값인지 알 수 있습니다.
onMeasure은 여러 번 호출될 수 있습니다. 예를 들어 부모가 자식들의 각 크기를 측정한 뒤, 자식들의 크기의 합이 너무 크거나 작다면 다시 measure() 메소드를 호출하여 구체적인 값을 구합니다.
child view를 가지는 커스텀 뷰라면 child의 사이즈를 측정해서 자신의 사이즈를 재야 할 수도 있는데, 이 메소드에서 설정해주면 됩니다.
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
파라미터로 넘겨오는 widthMeasureSpec과 heightMeasureSpec은 뷰의 모드와 사이즈를 조합한 값입니다. 따라서 위와 같은 코드를 통해 모드와 사이즈를 알아낼 수 있습니다.
모드는 총 3가지로 나뉩니다.
- MeasureSpec.AT_MOST
보통 XML에서 wrap_content로 설정된 경우에 이 모드가 내려옵니다. 해당 값보다 더 클 수는 없습니다. 측정 과정이 다시 발생할 수 있습니다. - MeasureSpec.EXACTLY
보통 XML에서 match_parent 또는 직접 크기(ex. 500dp)를 설정된 경우에 이 모드가 내려옵니다. 측정 과정은 다시 발생하지 않습니다. - MeasureSpec.UNSPECIFIED
보통 XML에서 wrap_content로 설정된 경우에 이 모드가 내려옵니다. 즉, 정해져 있지 않은 값이며, 원하는 값을 설정할 수 있습니다. 측정 과정이 다시 발생할 수 있습니다.
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// Witdh와 Height의 Mode, Size 획득하기
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
var width = 0
var height = 0
// match_parent, fill_parent인 경우
if(widthMode == MeasureSpec.EXACTLY) {
width = widthSize
// wrap_content인 경우
} else if(widthMode == MeasureSpec.AT_MOST) {
width = 200
}
if(heightMode == MeasureSpec.EXACTLY) {
height = heightSize
} else if(heightMode == MeasureSpec.AT_MOST) {
height = 500
}
// 뷰의 크기 지정
setMeasuredDimension(width, height)
}
예를 들어, multi-line을 가지는 CustomTextView를 만든다고 할 때, 위와 같은 방법으로 onMeasure()을 사용할 수 있습니다. 따라서 multiline을 예상하여 height를 계산해야 합니다. 이는 getTextHeight() 함수를 구현하여 얻어오고, 뷰의 mode에 따라 알맞는 값을 선택해 setMeasuredDimension()을 호출해주었습니다.
4. onLayout
- layout() 에서 호출하는 콜백 메소드 (뷰의 크기와 위치 지정)
- 즉, 뷰의 크기와 위치를 지정하여 화면에 배치한 후에 호출함 (주로 부모 뷰일 때 호출)
- 아직 뷰가 그려지는 단계는 아님 (헷갈리지 말자!)
뷰의 위치를 설정해주는 함수입니다. 뷰의 child들의 크기와 위치를 할당해야 할 때 호출됩니다. 즉, child를 가지는 뷰라면 해당 메소드를 오버라이드 해주어야 합니다. 이때 파라미터로 넘어오는 값들은 어플리케이션 전체를 기준으로 넘어오는 위치값임을 알아야 합니다.
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
children?.forEachIndexed { index, view ->
view.layout(x, y x + view.measuredWidth, y + view.measuredHeight)
}
}
이 메소드는 일반적으로 View가 현재 범위 내에서 더 이상 맞지 않는다고 판단되면 자체적으로 호출되며, requestLayout()을 호출하여 레이아웃을 시작할 수도 있습니다.
5. dispatchDraw
- ViewGroup 에 속한 메소드
- 뷰가 다시 그려져야 할 경우에 자식 뷰들도 싹 다 다시 그려지도록 함
자신을 다 그리고 자식뷰들을 그리기 전에 호출이 됩니다. ViewGroup 의 경우에 자식뷰들을 그릴때 호출이 됩니다.
6. onDraw
- 실제로 뷰를 그리는 단계
- Canvas : 뷰의 모양을 그리는 객체
- Paint : 뷰의 색상을 칠하는 객체
- 크기와 위치는 이전에 계산되기 때문에 그것들을 기준으로 뷰를 그리게 됨
- 해당 콜백 메소드는 언제든 다시 호출될 수 있기 때문에, 이 안에서 객체 생성은 하면 안 됨
- 스크롤, 스와이프 등 인터랙션이 발생하면 언제든 호출될 수 있음
뷰에 그림을 그리는 메소드입니다. Paint 클래스를 통해 도형을 그릴 수도 있고, canvas에 텍스트를 추가할 수도 있습니다.
onDraw()에서는 많은 시간이 소요되거나 여러 번 호출될 수 있기 때문에(초당 60번) 되도록 객체 선언, 할당을 피하고 기존 객체를 재사용하는 것이 좋습니다. 이보다 가비지 컬렉터가 더 빨라서 GC 관련된 drop이 없을 수도 있지만, 이 동작 역시 별도의 스레드에서 진행되므로 배터리 소모를 야기할 수 있습니다. 또한, onDraw에서 초기화되는 객체들은 주로 drawing object인데, 이들은 많은 소멸자를 호출하기 때문에 성능에 영향을 줄 수 있습니다.
override fun onDraw(canvas: Canvas?) {
canvas?.let { canvas ->
canvas.drawColor(resources.getColor(R.color.gray100_trans))
val xPos = width / 2
val yPos = height / 2 - ((textPaint.descent()+textPaint.ascent())/2)
val staticLayout = StaticLayout(mText, textPaint, measuredWidth, alignment, spacingMultiplier, spacingAddition, false)
canvas.save()
canvas.translate(xPos.toFloat(), 0f)
staticLayout.draw(this)
canvas.restore()
}
}
multiline 텍스트를 화면에 그려야 한다면 위와 같이 onDraw()를 구성할 수 있습니다. canvas.save()를 호출하면 현재 캔버스의 설정을 스택에 저장하게 됩니다. 그다음 캔버스의 설정(translate 등)을 변경한 후 원하는 것을 그리고, 그리기가 끝났다면 canvas.restore()을 통해 이전 구성으로 캔버스를 복원할 수 있습니다.
7. invalidate()
- 글자나 색상 등 크기 변화는 없이 단순히 뷰의 속성 등이 변경되어 다시 그려야하는 경우 View 를 다시 그리기 위해 호출하는 메소드
뷰가 변경될 경우 invalidate()를 호출하여 dispatchDraw() 과정부터 다시 그립니다. 이전에 onMeasure()와 onLayout()을 통해 알게 된 크기와 위치 정보는 그대로 이용합니다. 예를 들어 뷰의 text 또는 color가 변경되거나, touch interactivity가 발생할 때 onDraw()함수를 재호출하면서 뷰를 업데이트합니다.
8. requestLayout()
- 위에서 크기 변화 없이라고 했는데, 만약 뷰의 크기 변화가 발생할 경우 레이아웃의 배치도 달라질 수 있기 때문에 해당 메소드를 호출함으로써 뷰들의 크기 측정부터 다시하게 됨
레이아웃이 변경되어 이전에 onMeasure()와 onLayout()을 통해 알게 된 크기와 위치 정보는 그대로 이용하지 못할 경우 requestLayout()을 호출하여 onMeasure()와 onLayout()을 통해 변경된 크기와 위치 정보를 얻습니다. 뷰의 사이즈가 변경될 때 그것을 다시 재측정해야 하기에 lifecycle을 onMeasure()부터 순회하면서 뷰를 그립니다.
9. onDetachedFromWindow()
- View가 Window에서 분리될 때 호출됩니다. 이 시점에서 더 이상 드로잉을 할 표면이 없습니다. 예약된 자원을 정리하거나 정리하는 모든 종류의 작업을 중지해야 하는 곳입니다.
이 메소드는 ViewGroup에서 View 제거를 호출하거나 액티비티가 Destroyed 될 때 호출됩니다.
10. OnFinishInflate()
- 이 메소드는 View가 전개가 끝날 때 호출됩니다. 레이아웃의 경우 모든 Child View가 추가된 후에 호출되는 것입니다.
성능 최적화
- onDraw()에서 객체 생성 및 할당을 줄이고 작업도 최소한으로 해야합니다.
- 이유: onDraw() 메서드는 뷰가 화면에 그려질 때마다 호출되므로, 이곳에서 객체를 생성하거나 메모리를 할당하면 매번 가비지 컬렉션이 발생할 수 있습니다. 이는 성능 저하와 지연을 초래할 수 있습니다.
- 최적화 방법: 필요한 객체를 미리 생성하여 재사용하고, onDraw()에서는 최소한의 연산만 수행하도록 하여 그리기 성능을 개선할 수 있습니다. 이를 통해 프레임 레이트를 유지하고 UI 반응성을 높일 수 있습니다.
- invalidate()와 requestLayout()의 호출을 최대한 자제해야 합니다.
- 이유: invalidate()는 뷰를 다시 그리도록 요청하며, requestLayout()은 레이아웃을 다시 측정하고 배치하도록 요청합니다. 두 메서드 모두 CPU와 GPU 리소스를 소모하므로 빈번하게 호출되면 성능 저하를 초래합니다.
- 최적화 방법: 이러한 메서드는 상태 변화가 있을 때만 호출하고, 필요 없는 경우 호출을 자제하여 UI 부하를 줄이는 것이 중요합니다. 이렇게 하면 레이아웃 측정 및 그리기 과정에서 불필요한 작업을 줄일 수 있습니다.
- ViewGroup의 계층 구조는 최대한 얇게 만들어야 합니다.
- 이유: 깊은 ViewGroup 계층 구조는 레이아웃 측정과 배치 과정에서 더 많은 계산을 요구합니다. 각 뷰가 자신의 위치와 크기를 결정해야 하므로, 계층이 복잡할수록 성능이 저하됩니다.
- 최적화 방법: 불필요한 중첩을 피하고 얇은 계층 구조를 유지하는 것이 좋습니다. 이렇게 하면 레이아웃 처리 속도를 개선하고, UI의 반응성을 높일 수 있습니다.
참고자료
https://developer.android.com/guide/topics/ui/how-android-draws.html
Android에서 뷰를 그리는 방법 | Views | Android Developers
활동이 포커스를 받으면 레이아웃을 그리라는 요청을 받습니다. Android 프레임워크에서 그리기 절차를 처리하지만 활동에서 레이아웃 계층 구조의 루트 노드를 제공해야 합니다. 그리기는 레이
developer.android.com
안드로이드 커스텀뷰, 커스텀 뷰그룹 만들기 | Jungwoon Blog
안드로이드 커스텀뷰, 커스텀 뷰그룹 만들기 | Jungwoon Blog
최근에 직접 커스텀뷰 및 커스텀 뷰 그룹을 만들어야 하는 업무를 맡으면서 그 부분에 대해서 다시 정리할겸 이렇게 포스팅을 하게 되었습니다. View가 그려지는 과정 우리가 화면에서 보는 View
jungwoon.github.io
CustomView 이해하기
Overview
labs.brandi.co.kr
[안드로이드] 커스텀 뷰 작성
안드로이드 - 커스텀 뷰
velog.io
[안드로이드 스튜디오] View뷰가 뭐야? 레이아웃은 뭐야? 빠르게 보자
안드로이드 스튜디오를 처음 공부할 때 모든 것을 뷰(View)라고 통칭해 마음에 안 들 때가 있다. 도대체 뷰가 뭐야? 이유가 있었다 뷰 : 앱 실행 화면을 구성하는 요소의 통칭 앱을 실행하면 화면
pabu.tistory.com
[Android] View 의 한 평생 살펴보기
너무 아름다운 다운 다운 다운 View (죄송합니다)
velog.io
'Android > View' 카테고리의 다른 글
[Android] RecyclerView의 원리 및 내부 동작 (1) | 2025.01.23 |
---|---|
[Android] View에 대하여 (2) - CustomView 만들기 (0) | 2024.10.27 |
[Android] 리싸이클러뷰(RecyclerView) (3) - 성능 최적화 (DiffUtil) (0) | 2024.10.25 |
[Android] 리싸이클러뷰(RecyclerView) (2) - Multiple View Type (0) | 2024.10.24 |
[Android] 리싸이클러뷰(RecyclerView) (1) - 기본 개념 (0) | 2024.10.24 |