应用基础知识

利用 Jetpack Compose 和 Android 中的最新图形功能提升应用体验

扬声器

关于

演讲简介

准备好让您的 Android 界面从“能用”升级为“艺术品”了吗?

欢迎加入我们,一起探索 Jetpack Compose 的最新图形功能。我们将向您展示如何利用 Android 中强大的新图形功能,打造全新的视觉效果,从令人愉悦的网格渐变到渐进式模糊,应有尽有。我们还将向您展示如何在不牺牲电池续航时间或帧速率的情况下,提升图形功能的性能。

摘要

  • Compose 1.12 引入了网格渐变,可在二维顶点网格中实现丰富的多色混合。在 Android 14 及更高版本中,您可以使用原生 drawMesh API 构建自定义几何图形驱动的视觉效果。
  • Compose 支持广色域,可在受支持的硬件上提供更平滑的渐变混合。仅在显示富媒体时,才在 activity 或窗口中启用广色域模式。
  • 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
  • 性能

转写文稿

Jetpack Compose 中的图形功能简介

  • 大家好,欢迎参加我们的讲座“利用 Compose 和 Android 中的最新图形功能提升应用体验”。

  • 我叫 Rebecca Franks,今天与我同台的是

  • 我是 Aayush Chaudhary。

  • 大家好,我是 Rahul Ray。

  • 我们看到一个新兴趋势:大家都在不断突破 Android 和 Compose 的极限,利用 AI 将各种奇思妙想变为现实。

  • 我们非常乐于看到那些体验高端、原生且令人愉悦的应用。

  • 我们知道,那些体验出色且始终获得用户好评的应用,通常会充分利用最新的系统级 API 变更,通过共享元素和模糊效果在不同界面之间实现连贯性,在各种设备规格上都能提供令人愉悦的自适应体验,性能出色,不会让用户感到困惑,并突破了设备功能的极限。

  • 在今天的演讲中,我们将向您展示一些最新功能,帮助您充分利用 Android,包括使用网格渐变让用户感到愉悦、启用广色域支持、使用多边形形状实现令人愉悦的过渡、模糊处理的新功能,最后,我们将介绍在向应用添加新图形功能时如何考虑性能。

  • 但在今天的演讲中,我们将介绍堆栈不同层级的一些新 API。

  • 虽然旧版 View 界面工具包现在处于维护模式,但我们仍会继续投资于为 Jetpack Compose 提供支持的底层 Android 图形硬件渲染引擎。

  • 您可以将 Android 中的图形分为三个不同的层:第一层是 Compose API,所有高级别的新界面图形 API(例如网格渐变绘制器、多边形形状和渐进式模糊处理 API)都位于这一层。

  • 然后是中间层,您可以在其中使用 Compose 的 DrawScope、绘制修饰符节点和 AGSL 构建自定义效果。

  • 最后,我们还有 Android 平台,Compose 始终可以访问原生 Canvas,并在 Compose 修饰符中直接使用全新的操作系统渲染基元。

  • 今天,我们将向您展示一些这方面的示例。

  • 我们这就来深入了解一下。

网格渐变和自定义网格渲染

  • 首先,我们来看看网格渐变,这是 Compose 1.12 中引入的新 API。

  • 以前,我们有线性、扫描和径向渐变,它们非常适合简单的过渡,但如果您想要丰富的多色混合,例如环境背景或泛光效果,则需要一个 2D 网格系统,其中位置和颜色可以跨空间混合。

  • 现在,要创建这样的网格渐变,首先需要指定所需的色块行数和列数。

    val gradientPainter = remember {
    MeshGradientPainter(rows = 3, columns = 3) {
    }
    }
  • 然后,在尾随 lambda 中,调用 setVertex 并分配一个介于 0F 到 1F 之间的归一化偏移量,以及每个网格交点的颜色。

    val gradientPainter = remember {
    MeshGradientPainter(rows = 3, columns = 3) {
       setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
    
    }
    }
  • 然后,为了让这些顶点动起来,为八个顶点添加动画偏移。

    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)
    )
  • 定义好 Painter 后,只需通过绘制修饰符将其与任何可组合项一起调用即可。

  • 现在,我们在 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 Alpha 值的边缘的顶点外部环。

  • 由于外边界是静止且透明的,因此发光效果会自然地羽化。

  • 最后,我们将这些动画偏移量提供给 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,
        )
    //...
    }
  • 然后,我们使用各自的渐变绘制每条线。

  • 这种方法可行,但每条线都需要单独的绘制调用和 Paint 对象,并且整条路径仍然只有一种宽度。

  • 另一种选择是运行时着色器,但它只能获取像素在容器边界内的位置。

  • 它没有告诉我们绘制这条丝带需要什么。

  • 而且,我们也很难根据像素的位置来计算。

  • Draw mesh 为我们提供了顶点着色器。

  • 那么,什么是网格?

  • 网格由三角形组成,每个角都有数据,每个角称为一个顶点。

  • 每个顶点都包含一小段数据记录。

  • 通常,该记录会保留一个位置,但并非必须如此。

  • 顶点着色器读取该记录并从中计算位置。

  • 这里是三角形的路径:顶点着色器为每个角运行,计算角的位置,以及我们想要为每个像素传递的任何值。

  • 然后,光栅化器会找到该三角形覆盖的每个像素,并根据这三个角的值为每个像素插值。

  • 最后,片段着色器会针对这些像素运行,使用这些插值并计算像素的颜色。

  • 我们来看一个更简单的例子,一条直线上有一个脉冲在移动,看看如何编写顶点和片段着色器。

    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 在这些符号之间进行插值时,中心线会变为零。

  • 最后,我们计算角的位置,并将所有这些值存储到描边 Varying 变量中,然后将它们传递给片段着色器。

  • 与运行时着色器不同,网格 fragment 着色器通过形参输出颜色,并返回用于对绘制进行采样的位置(如果已设置)。

    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)
    }
  • 首先,我们将 corner 属性声明为两个浮点数,从字节 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,请参阅 do- 文档。

广色域支持

  • 在 Compose 1.12 中,我们引入了对广色域的支持。

  • 那么,这究竟意味着什么呢?

  • 您知道吗?屏幕可以再现的颜色范围与传统 sRGB 色彩空间不同。

  • 例如,如果您查看此图片,会看到白色三角形是大多数屏幕可以显示的标准 sRGB 颜色范围。

  • 不过,一些较新的屏幕实际上支持更广泛的颜色,如黄色三角形所示,其中一些颜色更饱和、更鲜艳。

  • 但当我们表示一种颜色时,实际上是用该三角形每个顶点的描述来表示。

  • 在查看此渐变对比时,请务必区分显示色域和渐变插值。

  • 在标准 sRGB 显示屏或此投影仪上,屏幕实际上无法物理渲染此 sRGB 色域之外的颜色。

  • 所以,现在在台上演示这个功能其实非常困难。

  • 因此,您必须相信我,在支持广色域的 Android 设备上,颜色看起来会更好。

  • 在此示例中,您实际看到的是在更宽的色彩空间中插值色标的效果。

  • 在广色域或线性色彩空间中混合颜色会改变此渐变的实际数学路径,从而防止在不使用广色域的情况下可能出现的中间灰色和刺眼的条带。

  • 借助 Compose 1.12,您可以在任何显示屏上实现更平滑的渐变混合,并在受支持的硬件上运行时获得真正的广色域支持。

  • 为了在设备上启用正确的支持,您应确保将 activity 或窗口设置为启用广色域模式。

  • 您可以在 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 渲染的所有广色域颜色都会被剪裁。

  • 因此,如果显示屏实际上不支持广色域,请务必避免使用广色域颜色。

  • 您应仅在实际显示媒体、照片或丰富图形的 activity 或代码路径上启用广色域,以节省电池电量并提高性能。

  • 这意味着,您可以在 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 检查位图的来源,常用的图片加载库会自动为您处理正确的位图色彩空间。

  • 您应使用 Bitmap.Config.HARDWARE 来保留颜色信息,因为像 565 这样的配置实际上会降低精度,并让用户看到明显的色带。

  • 如需详细了解颜色,您可以回顾 2017 年 Romain Guy 讲解的 Compose 中的颜色或 Android 中的颜色。

多边形形状和渐变

  • 在 Compose 1.13 中,我们引入了对多边形的原生支持。

  • 这将使构建自定义形状和自然外观的界面元素变得更加容易。

  • 以前,如果您想绘制像这样的复杂几何图形,必须使用 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 会在轮廓解析阶段(而不是在组合阶段)读取进度值。

  • 这意味着,当形状在帧与帧之间变形时,它只会解析为新的轮廓,而不会触发重组。

模糊处理和背景渲染效果

  • 接下来,我们来谈谈模糊处理。

  • 在过去几个版本中,我们一直在努力让模糊效果的实现变得更加简单且更具表现力。

  • 每当我们谈到模糊处理时,都会看到这个常用的修饰符。

    Modifier
       .blur(
           radiusX = 10.dp,
           radiusY = 10.dp,
           edgeTreatment = BlurredEdgeTreatment(
               shape = RoundedCornerShape(8.dp),
           ),
       )
  • 自 Android 12 起,Compose 就已提供此修饰符。

  • 如果您希望实现均匀模糊效果,即在整个可组合项中应用完全相同的模糊半径,则此修饰符非常适用。

  • 但现代界面设计通常要求从清晰区域到模糊区域的过渡要平滑,而不是生硬的截断。

  • 接下来是渐进式模糊处理,从 Compose 1.13 开始,它已成为官方 API。

  • 现在,您可以在整个空间内改变模糊强度,而不是在整个可组合项中使用单一的恒定半径。

  • 以下是 JetChat 应用中用户个人资料界面的示例。

  • 新的模糊修饰符接受模糊半径规范。

    Modifier.blur {
    blurRadiusSpec = BlurRadiusSpec.verticalGradient(
    listOf(
    BlurStop(0.5f, 0.dp),
    BlurStop(1.0f, 32.dp),
    )
    )
    edgeTreatment = BlurredEdgeTreatment.Unbounded
    }
  • 在这里,我们使用具有两个模糊停止点的垂直渐变,使照片的上半部分在 0 dp 处完全清晰,并在底部边缘逐渐增加到 32 dp 的模糊。

  • 请注意,我们还将 edgeTreatment 设置为 unbounded,这基本上会指示模糊效果也对可组合项边界之外的透明像素进行采样。

  • 除了垂直淡出之外,此模糊半径规范还提供了多种内置方式来实现渐进式模糊。

  • 例如,左侧显示的是水平渐变,其中起始半径为 0 dp,结束半径由滑块值决定。

    BlurRadiusSpec.horizontalGradient(
    startRadius = 0.dp,
    endRadius = sliderValue.dp,
    )
  • 右侧是径向渐变,中心处的模糊半径为 0 dp,随着向 fallOffRadius(在本例中为 300 dp)移动,模糊半径会平滑增加到 24 dp。

    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
       },
  • 在这里,我们编写了一个小型 fragment 着色器,用于计算动画对角正弦波,并将该强度输出到 Alpha 通道。

  • 然后,着色器规范会将该 0 到 1 的遮罩直接映射到 24 dp 遮罩 maxRadius。

  • 在讨论背景虚化之前,我们先来看看这两者之间的区别。

  • 现在,左侧有一个应用了图层模糊效果的顶部应用栏。

  • 也就是说,它会使可组合项本身变得模糊。

  • 如您所见,标题和操作图标是模糊的,但其背后的聊天组件一点也不模糊。

  • 右侧是背景虚化。

  • 在这里,只有在顶部栏后面滚动的聊天消息会被采样并模糊处理。

  • 不过,虽然文字和图标非常清晰易读,

  • 从 Android 37.2 开始,您可以使用名为 RenderNode.setBackdropRenderEffect 的新 API 实现背景虚化。

  • 不过,在深入了解模糊效果的改进之前,我们先来了解一下 Android 的底层工作原理,以确保模糊效果具有良好的性能。

  • Android 在后台使用 RenderNode,这是一种硬件加速的显示列表容器,这意味着它可以通过开始录制和结束录制来压缩和存储一系列不同的绘制命令。

  • RenderNode 包含 3D 平移、旋转、Alpha、缩放和位置边界等信息。

  • 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 中的内置背景模糊修饰符。

  • 这只是 Android 上的 Compose 底层技术的一个示例。

  • 我展示的这个示例也仅适用于 API 级别 37.2,但我们也在为 Compose 开发向后移植版本,敬请期待。

  • RenderNode 背景幕效果的另一个有趣示例是,我们实际上还可以将它们链接在一起,以形成非常美观的界面,并在其上执行多种不同的效果。

  • 因此,为了链接特效,我们创建了这两个特效。

    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 吉祥物变成一种变形界面元素,用于转换其下方的内容,我们也可以应用这些背景渲染效果,使其看起来就像我们正在通过这个小机器人观察一样。

  • 我们还可以使用背景渲染效果来实现这一点。

  • 因此,这个新 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
  • 此方法接受边界区域列表、模糊半径、Alpha 值以及边界区域上的圆角半径列表。

  • 传入这些边界时,它们必须相对于屏幕上的 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,因此完全相同的图片会延迟第二帧。

  • 现在,我们知道了时间花在了哪里,但我们真的需要做所有这些工作来绘制这一帧吗?

  • 我们如何才能分辨这两种情况?

  • 跟踪记录会告诉我们。

  • 每当将大型位图绘制到屏幕上小得多的区域时,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 中,这样就只会重新录制呈现动画效果的元素,而不会重新绘制其周围的界面。

  • “跳至底部”按钮会在我们滚动时显示。

  • 此示例使用两个 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 {
  • 每次按钮显示时,在主线程上解析这两个着色器并构建效果都需要数百微秒。

  • 相反,我们为整个过程创建一次着色器,或者我们可以记住它,然后更新其 uniform。

    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 应用。

代码

代码段

“网格渐变和自定义网格渲染”中 02:47 处的 Kotlin 代码段
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
}
}
“网格渐变和自定义网格渲染”中 02:55 处的 Kotlin 代码段
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
   setVertex(0, 0, Offset(0.0f, 0.0f), indigo)

}
}
“网格渐变和自定义网格渲染”中 03:05 处的 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)
)
“网格渐变和自定义网格渲染”视频中 03: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
}
“网格渐变和自定义网格渲染”中 04:09 处的 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",
)
“网格渐变和自定义网格渲染”中 04: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])
       }
   }
}
“网格渐变和自定义网格渲染”中 05: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;
}
“网格渐变和自定义网格渲染”中 08:01 处的文字片段
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)
}
“网格渐变和自定义网格渲染”中 09:06 处的 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)
)
“网格渐变和自定义网格渲染”中 09: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
)
“多边形形状和变形”中 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,
)
“模糊处理和背景渲染效果”中 15:53 处的 Kotlin 代码段
BlurRadiusSpec.radialGradient(
center = center,
fallOffRadius = 300.dp,
startRadius = 0.dp,
endRadius = 24.dp,
)
“模糊处理和背景渲染效果”中 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()
   }
}
“模糊处理和背景渲染效果”中 18:09 处的 Kotlin 代码段
JetchatAppBar(
    modifier = modifier
        .backdropBlur(
            tint = MaterialTheme.colorScheme
                .surface.copy(alpha = 0.5f),
            shape = RectangleShape,
            spec = BlurRadiusSpec.uniform(5.dp)
        )
)
“模糊处理和背景渲染效果”中 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),
                ),
            )
        )
)
“模糊处理和背景渲染效果”中 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 处的 Shell 代码段
android skills add perfetto-trace-analysis
android skills add jetpack-compose-performance