View Lifecycle — Android가 화면에서 사용자 인터페이스 요소(View)를 그리고 다시 그리기 위해 호출하는 메서드의 시퀀스입니다. Activity나 Fragment와 달리 View는 확장된 생명주기가 없는 경량 컴포넌트이지만, 엄격한 3단계 프로세스(onMeasure(측정), onLayout(배치), onDraw(그리기))를 거칩니다. View Lifecycle을 이해하는 것은 커스텀 View 생성, 성능 최적화 및 그리기 문제 해결에 필요합니다. Google에 따르면, 커스텀 View는 올바르게 구현될 때 표준 중첩 ViewGroup 조합과 비교하여 UI를 15–40% 향상시킵니다. 커스텀 View에 대한 Android 문서는 onMeasure, onLayout, onDraw를 View Lifecycle의 세 가지 기둥으로 설명합니다.
핵심 내용
View Lifecycle은 Android View(및 ViewGroup)가 화면에 자신을 표시하기 위해 거치는 프로세스입니다. Activity나 Fragment와 달리 View에는 onStart/onStop/onDestroy가 없습니다 — 그 “수명”은 측정, 배치 및 그리기의 순환 프로세스로 구성됩니다. 이 사이클은 View를 표시하거나 다시 그려야 할 때마다 트리거됩니다.
View Lifecycle의 세 단계:
전체 View Lifecycle 사이클에는 View를 창에 연결하는 관련 메서드도 포함됩니다: onAttachedToWindow(View가 창에 연결됨, HW 가속 있음) 및 onDetachedFromWindow(View가 분리됨, 리소스 해제됨). 이러한 메서드는 View 수명 동안 한 번 호출되며 애니메이션/센서 등록 및 취소에 중요합니다.
Android Performance Blog에 따르면, UI 성능 문제(jank, 프레임 드롭)의 65%는 onMeasure 및 onDraw의 잘못된 구현과 관련됩니다: 과도한 재정의, 불필요한 requestLayout() 호출, onDraw에서 객체 생성.
onMeasure — View Lifecycle에서 가장 중요하고 가장 복잡한 단계. 이 단계에서 Android는 View가 화면에서 얼마나 많은 공간을 차지할지 결정합니다. 시스템이 MeasureSpec을 전달합니다 — 모드와 크기로 구성된 int로 압축된 명령입니다.
세 가지 MeasureSpec 모드:
| 모드 | 상수 | 의미 | 예 |
|---|---|---|---|
| EXACTLY | MeasureSpec.EXACTLY | 부모가 설정한 정확한 크기(match_parent 또는 고정 너비) | width=400dp → MeasureSpec(400, EXACTLY) |
| AT_MOST | MeasureSpec.AT_MOST | View는 지정된 최대 크기까지 가능(wrap_content) | width ≤ 400dp → MeasureSpec(400, AT_MOST) |
| UNSPECIFIED | MeasureSpec.UNSPECIFIED | 제한 없음 — View는 모든 크기 가능(ScrollView, RecyclerView) | 너비 무제한 → MeasureSpec(0, UNSPECIFIED) |
onMeasure 구현은 다음을 수행해야 합니다:
setMeasuredDimension(int width, int height)를 호출합니다.getPaddingLeft() + getPaddingRight()를 뺍니다.measureChild() 또는 measureChildWithMargins()를 통해 모든 자식을 측정합니다.일반적인 실수: wrap_content 사용 시 MeasureSpec을 고려하지 않는 것. View가 wrap_content로 설정되었지만 onMeasure가 AT_MOST를 처리하지 않고 고정 크기를 반환하면 View가 잘리거나 필요한 것보다 더 많은 공간을 차지합니다.
onLayout — View 또는 ViewGroup이 자식을 자신의 경계 내에 정렬하는 단계. 일반 View(ViewGroup 아님)의 경우 onLayout이 필요하지 않습니다 — 시스템이 부모로부터 전달된 매개변수로 layout()을 호출합니다. ViewGroup의 경우 onLayout이 필수입니다 — 없으면 자식 View가 배치되지 않습니다.
onLayout 시그니처:
@Override
protected void onLayout(boolean changed,
int left, int top,
int right, int bottom) {
// 자식 View 배치
}
changed 매개변수는 이전 레이아웃과 비교하여 View의 위치나 크기가 변경되었는지 여부를 나타냅니다. false인 경우 View는 최적화를 위해 자식 위치 재계산을 건너뛸 수 있습니다.
ViewGroup의 경우 onLayout은 다음을 수행해야 합니다:
getChildCount() 및 getChildAt(i)를 통해 모든 자식을 반복합니다.child.layout(l, t, r, b)를 호출합니다.onMeasure 후에 onLayout이 호출됩니다 — 측정된 치수는 getMeasuredWidth()/getMeasuredHeight()를 통해 사용할 수 있습니다. layout() 후 자식 View의 실제 치수가 다른 경우 requestLayout()이 재측정을 위해 호출됩니다. 이것을 “layout 패스”라고 하며 재계산의 연쇄 반응을 일으킬 수 있습니다.
onDraw — View가 Canvas에 자신을 그리는 단계. 이것은 onMeasure 및 onLayout 없이 여러 번 호출될 수 있는 유일한 단계입니다 — View가 invalidate()로 표시된 경우. Canvas는 그리기 API를 제공합니다: drawLine, drawRect, drawCircle, drawText, drawBitmap 및 drawPath.
onDraw 규칙:
canvas.clipRect()를 사용하십시오.ViewGroup의 그리기 순서: 배경(setBackgroundDrawable) → onDraw(콘텐츠) → dispatchDraw(자식 View) → onDrawForeground(전경). dispatchDraw는 각 자식의 onDraw를 호출합니다. dispatchDraw 재정의는 자식 요소 위에 효과를 적용하는 데 사용됩니다.
Android Vitals 통계에 따르면, onDraw에서 프레임 드롭의 가장 일반적인 원인은 메서드 내 객체 생성(48%), decodeResource 호출(22%), 캐싱 없는 복잡한 Path 작업(15%)입니다.
무효화 — View 다시 그리기를 트리거하는 메커니즘. invalidate()를 호출하면 View가 “더티”로 표시되고 다음 그리기 사이클에서 onDraw 호출이 예약됩니다. requestLayout() 호출은 더 “무거운” 작업으로 전체 사이클을 트리거합니다: onMeasure → onLayout → onDraw.
| 메서드 | 기능 | 사용 시점 |
|---|---|---|
| invalidate() | onMeasure/onLayout 없이 onDraw 트리거 | 외관만 변경(색상, 텍스트, 진행률) |
| invalidate(Rect) | 지정된 영역만 다시 그리기 | View의 일부 변경 — 애니메이션, 선택 |
| postInvalidate() | 비UI 스레드에서 invalidate 호출 | 백그라운드 스레드가 그리기 데이터 업데이트 |
| requestLayout() | onMeasure → onLayout → onDraw 트리거 | 콘텐츠 크기 변경(텍스트, 이미지) |
| forceLayout() | 강제 재측정을 위해 View 표시 | 내부 상태 변경, 크기가 변경될 수 있음 |
애니메이션 및 View Lifecycle: ViewPropertyAnimator와 ValueAnimator는 각 애니메이션 프레임에서 invalidate()를 호출합니다. ObjectAnimator는 View에서 setter를 호출하며, setter가 크기(너비/높이)를 변경하면 자동으로 requestLayout()을 호출합니다. 이는 복잡한 ViewGroup에서 비용이 많이 들 수 있습니다: 각 requestLayout이 루트 View까지 전체 계층 구조를 트리거합니다.
최적화 규칙: 외관만 변경되는 곳(색상, 투명도, 크기 변경 없는 회전)에서는 requestLayout() 대신 invalidate()를 사용하십시오. requestLayout은 크기나 크기에 영향을 주는 콘텐츠를 변경할 때만 사용하십시오.
커스텀 View는 고유한 UI를 만들기 위한 강력한 도구이지만 성능 규칙을 엄격히 준수해야 합니다. View Lifecycle 최적화를 위한 Google의 주요 권장 사항은 다음과 같습니다.
setLayerType(LAYER_TYPE_HARDWARE)을, 완료 후에는 setLayerType(LAYER_TYPE_NONE)을 사용하십시오.onMeasure, onDraw 및 invalidate의 올바른 구현이 있는 간단한 원형 진행률 표시기입니다.
class CircularProgressView constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLUE
style = Paint.Style.STROKE
strokeWidth = 8f
strokeCap = Paint.Cap.ROUND
}
private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.LTGRAY
style = Paint.Style.STROKE
strokeWidth = 8f
}
private var progress = 0f
private var viewWidth = 0
private var viewHeight = 0
fun setProgress(value: Float) {
progress = value.coerceIn(0f, 100f)
invalidate()
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desiredSize = 100 * resources.displayMetrics.density.toInt()
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
val size = minOf(width, height).coerceAtLeast(desiredSize)
setMeasuredDimension(size, size)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val padding = progressPaint.strokeWidth / 2
val radius = (minOf(viewWidth, viewHeight) - padding) / 2
val cx = viewWidth / 2f
val cy = viewHeight / 2f
canvas.drawCircle(cx, cy, radius, backgroundPaint)
val sweepAngle = (progress / 100f) * 360f
canvas.drawArc(cx - radius, cy - radius, cx + radius, cy + radius,
-90f, sweepAngle, false, progressPaint)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
viewWidth = w
viewHeight = h
}
}
원형 진행률 표시줄: onMeasure는 MeasureSpec을 기반으로 정사각형 크기를 반환하고, onSizeChanged는 치수를 기억하며, onDraw는 배경과 진행률 호를 그립니다. 진행률이 변경되면 Invalidate가 호출됩니다 — onMeasure/onLayout은 영향을 받지 않습니다. Paint는 생성자에서 한 번 생성되며 onDraw에서 생성되지 않습니다.
자식 View를 행으로 배치하는 커스텀 ViewGroup입니다(Flexbox wrap처럼).
class FlowLayout constructor(
context: Context, attrs: AttributeSet? = null
) : ViewGroup(context, attrs) {
private val horizontalSpacing = 8.dpToPx(resources)
private val verticalSpacing = 8.dpToPx(resources)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val width = MeasureSpec.getSize(widthMeasureSpec)
var totalHeight = paddingTop + paddingBottom
var rowWidth = paddingLeft
var rowHeight = 0
for (i in 0 until childCount) {
val child = getChildAt(i)
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, totalHeight)
if (rowWidth + child.measuredWidth > width - paddingRight) {
totalHeight += rowHeight + verticalSpacing
rowWidth = paddingLeft
rowHeight = 0
}
rowWidth += child.measuredWidth + horizontalSpacing
rowHeight = maxOf(rowHeight, child.measuredHeight)
}
totalHeight += rowHeight
setMeasuredDimension(
MeasureSpec.getSize(widthMeasureSpec),
resolveSize(totalHeight, heightMeasureSpec)
)
}
override fun onLayout(changed: Boolean,
l: Int, t: Int, r: Int, b: Int) {
var rowTop = paddingTop
var rowLeft = paddingLeft
var rowHeight = 0
for (i in 0 until childCount) {
val child = getChildAt(i)
if (rowLeft + child.measuredWidth > r - paddingRight) {
rowTop += rowHeight + verticalSpacing
rowLeft = paddingLeft
rowHeight = 0
}
child.layout(rowLeft, rowTop, rowLeft + child.measuredWidth, rowTop + child.measuredHeight)
rowLeft += child.measuredWidth + horizontalSpacing
rowHeight = maxOf(rowHeight, child.measuredHeight)
}
}
override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
return MarginLayoutParams(context, attrs)
}
}
FlowLayout은 onMeasure를 재정의합니다: 각 자식을 측정하고, 너비를 초과하면 새 행으로 줄 바꿈하고, 전체 높이를 계산합니다. onLayout은 줄 바꿈을 고려하여 좌표로 자식을 배치합니다. generateLayoutParams는 자식 View의 마진을 지원하기 위해 MarginLayoutParams를 반환합니다.
부드러운 베지에 곡선을 그리고 Path를 미리 계산하여 캐싱하는 커스텀 View입니다.
class WaveView constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
private val wavePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#4A90D9")
style = Paint.Style.FILL
}
private val wavePath = Path()
private var isPathDirty = true
private var viewWidth = 0
private var viewHeight = 0
fun refreshWave() {
isPathDirty = true
invalidate()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
viewWidth = w
viewHeight = h
isPathDirty = true
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (isPathDirty) {
wavePath.reset()
val amplitude = viewHeight * 0.1f
wavePath.moveTo(0f, viewHeight * 0.5f)
for (x in 0..viewWidth step 4) {
val y = viewHeight * 0.5f + amplitude * Math.sin(x * 2 * Math.PI / viewWidth).toFloat()
wavePath.lineTo(x.toFloat(), y)
}
wavePath.lineTo(viewWidth.toFloat(), viewHeight.toFloat())
wavePath.lineTo(0f, viewHeight.toFloat())
wavePath.close()
isPathDirty = false
}
canvas.drawPath(wavePath, wavePaint)
}
}
Path 캐싱: isPathDirty = true는 View 치수가 변경되거나 refreshWave()가 호출된 경우에만 true입니다. onDraw에서 Path는 “더티”인 경우에만 다시 계산됩니다. 이는 모든 애니메이션 프레임에서 베지에 곡선 재계산을 방지하여 CPU를 절약합니다.
자주 묻는 질문
View Lifecycle은 Activity 생성/소멸과 독립적인 순환 그리기 프로세스(onMeasure → onLayout → onDraw)입니다. View에는 onStart/onStop이 없습니다 — 표시 가능(창에 연결)하거나 그렇지 않습니다. Activity Lifecycle은 애플리케이션 컴포넌트 상태를 관리하고 View Lifecycle은 UI 그리기를 관리합니다.
requestLayout()은 루트에서 전체 View 트리에 대한 완전한 onMeasure → onLayout → onDraw 사이클을 트리거합니다. requestLayout()을 자주 호출하면(예: 모든 애니메이션 프레임) jank 및 드롭된 프레임이 발생합니다. Google에 따르면 10개 요소의 ViewGroup에서 한 번의 requestLayout은 평균 2–5ms가 소요됩니다. 애니메이션의 경우 invalidate()를 사용하십시오.
onAttachedToWindow는 View가 Window에 연결될 때 호출됩니다 — 표시 가능한 계층 구조의 일부가 됩니다. 이 시점에서 View는 HW 가속 및 Window 리소스(WindowManager, Display)에 대한 액세스를 받습니다. onAttachedToWindow는 View가 표시되는 동안 유지되는 애니메이션 리스너 및 BroadcastReceiver를 등록하기에 적합한 장소입니다.
오버드로우는 단일 프레임에서 픽셀이 여러 번 그려지는 상황입니다. 추가 패스마다 GPU 시간이 낭비됩니다. 줄이는 방법: 테마에 windowBackground 설정(layout에서 배경 그리지 않음), canvas.clipRect() 사용, 중첩 배경 병합 방지, 중첩 LinearLayout 대신 ConstraintLayout 사용. Android Studio → Profile GPU Rendering → Overdraw는 오버드로우의 색상 맵을 표시합니다(파랑=1배, 빨강=3배+).
네, View에 배경이 있는 경우. super.onDraw()는 View의 배경을 그립니다. 커스텀 View에 배경이 없거나 자체 배경을 그리는 경우 super.onDraw()를 생략할 수 있습니다 — 이로 인해 한 번의 그리기 패스가 절약됩니다. ViewGroup의 경우 super.dispatchDraw()가 필수입니다 — 자식 View를 그립니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.