מהנדס תוכנה
מהנדס קשרי מפתחים
רוצים להפוך את ממשק המשתמש של Android מ "זה עובד" ל "זו יצירת אומנות"?
הצטרפו אלינו לסיור ביכולות הגרפיות החדשות ב-Jetpack Compose. נראה לכם איך ליצור אפקטים חזותיים חדשים, החל ממעברי צבעים נעימים לעין ועד לטשטוש הדרגתי, ואיך להשתמש בתכונות הגרפיקה החדשות והמתקדמות ב-Android. נראה לכם גם איך לשפר את הביצועים של התכונות הגרפיות בלי לפגוע בחיי הסוללה או בקצב הפריימים.
PolygonShape ו-MorphPolygonShape לגיאומטריות בהתאמה אישית. MorphPolygonShape קורא את ההתקדמות במהלך שלב הרזולוציה של קווי המתאר, ומעדכן את הצורות פריים אחרי פריים בלי להפעיל קומפוזיציה מחדש.RenderNode.setBackdropRenderEffect לדגימת תוכן ברקע. מפתחים יכולים לשרשר אפקטים של רקע כדי ליצור אפקטים של זכוכית חלבית או זכוכית מגדלת.graphicsLayer.תמליל
שלום לכולם, וברוכים הבאים לסשן שלנו, "שיפור האפליקציות באמצעות תכונות הגרפיקה העדכניות ביותר ב-Compose וב-Android".
קוראים לי רבקה פרנקס, ואיתי על הבמה היום נמצאת--
שמי Aayush Chaudhary.
היי, קוראים לי רהול ריי.
אנחנו רואים מגמה חדשה שבה אתם פורצים את הגבולות של מה שאפשר לעשות ב-Android וב-Compose, ומשתמשים ב-AI כדי להפוך את הרעיונות המדהימים שלכם למציאות.
אנחנו אוהבים לראות אפליקציות שמרגישות כמו פרימיום, מקוריות ומהנות.
אנחנו יודעים שאפליקציות שמעניקות חוויה טובה ומקבלות באופן עקבי דירוגים גבוהים מהמשתמשים הן בדרך כלל כאלה שמנצלות את השינויים האחרונים ב-API ברמת המערכת, יש להן המשכיות בין פלטפורמות עם אלמנטים משותפים וטשטוש, הן מתאימות את עצמן בצורה נעימה בין גורמי צורה, הן יעילות, לא משאירות את המשתמשים לנחש ופורצות את גבולות האפשרויות במכשיר.
בשיחה הזו נציג לכם כמה מהתכונות החדשות שיעזרו לכם לנצל את היתרונות של Android, החל משיפור חוויית המשתמש באמצעות הדרגות רשת, תמיכה בסולם צבעים רחב, מעברים נעימים עם צורות מצולעות, מה חדש בטשטוש, ולבסוף, נסביר איך אתם יכולים לחשוב על הביצועים כשאתם מוסיפים תכונות גרפיות חדשות לאפליקציות שלכם.
אבל במהלך השיחה היום, נסקור כמה מממשקי ה-API החדשים ברמות שונות של המחסנית.
ערכת הכלים הקודמת של ממשק המשתמש לתצוגה נמצאת עכשיו במצב תחזוקה, אבל אנחנו ממשיכים להשקיע במנועי הרינדור הבסיסיים של חומרת הגרפיקה ב-Android שמפעילים את Jetpack Compose.
אפשר לחשוב על גרפיקה ב-Android בשלוש שכבות שונות: ממשקי ה-API של Compose בשכבה הראשונה, וכאן נמצאים כל ממשקי ה-API החדשים של גרפיקה ברמה גבוהה בממשק המשתמש, כמו ציירי שיפוע רשת, צורות מצולעות וממשקי API של טשטוש הדרגתי.
ויש את שכבת הביניים, שבה DrawScope, צומתי ה-draw modifier ו-AGSL של Compose מאפשרים לכם ליצור אפקטים בהתאמה אישית.
אבל בסופו של דבר, יש לנו את פלטפורמת Android, ופיתוח נייטיב תמיד יכול לגשת ל-Canvas מקורי ולהשתמש בפרימיטיבים חדשים לגמרי לעיבוד של מערכת ההפעלה, ישירות בתוך מגדירי השינוי של פיתוח נייטיב.
נציג לכם כמה דוגמאות לאיך עושים את זה היום.
שנתחיל?
קודם כל, נתחיל עם שיפועי רשת, API חדש שהוצג ב-Compose 1.12.
בעבר היו לנו מעברי צבע לינאריים, מעברי צבע לינאריים, מעברי צבע של סריקה ומעברי צבע רדיאליים, שהם נהדרים למעברים פשוטים, אבל אם רוצים שילובים עשירים של כמה צבעים כמו רקעים של אווירה או אפקטים של תאורת הצפה, צריך רשת דו-ממדית של מערכת שבה מיקומים וצבעים יכולים להתמזג במרחב.
כדי ליצור מעבר צבעים ברשת כמו זה, קודם צריך לציין כמה שורות ועמודות של טלאים רוצים.
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, הוספנו תכונה שמאפשרת לסמן לייק להודעה כשלוחצים עליה לחיצה כפולה.
כדי ליצור את זה, אנחנו מגדירים רשת של 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 כ-true כאן.
כברירת מחדל, הצבעים משתלבים באופן לינארי בין המשולשים, ולפעמים זה משאיר כתמים גלויים.
הפעלת האפשרות הזו מעבירה את ההצללה לאינטרפולציה דו-קוביית חלקה, כך שהצבעים יכולים להתמזג בצורה חלקה זה בזה.
עכשיו, מה אם אתם רוצים ליצור זוהר סביבתי מונפש כמו זה שמאחורי תיבת הקלט?
כדי ליצור אפקט כזה עם מעבר צבעים ברשת, צריך שני דברים.
קודם כל, תנועה אורגנית חלקה של הקודקודים הפנימיים ודרך לרכך את הקצוות החיצוניים כדי שלא ייווצר מלבן קשיח מסביב לכרטיס.
אז נתחיל בהגדרה של rememberInfiniteTransition עם שתי זוויות פאזה שפועלות במשך זמן קצת שונה, כדי שהתנועה תרגיש אורגנית ולא מכנית.
val transition = rememberInfiniteTransition(label = "userInputGlowMesh")
val phase1 by transition.animateFloat(
initialValue = 0f,
targetValue = (2 * PI).toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 2800, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "glowPhase1",
)
val phase2 by transition.animateFloat(
initialValue = 0f,
targetValue = (2 * PI).toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 4200, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "glowPhase2",
)כאן אנחנו מוסיפים אנימציה לקודקודים הפנימיים ומצמידים את הטבעות החיצוניות, הטבעת החיצונית של הקודקודים עם קצוות עם אלפא 0.
מכיוון שהגבול החיצוני הוא נייח ושקוף, הזוהר מתפוגג באופן טבעי.
ולבסוף, אנחנו מזינים את ההיסטים המונפשים האלה ל-MeshGradientPainter.
MeshGradientPainter(
rows = 6,
columns = 5,
hasBicubicColor = true,
) {
for (r in 0..6) {
for (c in 0..5) {
val offset = if (r in 1..5 && c in 1..4) {
val ampX = when (r) {
1, 2 -> 0.048f
3 -> 0.042f
4 -> 0.028f
else -> 0.036f
}
val ampY = when (r) {
1, 2 -> 0.028f
3 -> 0.020f
4 -> 0.022f
else -> 0.016f
}
val d = orbit(c, r, ampX, ampY)
Offset(
x = (baseU + d.x).coerceIn(0.04f, 0.96f),
y = (baseV + d.y).coerceIn(0.05f, 0.95f),
)
} else {
Offset(baseU, baseV)
}
setVertex(r, c, offset, gridColors[r][c])
}
}
}השיפוע של הרשת שראיתם עכשיו נוצר באמצעות ה-API של drawMesh ב-Android מגרסה 14 ואילך.
בואו נבחן את ה-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 call) נפרדת ואובייקט צביעה, ולכל הנתיב עדיין יש רוחב אחד.
האפשרות השנייה היא הצללה בזמן ריצה, אבל היא מקבלת רק את מיקום הפיקסלים בתוך גבולות מאגר התגים.
הוא לא אומר לנו מה אנחנו צריכים כדי לשרטט את הסרט הזה.
וגם, אנחנו לא יכולים לחשב את זה בקלות גם מהמיקום של הפיקסלים.
הפונקציה Draw mesh נותנת לנו ורטקס שיידר בשביל זה.
אז מה זה רשת?
רשת מורכבת ממשולשים עם נתונים בכל פינה, וכל פינה נקראת קודקוד.
כל קודקוד מכיל רשומת נתונים קטנה.
בדרך כלל הרשומה הזו מכילה מיקום, אבל לא בהכרח.
ה-vertex shader קורא את הרשומה הזו ומחשב את המיקום לפיה.
הנה הנתיב של משולש אחד: הצללת הקודקודים פועלת בכל פינה, מחשבת את מיקום הפינות וכל ערך שאנחנו רוצים להעביר לכל פיקסל.
לאחר מכן, הרסטריזטור מוצא כל פיקסל שמכוסה על ידי המשולש הזה ומבצע אינטרפולציה של ערכים משלוש הפינות האלה לכל פיקסל.
לבסוף, ה-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;
}ה-vertex shader מקבל מאפיין כקלט ומחזיר משתנה.
המאפיינים הם הנתונים של פינה אחת.
הנה רק שני מספרים: ההתקדמות שלו לאורך הקו, ובאיזה צד של הקו הוא נמצא.
לאחר מכן, המשתנים עוברים אינטרפולציה ומועברים ל-fragment shader.
אנחנו מחשבים את המרחק בפיקסלים מההתחלה, ולרוחב הקו אנחנו משתמשים בזמן כדי ליצור פעימה ברדיוס.
אחר כך אנחנו מכפילים את הרדיוס הזה בצד הפינה, וזה נותן לנו את מרחק הסימן לאורך הקו.
כשה-GPU מבצע אינטרפולציה בין הסימנים האלה, הקווים המרכזיים הופכים לאפס.
לבסוף, אנחנו מחשבים את מיקום הפינות, מאחסנים את כל הערכים האלה במשתנים של הקו ומעבירים אותם ל-fragment shader.
בניגוד ל-runtime shader, פלט של mesh fragment 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, אנחנו מקשרים את ה-shaders ואת פריסת הנתונים לאובייקט MeshSpecification.
val spec = remember {
MeshSpecification.make(
arrayOf(Attribute(TYPE_FLOAT2, 0, "corner")),
vertexStride = 8,
arrayOf(Varying(TYPE_FLOAT3, "stroke")),
LineShaders.VERTEX, LineShaders.FRAGMENT)
}קודם כול, אנחנו מגדירים את מאפיין הפינה כשני ערכים מסוג float שמתחילים בבייט 0.
מכיוון ששני ערכי float תופסים 8 בייטים, כל צעד של פינה במאגר הוא 8.
בשלב הבא, אנחנו מגדירים את המשתנים של קו המתאר כשלושה ערכים מסוג float, ומעבירים את ה-vertex shader וה-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 וכותבים שתי פינות במאגר הנתונים הזמני.
בסוף, אנחנו משלבים את המפרט והמאגר לאובייקט רשת.
val mesh = Mesh(
spec,
mode = Mesh.TRIANGLE_STRIP,
vertexBuffer = corners,
vertexCount = (segments + 1) * 2,
bounds = RectF(0f, 0f, size.width, size.height)
)ב-Compose canvas עדיין אין חשיפה של 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 הזה זמין במסמכי התיעוד.
בגרסה 1.12 של Compose, הוספנו תמיכה בסולמות צבעים רחבים.
מה זה אומר בפועל?
ידעתם שמסכים יכולים לשחזר טווח צבעים שונה ממרחב הצבעים המסורתי 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 רגיל.
במפות סיביות (bitmap) ספציפיות, אפשר לבדוק מה המקור של מפת הסיביות באמצעות getColorSpace, וספריות פופולריות לטעינת תמונות יטפלו באופן אוטומטי במרחבי הצבעים הנכונים של מפת הסיביות.
כדאי להשתמש ב-Bitmap.Config.HARDWARE כדי לשמור על פרטי הצבע, כי הגדרה כמו 565 תגרום לאובדן הדיוק ולפסי צבעים שיהיו גלויים למשתמשים.
כדי לקבל מידע נוסף על צבעים, אתם יכולים לחזור אחורה בזמן לשנת 2017 ולצפות ברומיין גאי מדבר על צבעים ב-Compose או על צבעים ב-Android.
ב-Compose 1.13 אנחנו משיקים תמיכה מובנית בצורת פוליגון.
זה יקל מאוד על יצירת צורות מותאמות אישית ורכיבים בממשק המשתמש שנראים טבעיים.
בעבר, אם רציתם לצייר גיאומטריה מורכבת כזו, הייתם צריכים להשתמש ב-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 מותאם אישית של 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, אפשר לראות שצריך ליצור את הנתיב, לשנות את קנה המידה שלו ולתרגם אותו באופן ידני.
עכשיו, עם ה-API החדש של PolygonShape, אתם כבר לא צריכים את ה-wrapper המותאם אישית של 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)מכיוון שזהו ה-builder הייעודי של Compose Shape, הוא מקבל את הצפיפות והגודל ישירות מהקופסה.
זה אומר שאתם יכולים לחשב את הקודקודים באופן דינמי ולציין את עיגול הפינות ישירות ב-dp או אפילו באחוזים.
אחרי שמגדירים את הצורה, פשוט מעבירים אותה לכל API שמקבל צורה.
אנחנו משיקים גם את 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 קורא את ערך ההתקדמות בשלב של פתרון המתאר, כלומר לא במהלך ה-recom- לא במהלך הקומפוזיציה.
זה אומר שכשהצורה משתנה מפריים לפריים, היא פשוט הופכת לקו מתאר חדש בלי להפעיל אף פעם הרכבה מחדש.
עכשיו נדבר על טשטוש.
בגרסאות האחרונות, אנחנו מנסים להקל על הטמעת הטשטוש ולהפוך אותה לאקספרסיבית יותר.
בכל פעם שדיברנו על טשטוש, זה המונח שבו השתמשנו.
Modifier
.blur(
radiusX = 10.dp,
radiusY = 10.dp,
edgeTreatment = BlurredEdgeTreatment(
shape = RoundedCornerShape(8.dp),
),
)התכונה הזו קיימת ב-Compose מאז Android 12.
הוא פועל מצוין כשרוצים טשטוש אחיד, שבו אותו רדיוס טשטוש בדיוק מוחל על כל הרכיב הניתן לTex.
אבל עיצובים מודרניים של ממשקי משתמש דורשים לעיתים קרובות מעבר חלק מאזורים חדים לאזורים מטושטשים, ולא חיתוך חד.
וכך אנחנו מגיעים ל-Progressive Blur, שהוא עכשיו API רשמי החל מ-Compose 1.13.
במקום רדיוס קבוע אחד בכל הרכיב הניתן לשילוב, עכשיו אפשר לשנות את עוצמת הטשטוש במרחב.
הנה דוגמה ממסך פרופיל המשתמש באפליקציית JetChat שלנו.
המשתנה החדש של הטשטוש מקבל מפרט של רדיוס הטשטוש.
Modifier.blur {
blurRadiusSpec = BlurRadiusSpec.verticalGradient(
listOf(
BlurStop(0.5f, 0.dp),
BlurStop(1.0f, 32.dp),
)
)
edgeTreatment = BlurredEdgeTreatment.Unbounded
}כאן אנחנו משתמשים בשיפוע אנכי עם שתי נקודות טשטוש, כך שהחצי העליון של התמונה נשאר חד לחלוטין ב-0dp, והטשטוש גדל עד ל-32dp בקצה התחתון.
שימו לב שהגדרנו את edgeTreatment גם כ-unbounded, מה שאומר שהטשטוש ידגום גם פיקסלים שקופים מחוץ לגבולות של ה-composable.
בנוסף לטשטוש הדרגתי אנכי, המפרט הזה של רדיוס הטשטוש נותן לכם כמה דרכים מובנות להפעיל טשטוש הדרגתי.
לדוגמה, בצד שמאל אפשר לראות שיפוע אופקי, שבו רדיוס ההתחלה הוא 0dp, ורדיוס הסיום נקבע לפי ערך של פס הזזה.
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
},כאן אנחנו כותבים שיידר קטן של מקטע שמחשב גל סינוס אלכסוני מונפש, ומוציא את העוצמה הזו לערוץ אלפא.
לאחר מכן, מפרט ההצללה ממפה את המסכה הזו של 0 עד 1 ישירות למסכה של 24dp maxRadius.
לפני שנדבר על טשטוש הרקע, בואו נראה את ההבדל בין שתי האפשרויות.
עכשיו בצד ימין, יש לנו סרגל אפליקציה עליון שבו הפעלנו טשטוש שכבה.
כלומר, הוא מטשטש את ה-composable עצמו.
כמו שאתם רואים, הכותרת וסמלי הפעולה מטושטשים, אבל רכיב הצ'אט שמאחוריהם לא מטושטש בכלל.
בצד שמאל, יש לנו טשטוש רקע.
כאן רק הודעות הצ'אט שנגללות מאחורי הסרגל העליון נדגמות ומטושטשות.
עם זאת, הטקסט והסמלים חדים וקריאים לחלוטין.
החל מגרסה 37.2 של Android, אפשר לטשטש את הרקע באמצעות API חדש שנקרא RenderNode.setBackdropRenderEffect.
אבל לפני שנסביר איך הטשטוש משתפר, בואו נבין איך Android פועלת מאחורי הקלעים כדי לוודא שהטשטוש יעיל.
מערכת Android משתמשת ב-RenderNodes, שהם מאגרים של רשימות תצוגה עם שיפור מהירות באמצעות חומרה. זה אומר שהם יכולים לדחוס ולאחסן הרבה פקודות שרטוט שונות באמצעות התחלת הקלטה וסיום הקלטה.
ה-RenderNode מכיל מידע כמו תרגומים תלת-ממדיים, סיבובים, אלפא, קנה מידה וגבולות מיקום.
ובעצם, Compose משתמש ב-RenderNode מתחת לפני השטח.
כדי להשתמש באפקטים של רינדור הרקע ב-Compose, אנחנו יכולים ליצור Modifier.Node בהתאמה אישית כדי להשתמש שוב ב-modifier הזה.
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 שיצרנו, אנחנו קוראים ל-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, אבל אנחנו עובדים גם על היסב בגרסאות מוקדמות יותר (backporting) ל-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.
לכן, setBlurRegions של SurfaceView מאפשר לאפליקציה להצהיר ישירות על אזורי טשטוש.
לדוגמה, בנגן הווידאו הזה ב-JetChat, הוספנו טשטוש של SurfaceView כשהלחצנים מופיעים ומוסתרים.
כדי להשתמש ב-API החדש הזה, אתם יכולים לגשת אל setBlurRegions ב-method של 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 ואילך, עכשיו אפשר להעביר הצללה שמקבלת צבע, פיקסל הקלט של התוכן הספציפי הזה, ומפיקה צבע חדש.
לדוגמה, אם אנחנו רוצים להחיל פילטר סייברפאנק על תמונה, אנחנו יכולים להגדיר הצללה קטנה שמשמשת כקלט ל-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);
}
"""ההצללה הזו יוצרת עיצוב בסגנון סייברפאנק, אבל אתם יכולים ליצור כל אפקט אחר שתרצו.
כדי להשתמש ב-shader עצמו, אנחנו מפעילים את 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%'זה נותן לנו שישה פריימים מאוחרים.
בואו נפתח את אחד מהפריימים המאוחרים.
לכן, ממש לפני הפריים הזה, 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),
)לכן, אנחנו מחליפים אותו ב-AsyncImage מ-coil.
AsyncImage(
model = it,
contentScale = ContentScale.Fit,
modifier = Modifier.size(160.dp),
contentDescription = stringResource(id = R.string.attached_image),
)הוא מודד קודם את מאגר התגים, כך שהוא מפענח רק את הפיקסל שהוא צריך, והוא עושה את זה בשרשור אחר.
כשמריצים שוב את אותו מבחן ביצועים, פענוח התמונות ב-main thread יורד ל-0, והפריימים המאוחרים יורדים ל-2.
לא נבצע כאן ניפוי באגים של שני הפריימים האלה, אבל המסקנה פשוטה: נסו לא לחשב פיקסלים שלא יגיעו לפריים.
כלומר, פענוח התמונה היה גדול מספיק כדי לגרום לעיכוב בפריימים, אבל לא כל עלות מופיעה כפריים שהושמט, ועדיין יש עלויות קטנות יותר שאפשר להימנע מהן בזמן כתיבת הקוד.
בואו נראה שתיים מהן.
בכל פעם שמשתמשים ב-RuntimeShader, צריך ליצור אותו פעם אחת כי למרות שהוא רק עובר הידור ונשמר במטמון, הוא עדיין מנתח את מחרוזת המקור שלו בכל פעם שיוצרים אותו.
ובכל פעם שאפקט מפעיל אנימציה בכל פריים, אנחנו יכולים לעטוף אותו ב-graphicsLayer, כדי שהרכיב היחיד שמופעלת בו אנימציה יוקלט מחדש, במקום לשרטט מחדש את ממשק המשתמש סביבו.
הלחצן "דילוג לתחתית" מופיע בכל פעם שגוללים.
האפקט הזה נוצר באמצעות שני RuntimeShader, ובכל פעם שהלחצן מופיע, אנחנו יוצרים מחדש את שני ה-shaders האלה ממחרוזת המקור שלהם.
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 {ניתוח שני ההצללות האלה ובניית האפקט נמשכים מאות מיקרו-שניות בשרשור הראשי בכל פעם שהכפתור מופיע.
במקום זאת, אנחנו יוצרים את ה-shader פעם אחת לכל התהליך, או שאנחנו יכולים לזכור אותו, ואז לעדכן את ה-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)
}לכן, קריאת המצב בשלב השרטוט שומרת על הקומפוזיציה והפריסה, אבל רשימת התצוגה לא יכולה לעדכן פקודת שרטוט אחת במקום.
לכן, פיתוח נייטיב עולה במעלה העץ כדי למצוא את הצומת הקרוב ביותר עם שכבת גרפיקה. במקרה הזה, מדובר בקו המתאר האדום, שהוא המסך כולו, ומתעד מחדש את כל השכבה הזו בכל פריים.
אם נשים graphicsLayer ישירות על התיבה, התנועה תיעצר בקו המתאר הירוק, כך שרק תיבת הזוהר תוקלט מחדש, ופרוסת הציור של השרשור הראשי תרד בשתי עשיריות של אלפית השנייה.
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