아티클 목록으로 가기

Compose 드로잉 시스템의 내부 동작 원리

skydovesJaewoong Eum (skydoves)||16분 소요

Compose 드로잉 시스템의 내부 동작 원리

모든 Compose 앱은 이미지를 그립니다. Image(painterResource(R.drawable.photo))를 호출하여 비트맵을 표시하든, Icon(Icons.Default.Search)로 Material 아이콘을 렌더링하든, 벡터 드로어블(vector drawable)을 로드하든, 실제 드로잉을 담당하는 근간에는 동일한 추상화 계층이 존재합니다. 바로 Painter 클래스입니다. Painter는 기존 뷰 시스템(View system)에서 Drawable이 수행하던 역할을 Compose 세계에서 그대로 이어받은 것으로, 지정된 영역 안에 콘텐츠를 그리면서 알파(alpha), 컬러 필터(color filter), 레이아웃 방향(layout direction) 처리까지 책임지는 계층입니다.

이번 글에서는 Compose의 전체 드로잉 파이프라인을 소스 코드 수준에서 살펴봅니다. 추상 클래스인 Painter부터 시작하여, 래스터 이미지를 담당하는 BitmapPainter와 벡터 그래픽을 담당하는 VectorPainter의 구체적인 구현을 분석합니다. 또한 불변(immutable) 데이터 구조인 ImageVector와 이를 실제로 렌더링하는 가변(mutable) 렌더 트리인 VectorComponent의 관계, 렌더링된 벡터를 비트맵으로 캐싱하여 성능을 높여 주는 DrawCache, 비트맵과 벡터 포맷을 자동으로 분기하는 painterResource(), 그리고 Painter를 레이아웃 시스템에 연결하는 ImageIcon 컴포저블(composable)까지 단계별로 깊이 있게 다루겠습니다.

근본적인 문제: 다양한 이미지 포맷을 위한 단일 API

안드로이드에는 근본적으로 다른 두 종류의 이미지 에셋이 존재합니다. 비트맵(PNG, JPG, WEBP)은 픽셀의 격자(grid)이고, 벡터 드로어블은 수학적 경로 묘사(path description)를 담은 XML 파일로서 좌표 명령어로 표현된 선, 곡선, 채움을 포함합니다. 비트맵은 모든 픽셀의 정확한 색상 값을 저장하는 반면, 벡터는 어떤 크기에서든 이미지를 그리는 방법에 대한 명령어를 저장합니다.

Compose는 ImageIcon 같은 레이아웃 컴포저블이 내부 이미지 포맷을 알지 못해도 사용할 수 있는 단일 추상화가 필요합니다. 사진을 표시하는 컴포저블과 검색 아이콘을 표시하는 컴포저블이 동일한 인터페이스를 통해 작동해야 하기 때문입니다. Painter 추상화가 바로 이 문제를 해결합니다. 모든 이미지 포맷이 반드시 제공해야 하는 두 가지를 정의하는데, 이미지의 고유 치수(natural dimensions)를 보고하는 intrinsicSize와 실제 렌더링 방법을 알고 있는 onDraw() 메서드가 그것입니다. 각 포맷은 자체적으로 이 두 가지를 구현합니다.

Painter: 드로잉 추상화 계층

Painter를 인쇄소(print shop)에 비유하면 이해가 쉽습니다. 사진이든 일러스트든 벡터 아트든, 어떤 원본이라도 받아서 요청된 크기로 출력물을 만들어 내는 곳이 바로 인쇄소입니다. 소비자는 원본을 건네고 원하는 크기를 지정하기만 하면 되며, 나머지는 인쇄소가 처리합니다. 원본이 JPEG인지 SVG 파일인지 소비자가 알 필요는 없습니다.

Painter 추상 클래스는 모든 이미지 소스가 구현해야 하는 계약(contract)을 정의합니다. 핵심 구조를 간략화하여 살펴보겠습니다.

abstract class Painter {

    private var layerPaint: Paint? = null
    private var useLayer = false
    private var alpha: Float = DefaultAlpha
    private var colorFilter: ColorFilter? = null

    // 이미지의 고유 크기를 반환하는 추상 프로퍼티
    abstract val intrinsicSize: Size

    // 실제 드로잉 로직을 구현하는 추상 메서드
    protected abstract fun DrawScope.onDraw()

    // 서브클래스가 효과를 직접 처리할 수 있는지 여부를 반환하는 최적화 훅
    protected open fun applyAlpha(alpha: Float): Boolean = false
    protected open fun applyColorFilter(colorFilter: ColorFilter?): Boolean = false
    protected open fun applyLayoutDirection(layoutDirection: LayoutDirection): Boolean = false
}

Painter 서브클래스는 intrinsicSize를 통해 고유 치수를 보고합니다. BitmapPainter는 이미지의 픽셀 치수를 반환하고, VectorPainter는 dp 기반의 기본 크기를 반환합니다. 고유 크기가 없는 Painter, 가령 어떤 영역이든 단색으로 채우는 ColorPainter 같은 경우에는 Size.Unspecified를 반환합니다.

여기서 설계가 흥미로워지는 부분이 바로 최적화 훅(hook)인 applyAlpha()applyColorFilter()입니다. 이 메서드들은 Boolean 값을 반환합니다. 서브클래스가 true를 반환하면 "이 효과는 직접 처리하겠다"는 의미이고, false를 반환하면 기본 클래스가 withSaveLayer를 사용하여 오프스크린 레이어(offscreen layer)에 렌더링하는 방식으로 대체합니다. 이 방식은 어떤 상황에서든 동작하지만, 추가적인 버퍼 할당 비용이 발생합니다. 이러한 옵트인(opt-in) 패턴 덕분에 BitmapPainter처럼 단순한 Painter는 오프스크린 레이어를 완전히 우회할 수 있습니다.

draw() 메서드가 모든 것을 하나로 엮어 줍니다. 알파와 컬러 필터를 설정하고, 드로잉 영역을 요청된 크기로 인셋(inset)한 뒤, 레이어를 사용할지 onDraw()를 직접 호출할지 결정합니다.

fun DrawScope.draw(size: Size, alpha: Float = DefaultAlpha, colorFilter: ColorFilter? = null) {
    configureAlpha(alpha)
    configureColorFilter(colorFilter)
    configureLayoutDirection(layoutDirection)

    // 드로잉 영역을 요청된 크기로 인셋
    inset(
        left = 0.0f, top = 0.0f,
        right = this.size.width - size.width,
        bottom = this.size.height - size.height,
    ) {
        if (alpha > 0.0f && size.width > 0 && size.height > 0) {
            if (useLayer) {
                // 오프스크린 레이어를 사용하여 알파/컬러 필터 적용
                val layerRect = Rect(Offset.Zero, Size(size.width, size.height))
                drawIntoCanvas { canvas ->
                    canvas.withSaveLayer(layerRect, obtainPaint()) { onDraw() }
                }
            } else {
                // 레이어 없이 직접 드로잉 (더 효율적)
                onDraw()
            }
        }
    }
}

useLayertrue인 경우, 기본 클래스는 알파와 컬러 필터를 담은 Paint 객체와 함께 saveLayer에 콘텐츠를 렌더링합니다. useLayerfalse인 경우에는 중간 버퍼를 완전히 건너뛰고 onDraw()를 직접 호출하므로 성능 면에서 훨씬 효율적입니다.

BitmapPainter: 래스터 이미지 드로잉

BitmapPainter는 가장 단순한 Painter 구현체입니다. ImageBitmap을 감싸고 drawImage()를 사용하여 드로잉합니다.

class BitmapPainter(
    private val image: ImageBitmap,
    private val srcOffset: IntOffset = IntOffset.Zero,
    private val srcSize: IntSize = IntSize(image.width, image.height),
) : Painter() {

    internal var filterQuality: FilterQuality = FilterQuality.Low
    private var alpha: Float = 1.0f
    private var colorFilter: ColorFilter? = null

    override val intrinsicSize: Size
        get() = srcSize.toSize()

srcOffsetsrcSize 매개변수는 비트맵의 일부분만 드로잉하는 것을 지원합니다. 이 기능은 스프라이트 시트(sprite sheet)나 크롭된 영역처럼 큰 이미지의 특정 부분만 표시하고 싶을 때 유용합니다.

onDraw() 구현체는 모든 매개변수를 drawImage()에 그대로 전달합니다.

    override fun DrawScope.onDraw() {
        drawImage(
            image, srcOffset, srcSize,
            dstSize = IntSize(
                this@onDraw.size.width.fastRoundToInt(),
                this@onDraw.size.height.fastRoundToInt(),
            ),
            alpha = alpha,
            colorFilter = colorFilter,
            filterQuality = filterQuality,
        )
    }

    // alpha를 직접 처리하겠다고 알림 (true 반환)
    override fun applyAlpha(alpha: Float): Boolean {
        this.alpha = alpha
        return true
    }

    // colorFilter를 직접 처리하겠다고 알림 (true 반환)
    override fun applyColorFilter(colorFilter: ColorFilter?): Boolean {
        this.colorFilter = colorFilter
        return true
    }
}

applyAlpha()applyColorFilter() 모두 true를 반환하고 값을 내부에 저장합니다. 이를 통해 기본 Painter 클래스에 "이 효과들은 drawImage()에 직접 전달하여 자체적으로 처리한다"고 알려 주는 것이며, 결과적으로 오프스크린 레이어 오버헤드를 완전히 회피할 수 있습니다.

ImageVector: 불변 벡터 데이터 구조

VectorPainter를 다루기 전에, 먼저 렌더링 대상인 데이터 구조를 이해해야 합니다. ImageVector는 Compose 안정성(stability)을 위해 @Immutable로 어노테이션된 불변 데이터 구조로, 벡터 그래픽을 노드 트리(tree of nodes)로 표현합니다.

트리 구조는 비교적 단순합니다. ImageVector는 루트 VectorGroup을 가지며, 각 VectorGroup은 중첩된 VectorGroup 노드(경로 일부에 변환을 적용하기 위한 용도)와 실제 도형인 VectorPath 리프 노드를 포함할 수 있습니다. VectorGroup의 구조를 살펴보겠습니다.

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기