안드로이드 개발을 하다 보면 커스텀 View를 만들거나 성능 최적화를 해야 하는 경우가 종종 있다. 이때 View의 생명주기를 제대로 이해하지 못하면 메모리 누수나 성능 문제가 발생할 수 있다. 이번 글에서는 안드로이드 View의 생명주기와 실무에서 자주 마주치는 문제들에 대해 정리해보려 한다.
안드로이드 View는 생성부터 화면에 표시되기까지 여러 단계를 거친다. 각 단계는 명확한 목적을 가지고 있으며, 적절한 시점에 적절한 작업을 수행하는 것이 중요하다.
View 객체가 생성되는 첫 번째 단계이다. XML 레이아웃에서 인플레이트되거나 코드에서 직접 생성될 때 호출된다. 이 단계에서는 기본적인 초기화만 수행하는 것이 좋다.
class CustomView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
// 기본 속성만 초기화
private val paint = Paint()
}
View가 Window에 연결되는 시점이다. 이 단계부터 View는 실제로 화면에 그려질 준비가 된다. 여기서 중요한 것은 이 시점이 리소스를 초기화하기에 가장 적절한 타이밍이라는 것이다.
View의 크기를 측정하는 단계이다. 부모 View로부터 받은 크기 제약(MeasureSpec)을 바탕으로 자신의 크기를 결정한다. 커스텀 View를 만들 때 onMeasure()를 오버라이드하여 원하는 크기 계산 로직을 구현할 수 있다.
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desiredWidth = 200.dpToPx()
val desiredHeight = 200.dpToPx()
val width = resolveSize(desiredWidth, widthMeasureSpec)
val height = resolveSize(desiredHeight, heightMeasureSpec)
setMeasuredDimension(width, height)
}
측정된 크기를 바탕으로 View의 실제 위치를 결정하는 단계이다. ViewGroup의 경우 이 단계에서 자식 View들의 위치도 함께 배치한다.
실제로 View를 그리는 단계이다. Canvas 객체를 사용하여 화면에 원하는 그래픽을 그린다. 커스텀 View의 핵심 로직이 들어가는 부분이다.
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// Canvas를 사용한 그리기 작업
canvas.drawCircle(centerX, centerY, radius, paint)
}
View가 화면과 부모 ViewGroup에서 제거될 때(가령, Activity 또는 Fragment가 소멸 중일 때), onDetachedFromWindow() 메서드가 호출된다. 이 단계는 리소스를 정리하거나 리스너를 분리하는 데 사용된다.
View를 다시 그려야 할 때 호출하는 메서드이다. onDraw()만 다시 실행되므로 비교적 가벼운 작업이다. UI 업데이트가 필요할 때 주로 사용한다.
View의 크기나 위치가 변경되어 measure()부터 전체 과정을 다시 실행해야 할 때 사용한다. invalidate()보다 비용이 크므로 정말 필요한 경우에만 호출해야 한다.
커스텀 View를 만들 때 가장 흔하게 겪는 문제는 메모리 누수이다. 특히 이미지 로딩이나 애니메이션 같은 비용이 큰 작업을 다룰 때 주의해야 한다.
초기화는 onAttachedToWindow()에서, 정리는 onDetachedFromWindow()에서 수행하는 것이 기본 원칙이다.
class CustomImageView : View {
private var animator: ValueAnimator? = null
private var bitmap: Bitmap? = null
override fun onAttachedToWindow() {
super.onAttachedToWindow()
// View가 Window에 연결될 때 리소스 초기화
loadImage()
startAnimation()
}
private fun startAnimation() {
animator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 1000
repeatCount = ValueAnimator.INFINITE
addUpdateListener {
invalidate()
}
start()
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
// View가 Window에서 분리될 때 리소스 정리
animator?.cancel()
animator = null
bitmap?.recycle()
bitmap = null
}
}
이렇게 대칭적으로 리소스를 관리하면 메모리 누수를 효과적으로 방지할 수 있다.
생성자나 init 블록에서 리소스를 초기화하면 View가 실제로 사용되지 않는 상황에서도 리소스를 점유하게 된다. 특히 RecyclerView처럼 View를 재활용하는 경우 불필요한 리소스 낭비가 발생할 수 있다.
복잡한 레이아웃을 다룰 때는 측정과 배치 과정에서 성능 병목이 발생하기 쉽다. 몇 가지 최적화 기법을 알아보자.
1. 측정 결과 캐싱
동일한 MeasureSpec으로 반복 호출되는 경우 이전 결과를 재사용할 수 있다.
private var lastWidthSpec = 0
private var lastHeightSpec = 0
private var lastWidth = 0
private var lastHeight = 0
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
if (widthMeasureSpec == lastWidthSpec && heightMeasureSpec == lastHeightSpec) {
setMeasuredDimension(lastWidth, lastHeight)
return
}
// 측정 로직...
val width = calculateWidth(widthMeasureSpec)
val height = calculateHeight(heightMeasureSpec)
setMeasuredDimension(width, height)
lastWidthSpec = widthMeasureSpec
lastHeightSpec = heightMeasureSpec
lastWidth = width
lastHeight = height
}
2. 복잡한 계산은 한 번만
크기가 변경될 때만 계산하고 결과를 저장해두는 것이 효율적이다.
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// 크기 관련 계산을 여기서 한 번만 수행
centerX = w / 2f
centerY = h / 2f
radius = min(w, h) / 2f * 0.8f
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
if (!changed) return // 크기나 위치가 변경되지 않았으면 스킵
// 자식 View 배치 로직...
}
최근 안드로이드 개발에서는 Jetpack의 Lifecycle을 적극 활용하는 추세이다. View에서도 LifecycleOwner를 찾아 사용할 수 있는 편리한 기능이 있다. 이 함수는 LiveData, ViewModel 또는 LifecycleObserver와 같은 생명주기
인식 요소와 상호 작용해야 하는 커스텀 View나 서드파티 컴포넌트
로 작업할 때 특히 유용하다. 이를 통해 View는 호스팅 Activity나
Fragment에 대한 명시적인 의존성 없이 연관된 생명주기에 접근할 수
있다.
class CustomView : View {
override fun onAttachedToWindow() {
super.onAttachedToWindow()
findViewTreeLifecycleOwner()?.lifecycle?.addObserver(object : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
when (event) {
Lifecycle.Event.ON_PAUSE -> stopAnimation()
Lifecycle.Event.ON_RESUME -> startAnimation()
Lifecycle.Event.ON_DESTROY -> cleanup()
else -> {}
}
}
})
}
}
커스텀 View에서 LiveData를 관찰할 수 있다.
fun observeData(liveData: LiveData<String>) {
findViewTreeLifecycleOwner()?.let { owner ->
liveData.observe(owner) { data ->
updateUI(data)
}
}
}
findViewTreeLifecycleOwner()?.lifecycleScope?.launch {
// Lifecycle에 맞춰 자동으로 취소되는 코루틴
repository.getData().collect { data ->
updateView(data)
}
}
편리한 기능이지만 몇 가지 주의할 점이 있다.
View가 아직 Window에 attach되지 않았거나 LifecycleOwner가 설정되지 않은 경우 null을 반환한다. 항상 null 체크가 필요하다.
class CustomView : View {
init {
findViewTreeLifecycleOwner() // null! 아직 사용 불가
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
findViewTreeLifecycleOwner() // 여기서부터 사용 가능
}
}
생성자나 초기화 블록에서는 사용할 수 없다. onAttachedToWindow() 이후에만 안전하게 사용 가능하다.
Observer를 등록했다면 반드시 해제해야 한다.
class CustomView : View {
private var lifecycleObserver: LifecycleEventObserver? = null
override fun onAttachedToWindow() {
super.onAttachedToWindow()
lifecycleObserver = LifecycleEventObserver { _, event ->
handleLifecycleEvent(event)
}.also { observer ->
findViewTreeLifecycleOwner()?.lifecycle?.addObserver(observer)
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
lifecycleObserver?.let { observer ->
findViewTreeLifecycleOwner()?.lifecycle?.removeObserver(observer)
}
lifecycleObserver = null
}
}
중첩된 Fragment 구조에서는 의도하지 않은 상위 Lifecycle을 가져올 수 있다. 예를 들어 DialogFragment 내부의 View가 Activity의 Lifecycle을 참조하는 경우가 있다.
View의 생명주기를 제대로 이해하는 것은 안드로이드 개발의 기본이다. 특히 커스텀 View를 만들거나 성능 최적화를 할 때 각 생명주기 메서드의 목적과 호출 시점을 정확히 아는 것이 중요하다.
핵심을 정리하면 다음과 같다.
실무에서 이런 원칙들을 지키면 메모리 누수나 성능 문제를 크게 줄일 수 있다. 특히 복잡한 커스텀 View를 다룰 때는 각 생명주기 단계에서 어떤 작업을 해야 하는지 명확히 설계하는 것이 중요하다.