應用程式基礎

運用 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 的最新圖像功能提升應用程式品質」課程。

  • 我是 Rebecca Franks,今天與我一同上台的是

  • 我是 Aayush Chaudhary。

  • 大家好,我是 Rahul Ray。

  • 我們發現,越來越多開發人員運用 AI 突破 Android 和 Compose 的極限,將絕妙構想化為現實。

  • 我們也樂見應用程式提供優質、原生且令人愉悅的體驗。

  • 我們知道,如果應用程式能讓使用者感到滿意,並持續獲得良好評分,通常會利用最新的系統層級 API 變更、在共用元素和模糊處理的介面之間保持連續性、在各種板型規格中都能提供良好的體驗、效能良好、不會讓使用者猜測,並突破裝置的可能性。

  • 在今天的演講中,我們將介紹一些最新功能,協助您充分運用 Android,包括使用網格漸層、啟用廣色域支援、使用多邊形形狀進行令人愉悅的轉場、模糊處理的新功能,最後,我們將介紹在應用程式中新增圖形功能時,如何考量效能。

  • 不過,在今天的講座中,我們會介紹堆疊中不同層級的一些新 API。

  • 雖然舊版 View UI 工具包目前處於維護模式,但我們仍會持續投資 Jetpack Compose 採用的基礎 Android 圖形硬體轉譯引擎。

  • Android 的圖形可分為三個層級:第一層是 Compose API,所有高階的新 UI 圖形 API 都在這裡,例如網格漸層繪圖器、多邊形和漸進式模糊處理 API。

  • 接著是中層,Compose 的 DrawScope、繪圖修飾符節點和 AGSL 可讓您建構自訂效果。

  • 最後,我們有 Android 平台,Compose 隨時可以存取原生 Canvas,並在 Compose 修飾符中使用全新的 OS 轉譯原始物件。

  • 今天我們會展示一些範例。

  • 現在就開始吧。

網格漸層和自訂網格算繪

  • 首先,我們來看看 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)
    )
  • 定義繪圖器後,只要透過繪圖修飾符,即可使用任何可組合項呼叫。

  • 現在,我們在 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",
    )
  • 在這裡,我們為內部頂點加上動畫,並固定外部環,也就是 Alpha 值為 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,
        )
    //...
    }
  • 接著,我們繪製每條線,並套用各自的漸層。

  • 雖然這個方法可行,但每條線都需要個別的繪製呼叫和繪製物件,而且整個路徑仍只有單一寬度。

  • 另一個選項是執行階段著色器,但只能取得容器邊界內的像素位置。

  • 但並未說明繪製這條緞帶所需的資訊。

  • 我們也無法輕易從像素位置計算。

  • 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 在這些正負號之間進行內插時,中心線會變成零。

  • 最後,我們會計算角落的位置,並將所有這些值儲存到筆劃變化中,然後傳遞至片段著色器。

  • 與執行階段著色器不同,網格片段著色器會透過參數輸出顏色,並傳回用於取樣繪製的位置 (如有設定)。

    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 檢查點陣圖的來源,熱門的圖片載入程式庫會自動為您處理正確的點陣圖色域。

  • 請使用 Bitmap.Config.HARDWARE 保留顏色資訊,因為 565 等設定實際上會失去精確度,並向使用者顯示可見的條帶。

  • 如要進一步瞭解顏色,您可以回顧 2017 年 Romain Guy 談論 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 會在輪廓解析階段讀取進度值,也就是說,不是在重組期間,也不是在組成期間。

  • 這表示當形狀在影格之間變形時,只會解析成新的輪廓,不會觸發重組。

模糊和背景算繪效果

  • 接著來談談模糊處理。

  • 在過去幾個版本中,我們致力讓模糊效果的實作更簡單、更具表現力。

  • 只要談到模糊處理,我們通常會看到這個修飾符。

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

  • 如果想在整個可組合項中套用完全相同的模糊半徑,達到一致的模糊效果,這個方法非常實用。

  • 不過,現代 UI 設計通常會要求從清晰區域平滑轉換到模糊區域,而不是硬性切換。

  • 這就帶我們來到漸進式模糊處理,從 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 設為無限制,這基本上會指示模糊效果,對可組合項邊界外的透明像素進行取樣。

  • 除了垂直淡出效果,這項模糊半徑規格還提供多種內建方式,可產生漸進式模糊效果。

  • 舉例來說,左側顯示的是水平漸層,起始半徑為 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
       },
  • 我們在這裡編寫一個小型片段著色器,用於計算動畫對角正弦波,並將該強度輸出至 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 吉祥物變成一種變形 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
  • 這會採用邊界區域、模糊半徑、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%'
  • 因此有六個延遲影格。

  • 我們來開啟其中一個延遲影格。

  • 因此,在這一幀之前,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 中,這樣系統只會重新記錄動畫元素,而不會重新繪製周圍的 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 應用程式。

程式碼

程式碼片段

「網格漸層和自訂網格算繪」影片 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])
       }
   }
}
「Mesh gradients and custom mesh rendering」影片 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)
}
「Mesh gradients and custom mesh rendering」影片 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)
       }
   }
})
「Wide color gamut support」影片 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)
    )
}
「Polygon shapes and morphing」影片 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)
「Polygon shapes and morphing」影片 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(),
)
「Graphics performance optimization」影片 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()
    }
//...
「Graphics performance optimization」影片 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%'
「Graphics performance optimization」中 23:54 的 Kotlin 程式碼片段
Image(
    painter = painterResource(it),
    contentScale = ContentScale.Fit,
    modifier = Modifier.size(160.dp),
    contentDescription = stringResource(id = R.string.attached_image),
)
「Graphics performance optimization」影片 24:02 處的 Kotlin 程式碼片段
AsyncImage(
    model = it,
    contentScale = ContentScale.Fit,
    modifier = Modifier.size(160.dp),
    contentDescription = stringResource(id = R.string.attached_image),
)
「Graphics performance optimization」影片中的 Kotlin 程式碼片段 (25:14)
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 {
「Graphics performance optimization」影片中 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) }
「Graphics performance optimization」影片 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)
}
「Graphics performance optimization」影片 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)
}
「Graphics performance optimization」影片 26:37 處的殼層程式碼片段
android skills add perfetto-trace-analysis
android skills add jetpack-compose-performance