앱 기본사항

Jetpack Compose 및 Android의 최신 그래픽 기능으로 앱을 개선하세요

스피커

  • 레베카 프랭크스

    개발자 관계팀 엔지니어

  • 아유시 차우다리

    소프트웨어 엔지니어

  • 라훌 레이

    개발자 관계팀 엔지니어

정보

강연 소개

Android UI를 '작동하는 수준'에서 '예술 작품'으로 끌어올릴 준비가 되셨나요?

Jetpack Compose의 최신 그래픽 기능을 살펴보는 시간을 마련했습니다. Android의 강력한 새 그래픽 기능을 사용하여 멋진 메시 그라데이션부터 점진적 블러에 이르기까지 새로운 시각 효과를 빌드하는 방법을 보여드리겠습니다. 또한 배터리 수명이나 프레임 속도를 희생하지 않고 그래픽 기능의 성능을 개선하는 방법도 보여드리겠습니다.

요약

  • Compose 1.12에서는 2D 꼭짓점 그리드에서 풍부한 다색 혼합을 위한 메시 그라데이션을 도입합니다. Android 14 이상에서는 네이티브 drawMesh API를 사용하여 맞춤 도형 기반 시각 효과를 빌드할 수 있습니다.
  • Compose는 지원되는 하드웨어에서 더 부드러운 그라데이션 블렌딩을 제공하기 위해 넓은 색 영역을 지원합니다. 리치 미디어를 표시할 때만 활동 또는 창에서 넓은 색 공간 모드를 사용 설정합니다.
  • Compose 1.13에는 맞춤 도형을 위한 네이티브 PolygonShape 및 MorphPolygonShape API가 도입되었습니다. MorphPolygonShape는 윤곽선 해상도 단계에서 진행 상황을 읽고 재구성을 트리거하지 않고 프레임별로 도형을 업데이트합니다.
  • Compose 1.13에는 점진적 블러가 추가되었으며 API 수준 37.2에는 배경 콘텐츠를 샘플링하는 RenderNode.setBackdropRenderEffect가 도입되었습니다. 개발자는 배경 효과를 연결하여 불투명 유리 효과나 확대 효과를 만들 수 있습니다.
  • 프레임 간에 불필요한 작업을 방지하여 그래픽 성능을 최적화합니다. 기본 스레드에서 필요한 이미지 픽셀만 디코딩하고 애니메이션 효과를 graphicsLayer로 래핑합니다.

리소스

Gemini Notebook

주제

  • jetpack-compose
  • android-graphics
  • mesh-gradients
  • 셰이더
  • 흐리게 처리
  • color-gamut
  • performance

스크립트

Jetpack Compose의 그래픽 기능 소개

  • 안녕하세요. 'Compose 및 Android의 최신 그래픽 기능으로 앱 개선하기' 세션에 오신 것을 환영합니다.

  • 저는 레베카 프랭크스입니다. 오늘 함께 무대에 선 분은…

  • 저는 아유시 차우다리입니다.

  • 안녕하세요, 저는 라훌 레이입니다.

  • AI를 사용하여 놀라운 아이디어를 실제로 구현하면서 Android와 Compose에서 가능한 것의 한계를 뛰어넘는 새로운 트렌드가 나타나고 있습니다.

  • 또한 프리미엄, 네이티브, 쾌적한 앱을 선호합니다.

  • 사용자에게 만족감을 주고 지속적으로 높은 평점을 받는 앱은 일반적으로 최신 시스템 수준 API 변경사항을 활용하고, 공유 요소와 블러를 사용하여 표시 경로 간에 연속성을 유지하며, 폼 팩터 전반에서 원활하게 적응하고, 성능이 우수하며, 사용자가 추측할 필요가 없고, 기기로 가능한 작업의 한계를 뛰어넘는 앱입니다.

  • 오늘 이 강연에서는 메시 그라데이션으로 사용자에게 즐거움을 선사하고, 넓은 색 영역 지원을 사용 설정하고, 다각형 모양으로 즐거운 전환을 구현하고, 블러 처리의 새로운 기능을 활용하고, 마지막으로 앱에 새로운 그래픽 기능을 추가할 때 성능을 고려하는 방법 등 Android를 활용하는 데 도움이 되는 몇 가지 최신 기능을 보여드리겠습니다.

  • 하지만 오늘 강연에서는 스택의 다양한 수준에서 새로운 API를 다룰 예정입니다.

  • 기존 뷰 UI 툴킷은 현재 유지보수 모드에 있지만, Google은 Jetpack Compose를 구동하는 기본 Android 그래픽 하드웨어 렌더링 엔진에 계속 투자하고 있습니다.

  • Android의 그래픽은 세 가지 레이어로 생각할 수 있습니다. 첫 번째 레이어에는 Compose API가 있으며, 여기에는 메시 그라데이션 페인터, 다각형 도형, 점진적 블러 API와 같은 모든 상위 수준의 새로운 UI 그래픽 API가 있습니다.

  • 중간 레이어에서는 Compose의 DrawScope, 그리기 수정자 노드, AGSL을 사용하여 맞춤 효과를 빌드할 수 있습니다.

  • 마지막으로 Android 플랫폼이 있습니다. Compose는 항상 네이티브 Canvas에 액세스할 수 있으며 Compose 수정자 내에서 바로 새로운 OS 렌더링 프리미티브를 사용할 수 있습니다.

  • 오늘 이와 관련된 몇 가지 예를 보여드리겠습니다.

  • 이제 본격적인 내용으로 들어가 보겠습니다.

메시 그라데이션 및 맞춤 메시 렌더링

  • 먼저 Compose 1.12에 도입된 새로운 API인 메시 그라데이션을 살펴보겠습니다.

  • 이전에는 선형, 스윕, 방사형 그래디언트가 있었는데, 이는 간단한 전환에 적합하지만, 주변 배경이나 플러드라이트 효과와 같은 풍부한 다색 혼합을 원한다면 공간에서 위치와 색상을 혼합할 수 있는 2D 그리드 시스템이 필요합니다.

  • 이와 같은 메시 그라데이션을 만들려면 먼저 원하는 패치의 행과 열 수를 지정합니다.

    val gradientPainter = remember {
    MeshGradientPainter(rows = 3, columns = 3) {
    }
    }
  • 그런 다음 후행 람다 내에서 setVertex를 호출하고 각 그리드 교차점의 색상과 함께 0F에서 1F 사이의 정규화된 오프셋을 할당합니다.

    val gradientPainter = remember {
    MeshGradientPainter(rows = 3, columns = 3) {
       setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
    
    }
    }
  • 그런 다음 꼭짓점에 애니메이션을 적용하려면 꼭짓점 8개에 애니메이션 오프셋을 추가합니다.

    val gradientPainter = remember {
    MeshGradientPainter(rows = 3, columns = 3) {
       setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
         // ....
       setVertex(1, 1, Offset(0.2f, 0.4f) + Offset(animatedOffset, animatedOffset), coral)
       // ....
       setVertex(3, 0, Offset(0.0f, 1.0f), sunshine)
       // ....
    }
    }
    
    Box(
       modifier = modifier.padding(32.dp)
           .safeContentPadding()
           .aspectRatio(16 / 9f)
           .fillMaxWidth()
           .paint(gradientPainter)
    )
  • 페인터를 정의한 후에는 페인트 수정자를 통해 컴포저블로 호출하면 됩니다.

  • 이제 JetChat 앱에서 메시지를 두 번 탭하면 메시지에 좋아요를 표시하는 기능을 추가했습니다.

  • 이를 빌드하기 위해 2x3 메시를 설정하고 구성요소 전체에 색상 정지를 배치합니다.

    MeshGradientPainter(
       rows = 2,
       columns = 3,
       hasBicubicColor = true,
    ) {
       // Row 0 (top edge, y = 0.0f)
       setVertex(0, 0, Offset(0.0000f, 0.0000f), Color(0xFFFF6B6B))
       setVertex(0, 1, Offset(0.3300f, 0.0000f), Color(0xFFFFD6D6))
       setVertex(0, 2, Offset(0.6700f, 0.0000f), Color(0xFFE8C3FF))
       setVertex(0, 3, Offset(1.0000f, 0.0000f), Color(0xFFE396FF))
    
       // ... remaining vertices for Rows 0, 1, and 2
    }
  • 여기에서 hasBicubicColor를 true로 설정합니다.

  • 기본적으로 색상은 삼각형 전체에서 선형으로 보간되므로 때때로 눈에 보이는 패치가 남을 수 있습니다.

  • 이 기능을 사용 설정하면 셰이더가 부드러운 바이큐빅 보간으로 전환되어 색상이 서로 매끄럽게 녹아들 수 있습니다.

  • 이제 입력 상자 뒤에 이와 같은 애니메이션 주변광을 만들고 싶다면 어떻게 해야 할까요?

  • 메시 그라데이션으로 이와 같은 효과를 구현하려면 두 가지가 필요합니다.

  • 첫째, 내부 꼭짓점의 부드럽고 자연스러운 움직임과 카드 주변에 딱딱한 직사각형이 생기지 않도록 바깥쪽 가장자리를 부드럽게 만드는 방법입니다.

  • 따라서 먼저 두 개의 위상 각도가 약간 다른 지속 시간으로 실행되는 rememberInfiniteTransition를 설정하여 움직임이 기계적이지 않고 자연스럽게 느껴지도록 합니다.

    val transition = rememberInfiniteTransition(label = "userInputGlowMesh")
    
    val phase1 by transition.animateFloat(
       initialValue = 0f,
       targetValue = (2 * PI).toFloat(),
       animationSpec = infiniteRepeatable(
           animation = tween(durationMillis = 2800, easing = LinearEasing),
           repeatMode = RepeatMode.Restart,
       ),
       label = "glowPhase1",
    )
    
    val phase2 by transition.animateFloat(
       initialValue = 0f,
       targetValue = (2 * PI).toFloat(),
       animationSpec = infiniteRepeatable(
           animation = tween(durationMillis = 4200, easing = LinearEasing),
           repeatMode = RepeatMode.Restart,
       ),
       label = "glowPhase2",
    )
  • 여기서는 내부 꼭짓점에 애니메이션을 적용하고 외부 링, 즉 알파가 0인 가장자리가 있는 꼭짓점의 외부 링을 고정합니다.

  • 바깥쪽 경계는 고정되어 있고 투명하므로 빛이 자연스럽게 퍼집니다.

  • 마지막으로 애니메이션 오프셋을 MeshGradientPainter에 제공합니다.

    MeshGradientPainter(
       rows = 6,
       columns = 5,
       hasBicubicColor = true,
    ) {
       for (r in 0..6) {
           for (c in 0..5) {
                   val offset = if (r in 1..5 && c in 1..4) {
                   val ampX = when (r) {
                       1, 2 -> 0.048f
                       3 -> 0.042f
                       4 -> 0.028f
                       else -> 0.036f
                   }
                   val ampY = when (r) {
                       1, 2 -> 0.028f
                       3 -> 0.020f
                       4 -> 0.022f
                       else -> 0.016f
                   }
                   val d = orbit(c, r, ampX, ampY)
                   Offset(
                       x = (baseU + d.x).coerceIn(0.04f, 0.96f),
                       y = (baseV + d.y).coerceIn(0.05f, 0.95f),
                   )
               } else {
                   Offset(baseU, baseV)
               }
               setVertex(r, c, offset, gridColors[r][c])
           }
       }
    }
  • 방금 본 메시 그라데이션은 Android 14 이상에서 drawMesh API를 사용하여 그려집니다.

  • 그럼 이 API를 자세히 살펴보면서 효과를 처음부터 빌드해 보겠습니다.

  • JetChat 시작 화면의 경우 리본 모양의 경로를 그리려고 합니다.

  • 여기서 두 가지를 확인할 수 있습니다. 획 너비가 일정하지 않고 색상이 경로의 각 획을 따라 흐릅니다.

  • 이 효과를 빌드하는 데 실제로 필요한 것이 무엇인지 자세히 살펴보겠습니다.

  • 각 픽셀에 대해 그릴지 여부와 사용할 색상을 결정해야 합니다.

  • 이를 파악하려면 두 가지 거리가 필요합니다.

  • 첫 번째 값은 픽셀이 획의 중심에서 얼마나 떨어져 있는지를 나타냅니다.

  • 두 번째 거리는 시작부터 끝까지 경로를 따라 픽셀이 얼마나 멀리 있는지입니다.

  • 이렇게 하면 그라데이션에서 선택할 색상을 알 수 있으며 첫 번째 값을 약간 혼합하면 너비에 걸쳐 색상 곡선이 만들어집니다.

  • 그렇다면 경로와 페인트만 사용하여 경로를 따라 그라데이션을 그릴 수 있을까요?

  • 네, 가능합니다.

  • 이를 위해서는 경로를 짧은 직선 목록으로 평면화해야 하며, 이를 통해 경로를 따라 이동하는 거리도 알 수 있습니다.

    val lines = remember {
            path.asAndroidPath().flatten(0.5f)
        }
    //... inside draw scope
    lines.forEach { line ->
    //...
        drawLine(
            brush = Brush.linearGradient(...),
            start = Offset(line.start.x,
    line.start.y),
            end = Offset(line.end.x, line.end.y),
            strokeWidth = 30f,
        )
    //...
    }
  • 그런 다음 각 선을 자체 그라데이션으로 그립니다.

  • 이 방법은 작동하지만 각 선에 별도의 그리기 호출과 페인트 객체가 필요하며 전체 경로의 너비는 여전히 하나입니다.

  • 다른 옵션은 런타임 셰이더이지만 컨테이너 경계 내의 픽셀 위치만 가져옵니다.

  • 이 리본을 그리는 데 필요한 정보는 알려주지 않습니다.

  • 픽셀의 위치에서 쉽게 계산할 수도 없습니다.

  • 메시 그리기는 이를 위한 꼭짓점 셰이더를 제공합니다.

  • 그렇다면 메시란 무엇일까요?

  • 메시는 각 모서리에 데이터가 있는 삼각형으로 구성되며 각 모서리를 꼭짓점이라고 합니다.

  • 각 꼭짓점은 작은 데이터 레코드를 전달합니다.

  • 일반적으로 이 레코드는 위치를 보유하지만 반드시 그럴 필요는 없습니다.

  • 꼭짓점 셰이더는 이 레코드를 읽고 이 레코드에서 위치를 계산합니다.

  • 여기에서 삼각형 하나가 취하는 경로는 다음과 같습니다. 꼭짓점 셰이더는 각 모서리에 대해 실행되며, 모서리의 위치와 각 픽셀에 전달하려는 값을 계산합니다.

  • 그런 다음 래스터라이저가 해당 삼각형이 차지하는 모든 픽셀을 찾아 각 픽셀에 대해 세 모서리의 값을 보간합니다.

  • 마지막으로 조각 셰이더가 해당 픽셀에 대해 실행되고, 보간된 값을 사용하여 픽셀의 색상을 계산합니다.

  • 더 간단한 예시를 통해 꼭짓점 및 프래그먼트 셰이더를 작성하는 방법을 살펴보겠습니다. 이 예시에서는 펄스가 직선을 따라 이동합니다.

    LineShaders.VERTEX
    
    uniform float2 uStart;
    uniform float2 uEnd;
    uniform float uHalfWidth;
    uniform float uTime;
    
    Varyings main(const Attributes a) {
        Varyings v;
        float2 dir = uEnd - uStart;
        float2 normal = normalize(float2(-dir.y, dir.x));
        float along = a.corner.x * length(dir);
        float pulse = (a.corner.x - uTime) / 0.18;
        float radius = uHalfWidth * (1.0 + 0.6 * exp(-pulse * pulse));
        float across = a.corner.y * (radius + 1.0);
        v.position = uStart + dir * a.corner.x + normal * across;
        v.stroke = float3(along, across, radius);
        return v;
    }
  • 정점 셰이더는 속성을 입력으로 사용하고 가변을 반환합니다.

  • 속성은 단일 코너의 데이터입니다.

  • 여기에는 선을 따라 진행되는 정도와 선의 어느 쪽에 있는지에 관한 두 가지 숫자만 있습니다.

  • 그런 다음 가변 변수가 보간되어 프래그먼트 셰이더에 전달됩니다.

  • 시작점으로부터의 픽셀 거리를 계산하고, 획 너비의 경우 시간을 사용하여 반지름에 펄스를 생성합니다.

  • 그런 다음 이 반경에 모서리 측면을 곱하면 획 전체의 부호 거리가 나옵니다.

  • GPU가 이러한 부호 사이를 보간하면 중심선이 0이 됩니다.

  • 마지막으로 모서리 위치를 계산하고 이 모든 값을 획 변수에 저장한 후 프래그먼트 셰이더에 전달합니다.

  • 런타임 셰이더와 달리 메시 프래그먼트 셰이더는 매개변수를 통해 색상을 출력하고 페인트를 샘플링할 위치를 반환합니다(설정된 경우).

    LineShaders.FRAGMENT
    
    uniform float uLength;
    layout(color) uniform float4 uStartColor;
    layout(color) uniform float4 uEndColor;
    
    float2 main(const Varyings v, out float4 color) {
        float distance = abs(v.stroke.y);
        float coverage = 1.0 - smoothstep(
        v.stroke.z - 0.5, v.stroke.z + 0.5, distance);
        float side = distance / v.stroke.z;
        float4 ink = mix(uStartColor, uEndColor,
            clamp(v.stroke.x / uLength + 0.4 * side * side, 0.0, 1.0));
        color = ink * coverage;
        return v.position;
    }
  • 가로 좌표의 절댓값을 구하면 중심선으로부터의 픽셀 단위 거리를 얻을 수 있습니다.

  • 이 거리를 반지름과 비교하여 적용 범위를 구하고, 색상의 경우 선의 경로를 따라 혼합하고 그라데이션을 가장자리 쪽으로 약간 구부립니다.

  • 이 적용 범위에 적용 범위를 곱하면 최종 색상이 됩니다.

  • Compose 측에서는 MeshSpecification 객체를 사용하여 셰이더와 데이터 레이아웃을 함께 연결합니다.

    val spec = remember {
        MeshSpecification.make(
            arrayOf(Attribute(TYPE_FLOAT2, 0, "corner")),
            vertexStride = 8,
            arrayOf(Varying(TYPE_FLOAT3, "stroke")),
            LineShaders.VERTEX, LineShaders.FRAGMENT)
    }
  • 먼저 모서리 속성을 바이트 0에서 시작하는 두 개의 부동 소수점 수로 선언합니다.

  • 두 개의 부동 소수점은 8바이트를 차지하므로 버퍼의 각 모서리 보폭은 8입니다.

  • 다음으로, 획 변수를 세 개의 부동 소수점으로 선언하고 방금 작성한 정점 및 프래그먼트 셰이더를 전달합니다.

  • 꼭짓점 버퍼를 빌드합니다.

    fun lineCorners(segments: Int): ByteBuffer {
        val buffer = ByteBuffer.allocateDirect((segments + 1) * 2 * 8)
            .order(ByteOrder.nativeOrder())
        for (i in 0..segments) {
            val t = i / segments.toFloat()
            buffer.putFloat(t).putFloat(-1f) // one side
            buffer.putFloat(t).putFloat(1f)  // the other side
        }
        return buffer.rewind() as ByteBuffer
    }
  • 선을 따라 각 단계에서 진행률을 완료하고 0에서 1까지의 진행률을 계산하고 두 모서리를 버퍼에 씁니다.

  • 마지막으로 사양과 버퍼를 메시 객체로 결합합니다.

    val mesh = Mesh(
        spec,
        mode = Mesh.TRIANGLE_STRIP,
        vertexBuffer = corners,
        vertexCount = (segments + 1) * 2,
        bounds = RectF(0f, 0f, size.width, size.height)
    )
  • Compose 캔버스는 아직 drawMesh를 노출하지 않으므로 nativeCanvas로 내려가서 메시를 그립니다.

    Box(Modifier.fillMaxSize().drawWithCache {
       val mesh = Mesh(...)
       onDrawBehind {
           mesh.setFloatUniform("uTime", time.value)
           drawIntoCanvas {
              it.nativeCanvas.drawMesh(mesh,android.graphics.BlendMode.DST, paint)
           }
       }
    })
  • 도형과 픽셀을 제어하므로 메시 그리기를 사용하여 도형 기반 시각 효과를 그릴 수 있습니다.

  • 이 API에 대한 자세한 내용은 문서를 참고하세요.

넓은 색 공간 지원

  • Compose 1.12에서는 넓은 색 공간 지원이 도입되었습니다.

  • 이것이 실제로 의미하는 바는 무엇일까요?

  • 화면은 기존 sRGB 색상 공간과 다른 범위의 색상을 재현할 수 있다는 사실을 알고 계셨나요?

  • 예를 들어 이 이미지를 보면 흰색 삼각형은 대부분의 화면에서 표시할 수 있는 표준 sRGB 색상의 범위입니다.

  • 하지만 최신 화면 중 일부는 노란색 삼각형으로 표시된 것처럼 더 넓은 범위의 색상을 지원하며, 일부는 더 채도가 높고 선명합니다.

  • 하지만 색상을 표현할 때는 실제로 삼각형의 각 꼭짓점에 대한 설명으로 표현합니다.

  • 이 그라데이션 비교를 볼 때는 디스플레이 색 영역과 그라데이션 보간을 구분하는 것이 중요합니다.

  • 표준 sRGB 디스플레이나 이 프로젝터에서는 화면이 이 sRGB 색 영역 외부의 색상을 실제로 물리적으로 렌더링할 수 없습니다.

  • 그래서 지금 무대에서 시연하기가 정말 어렵습니다.

  • 따라서 넓은 색 영역을 지원하는 Android에서 색상이 더 잘 보인다는 점을 믿으셔야 합니다.

  • 이 예시에서 실제로 볼 수 있는 것은 더 넓은 색상 공간에서 색상 중지점을 보간하는 효과입니다.

  • 넓은 색 공간 또는 선형 색 공간에서 색상을 혼합하면 이 그라데이션의 실제 수학적 경로가 변경되어 넓은 색 공간 없이 수행할 때 발생할 수 있는 중간 회색화 및 거친 밴딩이 방지됩니다.

  • Compose 1.12를 사용하면 모든 디스플레이에서 더 부드러운 그라데이션 블렌딩을 활용할 수 있으며, 지원되는 하드웨어에서 실행할 때 진정한 넓은 색 공간을 지원합니다.

  • 기기에서 올바른 지원을 사용 설정하려면 활동 또는 창이 넓은 색 공간 모드를 사용 설정하도록 설정되어 있는지 확인해야 합니다.

  • 이 작업은 AndroidManifest에서 할 수 있습니다.

    <!-- AndroidManifest.xml -->
    <activity
    android:name=".NavActivity"
    android:colorMode="wideColorGamut"
    android:exported="true">
    </activity>
  • 또는 컴포저블에서 프로그래매틱 방식으로 설정할 수 있습니다.

    // Dynamic toggle in code
    DisposableEffect(window) {
       window?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
       onDispose {
           window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
       }
    }
  • colorMode를 설정하지 않으면 창은 표준 8비트 sRGB로 유지되고 GPU에서 렌더링된 모든 넓은 색 영역 색상이 잘립니다.

  • 따라서 디스플레이가 실제로 지원할 수 없는 경우 넓은 색 공간 색상을 사용하지 않는 것이 중요합니다.

  • 배터리와 성능을 절약하려면 실제로 미디어, 사진 또는 리치 그래픽을 표시하는 활동이나 코드 경로에서만 넓은 색 공간을 사용 설정해야 합니다.

  • 즉, Compose에서 다양한 색상 공간의 색상을 만들 수 있으며 이제 이러한 색상이 적용됩니다.

    // 1. Display P3 Color (Wider gamut: greens & reds are more saturated) colorMode=wideColorGamut
    val VividP3Red = Color(
       red = 1.0f,
       green = 0.05f,
       blue = 0.05f,
       alpha = 1.0f,
       colorSpace = ColorSpaces.DisplayP3
    )
    // 2. Extended sRGB (Enables HDR values > 1.0f) use colorMode=hdr
    val HdrNeonCyan = Color(
       red = 0.0f,
       green = 1.8f,  // Peak brightness > 1.0 in extended gamut
       blue = 2.0f,
       alpha = 1.0f,
       colorSpace = ColorSpaces.ExtendedSrgb
    )
    // 3. BT.2020 HLG (Broadcast HDR standard, supported API 34+) - use colorMode=hdr
    val HdrSunsetOrange = Color(
       red = 1.0f,
       green = 0.45f,
       blue = 0.1f,
       alpha = 1.0f,
       colorSpace = ColorSpaces.Bt2020Hlg
    )
  • 색상을 만들려는 colorSpace 유형을 전달할 수 있으며, colorSpace를 전달하지 않으면 표준 sRGB가 사용됩니다.

  • 특히 비트맵의 경우 getColorSpace를 사용하여 비트맵의 소스를 확인할 수 있으며, 인기 있는 이미지 로드 라이브러리가 올바른 비트맵 색상 공간을 자동으로 처리합니다.

  • 565와 같은 구성은 실제로 정밀도가 떨어지고 사용자에게 밴딩이 표시되므로 색상 정보를 보존하려면 Bitmap.Config.HARDWARE를 사용해야 합니다.

  • 색상에 관해 자세히 알아보려면 2017년으로 돌아가 로메인 가이가 Compose의 색상 또는 Android의 색상에 관해 설명하는 동영상을 시청하세요.

다각형 도형 및 변형

  • Compose 1.13에서는 다각형 도형에 대한 네이티브 지원이 도입됩니다.

  • 이를 통해 맞춤 도형과 유기적인 UI 요소를 훨씬 쉽게 만들 수 있습니다.

  • 이전에는 이와 같은 복잡한 도형을 그리려면 RoundedPolygon를 사용하여 경로를 출력하는 도형을 설명한 다음 해당 경로를 Compose 도형 API에 매핑해야 했습니다.

    fun cookiePolygon(): RoundedPolygon {
        val vertices = FloatArray(32)
        for (i in 0 until 16) {
            val radius = if (i % 2 == 0) 1f else 0.84f
    // …
        }
        return RoundedPolygon(
            vertices,
            rounding = CornerRounding(0.12f)
        )
    }
  • 예를 들어 먼저 RoundedPolygon를 사용하여 비율을 정의합니다. 이 스니펫에서는 내부 점을 외부 점과 비교하여 중심에서 84% 에 위치하도록 설정합니다.

  • 여기에서 모서리 라운딩을 적용할 수도 있지만 이는 수학적 곡선만 설명합니다.

  • Compose에서 이 다각형을 클리핑이나 배경에 실제로 사용하려면 다음과 같이 맞춤 Shape 래퍼를 작성해야 합니다.

    class RoundedPolygonShape(
     polygon: RoundedPolygonShape
    ) : Shape {
        // …
        override fun createOutline(size: Size, layoutDirection: LayoutDirection,
        density: Density,
        ): Outline {
            path = polygon.toPath().asComposePath()
            // …
            matrix.scale(size.width / maxDimension, size.height / maxDimension)
            matrix.translate(-bounds.left, -bounds.top)
            // …
            return Outline.Generic(path)
        }
    }
  • 이 createOutline 스니펫 내에서 경로를 가져오고 직접 수동으로 확장하고 변환해야 한다는 것을 알 수 있습니다.

  • 이제 새로운 PolygonShape API를 사용하면 더 이상 맞춤 Shape 래퍼가 필요하지 않습니다.

    private val AvatarShape = PolygonShape {
        val vertices = List(16) { i ->
            val radius = size.minDimension / 2f * if (i % 2 == 0) 1f else 0.86f
            val angle = i * PI.toFloat() / 8f
            size.center + Offset(cos(angle), sin(angle)) * radius
        }
        polygon(vertices, rounding = CornerRounding(radius = 8.dp))
    }
    
        // …
    Modifier.clip(AvatarShape)
  • 전용 Compose Shape 빌더이므로 밀도와 크기를 바로 수신합니다.

  • 즉, 꼭짓점을 동적으로 계산하고 모서리 반올림을 dp로 직접 지정하거나 백분율로 지정할 수도 있습니다.

  • 정의한 후에는 Shape를 사용하는 API에 전달하기만 하면 됩니다.

  • 또한 다양한 다각형 도형 간에 원활하게 전환할 수 있는 MorphPolygonShape API도 도입합니다.

    val recordShape = remember {
        MorphPolygonShape(
            start = PolygonShape.circle(),
            end = PolygonShape.star(
                numPoints = 9,
                innerRadiusRatio = 0.8f,
                outerRounding = CornerRounding(percent = 50),
            ),
        ) { shapeProgress.value }
    }
  • 이 예시에는 상호작용 시 원에서 별로 변형되는 녹화 버튼이 있습니다.

  • 시작 도형, 종료 도형, 진행률 값만 제공하면 됩니다.

  • MorphPolygonShape API는 컴포지션 중이 아닌 윤곽선 해상도 단계에서 진행률 값을 읽습니다.

  • 즉, 도형이 프레임 간에 변형될 때 리컴포지션을 트리거하지 않고 단순히 새로운 윤곽선으로 변환됩니다.

블러 및 배경 렌더링 효과

  • 다음으로 흐리게 처리에 대해 알아보겠습니다.

  • 지난 몇 번의 출시에서 Google은 블러 구현을 훨씬 더 쉽고 표현력 있게 만들기 위해 노력하고 있습니다.

  • 블러 처리에 관해 이야기할 때마다 흔히 사용되는 수정자가 바로 이 수정자입니다.

    Modifier
       .blur(
           radiusX = 10.dp,
           radiusY = 10.dp,
           edgeTreatment = BlurredEdgeTreatment(
               shape = RoundedCornerShape(8.dp),
           ),
       )
  • Compose에는 Android 12부터 이 수정자가 있었습니다.

  • 구성 가능한 함수 전체에 정확히 동일한 블러 반경이 적용되는 균일한 블러를 원하는 경우에 적합합니다.

  • 하지만 최신 UI 디자인에서는 선명한 영역에서 흐린 영역으로의 전환이 급격하게 이루어지기보다는 부드럽게 이루어지는 경우가 많습니다.

  • 이제 Compose 1.13부터 공식 API가 된 Progressive Blur에 대해 알아보겠습니다.

  • 이제 전체 컴포저블에서 단일한 일정한 반경을 사용하는 대신 공간 전체에서 블러 강도를 다양하게 조정할 수 있습니다.

  • 다음은 JetChat 앱의 사용자 프로필 화면 예입니다.

  • 새로운 블러 수정자는 블러 반경 사양을 사용합니다.

    Modifier.blur {
    blurRadiusSpec = BlurRadiusSpec.verticalGradient(
    listOf(
    BlurStop(0.5f, 0.dp),
    BlurStop(1.0f, 32.dp),
    )
    )
    edgeTreatment = BlurredEdgeTreatment.Unbounded
    }
  • 여기서는 두 개의 블러 정지가 있는 세로 그라데이션을 사용하여 사진의 상단 절반은 0dp로 완전히 선명하게 유지하고 하단 가장자리에서는 32dp 블러로 점차 증가시킵니다.

  • 또한 edgeTreatment를 Unbounded로 설정하여 기본적으로 컴포저블 경계 외부의 투명 픽셀도 샘플링하도록 흐리게 처리합니다.

  • 이 블러 반경 사양은 수직 페이드 아웃 외에도 점진적 블러를 적용하는 여러 가지 기본 제공 방법을 제공합니다.

  • 예를 들어 왼쪽에는 가로 그라데이션이 표시되며, 시작 반경은 0dp이고 종료 반경은 슬라이더 값에 의해 결정됩니다.

    BlurRadiusSpec.horizontalGradient(
    startRadius = 0.dp,
    endRadius = sliderValue.dp,
    )
  • 오른쪽에는 방사형 그래디언트가 있습니다. 중심에서 블러 반경은 0dp이며, fallOffRadius(이 경우 300dp)로 이동할수록 블러가 최대 24dp까지 부드럽게 증가합니다.

    BlurRadiusSpec.radialGradient(
    center = center,
    fallOffRadius = 300.dp,
    startRadius = 0.dp,
    endRadius = 24.dp,
    )
  • 이러한 수직, 방사형, 수평 그라데이션으로 충분하지 않다면 언제든지 맞춤 셰이더를 사용하여 점진적 블러를 적용할 수 있습니다.

    val customShader = remember {
       RuntimeShader(
           """
           uniform float2 size;
           uniform float time;
    
           half4 main(float2 coords) {
              // Diagonal wave mask
              float normalizedX = coords.x / size.x;
              float normalizedY = coords.y / size.y;
              float wave = sin((normalizedX + normalizedY) * 6.0 + time) * 0.5 + 0.5;
              return half4(0.0, 0.0, 0.0, wave);
           }
           """.trimIndent()
       )
    }
    
    val customBlurRadius = remember(customShader) {
       BlurRadiusSpec.shader(maxRadius = 24.dp) { customShader }
    }
    
    Modifier.blur {
           val w = size.width.toPx()
           val h = size.height.toPx()
           customShader.setFloatUniform("size", w, h)
           customShader.setFloatUniform("time", animatedTime)
           blurRadiusSpec = customBlurRadius
       },
  • 여기서는 애니메이션 대각선 사인파를 계산하고 이 강도를 알파 채널로 출력하는 작은 프래그먼트 셰이더를 작성합니다.

  • 그러면 셰이더 사양이 0~1 마스크를 24dp 마스크 maxRadius에 직접 매핑합니다.

  • 배경 흐리게 처리에 대해 이야기하기 전에 두 기능의 차이점을 살펴보겠습니다.

  • 이제 왼쪽에는 레이어 블러를 적용한 상단 앱 바가 있습니다.

  • 즉, 컴포저블 자체를 흐리게 처리합니다.

  • 보시다시피 제목과 작업 아이콘은 흐리게 표시되지만 그 뒤에 있는 채팅 구성요소는 전혀 흐리게 표시되지 않습니다.

  • 오른쪽에는 배경 흐림 효과가 있습니다.

  • 여기서는 상단 표시줄 뒤로 스크롤되는 채팅 메시지만 샘플링되고 흐리게 처리됩니다.

  • 하지만 텍스트와 아이콘은 완전히 선명하고 읽기 쉽습니다.

  • Android 37.2부터는 RenderNode.setBackdropRenderEffect라는 새 API를 사용하여 배경을 흐리게 처리할 수 있습니다.

  • 블러가 어떻게 개선되고 있는지 알아보기 전에, 블러가 원활하게 작동하도록 Android가 내부적으로 어떻게 작동하는지 알아보겠습니다.

  • Android는 내부적으로 하드웨어 가속 디스플레이 목록 컨테이너인 RenderNode를 사용합니다. 즉, 녹화 시작과 녹화 종료를 통해 다양한 그리기 명령어를 압축하고 저장할 수 있습니다.

  • RenderNode에는 3D 변환, 회전, 알파, 배율, 위치 경계와 같은 정보가 포함됩니다.

  • Compose는 실제로 내부적으로 RenderNode를 사용합니다.

  • 따라서 Compose에서 배경 렌더링 효과를 사용하려면 이 수정자를 재사용할 수 있도록 맞춤 Modifier.Node를 만들면 됩니다.

    fun Modifier.backdropBlur(spec: BlurRadiusSpec, shape: Shape, tint: Color): Modifier =
       this then BackdropBlurElement(spec, shape, tint)
    
    private class BackdropBlurNode(...) : Modifier.Node(), DrawModifierNode {
       private var renderNode: RenderNode? = null
    
       override fun ContentDrawScope.draw() {
           val node = getOrCreateNode()
           if (isDirty || sizeChanged) {
               node.setPosition(0, 0, widthPx, heightPx)
               node.setBackdropRenderEffect(spec.createRenderEffect(this))
           }
           drawIntoCanvas { it.nativeCanvas.drawRenderNode(node) }
           drawContent()
       }
    }
  • RenderNode에 대한 참조를 유지하고 RenderNode는 다른 함수에서 원하는 효과로 구성됩니다.

  • 예를 들어 여기에서 흐림 효과를 전달합니다.

  • 다음으로 drawIntoCanvas를 호출하여 네이티브 캔버스에서 drawRenderNode를 호출합니다.

  • 마지막으로 drawContent를 호출하여 흐리게 처리되지 않은 실제 콘텐츠도 상단에 그립니다.

  • 방금 만든 이 수정자를 사용하려면 Modifier.backdropBlur를 호출하고 블러 반경 사양을 전달합니다.

    JetchatAppBar(
        modifier = modifier
            .backdropBlur(
                tint = MaterialTheme.colorScheme
                    .surface.copy(alpha = 0.5f),
                shape = RectangleShape,
                spec = BlurRadiusSpec.uniform(5.dp)
            )
    )
  • 이 접근 방식에서는 기본 레이어에 아무것도 배치할 필요가 없습니다.

  • RenderNode.setBackdropRenderEffect는 JetChat 앱 바 아래에 있는 콘텐츠를 자동으로 샘플링합니다.

  • 그 결과 이처럼 멋진 배경 흐림 효과가 적용됩니다.

  • 또한 점진적 블러뿐만 아니라 점진적 블러 반경 사양을 설정하여 콘텐츠에 세로 블러를 적용할 수도 있습니다.

    JetchatAppBar(
        modifier = modifier
            .backdropBlur(
                tint = MaterialTheme.colorScheme
                    .surface.copy(alpha = 0.5f),
                shape = RectangleShape,
                spec = BlurRadiusSpec.verticalGradient(
                    listOf(
                        BlurStop(0.5f, 32.dp),
                        BlurStop(1f, 0.dp),
                    ),
                )
            )
    )
  • 세로 그라데이션을 사용하면 컨테이너의 여러 부분에서 다양한 반지름으로 흐리게 처리된 콘텐츠가 아래에 표시됩니다.

  • Compose에서 기본 제공되는 배경 흐림 수정자를 개발 중입니다.

  • 이것은 Compose를 사용하는 Android의 기본 기술의 예일 뿐입니다.

  • 이 예시는 API 수준 37.2에서만 작동하지만 Compose용 백포트도 개발 중이니 기대해 주세요.

  • RenderNode 배경 효과를 사용한 또 다른 재미있는 예는 실제로 여러 효과를 연결하여 멋진 UI를 만들고 그 위에 여러 가지 효과를 적용할 수 있다는 것입니다.

  • 효과를 연결하기 위해 이 두 가지 효과를 만듭니다.

    val blur = RenderEffect.createBlurEffect(24f, 24f, Shader.TileMode.CLAMP)
    
    // Boost saturation by 30% and apply blur together
    val matrix = ColorMatrix().apply { setSaturation(1.3f) }
    val vibrancy = RenderEffect.createColorFilterEffect(ColorMatrixColorFilter(matrix))
    val vibrantBlur = RenderEffect.createChainEffect(vibrancy, blur)
  • 이 예시에서는 흐림 효과와 30%의 채도를 만듭니다.

  • 블러 효과를 먼저 적용하고 채도를 나중에 적용하여 createChainEffect를 호출합니다.

  • 다음은 JetChat의 예입니다.

    val agslShader = RuntimeShader(CHROMATIC_ABERRATION)
    val noiseEffect = RenderEffect.createRuntimeShaderEffect(agslShader, "backdropInput")
    
    val finalEffect = RenderEffect.createChainEffect(noiseEffect, vibrantBlur)
    renderNode?.setBackdropRenderEffect(finalEffect)
  • 하단으로 이동 버튼은 몇 가지 효과를 결합하여 이 컴포저블의 가장자리를 돋보기처럼 둥글게 만들어 작은 돋보기처럼 보이게 할 수 있습니다.

  • 하지만 확대 효과를 적용할 수 있을 뿐만 아니라 모든 종류의 셰이더를 함께 연결하여 배경에 적용할 수 있습니다.

  • 다음은 이 두 가지를 결합하여 구현한 재미있는 예입니다.

  • 이제 기존의 반투명 유리 유형 효과를 사용할 수 있습니다.

  • 만화경과 같은 다른 재미있는 효과도 적용할 수 있습니다.

  • 또는 이 Android 마스코트를 아래의 콘텐츠를 변환하는 일종의 모핑 UI 요소로 바꾸고 싶다면 이 배경 렌더링 효과를 적용하여 이 작은 드로이드를 통해 보는 것처럼 보이게 할 수도 있습니다.

  • 배경 렌더링 효과를 사용해도 됩니다.

  • 이 새로운 API를 사용하면 배경 흐리게 처리와 반투명 유리 효과를 사용할 수 있을 뿐만 아니라 상상하는 모든 것을 구현할 수 있습니다.

  • 플랫폼에 도입된 또 다른 새로운 API는 SurfaceView.setBlurRegions입니다.

  • 현재는 Modifier.blur 및 RenderNode 배경 효과를 사용하여 텍스처 뷰와 모든 컴포저블을 흐리게 처리할 수 있지만, SurfaceView의 일부를 흐리게 처리하는 것은 까다롭고 앱의 뷰 RenderNode 트리에 포함되지 않습니다. 실제로 창에 구멍을 뚫기 때문입니다.

  • 따라서 Compose는 실제로 SurfaceView의 지원 버퍼를 샘플링할 수 없으므로 API 37.2 이전에는 이 기능이 불가능했습니다.

  • 따라서 SurfaceView의 setBlurRegions를 사용하면 앱에서 블러 영역을 직접 선언할 수 있습니다.

  • 예를 들어 JetChat의 이 동영상 플레이어에서는 컨트롤이 표시되고 숨겨질 때 SurfaceView 블러를 추가했습니다.

  • 이 새로운 API를 사용하려면 SurfaceView 메서드에서 setBlurRegions에 액세스하면 됩니다.

    val region = RoundedRectBlurRegion().apply {
       bounds = RectF(left, top, right, bottom)
       setCornerRadii(spec.cornerRadiusPx.coerceAtLeast(0f))
       alpha = spec.alpha.coerceIn(0f, 1f)
       blurRadius = spec.blurRadiusPx.coerceAtLeast(0f)
    }
    
    surfaceView.setBlurRegions(listOf(region))
    
    surfaceView.setBlurRegions(emptyList()) // Clear
  • 이렇게 하면 경계 영역 목록, 블러 반경, 알파, 모서리 반경 목록이 표시됩니다.

  • 이러한 경계를 전달할 때는 화면의 SurfaceView를 기준으로 해야 합니다.

  • 따라서 SurfaceView의 좌표와 흐리게 처리하려는 영역을 추적해야 합니다.

RuntimeColorFilter

  • Android 16 이상에서 제공되는 또 다른 새로운 기능은 RuntimeColorFilter입니다.

  • 이전에는 이미지의 경우 정적 매트릭스 색상 필터만 설정하고 채도와 같은 효과를 적용할 수 있었습니다.

  • 하지만 Android 16 이상에서는 이제 특정 콘텐츠의 입력 픽셀인 색상을 가져와 새 색상을 생성하는 셰이더를 전달할 수 있습니다.

  • 예를 들어 사진에 사이버펑크 필터를 적용하려면 RuntimeColorFilter에 입력으로 사용되는 작은 셰이더를 정의하면 됩니다.

    @Language("AGSL")
    private const val CYBERPUNK_SHADER = """
        vec4 main(half4 in_color) {
            float lum = dot(in_color.rgb, vec3(0.299, 0.587, 0.114));
            vec3 shadow = vec3(0.06, 0.04, 0.25);
            vec3 mid = vec3(0.92, 0.12, 0.58);
            vec3 high = vec3(0.18, 0.94, 0.98);
            vec3 col = lum < 0.5 ? mix(shadow, mid, lum * 2.0) : mix(mid, high, (lum - 0.5) * 2.0);
            return vec4(mix(in_color.rgb, col, 0.7), in_color.a);
        }
    """
  • 이 셰이더는 사이버펑크 테마를 생성하지만 원하는 다른 효과를 적용할 수 있습니다.

  • 따라서 셰이더 자체를 사용하려면 RuntimeColorFilter를 적용하고 Compose 색상 필터로 변환합니다.

    AsyncImage(
        model = imageUri,
        contentDescription = filter.displayName,
        colorFilter = RuntimeColorFilter(CYBERPUNK_SHADER)
                       .asComposeColorFilter(),
        contentScale = ContentScale.Crop,
        modifier = Modifier.fillMaxSize(),
    )
  • 이 예에서 볼 수 있듯이 이제 OpenGL, SurfaceView 설정 등을 자세히 살펴보지 않고도 이전보다 더 동적인 효과를 만들 수 있습니다.

  • 이미지뿐만 아니라 모든 컴포저블에 적용할 수 있습니다.

그래픽 성능 최적화

  • 감사합니다.

  • 지금까지 멋진 효과를 만들었지만 앱이 여전히 빠르고 부드럽게 느껴지는지 확인해 보겠습니다.

  • 대화를 스크롤하면 모든 효과가 함께 실행됩니다.

  • 이 스크롤을 측정하는 것부터 시작해 보겠습니다.

    @Test
    fun scrollFling() = measureScroll(swipeSteps = 5)
    
    private fun measureScroll(swipeSteps: Int) = rule.measureRepeated(
        packageName = TARGET_PACKAGE,
        metrics = defaultComposeScrollingMetrics(),
        compilationMode = CompilationMode.Full(),
        iterations = 5,
        setupBlock = {
            killProcess()
        }
    //...
  • 새 프로세스에서 앱을 실행하고 대화를 스크롤하는 매크로 벤치마크를 사용합니다.

  • 그런 다음 트레이스를 쿼리하여 기한을 놓친 프레임을 계산합니다.

    select count(*) as late_frames
    from actual_frame_timeline_slice
    where upid = (select upid from process where name = 'com.example.compose.jetchat.perf')
      and jank_type like '%App Deadline Missed%'
  • 그러면 지연 프레임이 6개가 됩니다.

  • 늦은 프레임 중 하나를 열어 보겠습니다.

  • 따라서 이 프레임 직전에 Compose는 다음 메시지가 위로 스크롤되어 뷰에 표시되기 전에 미리 가져옵니다.

  • 미리 가져오기는 프레임 간의 짧은 간격에 맞아야 하지만 이 메시지를 준비하면 기본 스레드에서 이미지를 디코딩하여 양보 없이 오랫동안 실행됩니다.

  • 기본 스레드가 이 이미지를 디코딩하는 데 멈춰 있으므로 새 프레임을 시작할 수 없습니다.

  • 그런 다음 해당 메시지가 실제로 뷰로 스크롤되면 RenderThread는 메모리를 할당하고 모든 픽셀을 GPU에 업로드해야 하므로 동일한 이미지가 두 번째 프레임을 지연시킵니다.

  • 이제 시간이 어디에 소요되었는지 알 수 있습니다. 하지만 이 프레임을 그리는 데 이 모든 작업이 필요할까요?

  • 어떻게 알 수 있나요?

  • trace를 보면 알 수 있습니다.

  • 큰 비트맵이 화면의 훨씬 작은 영역에 그려질 때마다 Android는 트레이스에 마커를 기록합니다.

  • 전체 비트맵 크기와 화면에서 실제로 차지하는 영역이라는 두 가지 숫자가 픽셀 단위로 표시됩니다.

  • 이를 화면 밀도로 나누면 DP 단위의 값을 얻을 수 있습니다.

  • 그런 다음 해당 DP 값으로 코드를 검색하여 이미지를 찾습니다.

  • 이것이 우리가 발견한 것입니다.

  • 이미지가 painterResource로 로드되는데, 이 라이브러리는 컨테이너의 크기를 알지 못하므로 전체 이미지를 전체 해상도로 디코딩합니다.

    Image(
        painter = painterResource(it),
        contentScale = ContentScale.Fit,
        modifier = Modifier.size(160.dp),
        contentDescription = stringResource(id = R.string.attached_image),
    )
  • 따라서 Coil의 AsyncImage로 대체합니다.

    AsyncImage(
        model = it,
        contentScale = ContentScale.Fit,
        modifier = Modifier.size(160.dp),
        contentDescription = stringResource(id = R.string.attached_image),
    )
  • 먼저 컨테이너를 측정하므로 필요한 픽셀만 디코딩하고 다른 스레드에서 이 작업을 실행합니다.

  • 동일한 벤치마크를 다시 실행하면 기본 스레드의 이미지 디코딩이 0으로, 지연 프레임이 2로 감소합니다.

  • 여기서는 이 두 프레임을 디버깅하지 않지만, 핵심은 간단합니다. 프레임에 도달하지 않는 픽셀은 계산하지 않도록 하세요.

  • 이미지 디코딩은 프레임을 지연시킬 만큼 큰 작업이었지만, 모든 비용이 프레임 드롭으로 나타나는 것은 아니며, 코드를 작성하는 동안 피할 수 있는 더 작은 비용도 있습니다.

  • 그중 두 가지를 살펴보겠습니다.

  • RuntimeShader를 사용할 때마다 한 번만 만들어야 합니다. 컴파일되고 캐시되지만 만들 때마다 소스 문자열을 파싱하기 때문입니다.

  • 효과가 모든 프레임에 애니메이션을 적용할 때마다 graphicsLayer로 래핑할 수 있으므로 애니메이션 요소만 다시 기록되고 주변 UI는 다시 그려지지 않습니다.

  • '하단으로 이동' 버튼은 스크롤할 때마다 표시됩니다.

  • 이 효과를 표시하기 위해 두 개의 RuntimeShader를 사용하며, 버튼이 표시될 때마다 소스 문자열에서 두 셰이더를 모두 다시 만듭니다.

    val lensShader = RuntimeShader(MAGNIFIER_LENS_SHADER).apply {
        setFloatUniform("size", size.width, size.height)
        setFloatUniform("zoom", zoom)
    // …
    val lensEffect = RenderEffect.createRuntimeShaderEffect(lensShader, "content")
    // …
    val glassShader = RuntimeShader(GLASS_FINISH_SHADER).apply {
  • 이 두 셰이더를 파싱하고 효과를 빌드하는 데는 버튼이 표시될 때마다 기본 스레드에서 수백 마이크로초가 걸립니다.

  • 대신 전체 프로세스에 대해 셰이더를 한 번 만들거나 기억한 다음 유니폼을 업데이트할 수 있습니다.

    val lensShader = MagnifierShaders.lens.apply {
        setFloatUniform("size", size.width, size.height)
        setFloatUniform("zoom", zoom)
    // …
    private object MagnifierShaders {
        val lens: RuntimeShader by lazy(LazyThreadSafetyMode.NONE) { RuntimeShader(MAGNIFIER_LENS_SHADER) }
        val glassFinish: RuntimeShader by lazy(LazyThreadSafetyMode.NONE) { RuntimeShader(GLASS_FINISH_SHADER) }
  • 이러한 변경으로 효과를 빌드하는 속도가 거의 40배 빨라졌고 버튼이 표시될 때의 비용은 20배 감소했습니다.

  • 앞서 살펴본 메시 그라데이션 글로우에서는 drawBehind 내에서 프레임마다 글로우가 애니메이션됩니다.

    Box(
        modifier = Modifier
            .matchParentSize()
            .drawBehind {
                // Behind the card, scaled past its bounds.
                translate(top = -58.dp.toPx()) {
                    scale(scaleX = 1.38f, scaleY = 2.85f) {
                        with(glowMeshPainter) {
    draw(size, alpha = glowAlpha)
    }
  • 따라서 그리기 단계에서 상태를 읽으면 컴포지션과 레이아웃이 유지되지만 디스플레이 목록은 단일 그리기 명령어를 제자리에서 업데이트할 수 없습니다.

  • 따라서 Compose는 트리를 올라가 그래픽 레이어가 있는 가장 가까운 노드를 찾습니다. 여기서는 전체 화면인 빨간색 윤곽선이며, 프레임마다 전체 레이어를 다시 기록합니다.

  • 상자에 graphicsLayer를 바로 배치하면 이동이 녹색 윤곽선에서 중지되므로 글로우 상자만 다시 기록되고 기본 스레드 그리기 슬라이스가 0.2밀리초만큼 줄어듭니다.

    Box(
        modifier = Modifier
            .matchParentSize()
                      .graphicsLayer()
            .drawBehind {
                // Behind the card, scaled past its bounds.
                translate(top = -58.dp.toPx()) {
                    scale(scaleX = 1.38f, scaleY = 2.85f) {
                        with(glowMeshPainter) {
    draw(size, alpha = glowAlpha)
    }
  • Perfetto를 사용하면 트레이스에서 무엇이 잘못되었는지 분석하기 어려울 수 있으므로 트레이스 분석 기술을 사용하여 유용한 정보를 수집할 수 있습니다.

  • 성능이 우수한 Compose 코드를 작성하는 데 도움이 더 필요하다면 Android CLI를 설치하고 Compose 성능 기술을 사용하여 시작하세요.

    android skills add perfetto-trace-analysis
    android skills add jetpack-compose-performance

요약 및 리소스

  • 오늘 시연한 예시를 확인하려면 이 링크에서 제공되는 JetChat 앱을 살펴보세요.

코드

코드 스니펫

'메시 그라데이션 및 맞춤 메시 렌더링'의 2분 47초 지점에 있는 Kotlin 스니펫
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
}
}
'메시 그라데이션 및 맞춤 메시 렌더링'의 2분 55초 지점에 있는 Kotlin 스니펫
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
   setVertex(0, 0, Offset(0.0f, 0.0f), indigo)

}
}
'메시 그라데이션 및 맞춤 메시 렌더링'의 3분 5초 지점에 있는 Kotlin 스니펫
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
   setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
     // ....
   setVertex(1, 1, Offset(0.2f, 0.4f) + Offset(animatedOffset, animatedOffset), coral)
   // ....
   setVertex(3, 0, Offset(0.0f, 1.0f), sunshine)
   // ....
}
}

Box(
   modifier = modifier.padding(32.dp)
       .safeContentPadding()
       .aspectRatio(16 / 9f)
       .fillMaxWidth()
       .paint(gradientPainter)
)
'메시 그라데이션 및 맞춤 메시 렌더링'의 3분 24초 지점에 있는 Kotlin 스니펫
MeshGradientPainter(
   rows = 2,
   columns = 3,
   hasBicubicColor = true,
) {
   // Row 0 (top edge, y = 0.0f)
   setVertex(0, 0, Offset(0.0000f, 0.0000f), Color(0xFFFF6B6B))
   setVertex(0, 1, Offset(0.3300f, 0.0000f), Color(0xFFFFD6D6))
   setVertex(0, 2, Offset(0.6700f, 0.0000f), Color(0xFFE8C3FF))
   setVertex(0, 3, Offset(1.0000f, 0.0000f), Color(0xFFE396FF))

   // ... remaining vertices for Rows 0, 1, and 2
}
'메시 그라데이션 및 맞춤 메시 렌더링'의 4분 9초 지점에 있는 Kotlin 스니펫
val transition = rememberInfiniteTransition(label = "userInputGlowMesh")

val phase1 by transition.animateFloat(
   initialValue = 0f,
   targetValue = (2 * PI).toFloat(),
   animationSpec = infiniteRepeatable(
       animation = tween(durationMillis = 2800, easing = LinearEasing),
       repeatMode = RepeatMode.Restart,
   ),
   label = "glowPhase1",
)

val phase2 by transition.animateFloat(
   initialValue = 0f,
   targetValue = (2 * PI).toFloat(),
   animationSpec = infiniteRepeatable(
       animation = tween(durationMillis = 4200, easing = LinearEasing),
       repeatMode = RepeatMode.Restart,
   ),
   label = "glowPhase2",
)
'메시 그라데이션 및 맞춤 메시 렌더링'의 4분 33초 지점에 있는 Kotlin 스니펫
MeshGradientPainter(
   rows = 6,
   columns = 5,
   hasBicubicColor = true,
) {
   for (r in 0..6) {
       for (c in 0..5) {
               val offset = if (r in 1..5 && c in 1..4) {
               val ampX = when (r) {
                   1, 2 -> 0.048f
                   3 -> 0.042f
                   4 -> 0.028f
                   else -> 0.036f
               }
               val ampY = when (r) {
                   1, 2 -> 0.028f
                   3 -> 0.020f
                   4 -> 0.022f
                   else -> 0.016f
               }
               val d = orbit(c, r, ampX, ampY)
               Offset(
                   x = (baseU + d.x).coerceIn(0.04f, 0.96f),
                   y = (baseV + d.y).coerceIn(0.05f, 0.95f),
               )
           } else {
               Offset(baseU, baseV)
           }
           setVertex(r, c, offset, gridColors[r][c])
       }
   }
}
'메시 그라데이션 및 맞춤 메시 렌더링'의 5분 40초 지점에 있는 Kotlin 스니펫
val lines = remember {
        path.asAndroidPath().flatten(0.5f)
    }
//... inside draw scope
lines.forEach { line ->
//...
    drawLine(
        brush = Brush.linearGradient(...),
        start = Offset(line.start.x,
line.start.y),
        end = Offset(line.end.x, line.end.y),
        strokeWidth = 30f,
    )
//...
}
'메시 그라데이션 및 커스텀 메시 렌더링'의 07:03 텍스트 스니펫
LineShaders.VERTEX

uniform float2 uStart;
uniform float2 uEnd;
uniform float uHalfWidth;
uniform float uTime;

Varyings main(const Attributes a) {
    Varyings v;
    float2 dir = uEnd - uStart;
    float2 normal = normalize(float2(-dir.y, dir.x));
    float along = a.corner.x * length(dir);
    float pulse = (a.corner.x - uTime) / 0.18;
    float radius = uHalfWidth * (1.0 + 0.6 * exp(-pulse * pulse));
    float across = a.corner.y * (radius + 1.0);
    v.position = uStart + dir * a.corner.x + normal * across;
    v.stroke = float3(along, across, radius);
    return v;
}
'메시 그라데이션 및 맞춤 메시 렌더링'의 8분 1초 지점 텍스트 스니펫
LineShaders.FRAGMENT

uniform float uLength;
layout(color) uniform float4 uStartColor;
layout(color) uniform float4 uEndColor;

float2 main(const Varyings v, out float4 color) {
    float distance = abs(v.stroke.y);
    float coverage = 1.0 - smoothstep(
    v.stroke.z - 0.5, v.stroke.z + 0.5, distance);
    float side = distance / v.stroke.z;
    float4 ink = mix(uStartColor, uEndColor,
        clamp(v.stroke.x / uLength + 0.4 * side * side, 0.0, 1.0));
    color = ink * coverage;
    return v.position;
}
'메시 그라데이션 및 맞춤 메시 렌더링'의 08:37에 나오는 Kotlin 스니펫
val spec = remember {
    MeshSpecification.make(
        arrayOf(Attribute(TYPE_FLOAT2, 0, "corner")),
        vertexStride = 8,
        arrayOf(Varying(TYPE_FLOAT3, "stroke")),
        LineShaders.VERTEX, LineShaders.FRAGMENT)
}
'메시 그라데이션 및 맞춤 메시 렌더링'의 9분 6초 지점에 있는 Kotlin 스니펫
fun lineCorners(segments: Int): ByteBuffer {
    val buffer = ByteBuffer.allocateDirect((segments + 1) * 2 * 8)
        .order(ByteOrder.nativeOrder())
    for (i in 0..segments) {
        val t = i / segments.toFloat()
        buffer.putFloat(t).putFloat(-1f) // one side
        buffer.putFloat(t).putFloat(1f)  // the other side
    }
    return buffer.rewind() as ByteBuffer
}
'메시 그라데이션 및 맞춤 메시 렌더링'의 09:18에 있는 Kotlin 스니펫
val mesh = Mesh(
    spec,
    mode = Mesh.TRIANGLE_STRIP,
    vertexBuffer = corners,
    vertexCount = (segments + 1) * 2,
    bounds = RectF(0f, 0f, size.width, size.height)
)
'메시 그라데이션 및 맞춤 메시 렌더링'의 9분 24초 지점에 있는 Kotlin 스니펫
Box(Modifier.fillMaxSize().drawWithCache {
   val mesh = Mesh(...)
   onDrawBehind {
       mesh.setFloatUniform("uTime", time.value)
       drawIntoCanvas {
          it.nativeCanvas.drawMesh(mesh,android.graphics.BlendMode.DST, paint)
       }
   }
})
'넓은 색 영역 지원'의 11:22에 있는 XML 스니펫
<!-- AndroidManifest.xml -->
<activity
android:name=".NavActivity"
android:colorMode="wideColorGamut"
android:exported="true">
</activity>
'넓은 색 영역 지원'의 11:24에 나오는 Kotlin 스니펫
// Dynamic toggle in code
DisposableEffect(window) {
   window?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
   onDispose {
       window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
   }
}
'넓은 색 영역 지원'의 11:49에 있는 Kotlin 스니펫
// 1. Display P3 Color (Wider gamut: greens & reds are more saturated) colorMode=wideColorGamut
val VividP3Red = Color(
   red = 1.0f,
   green = 0.05f,
   blue = 0.05f,
   alpha = 1.0f,
   colorSpace = ColorSpaces.DisplayP3
)
// 2. Extended sRGB (Enables HDR values > 1.0f) use colorMode=hdr
val HdrNeonCyan = Color(
   red = 0.0f,
   green = 1.8f,  // Peak brightness > 1.0 in extended gamut
   blue = 2.0f,
   alpha = 1.0f,
   colorSpace = ColorSpaces.ExtendedSrgb
)
// 3. BT.2020 HLG (Broadcast HDR standard, supported API 34+) - use colorMode=hdr
val HdrSunsetOrange = Color(
   red = 1.0f,
   green = 0.45f,
   blue = 0.1f,
   alpha = 1.0f,
   colorSpace = ColorSpaces.Bt2020Hlg
)
'Polygon shapes and morphing'의 12:45에 나오는 Kotlin 스니펫
fun cookiePolygon(): RoundedPolygon {
    val vertices = FloatArray(32)
    for (i in 0 until 16) {
        val radius = if (i % 2 == 0) 1f else 0.84f
// …
    }
    return RoundedPolygon(
        vertices,
        rounding = CornerRounding(0.12f)
    )
}
'다각형 도형 및 변형'의 13:15에 나오는 Kotlin 스니펫
class RoundedPolygonShape(
 polygon: RoundedPolygonShape
) : Shape {
    // …
    override fun createOutline(size: Size, layoutDirection: LayoutDirection,
    density: Density,
    ): Outline {
        path = polygon.toPath().asComposePath()
        // …
        matrix.scale(size.width / maxDimension, size.height / maxDimension)
        matrix.translate(-bounds.left, -bounds.top)
        // …
        return Outline.Generic(path)
    }
}
'다각형 도형 및 변형'의 13:30에 나오는 Kotlin 스니펫
private val AvatarShape = PolygonShape {
    val vertices = List(16) { i ->
        val radius = size.minDimension / 2f * if (i % 2 == 0) 1f else 0.86f
        val angle = i * PI.toFloat() / 8f
        size.center + Offset(cos(angle), sin(angle)) * radius
    }
    polygon(vertices, rounding = CornerRounding(radius = 8.dp))
}

    // …
Modifier.clip(AvatarShape)
'다각형 도형 및 변형'의 13:53에 있는 Kotlin 스니펫
val recordShape = remember {
    MorphPolygonShape(
        start = PolygonShape.circle(),
        end = PolygonShape.star(
            numPoints = 9,
            innerRadiusRatio = 0.8f,
            outerRounding = CornerRounding(percent = 50),
        ),
    ) { shapeProgress.value }
}
'흐리게 처리 및 배경 렌더링 효과'의 14:37에 나오는 Kotlin 스니펫
Modifier
   .blur(
       radiusX = 10.dp,
       radiusY = 10.dp,
       edgeTreatment = BlurredEdgeTreatment(
           shape = RoundedCornerShape(8.dp),
       ),
   )
'흐리게 처리 및 배경 렌더링 효과'의 15:15에 나오는 Kotlin 스니펫
Modifier.blur {
blurRadiusSpec = BlurRadiusSpec.verticalGradient(
listOf(
BlurStop(0.5f, 0.dp),
BlurStop(1.0f, 32.dp),
)
)
edgeTreatment = BlurredEdgeTreatment.Unbounded
}
'흐리게 처리 및 배경 렌더링 효과'의 15:42에 나오는 Kotlin 스니펫
BlurRadiusSpec.horizontalGradient(
startRadius = 0.dp,
endRadius = sliderValue.dp,
)
'Blurring and backdrop render effects'의 15:53에 나오는 Kotlin 스니펫
BlurRadiusSpec.radialGradient(
center = center,
fallOffRadius = 300.dp,
startRadius = 0.dp,
endRadius = 24.dp,
)
'Blurring and backdrop render effects'의 16:07에 나오는 Kotlin 스니펫
val customShader = remember {
   RuntimeShader(
       """
       uniform float2 size;
       uniform float time;

       half4 main(float2 coords) {
          // Diagonal wave mask
          float normalizedX = coords.x / size.x;
          float normalizedY = coords.y / size.y;
          float wave = sin((normalizedX + normalizedY) * 6.0 + time) * 0.5 + 0.5;
          return half4(0.0, 0.0, 0.0, wave);
       }
       """.trimIndent()
   )
}

val customBlurRadius = remember(customShader) {
   BlurRadiusSpec.shader(maxRadius = 24.dp) { customShader }
}

Modifier.blur {
       val w = size.width.toPx()
       val h = size.height.toPx()
       customShader.setFloatUniform("size", w, h)
       customShader.setFloatUniform("time", animatedTime)
       blurRadiusSpec = customBlurRadius
   },
'흐리게 처리 및 배경 렌더링 효과'의 17:40에 나오는 Kotlin 스니펫
fun Modifier.backdropBlur(spec: BlurRadiusSpec, shape: Shape, tint: Color): Modifier =
   this then BackdropBlurElement(spec, shape, tint)

private class BackdropBlurNode(...) : Modifier.Node(), DrawModifierNode {
   private var renderNode: RenderNode? = null

   override fun ContentDrawScope.draw() {
       val node = getOrCreateNode()
       if (isDirty || sizeChanged) {
           node.setPosition(0, 0, widthPx, heightPx)
           node.setBackdropRenderEffect(spec.createRenderEffect(this))
       }
       drawIntoCanvas { it.nativeCanvas.drawRenderNode(node) }
       drawContent()
   }
}
'Blurring and backdrop render effects'의 18:09에 나오는 Kotlin 스니펫
JetchatAppBar(
    modifier = modifier
        .backdropBlur(
            tint = MaterialTheme.colorScheme
                .surface.copy(alpha = 0.5f),
            shape = RectangleShape,
            spec = BlurRadiusSpec.uniform(5.dp)
        )
)
'Blurring and backdrop render effects'의 18:30에 나오는 Kotlin 스니펫
JetchatAppBar(
    modifier = modifier
        .backdropBlur(
            tint = MaterialTheme.colorScheme
                .surface.copy(alpha = 0.5f),
            shape = RectangleShape,
            spec = BlurRadiusSpec.verticalGradient(
                listOf(
                    BlurStop(0.5f, 32.dp),
                    BlurStop(1f, 0.dp),
                ),
            )
        )
)
'Blurring and backdrop render effects'의 19:10에 나오는 Kotlin 스니펫
val blur = RenderEffect.createBlurEffect(24f, 24f, Shader.TileMode.CLAMP)

// Boost saturation by 30% and apply blur together
val matrix = ColorMatrix().apply { setSaturation(1.3f) }
val vibrancy = RenderEffect.createColorFilterEffect(ColorMatrixColorFilter(matrix))
val vibrantBlur = RenderEffect.createChainEffect(vibrancy, blur)
'흐리게 처리 및 배경 렌더링 효과'의 19:23에 나오는 Kotlin 스니펫
val agslShader = RuntimeShader(CHROMATIC_ABERRATION)
val noiseEffect = RenderEffect.createRuntimeShaderEffect(agslShader, "backdropInput")

val finalEffect = RenderEffect.createChainEffect(noiseEffect, vibrantBlur)
renderNode?.setBackdropRenderEffect(finalEffect)
'흐리게 처리 및 배경 렌더링 효과'의 20:53에 나오는 Kotlin 스니펫
val region = RoundedRectBlurRegion().apply {
   bounds = RectF(left, top, right, bottom)
   setCornerRadii(spec.cornerRadiusPx.coerceAtLeast(0f))
   alpha = spec.alpha.coerceIn(0f, 1f)
   blurRadius = spec.blurRadiusPx.coerceAtLeast(0f)
}

surfaceView.setBlurRegions(listOf(region))

surfaceView.setBlurRegions(emptyList()) // Clear
'RuntimeColorFilter'의 21:33에 있는 Kotlin 스니펫
@Language("AGSL")
private const val CYBERPUNK_SHADER = """
    vec4 main(half4 in_color) {
        float lum = dot(in_color.rgb, vec3(0.299, 0.587, 0.114));
        vec3 shadow = vec3(0.06, 0.04, 0.25);
        vec3 mid = vec3(0.92, 0.12, 0.58);
        vec3 high = vec3(0.18, 0.94, 0.98);
        vec3 col = lum < 0.5 ? mix(shadow, mid, lum * 2.0) : mix(mid, high, (lum - 0.5) * 2.0);
        return vec4(mix(in_color.rgb, col, 0.7), in_color.a);
    }
"""
'RuntimeColorFilter'의 21:48에 있는 Kotlin 스니펫
AsyncImage(
    model = imageUri,
    contentDescription = filter.displayName,
    colorFilter = RuntimeColorFilter(CYBERPUNK_SHADER)
                   .asComposeColorFilter(),
    contentScale = ContentScale.Crop,
    modifier = Modifier.fillMaxSize(),
)
'그래픽 성능 최적화'의 22:21에 있는 Kotlin 스니펫
@Test
fun scrollFling() = measureScroll(swipeSteps = 5)

private fun measureScroll(swipeSteps: Int) = rule.measureRepeated(
    packageName = TARGET_PACKAGE,
    metrics = defaultComposeScrollingMetrics(),
    compilationMode = CompilationMode.Full(),
    iterations = 5,
    setupBlock = {
        killProcess()
    }
//...
'그래픽 성능 최적화'의 22:32에 있는 SQL 스니펫
select count(*) as late_frames
from actual_frame_timeline_slice
where upid = (select upid from process where name = 'com.example.compose.jetchat.perf')
  and jank_type like '%App Deadline Missed%'
'그래픽 성능 최적화'의 23:54에 나오는 Kotlin 스니펫
Image(
    painter = painterResource(it),
    contentScale = ContentScale.Fit,
    modifier = Modifier.size(160.dp),
    contentDescription = stringResource(id = R.string.attached_image),
)
'그래픽 성능 최적화'의 24:02에 있는 Kotlin 스니펫
AsyncImage(
    model = it,
    contentScale = ContentScale.Fit,
    modifier = Modifier.size(160.dp),
    contentDescription = stringResource(id = R.string.attached_image),
)
'그래픽 성능 최적화'의 25:14에 있는 Kotlin 스니펫
val lensShader = RuntimeShader(MAGNIFIER_LENS_SHADER).apply {
    setFloatUniform("size", size.width, size.height)
    setFloatUniform("zoom", zoom)
// …
val lensEffect = RenderEffect.createRuntimeShaderEffect(lensShader, "content")
// …
val glassShader = RuntimeShader(GLASS_FINISH_SHADER).apply {
'그래픽 성능 최적화'의 25:32에 나오는 Kotlin 스니펫
val lensShader = MagnifierShaders.lens.apply {
    setFloatUniform("size", size.width, size.height)
    setFloatUniform("zoom", zoom)
// …
private object MagnifierShaders {
    val lens: RuntimeShader by lazy(LazyThreadSafetyMode.NONE) { RuntimeShader(MAGNIFIER_LENS_SHADER) }
    val glassFinish: RuntimeShader by lazy(LazyThreadSafetyMode.NONE) { RuntimeShader(GLASS_FINISH_SHADER) }
'그래픽 성능 최적화'의 25:49에 있는 Kotlin 스니펫
Box(
    modifier = Modifier
        .matchParentSize()
        .drawBehind {
            // Behind the card, scaled past its bounds.
            translate(top = -58.dp.toPx()) {
                scale(scaleX = 1.38f, scaleY = 2.85f) {
                    with(glowMeshPainter) {
draw(size, alpha = glowAlpha)
}
'그래픽 성능 최적화'의 26:15에 있는 Kotlin 스니펫
Box(
    modifier = Modifier
        .matchParentSize()
                  .graphicsLayer()
        .drawBehind {
            // Behind the card, scaled past its bounds.
            translate(top = -58.dp.toPx()) {
                scale(scaleX = 1.38f, scaleY = 2.85f) {
                    with(glowMeshPainter) {
draw(size, alpha = glowAlpha)
}
'그래픽 성능 최적화'의 26:37에 있는 셸 스니펫
android skills add perfetto-trace-analysis
android skills add jetpack-compose-performance