วิศวกรซอฟต์แวร์
วิศวกรนักพัฒนาซอฟต์แวร์สัมพันธ์
พร้อมที่จะยกระดับ UI ของ Android จาก "ใช้งานได้" เป็น "สวยงามราวกับงานศิลปะ" แล้วหรือยัง
เข้าร่วมกับเราเพื่อดูความสามารถด้านกราฟิกล่าสุดใน Jetpack Compose เราจะแสดงวิธีสร้างเอฟเฟกต์ภาพใหม่ๆ ตั้งแต่การไล่ระดับสีแบบเมชที่สวยงามไปจนถึงการเบลอแบบโปรเกรสซีฟ และการใช้ฟีเจอร์กราฟิกใหม่ที่มีประสิทธิภาพใน Android นอกจากนี้ เรายังจะแสดงวิธีปรับปรุงประสิทธิภาพของฟีเจอร์กราฟิกโดยไม่ส่งผลต่ออายุการใช้งานแบตเตอรี่หรืออัตราเฟรม
PolygonShape และ MorphPolygonShape API แบบเนทีฟสำหรับเรขาคณิตที่กำหนดเอง MorphPolygonShape จะอ่านความคืบหน้าในระหว่างขั้นตอนการแก้ไขโครงร่าง โดยอัปเดตรูปร่างทีละเฟรมโดยไม่ทริกเกอร์การจัดองค์ประกอบใหม่RenderNode.setBackdropRenderEffect สำหรับการสุ่มตัวอย่างเนื้อหาพื้นหลัง นักพัฒนาแอปสามารถเชื่อมโยงเอฟเฟกต์ฉากหลังเข้าด้วยกันเพื่อสร้างเอฟเฟกต์กระจกฝ้าหรือเอฟเฟกต์แว่นขยายได้graphicsLayerข้อความถอดเสียง
สวัสดีทุกคน และยินดีต้อนรับสู่เซสชัน "ยกระดับแอปด้วยฟีเจอร์กราฟิกล่าสุดใน Compose และ Android"
ฉันชื่อ Rebecca Franks และผู้ที่จะมาร่วมพูดคุยกับฉันบนเวทีในวันนี้คือ
ฉันชื่อ Aayush Chaudhary
สวัสดี ฉันชื่อ Rahul Ray
เราเห็นเทรนด์ใหม่ๆ ที่คุณกำลังก้าวข้ามขีดจำกัดของสิ่งที่เป็นไปได้ใน Android และ Compose โดยใช้ AI เพื่อทำให้ไอเดียอันน่าทึ่งของคุณเป็นจริง
และเราชอบเห็นแอปที่ให้ความรู้สึกพรีเมียม เป็นแบบเนทีฟ และน่าพึงพอใจ
เราทราบดีว่าแอปที่ให้ความรู้สึกดีและได้รับคะแนนที่ยอดเยี่ยมจากผู้ใช้อย่างสม่ำเสมอคือแอปที่โดยทั่วไปแล้วใช้ประโยชน์จากการเปลี่ยนแปลง API ระดับระบบล่าสุด มีความต่อเนื่องระหว่างแพลตฟอร์มต่างๆ ด้วยองค์ประกอบที่ใช้ร่วมกันและการเบลอ ปรับเปลี่ยนได้อย่างน่าพึงพอใจในรูปแบบต่างๆ และมีประสิทธิภาพ ไม่ทำให้ผู้ใช้ต้องคาดเดา และขยายขอบเขตสิ่งที่เป็นไปได้ด้วยอุปกรณ์ของคุณ
ในการพูดคุยวันนี้ เราจะแสดงฟีเจอร์ล่าสุดบางส่วนที่ช่วยให้คุณใช้ประโยชน์จาก Android ได้ ตั้งแต่การสร้างความพึงพอใจให้ผู้ใช้ด้วยการไล่ระดับสีแบบเมช การเปิดใช้การรองรับขอบเขตสีกว้าง การเปลี่ยนภาพที่น่าพึงพอใจด้วยรูปทรงหลายเหลี่ยม สิ่งใหม่ๆ ในการเบลอ และสุดท้าย เราจะพูดถึงวิธีคิดเกี่ยวกับประสิทธิภาพเมื่อเพิ่มฟีเจอร์กราฟิกใหม่ๆ ลงในแอป
แต่ตลอดการพูดคุยในวันนี้ เราจะพูดถึง API ใหม่บางรายการในระดับต่างๆ ของสแต็ก
แม้ว่าชุดเครื่องมือ UI ของมุมมองเดิมจะอยู่ในโหมดการบำรุงรักษาแล้ว แต่เรายังคงลงทุนในเครื่องมือแสดงผลฮาร์ดแวร์กราฟิก Android พื้นฐานที่ขับเคลื่อน Jetpack Compose
คุณอาจมองกราฟิกใน Android เป็น 3 เลเยอร์ที่แตกต่างกัน ได้แก่ Compose API ในเลเยอร์แรก ซึ่งเป็นที่ที่ API กราฟิก UI ใหม่ระดับสูงทั้งหมดอยู่ เช่น Mesh Gradient Painter, รูปทรงหลายเหลี่ยม และ Progressive Blur API
จากนั้นเรามีเลเยอร์ระดับกลาง ซึ่ง DrawScope ของ Compose, โหนดตัวแก้ไขการวาด และ AGSL ช่วยให้คุณสร้างเอฟเฟกต์ที่กำหนดเองได้
แต่สุดท้ายแล้ว เรามีแพลตฟอร์ม Android และ Compose สามารถเข้าถึง Canvas แบบเนทีฟและใช้พื้นฐานการแสดงผลระบบปฏิบัติการใหม่ล่าสุดได้เสมอภายในตัวแก้ไข Compose
และเราจะแสดงตัวอย่างการดำเนินการนี้ให้คุณดูในวันนี้
มาเริ่มกันเลย
ก่อนอื่น เรามาดูการไล่ระดับสีแบบเมช ซึ่งเป็น API ใหม่ที่เปิดตัวใน Compose 1.12 กัน
ก่อนหน้านี้เรามีการไล่ระดับสีแบบเส้นตรง แบบกวาด และแบบรัศมี ซึ่งเหมาะสำหรับการเปลี่ยนสีแบบง่ายๆ แต่หากต้องการการผสมสีที่หลากหลาย เช่น พื้นหลังแบบแอมเบียนท์หรือเอฟเฟกต์แสงฟลัดไลท์ คุณต้องใช้ตารางกริด 2 มิติของระบบที่ตำแหน่งและสีสามารถผสมกันได้ทั่วทั้งพื้นที่
หากต้องการสร้างการไล่ระดับสีแบบเมชเช่นนี้ ก่อนอื่นให้ระบุจำนวนแถวและคอลัมน์ของแพตช์ที่ต้องการ
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)
}
}จากนั้นหากต้องการสร้างภาพเคลื่อนไหวให้จุดยอดเหล่านั้น ให้เพิ่มออฟเซ็ตแบบเคลื่อนไหวสำหรับจุดยอด 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)
)เมื่อกำหนด Painter แล้ว ให้เรียกใช้ด้วย Composable ใดก็ได้ผ่านตัวแก้ไขการวาด
ตอนนี้เราได้เพิ่มฟีเจอร์ในแอป JetChat เพื่อกดชอบข้อความเมื่อแตะสองครั้ง
ในการสร้าง เราจะตั้งค่าตารางกริดขนาด 2 คูณ 3 และวางจุดสีต่างๆ ไว้ทั่วคอมโพเนนต์
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 เป็น "จริง" ที่นี่
โดยค่าเริ่มต้น สีจะสอดแทรกเป็นเส้นตรงข้ามสามเหลี่ยม ซึ่งบางครั้งอาจทำให้มองเห็นเป็นหย่อมๆ
การเปิดตัวเลือกนี้จะเปลี่ยนเฉดสีเป็นการประมาณค่าในช่วงแบบไบคิวบิกที่ราบรื่น เพื่อให้สีต่างๆ ผสมผสานกันได้อย่างราบรื่น
ทีนี้ถ้าคุณต้องการสร้างแสงเรืองรอบๆ แบบเคลื่อนไหวเหมือนในตัวอย่างนี้ไว้ด้านหลังช่องป้อนข้อมูลล่ะ
หากต้องการสร้างเอฟเฟกต์แบบนี้ด้วยการไล่ระดับสีแบบเมช คุณต้องมี 2 สิ่ง
อย่างแรกคือการเคลื่อนไหวแบบออร์แกนิกที่ราบรื่นสำหรับจุดยอดภายใน และวิธีทำให้ขอบด้านนอกดูนุ่มนวลขึ้นเพื่อไม่ให้เกิดสี่เหลี่ยมผืนผ้าแข็งๆ รอบการ์ด
เราจึงเริ่มด้วยการตั้งค่า rememberInfiniteTransition ที่มีมุมเฟส 2 มุมซึ่งทำงานในระยะเวลาที่ต่างกันเล็กน้อย เพื่อให้การเคลื่อนไหวดูเป็นธรรมชาติมากกว่ากลไก
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])
}
}
}การไล่ระดับสีแบบเมชที่คุณเพิ่งเห็นนั้นวาดขึ้นโดยใช้ drawMesh API ใน Android 14 ขึ้นไป
มาดูรายละเอียดเพิ่มเติมเกี่ยวกับ API นี้ด้วยการสร้างเอฟเฟกต์ตั้งแต่ต้นกัน
สำหรับหน้าจอต้อนรับของ JetChat เราต้องการวาดเส้นทางที่ดูเหมือนริบบิ้น
โปรดสังเกต 2 สิ่งที่นี่ ได้แก่ ความกว้างของเส้นไม่คงที่และสีจะวิ่งไปตามแต่ละเส้นของเส้นทาง
มาดูรายละเอียดและทำความเข้าใจสิ่งที่เราต้องใช้ในการสร้างเอฟเฟกต์นี้กัน
สำหรับแต่ละพิกเซล เราต้องตัดสินใจว่าจะวาดหรือไม่ และจะใช้สีใด
ในการหาคำตอบ เราต้องใช้ระยะทาง 2 ค่า
ค่าแรกคือระยะห่างของพิกเซลจากจุดศูนย์กลางของเส้นขีด
ระยะทางที่ 2 คือระยะทางตามเส้นทางของพิกเซลจากจุดเริ่มต้นไปยังจุดสิ้นสุด
ซึ่งจะบอกเราว่าจะเลือกสีใดจากการไล่ระดับสี และการผสมค่าแรกเล็กน้อยจะทำให้สีโค้งตามความกว้าง
เราจะวาดการไล่ระดับสีตามเส้นทางโดยใช้เพียงเส้นทางและสีได้ไหม
เราทำได้
ซึ่งต้องทำให้เส้นทางแบนราบเป็นรายการเส้นตรงสั้นๆ ซึ่งจะให้ระยะทางตามเส้นทางด้วย
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,
)
//...
}จากนั้นเราจะวาดแต่ละเส้นด้วยการไล่ระดับสีของตัวเอง
วิธีนี้ใช้ได้ แต่แต่ละเส้นต้องใช้คำสั่งประมวลผลและออบเจ็กต์การวาดแยกกัน และทั้งเส้นทางยังคงมีความกว้างเดียว
อีกตัวเลือกหนึ่งคือ Shader รันไทม์ แต่จะได้รับเฉพาะตำแหน่งของพิกเซลภายในขอบเขตคอนเทนเนอร์เท่านั้น
แต่ไม่ได้บอกว่าเราต้องใช้อะไรบ้างในการวาดริบบิ้นนี้
และเราไม่สามารถคำนวณจากตำแหน่งของพิกเซลได้ง่ายๆ เช่นกัน
Draw mesh จะให้เวอร์เท็กซ์เชเดอร์สำหรับสิ่งนั้น
แล้ว Mesh คืออะไร
Mesh ประกอบด้วยสามเหลี่ยมที่มีข้อมูลอยู่ที่แต่ละมุม และแต่ละมุมเรียกว่าจุดยอด
จุดยอดแต่ละจุดจะมีบันทึกข้อมูลขนาดเล็ก
โดยปกติแล้ว ระเบียนนั้นจะเก็บตำแหน่งไว้ แต่ก็ไม่จำเป็นเสมอไป
เวอร์เท็กซ์เชเดอร์จะอ่านระเบียนนั้นและคำนวณตำแหน่งจากระเบียนดังกล่าว
นี่คือเส้นทางที่สามเหลี่ยมหนึ่งใช้: ตัวปรับแสงเงาเวอร์เท็กซ์จะทำงานสำหรับแต่ละมุม โดยจะคำนวณตำแหน่งของมุม และค่าใดๆ ที่เราต้องการส่งต่อสำหรับแต่ละพิกเซล
จากนั้นตัวสร้างราสเตอร์จะค้นหาทุกพิกเซลที่สามเหลี่ยมนั้นครอบคลุม และประมาณค่าจากมุมทั้ง 3 มุมสำหรับแต่ละพิกเซล
สุดท้าย Fragment Shader จะทำงานสำหรับพิกเซลเหล่านั้น โดยใช้ค่าที่ประมาณในช่วงและคำนวณสีของพิกเซลที่คำนวณ
มาดูวิธีเขียนเวอร์เท็กซ์และแฟรกเมนต์เชเดอร์ด้วยตัวอย่างที่ง่ายขึ้นกัน ซึ่งก็คือเส้นตรงที่มีพัลส์เคลื่อนที่ไปตามเส้น
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;
}เวอร์เท็กซ์เชเดอร์จะรับแอตทริบิวต์เป็นอินพุตและแสดงผลลัพธ์เป็นตัวแปร
แอตทริบิวต์คือข้อมูลสำหรับมุมเดียว
ซึ่งมีตัวเลขเพียง 2 ตัว ได้แก่ ความคืบหน้าตามเส้น และด้านของเส้นที่อยู่
จากนั้นระบบจะประมาณค่า Varying และส่งไปยัง Fragment Shader
เราคำนวณระยะทางพิกเซลจากจุดเริ่มต้น และสำหรับความกว้างของเส้นขีด เราใช้เวลาในการสร้างพัลส์บนรัศมี
จากนั้นเราจะคูณรัศมีนั้นด้วยด้านมุม ซึ่งจะทำให้เราได้ระยะห่างของเครื่องหมายตลอดเส้นขีด
เมื่อ GPU ทำการประมาณค่าระหว่างเครื่องหมายเหล่านั้น เส้นกึ่งกลางจะกลายเป็นศูนย์
สุดท้าย เราจะคำนวณตำแหน่งของมุม และจัดเก็บค่าทั้งหมดนี้ไว้ในตัวแปรของเส้นขอบ แล้วส่งไปยัง Fragment Shader
Mesh Fragment Shader จะแสดงผลสีผ่านพารามิเตอร์ และแสดงผลตำแหน่งสำหรับการสุ่มตัวอย่างสีหากมีการตั้งค่าไว้ ซึ่งแตกต่างจากรันไทม์ Shader
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)
}ก่อนอื่น เราจะประกาศแอตทริบิวต์มุมเป็นค่า Float 2 ค่าที่เริ่มต้นที่ไบต์ 0
เนื่องจาก Float 2 ตัวใช้ 8 ไบต์ สไตรด์มุมแต่ละมุมในบัฟเฟอร์จึงเป็น 8
ถัดไป เราจะประกาศตัวแปร Stroke Stroke เป็น Float 3 ตัว และส่งผ่าน Vertex และ Fragment Shader ที่เราเพิ่งเขียน
เราสร้างบัฟเฟอร์จุดยอด
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 และเขียนมุม 2 มุมลงในบัฟเฟอร์
สุดท้าย เราจะรวมข้อมูลจำเพาะและบัฟเฟอร์เข้าเป็นออบเจ็กต์ Mesh
val mesh = Mesh(
spec,
mode = Mesh.TRIANGLE_STRIP,
vertexBuffer = corners,
vertexCount = (segments + 1) * 2,
bounds = RectF(0f, 0f, size.width, size.height)
)Canvas ของ Compose ยังไม่แสดง drawMesh เราจึงลดระดับลงเป็น nativeCanvas และวาด Mesh
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>หรือแบบเป็นโปรแกรมใน Composable
// Dynamic toggle in code
DisposableEffect(window) {
window?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
onDispose {
window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
}
}หากไม่ได้ตั้งค่า colorMode หน้าต่างจะยังคงอยู่ใน sRGB แบบ 8 บิตมาตรฐาน และสีขอบเขตกว้างที่แสดงผลโดย 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 เพื่ออธิบายเรขาคณิตที่จะแสดงผลเป็นเส้นทาง แล้วแมปเส้นทางนั้นกับ API รูปร่างของ Compose
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 คุณจะต้องเขียน Wrapper รูปร่างที่กำหนดเองดังนี้
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 ใหม่ คุณไม่จำเป็นต้องใช้ Wrapper รูปร่างที่กำหนดเองนั้นอีกต่อไป
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 หรือแม้แต่เป็นเปอร์เซ็นต์ได้โดยตรง
เมื่อกำหนดแล้ว คุณก็เพียงแค่ส่งไปยัง API ใดก็ได้ที่ใช้ Shape
นอกจากนี้ เรายังเปิดตัว MorphPolygonShape API ซึ่งช่วยให้คุณเปลี่ยนระหว่างรูปทรงหลายเหลี่ยมต่างๆ ได้อย่างราบรื่น
val recordShape = remember {
MorphPolygonShape(
start = PolygonShape.circle(),
end = PolygonShape.star(
numPoints = 9,
innerRadiusRatio = 0.8f,
outerRounding = CornerRounding(percent = 50),
),
) { shapeProgress.value }
}ในตัวอย่างนี้ เรามีปุ่มบันทึกที่เปลี่ยนจากวงกลมเป็นดาวเมื่อคุณโต้ตอบด้วย
คุณเพียงแค่ระบุรูปร่างเริ่มต้น รูปร่างสิ้นสุด และค่าความคืบหน้า
API MorphPolygonShape จะอ่านค่าความคืบหน้าในระหว่างขั้นตอนการแก้ไขโครงร่าง ซึ่งหมายความว่าจะไม่อ่านในระหว่างการจัดองค์ประกอบใหม่หรือการจัดองค์ประกอบ
ซึ่งหมายความว่าเมื่อรูปร่างเปลี่ยนไปในแต่ละเฟรม รูปร่างจะเปลี่ยนเป็นโครงร่างใหม่โดยไม่ทริกเกอร์การจัดองค์ประกอบใหม่
ต่อไปเรามาพูดถึงการเบลอกัน
ในช่วง 2-3 รุ่นที่ผ่านมา เราพยายามทำให้การใช้การเบลอทำได้ง่ายขึ้นและสื่อความหมายได้มากขึ้น
เมื่อใดก็ตามที่เราพูดถึงการเบลอ นี่คือตัวแก้ไขที่ใช้กันโดยทั่วไปที่เราเห็น
Modifier
.blur(
radiusX = 10.dp,
radiusY = 10.dp,
edgeTreatment = BlurredEdgeTreatment(
shape = RoundedCornerShape(8.dp),
),
)Compose มีตัวแก้ไขนี้ตั้งแต่ Android 12
ซึ่งเหมาะอย่างยิ่งเมื่อคุณต้องการการเบลอที่สม่ำเสมอ โดยใช้รัศมีการเบลอเดียวกันตลอดทั้ง Composable
แต่การออกแบบ UI สมัยใหม่มักต้องการการเปลี่ยนผ่านที่ราบรื่นจากบริเวณที่คมชัดไปยังบริเวณที่เบลอ แทนที่จะเป็นการตัดขอบที่ชัดเจน
และนั่นก็นำเราไปสู่ Progressive Blur ซึ่งตอนนี้เป็น API อย่างเป็นทางการแล้ว โดยเริ่มตั้งแต่ Compose 1.13
ตอนนี้คุณสามารถปรับความเข้มของภาพเบลอในพื้นที่ต่างๆ ได้ แทนที่จะใช้รัศมีคงที่เดียวทั่วทั้ง Composable
นี่คือตัวอย่างจากแอป JetChat จากหน้าจอโปรไฟล์ผู้ใช้
ตัวแก้ไขการเบลอใหม่จะใช้ข้อกำหนดรัศมีการเบลอ
Modifier.blur {
blurRadiusSpec = BlurRadiusSpec.verticalGradient(
listOf(
BlurStop(0.5f, 0.dp),
BlurStop(1.0f, 32.dp),
)
)
edgeTreatment = BlurredEdgeTreatment.Unbounded
}ในที่นี้เราใช้การไล่ระดับสีแนวตั้งที่มีจุดเบลอ 2 จุด โดยให้ครึ่งบนของรูปภาพคมชัดอย่างสมบูรณ์ที่ 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,
)และหากการไล่ระดับสีแนวตั้ง รัศมี และแนวนอนทั้งหมดนี้ยังไม่เพียงพอ คุณก็สามารถใช้ Shaders ที่กำหนดเองเพื่อสร้างการเบลอแบบโปรเกรสซีฟได้เสมอ
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 Shader ขนาดเล็กที่คำนวณคลื่นไซน์แนวทแยงแบบเคลื่อนไหว และแสดงผลความเข้มนั้นลงในช่องอัลฟ่า
จากนั้นข้อกำหนดของเฉดสีจะแมปมาสก์ 0 ถึง 1 นั้นกับมาสก์ 24 dp โดยตรง maxRadius
ก่อนที่เราจะพูดถึงการเบลอพื้นหลัง มาดูความแตกต่างระหว่างทั้ง 2 แบบกันก่อน
ทางด้านซ้าย เรามีแถบแอปด้านบนที่ใช้การเบลอเลเยอร์
กล่าวคือจะเบลอ Composable เอง
อย่างที่คุณเห็น ชื่อและไอคอนการดำเนินการเบลอ แต่องค์ประกอบแชทที่อยู่ด้านหลังไม่เบลอเลย
ทางด้านขวามือ เรามีการเบลอพื้นหลัง
ในกรณีนี้ ระบบจะสุ่มตัวอย่างและเบลอเฉพาะข้อความแชทที่เลื่อนอยู่ด้านหลังแถบด้านบนเท่านั้น
อย่างไรก็ตาม ข้อความและไอคอนมีความคมชัดและอ่านได้ง่าย
ตั้งแต่ Android 37.2 เป็นต้นไป คุณสามารถเบลอพื้นหลังได้โดยใช้ API ใหม่ที่ชื่อว่า RenderNode.setBackdropRenderEffect
แต่ก่อนที่จะเจาะลึกถึงวิธีปรับปรุงการเบลอ เรามาทำความเข้าใจวิธีการทำงานของ Android เบื้องหลังกันก่อนเพื่อให้มั่นใจว่าการเบลอจะมีประสิทธิภาพ
Android ใช้ RenderNode เบื้องหลัง ซึ่งเป็นคอนเทนเนอร์รายการแสดงผลที่เร่งด้วยฮาร์ดแวร์ ซึ่งหมายความว่าสามารถบีบอัดและจัดเก็บคำสั่งการวาดต่างๆ จำนวนมากผ่านการเริ่มบันทึกและสิ้นสุดการบันทึก
RenderNode จะเก็บข้อมูลต่างๆ เช่น การแปล 3 มิติ การหมุน อัลฟา ขนาด และขอบเขตตำแหน่ง
และ 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 ใน Canvas แบบเนทีฟ
และสุดท้าย เราจะเรียก 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 เท่านั้น แต่เรากำลังพัฒนา Backport สำหรับ Compose ด้วย โปรดติดตาม
ตัวอย่างสนุกๆ อีกตัวอย่างหนึ่งของเอฟเฟกต์ฉากหลัง RenderNode คือเราสามารถเชื่อมโยงเอฟเฟกต์เข้าด้วยกันเพื่อสร้าง UI ที่ดูดีมากๆ และใช้เอฟเฟกต์ต่างๆ หลายรายการซ้อนกันได้
ดังนั้นหากต้องการเชื่อมโยงเอฟเฟกต์ เราจะสร้างเอฟเฟกต์ 2 รายการนี้
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)ปุ่มข้ามไปยังด้านล่าง ซึ่งเราสามารถทำให้ดูเหมือนแว่นขยายขนาดเล็กได้โดยการรวมเอฟเฟกต์ต่างๆ เข้าด้วยกันเพื่อทำให้ขอบของ Composable นี้โค้งมนเหมือนแว่นขยาย
แต่คุณไม่เพียงใช้เอฟเฟกต์การขยายได้เท่านั้น แต่ยังใช้เฉดสีประเภทใดก็ได้ที่เชื่อมโยงเข้าด้วยกันเพื่อใช้กับฉากหลังได้ด้วย
ต่อไปนี้คือตัวอย่างสนุกๆ ที่เราได้นำมาใช้เพื่อแสดงให้เห็นถึงสิ่งที่เป็นไปได้เมื่อนำเทคโนโลยีเหล่านี้มารวมกัน
ตอนนี้คุณสามารถใช้เอฟเฟกต์แบบกระจกฝ้าแบบดั้งเดิมได้แล้ว
แต่เรายังทำเอฟเฟกต์สนุกๆ อื่นๆ ได้ด้วย เช่น กล้องสลับลาย
หรือหากเราต้องการเปลี่ยนมาสคอต Android นี้ให้เป็นองค์ประกอบ UI ที่เปลี่ยนรูปแบบได้ซึ่งจะเปลี่ยนเนื้อหาที่อยู่ด้านล่าง เราก็สามารถใช้เอฟเฟกต์การแสดงผลฉากหลังเหล่านี้เพื่อให้ดูเหมือนว่าเรากำลังมองผ่านหุ่นยนต์ตัวน้อยนี้ได้ด้วย
และเรายังทำแบบนี้ได้ด้วยเอฟเฟกต์การแสดงผลฉากหลัง
ดังนั้น API ใหม่นี้จึงไม่เพียงแต่ปลดล็อกการเบลอฉากหลังและกระจกฝ้าเท่านั้น แต่ตอนนี้ขีดจำกัดเพียงอย่างเดียวคือจินตนาการของคุณ
API ใหม่อีกรายการที่เราได้เปิดตัวในแพลตฟอร์มคือ SurfaceView.setBlurRegions
ปัจจุบันคุณสามารถเบลอมุมมองพื้นผิวและ Composable ใดๆ โดยใช้เอฟเฟกต์ฉากหลัง Modifier.blur และ RenderNode แต่การเบลอบางส่วนของ SurfaceView นั้นทำได้ยาก และไม่ได้เป็นส่วนหนึ่งของต้นไม้ RenderNode ของมุมมองแอป เนื่องจากเป็นการเจาะรูผ่านหน้าต่างแทน
ดังนั้น Compose จึงไม่สามารถสุ่มตัวอย่างบัฟเฟอร์สำรองของ SurfaceView ได้ ซึ่งทำให้ก่อนหน้านี้ไม่สามารถทำได้จนกว่าจะมี API 37.2
ดังนั้น setBlurRegions ของ SurfaceView จึงช่วยให้แอปประกาศขอบเขตการเบลอได้โดยตรง
ตัวอย่างเช่น ในวิดีโอเพลเยอร์นี้ใน JetChat เราได้เพิ่มการเบลอ SurfaceView เมื่อตัวควบคุมแสดงและซ่อน
หากต้องการใช้ API ใหม่นี้ คุณสามารถเข้าถึง setBlurRegions ในเมธอด SurfaceView
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 และพื้นที่ที่ต้องการเบลอ
อีกหนึ่งสิ่งใหม่จาก Android 16 ขึ้นไปคือ RuntimeColorFilter
ก่อนหน้านี้ คุณทำได้เพียงตั้งค่าฟิลเตอร์สีเมทริกซ์แบบคงที่และใช้เอฟเฟกต์ต่างๆ เช่น ความอิ่มตัวของสีกับรูปภาพ
อย่างไรก็ตาม ตั้งแต่ Android 16 ขึ้นไป คุณสามารถส่ง Shader ที่ใช้สีอินพุต ซึ่งเป็นพิกเซลอินพุตสำหรับเนื้อหานั้นๆ และสร้างสีใหม่ได้
ตัวอย่างเช่น หากต้องการใช้ฟิลเตอร์ไซเบอร์พังก์กับรูปภาพ เราสามารถกำหนดเฉดสีขนาดเล็กที่ใช้เป็นอินพุตใน 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 หรืออะไรทำนองนั้น
และยังใช้กับ Composable ใดก็ได้ ไม่ใช่แค่รูปภาพเท่านั้น
ขอขอบคุณ
เราได้สร้างเอฟเฟกต์ที่สวยงามมาก แต่ก็ต้องตรวจสอบว่าแอปยังคงทำงานได้อย่างรวดเร็วและราบรื่น
เมื่อเราเลื่อนดูการสนทนา เอฟเฟกต์ทั้งหมดจะทำงานร่วมกัน
มาเริ่มด้วยการวัดการเลื่อนนี้กัน
@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 ดังนั้นรูปภาพเดียวกันจึงทำให้เฟรมที่ 2 ล่าช้า
ตอนนี้เราเห็นแล้วว่าเวลาของเราหายไปไหน แต่เราจำเป็นต้องทำงานทั้งหมดนี้เพื่อวาดเฟรมนี้ไหม
และเราจะรู้ได้อย่างไร
การติดตามจะบอกเรา
เมื่อใดก็ตามที่มีการวาดบิตแมปขนาดใหญ่ลงในพื้นที่ขนาดเล็กกว่ามากบนหน้าจอ Android จะบันทึกเครื่องหมายลงในการติดตามโดยตรง
โดยจะแสดงตัวเลข 2 ตัวเป็นพิกเซล ได้แก่ ขนาดบิตแมปแบบเต็ม และพื้นที่ที่ครอบคลุมบนหน้าจอจริงๆ
หารด้วยความหนาแน่นของหน้าจอ แล้วคุณจะได้ค่าเป็น DP
จากนั้นเราจะค้นหาโค้ดด้วยค่า DP นั้นเพื่อค้นหารูปภาพดังกล่าว
นี่คือสิ่งที่เราพบ
ระบบโหลดรูปภาพด้วย painterResource ซึ่งไม่ทราบขนาดของคอนเทนเนอร์ จึงถอดรหัสรูปภาพทั้งหมดที่ความละเอียดเต็ม
Image(
painter = painterResource(it),
contentScale = ContentScale.Fit,
modifier = Modifier.size(160.dp),
contentDescription = stringResource(id = R.string.attached_image),
)เราจึงแทนที่ด้วย AsyncImage จากขดลวด
AsyncImage(
model = it,
contentScale = ContentScale.Fit,
modifier = Modifier.size(160.dp),
contentDescription = stringResource(id = R.string.attached_image),
)โดยจะวัดคอนเทนเนอร์ก่อน เพื่อให้ถอดรหัสเฉพาะพิกเซลที่ต้องการ และดำเนินการดังกล่าวในชุดข้อความอื่น
เมื่อเรียกใช้การเปรียบเทียบเดียวกันอีกครั้ง การถอดรหัสรูปภาพในเทรดหลักลดลงเหลือ 0 และเฟรมที่ล่าช้าลดลงเหลือ 2
เราจะไม่แก้ไขข้อบกพร่องของทั้ง 2 เฟรมนี้ที่นี่ แต่ประเด็นสำคัญนั้นง่ายมาก นั่นคือพยายามอย่าคำนวณพิกเซลที่จะไม่ไปถึงเฟรม
ดังนั้น การถอดรหัสรูปภาพจึงมีขนาดใหญ่พอที่จะทำให้เฟรมล่าช้า แต่ไม่ใช่ว่าต้นทุนทุกอย่างจะแสดงเป็นเฟรมที่ถูกข้าม และยังมีต้นทุนที่น้อยกว่าซึ่งเราสามารถหลีกเลี่ยงได้ขณะเขียนโค้ด
มาดู 2 ตัวอย่างกัน
เมื่อใดก็ตามที่เราใช้ RuntimeShader เราควรสร้างเพียงครั้งเดียว เพราะแม้ว่าจะคอมไพล์และแคชเท่านั้น แต่ก็ยังคงแยกวิเคราะห์สตริงต้นทางทุกครั้งที่เราสร้าง
และเมื่อใดก็ตามที่เอฟเฟกต์ทำให้ทุกเฟรมเคลื่อนไหว เราสามารถรวมเอฟเฟกต์นั้นไว้ใน graphicsLayer เพื่อให้ระบบบันทึกเฉพาะองค์ประกอบที่เคลื่อนไหวอีกครั้ง แทนที่จะวาด UI รอบๆ องค์ประกอบนั้นใหม่
สำหรับปุ่มข้ามไปด้านล่าง ปุ่มนี้จะปรากฏขึ้นทุกครั้งที่เราเลื่อน
ซึ่งใช้ RuntimeShader 2 ตัวเพื่อแสดงเอฟเฟกต์นั้น และทุกครั้งที่ปุ่มปรากฏขึ้น เราจะสร้างทั้ง 2 ตัวขึ้นมาใหม่ โดยสร้างเฉดสีทั้ง 2 ตัวนี้จากสตริงต้นทาง
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 {การแยกวิเคราะห์เฉดสีทั้ง 2 รายการและการสร้างเอฟเฟกต์จะใช้เวลาหลายร้อยไมโครวินาทีในเทรดหลักทุกครั้งที่ปุ่มปรากฏขึ้น
แต่เราจะสร้างเฉดสีเพียงครั้งเดียวสำหรับทั้งกระบวนการ หรือเราจะจดจำเฉดสีนั้นไว้ แล้วอัปเดตตัวแปร 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 ไว้บนกล่องโดยตรงจะทำให้การเดินทางหยุดที่เส้นขอบสีเขียว ดังนั้นระบบจะบันทึกเฉพาะกล่องเรืองแสงซ้ำ และส่วนการวาดเทรดหลักจะลดลง 2 ใน 10 ของมิลลิวินาที
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-performanceval gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
}
}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)
)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
}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",
)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])
}
}
}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;
}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;
}val spec = remember {
MeshSpecification.make(
arrayOf(Attribute(TYPE_FLOAT2, 0, "corner")),
vertexStride = 8,
arrayOf(Varying(TYPE_FLOAT3, "stroke")),
LineShaders.VERTEX, LineShaders.FRAGMENT)
}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
}val mesh = Mesh(
spec,
mode = Mesh.TRIANGLE_STRIP,
vertexBuffer = corners,
vertexCount = (segments + 1) * 2,
bounds = RectF(0f, 0f, size.width, size.height)
)Box(Modifier.fillMaxSize().drawWithCache {
val mesh = Mesh(...)
onDrawBehind {
mesh.setFloatUniform("uTime", time.value)
drawIntoCanvas {
it.nativeCanvas.drawMesh(mesh,android.graphics.BlendMode.DST, paint)
}
}
})<!-- 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
}
}// 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
)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)
)
}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)
}
}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)val recordShape = remember {
MorphPolygonShape(
start = PolygonShape.circle(),
end = PolygonShape.star(
numPoints = 9,
innerRadiusRatio = 0.8f,
outerRounding = CornerRounding(percent = 50),
),
) { shapeProgress.value }
}Modifier
.blur(
radiusX = 10.dp,
radiusY = 10.dp,
edgeTreatment = BlurredEdgeTreatment(
shape = RoundedCornerShape(8.dp),
),
)Modifier.blur {
blurRadiusSpec = BlurRadiusSpec.verticalGradient(
listOf(
BlurStop(0.5f, 0.dp),
BlurStop(1.0f, 32.dp),
)
)
edgeTreatment = BlurredEdgeTreatment.Unbounded
}BlurRadiusSpec.horizontalGradient(
startRadius = 0.dp,
endRadius = sliderValue.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
},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()
}
}JetchatAppBar(
modifier = modifier
.backdropBlur(
tint = MaterialTheme.colorScheme
.surface.copy(alpha = 0.5f),
shape = RectangleShape,
spec = BlurRadiusSpec.uniform(5.dp)
)
)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),
),
)
)
)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)val agslShader = RuntimeShader(CHROMATIC_ABERRATION)
val noiseEffect = RenderEffect.createRuntimeShaderEffect(agslShader, "backdropInput")
val finalEffect = RenderEffect.createChainEffect(noiseEffect, vibrantBlur)
renderNode?.setBackdropRenderEffect(finalEffect)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@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);
}
"""AsyncImage(
model = imageUri,
contentDescription = filter.displayName,
colorFilter = RuntimeColorFilter(CYBERPUNK_SHADER)
.asComposeColorFilter(),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)@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%'Image(
painter = painterResource(it),
contentScale = ContentScale.Fit,
modifier = Modifier.size(160.dp),
contentDescription = stringResource(id = R.string.attached_image),
)AsyncImage(
model = it,
contentScale = ContentScale.Fit,
modifier = Modifier.size(160.dp),
contentDescription = stringResource(id = R.string.attached_image),
)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) }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)
}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)
}android skills add perfetto-trace-analysis
android skills add jetpack-compose-performance