مهندس برمجيات
مهندسة علاقات المطوّرين
هل أنت مستعد للانتقال بواجهة مستخدم Android من "مقبولة" إلى "رائعة"؟
انضم إلينا في جولة للتعرّف على أحدث إمكانات الرسومات في Jetpack Compose. سنوضّح لك كيفية إنشاء مؤثرات بصرية جديدة، بدءًا من التدرجات الشبكية الرائعة وحتى التمويه التدريجي، واستخدام ميزات الرسومات الجديدة الفعّالة في Android. سنوضّح لك أيضًا كيفية تحسين أداء ميزات الرسومات بدون التأثير سلبًا في عمر البطارية أو عدد اللقطات في الثانية.
PolygonShape وMorphPolygonShape الأصليتَين للأشكال الهندسية المخصّصة. يقرأ MorphPolygonShape مستوى التقدّم خلال مرحلة تحديد المخطط، ويعدّل الأشكال إطارًا تلو الآخر بدون تفعيل إعادة التركيب.RenderNode.setBackdropRenderEffect لأخذ عيّنات من محتوى الخلفية. يمكن للمطوّرين ربط تأثيرات الخلفية معًا لإنشاء تأثيرات الزجاج المصنفر أو التكبير.graphicsLayer.النص
مرحبًا بكم جميعًا في جلستنا بعنوان "تحسين تطبيقاتكم باستخدام أحدث ميزات الرسومات في Compose وAndroid".
اسمي "ريبيكا فرانكس"، وينضم إليّ على المسرح اليوم--
اسمي "أيوش تشودري".
مرحبًا، أنا "راهول راي".
نلاحظ توجّهًا جديدًا في كيفية تجاوزكم حدود الممكن على Android وCompose، باستخدام الذكاء الاصطناعي لتحويل أفكاركم الرائعة إلى واقع.
ويسعدنا أن نرى تطبيقات متميّزة ومصمَّمة خصيصًا لنظام التشغيل وتوفّر تجربة رائعة.
نعلم أنّ التطبيقات التي توفّر تجربة جيدة وتحصل باستمرار على تقييمات رائعة من المستخدمين هي تلك التي تستفيد بشكل عام من أحدث التغييرات في واجهة برمجة التطبيقات على مستوى النظام، وتتسم بالاستمرارية بين مساحات العرض مع العناصر المشتركة والتمويه، وتتكيّف بشكل رائع مع أشكال الأجهزة المختلفة، وتتميّز بأداء عالٍ، ولا تترك المستخدمين في حيرة من أمرهم، وتتجاوز حدود ما هو ممكن مع جهازك.
في هذه الجلسة، سنعرض لكم بعضًا من أحدث الميزات التي تساعدكم في الاستفادة من Android، بدءًا من إرضاء المستخدمين من خلال التدرجات الشبكية، وإتاحة دعم مجموعة الألوان الواسعة، والانتقالات الرائعة باستخدام الأشكال المضلّعة، والميزات الجديدة في التمويه، وأخيرًا، سنتناول كيفية التفكير في الأداء عند إضافة ميزات رسومات جديدة إلى تطبيقاتكم.
ولكن خلال حديثنا اليوم، سنتناول بعض واجهات برمجة التطبيقات الجديدة على مستويات مختلفة من حزمة التطوير.
على الرغم من أنّ مجموعة أدوات واجهة المستخدم القديمة للعرض في وضع الصيانة الآن، فإنّنا نواصل الاستثمار في محرّكات عرض الرسومات الأساسية في Android التي تشغّل Jetpack Compose.
يمكنك التفكير في الرسومات في Android في ثلاث طبقات مختلفة: واجهات برمجة تطبيقات Compose في الطبقة الأولى، وهذا هو المكان الذي توجد فيه جميع واجهات برمجة تطبيقات رسومات واجهة المستخدم الجديدة عالية المستوى، مثل رسامي التدرج الشبكي والأشكال المضلعة وواجهات برمجة تطبيقات التمويه التدريجي.
بعد ذلك، لدينا الطبقة المتوسطة، حيث تتيح لك واجهة DrawScope وعُقد معدِّل الرسم ولغة AGSL في Compose إنشاء تأثيرات مخصّصة.
أخيرًا، لدينا نظام Android الأساسي، ويمكن لـ Compose دائمًا الوصول إلى Canvas الأصلي واستخدام عناصر عرض نظام التشغيل الجديدة تمامًا داخل معدِّلات Compose.
وسنعرض لك بعض الأمثلة على ذلك اليوم.
لنبدأ
لِنلقِ نظرة أولاً على تدرّجات الشبكة، وهي واجهة برمجة تطبيقات جديدة تم تقديمها في الإصدار 1.12 من Compose.
في السابق، كان لدينا تدرجات خطية، وتدرجات خطية ومسحية وشعاعية، وهي رائعة للانتقالات البسيطة، ولكن إذا كنت تريد مزيجًا غنيًا من الألوان المتعددة مثل الخلفيات المحيطة أو تأثيرات الإضاءة الغامرة، فأنت بحاجة إلى شبكة ثنائية الأبعاد من النظام حيث يمكن أن تمتزج المواضع والألوان عبر المساحة.
لإنشاء تدرج شبكي مثل هذا، حدد أولاً عدد صفوف وأعمدة الرقع التي تريدها.
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
}
}بعد ذلك، داخل دالة lambda اللاحقة، يمكنك استدعاء setVertex وتعيين إزاحة عادية تتراوح بين 0F و1F، بالإضافة إلى لون لكل تقاطع في الشبكة.
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
}
}ولتحريك هذه الرؤوس، أضِف إزاحات متحركة لثمانية رؤوس.
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
// ....
setVertex(1, 1, Offset(0.2f, 0.4f) + Offset(animatedOffset, animatedOffset), coral)
// ....
setVertex(3, 0, Offset(0.0f, 1.0f), sunshine)
// ....
}
}
Box(
modifier = modifier.padding(32.dp)
.safeContentPadding()
.aspectRatio(16 / 9f)
.fillMaxWidth()
.paint(gradientPainter)
)بعد تحديد الرسام، ما عليك سوى استدعاؤه باستخدام أي دالة مركّبة من خلال معدِّل الرسم.
الآن، في تطبيق JetChat، أضفنا ميزة للإعجاب برسالة عند النقر عليها مرّتين.
لإنشاء هذا التدرج، نُعدّ شبكة من صفَّين و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 على "صحيح" هنا.
تتداخل الألوان تلقائيًا بشكل خطي عبر المثلثات، ما قد يؤدي أحيانًا إلى ترك بقع مرئية.
يؤدي تفعيل هذا الخيار إلى تبديل أداة التظليل إلى الاستكمال الثنائي التكعيبي السلس حتى تندمج الألوان بسلاسة مع بعضها البعض.
الآن، ماذا لو أردت إنشاء توهج محيطي متحرك مثل هذا خلف مربع الإدخال؟
لإنشاء تأثير مثل هذا باستخدام التدرج الشبكي، تحتاج إلى شيئين.
أولاً، حركة سلسة للرؤوس الداخلية وطريقة لتنعيم الحواف الخارجية حتى لا ينتهي بك الأمر بالحصول على مستطيل صلب حول البطاقة.
لذا نبدأ بإعداد 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])
}
}
}يتم رسم التدرج الشبكي الذي رأيته للتو باستخدام واجهة برمجة التطبيقات drawMesh على الإصدار 14 من Android والإصدارات الأحدث.
لنتعرّف أكثر على واجهة برمجة التطبيقات هذه من خلال إنشاء تأثير من الصفر.
بالنسبة إلى شاشة الترحيب في JetChat، نريد رسم مسار يشبه الشريط.
لاحظ شيئين هنا: عرض الحد ليس ثابتًا واللون يمتد على طول كل حد من المسار.
لنتعرّف على التفاصيل ونفهم ما نحتاجه فعليًا لإنشاء هذا التأثير.
بالنسبة إلى كل بكسل، علينا تحديد ما إذا كان سيتم رسمه أم لا واللون الذي سيتم استخدامه.
لمعرفة ذلك، نحتاج إلى مسافتين.
تمثّل القيمة الأولى مدى بُعد البكسل عن مركز الخط.
المسافة الثانية هي مدى بُعد البكسل على طول المسار، من البداية إلى النهاية.
يخبرنا هذا باللون الذي يجب اختياره من التدرج، ويؤدي خلط القليل من القيمة الأولى إلى جعل اللون ينحني عبر العرض.
هل يمكننا رسم تدرج على طول مسار باستخدام المسار والطلاء فقط؟
نعم، يمكننا ذلك.
يتطلب ذلك تسوية المسار إلى قائمة من الخطوط المستقيمة القصيرة، والتي تعطي أيضًا مسافة على طول المسار.
val lines = remember {
path.asAndroidPath().flatten(0.5f)
}
//... inside draw scope
lines.forEach { line ->
//...
drawLine(
brush = Brush.linearGradient(...),
start = Offset(line.start.x,
line.start.y),
end = Offset(line.end.x, line.end.y),
strokeWidth = 30f,
)
//...
}بعد ذلك، نرسم كل خط بتدرّج خاص به.
هذه الطريقة فعّالة، ولكن يتطلّب كل خط طلب رسم منفصلاً وعنصر رسم، ولا يزال المسار بأكمله بعرض واحد.
الخيار الآخر هو تظليل وقت التشغيل، ولكنه يحصل فقط على موضع وحدات البكسل داخل حدود الحاوية.
ولا يخبرنا بما نحتاجه لرسم هذا الشريط.
ولا يمكننا أيضًا احتسابها بسهولة من موضع وحدات البكسل.
توفّر لنا دالة Draw mesh تظليل الرؤوس لذلك.
إذًا، ما هي الشبكة؟
تتكوّن الشبكة من مثلثات تحتوي على بيانات في كل زاوية، وتُسمّى كل زاوية رأسًا.
يحمل كل رأس سجلًا صغيرًا من البيانات.
عادةً ما يكون هذا السجلّ يشغل منصبًا، ولكن ليس بالضرورة.
يقرأ مظلل الرأس هذا السجل ويحسب الموضع منه.
إليكم المسار الذي يتّبعه مثلث واحد: يتم تشغيل برنامج تظليل الرؤوس لكل زاوية، ويحسب موضع الزوايا وأي قيمة نريد تمريرها لكل بكسل.
بعد ذلك، يعثر المحوّل على كل بكسل يغطيه هذا المثلث ويستنبط القيم من هذه الزوايا الثلاث لكل بكسل.
أخيرًا، يتم تشغيل أداة تظليل الأجزاء لتلك البكسلات، واستخدام تلك القيم المُقحمة وحساب لون البكسلات.
لنطّلِع على كيفية كتابة تظليل الرأس وتظليل الأجزاء من خلال مثال أبسط، وهو خط مستقيم مع نبضة تتحرك على طوله.
LineShaders.VERTEX
uniform float2 uStart;
uniform float2 uEnd;
uniform float uHalfWidth;
uniform float uTime;
Varyings main(const Attributes a) {
Varyings v;
float2 dir = uEnd - uStart;
float2 normal = normalize(float2(-dir.y, dir.x));
float along = a.corner.x * length(dir);
float pulse = (a.corner.x - uTime) / 0.18;
float radius = uHalfWidth * (1.0 + 0.6 * exp(-pulse * pulse));
float across = a.corner.y * (radius + 1.0);
v.position = uStart + dir * a.corner.x + normal * across;
v.stroke = float3(along, across, radius);
return v;
}يأخذ مظلل الرأس سمة كمدخلات ويعيد المتغيرات.
السمات هي البيانات الخاصة بزاوية واحدة.
إليك رقمين فقط: مدى تقدمه على طول الخط، وأي جانب من الخط هو.
بعد ذلك، يتم استيفاء المتغيّرات وتمريرها إلى أداة تظليل الأجزاء.
نحسب مسافة البكسل من البداية، وبالنسبة إلى عرض الخط، نستخدم الوقت لإنشاء نبضة على نصف القطر.
ثم نضرب نصف القطر في جانب الزاوية، ما يعطينا مسافة الإشارة عبر الخط.
عندما تستكمل وحدة معالجة الرسومات بين هذه الإشارات، تصبح الخطوط المركزية صفرًا.
أخيرًا، نحسب موضع الزوايا، ونخزّن كل هذه القيم في متغيّرات الخطوط ونمرّرها إلى أداة تظليل الأجزاء.
على عكس تظليل وقت التشغيل، يُخرج تظليل أجزاء الشبكة لونًا من خلال مَعلمة، ويعرض موضعًا لأخذ عيّنات من الطلاء إذا تم ضبطه.
LineShaders.FRAGMENT
uniform float uLength;
layout(color) uniform float4 uStartColor;
layout(color) uniform float4 uEndColor;
float2 main(const Varyings v, out float4 color) {
float distance = abs(v.stroke.y);
float coverage = 1.0 - smoothstep(
v.stroke.z - 0.5, v.stroke.z + 0.5, distance);
float side = distance / v.stroke.z;
float4 ink = mix(uStartColor, uEndColor,
clamp(v.stroke.x / uLength + 0.4 * side * side, 0.0, 1.0));
color = ink * coverage;
return v.position;
}إنّ أخذ القيمة المطلقة لإحداثياتنا العرضية يعطينا المسافة بين كل بكسل وآخر من الخط المركزي.
نقارن هذه المسافة بنصف القطر للحصول على التغطية، وبالنسبة إلى اللون، نمزجه على طول مسار الخط، ونحني التدرج قليلاً نحو الحافة.
نضرب هذه التغطية في التغطية، وهذا هو اللون النهائي.
في Compose، نربط بين أدوات التظليل وتنسيق البيانات باستخدام عنصر MeshSpecification.
val spec = remember {
MeshSpecification.make(
arrayOf(Attribute(TYPE_FLOAT2, 0, "corner")),
vertexStride = 8,
arrayOf(Varying(TYPE_FLOAT3, "stroke")),
LineShaders.VERTEX, LineShaders.FRAGMENT)
}أولاً، نعرّف سمة الزاوية كقيمتَين عشريتَين تبدآن من البايت 0.
بما أنّ كل عدد عشري يشغل 8 بايت، فإنّ كل خطوة في الزاوية في المخزن المؤقت هي 8.
بعد ذلك، نُعلن عن متغيّرات stroke varyings كأرقام عشرية، ونمرّر تظليل الرؤوس وتظليل الأجزاء الذي كتبناه للتو.
ننشئ مخزن الرؤوس المؤقت.
fun lineCorners(segments: Int): ByteBuffer {
val buffer = ByteBuffer.allocateDirect((segments + 1) * 2 * 8)
.order(ByteOrder.nativeOrder())
for (i in 0..segments) {
val t = i / segments.toFloat()
buffer.putFloat(t).putFloat(-1f) // one side
buffer.putFloat(t).putFloat(1f) // the other side
}
return buffer.rewind() as ByteBuffer
}في كل خطوة على طول الخط، نكمل التقدم، ونحسب التقدم من 0 إلى 1، ونكتب زاويتين في المخزن المؤقت.
أخيرًا، نجمع بين المواصفات والمخزن المؤقت في كائن شبكي.
val mesh = Mesh(
spec,
mode = Mesh.TRIANGLE_STRIP,
vertexBuffer = corners,
vertexCount = (segments + 1) * 2,
bounds = RectF(0f, 0f, size.width, size.height)
)لا تعرض لوحة Compose drawMesh بعد، لذا ننتقل إلى nativeCanvas ونرسم الشبكة.
Box(Modifier.fillMaxSize().drawWithCache {
val mesh = Mesh(...)
onDrawBehind {
mesh.setFloatUniform("uTime", time.value)
drawIntoCanvas {
it.nativeCanvas.drawMesh(mesh,android.graphics.BlendMode.DST, paint)
}
}
})بما أنّك تتحكّم في الشكل الهندسي ووحدات البكسل، يمكنك رسم أي تأثير مرئي مستند إلى الشكل الهندسي باستخدام ميزة "رسم الشبكة".
يمكنك الاطّلاع على مزيد من المعلومات حول واجهة برمجة التطبيقات هذه في مستندات do-.
في الإصدار 1.12 من Compose، أضفنا ميزة التوافق مع نطاقات الألوان الواسعة.
ماذا يعني ذلك في الواقع؟
هل تعلم أنّ الشاشات يمكنها إعادة إنتاج مجموعة مختلفة من الألوان مقارنةً بالفضاء اللوني sRGB التقليدي؟
على سبيل المثال، إذا نظرت إلى هذه الصورة، سترى أنّ المثلث الأبيض هو نطاق ألوان sRGB العادية التي يمكن لمعظم الشاشات عرضها.
ومع ذلك، تتوافق بعض الشاشات الأحدث مع مجموعة أوسع من الألوان، مثل ما يمثّله المثلث الأصفر، وبعضها أكثر تشبّعًا وحيوية.
ولكن عندما نمثّل لونًا، فإننا نمثّله في الواقع بوصف كل رأس من رؤوس هذا المثلث.
عند النظر إلى مقارنة التدرجات هذه، من المهم التمييز بين نطاق العرض واستكمال التدرجات.
على شاشة sRGB عادية أو جهاز العرض هذا على الأرجح، لا يمكن للشاشة فعليًا عرض الألوان خارج نطاق sRGB هذا.
من الصعب جدًا عرض هذه الميزة لكم على المسرح الآن.
لذا، عليكم أن تثقوا بي عندما أقول إنّ الألوان تبدو أفضل على Android مع ميزة "التدرّج اللوني الواسع".
في هذا المثال، ما تراه هنا هو تأثير استكمال نقاط توقف الألوان في فضاء لوني أوسع.
يؤدي مزج الألوان في فضاء لوني واسع أو خطي إلى تغيير المسار الرياضي الفعلي لهذا التدرج، ما يمنع ظهور اللون الرمادي المتوسط الذي يمكن أن يحدث والتدرج الحاد الذي يمكن أن يحدث عند إجراء ذلك بدون نطاق لوني واسع.
مع الإصدار 1.12 من Compose، يمكنك الاستفادة من مزج تدرّج الألوان بسلاسة أكبر على أي شاشة، بالإضافة إلى دعم حقيقي للتدرّج اللوني الواسع عند التشغيل على جهاز متوافق.
لتفعيل الدعم الصحيح على الجهاز، عليك التأكّد من ضبط نشاطك أو نافذتك لتفعيل وضع مجموعة الألوان الواسعة.
ويمكن إجراء ذلك في ملف AndroidManifest.
<!-- AndroidManifest.xml -->
<activity
android:name=".NavActivity"
android:colorMode="wideColorGamut"
android:exported="true">
</activity>أو آليًا في الدالة المركّبة.
// Dynamic toggle in code
DisposableEffect(window) {
window?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
onDispose {
window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
}
}بدون ضبط السمة colorMode، تظل النافذة في وضع sRGB العادي بدقة 8 بت، وسيتم اقتصاص أي ألوان من مجموعة الألوان الواسعة التي تعرضها وحدة معالجة الرسومات.
لذلك، من المهم تجنُّب استخدام ألوان التدرج اللوني الواسع إذا كانت الشاشة لا تتيح ذلك.
يجب تفعيل نطاقات الألوان الواسعة فقط في الأنشطة أو المسارات البرمجية التي تعرض الوسائط أو الصور أو الرسومات الغنية فعليًا لتوفير طاقة البطارية وتحسين الأداء.
هذا يعني أنّه يمكنك إنشاء ألوان في فضاءات لونية مختلفة في 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 لمشاهدة "رومان غاي" وهو يتحدث عن الألوان في Compose أو عن الألوان في Android.
في الإصدار 1.13 من Compose، نقدّم دعمًا أصليًا لشكل المضلّع.
سيسهّل ذلك إنشاء أشكال مخصّصة وعناصر واجهة مستخدم ذات مظهر طبيعي.
في السابق، إذا أردت رسم شكل هندسي معقّد مثل هذا، كان عليك استخدام RoundedPolygon لوصف شكل هندسي ينتج عنه مسار، ثم ربط هذا المسار بواجهة برمجة تطبيقات شكل 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، عليك كتابة برنامج تضمين مخصّص لـ Shape مثل هذا.
class RoundedPolygonShape(
polygon: RoundedPolygonShape
) : Shape {
// …
override fun createOutline(size: Size, layoutDirection: LayoutDirection,
density: Density,
): Outline {
path = polygon.toPath().asComposePath()
// …
matrix.scale(size.width / maxDimension, size.height / maxDimension)
matrix.translate(-bounds.left, -bounds.top)
// …
return Outline.Generic(path)
}
}وفي مقتطف createOutline هذا، يمكنك أن ترى أنه عليك إنشاء المسار وتغيير حجمه وترجمته يدويًا بنفسك.
الآن، مع واجهة برمجة التطبيقات PolygonShape الجديدة، لن تحتاج إلى برنامج تضمين Shape المخصّص.
private val AvatarShape = PolygonShape {
val vertices = List(16) { i ->
val radius = size.minDimension / 2f * if (i % 2 == 0) 1f else 0.86f
val angle = i * PI.toFloat() / 8f
size.center + Offset(cos(angle), sin(angle)) * radius
}
polygon(vertices, rounding = CornerRounding(radius = 8.dp))
}
// …
Modifier.clip(AvatarShape)بما أنّ هذه هي أداة إنشاء الأشكال المخصّصة في Compose، فإنّها تتلقّى الكثافة والحجم مباشرةً من الإعدادات التلقائية.
هذا يعني أنّه يمكنك احتساب الرؤوس ديناميكيًا وتحديد زواياك المستديرة مباشرةً بوحدة dp أو حتى كنسبة مئوية.
بعد تحديد الشكل، ما عليك سوى تمريره إلى أي واجهة برمجة تطبيقات تستخدم Shape.
نقدّم أيضًا واجهة برمجة التطبيقات MorphPolygonShape التي تتيح لك الانتقال بسلاسة بين أشكال المضلّعات المختلفة.
val recordShape = remember {
MorphPolygonShape(
start = PolygonShape.circle(),
end = PolygonShape.star(
numPoints = 9,
innerRadiusRatio = 0.8f,
outerRounding = CornerRounding(percent = 50),
),
) { shapeProgress.value }
}في هذا المثال، لدينا زر تسجيل يتحول من دائرة إلى نجمة عند التفاعل معه.
ما عليك سوى تقديم الشكل الأولي والشكل النهائي وقيمة مستوى التقدّم.
تقرأ واجهة برمجة التطبيقات MorphPolygonShape قيمة مستوى التقدّم خلال مرحلة تحليل المخطط، أي ليس خلال مرحلة إعادة الإنشاء أو مرحلة الإنشاء.
هذا يعني أنّه عندما يتغيّر شكل العنصر من إطار إلى آخر، يتم ببساطة تحويله إلى شكل جديد بدون الحاجة إلى إعادة إنشائه.
لنتحدّث الآن عن التمويه.
خلال الإصدارات القليلة الماضية، حاولنا تسهيل تنفيذ تأثيرات التمويه وجعلها أكثر تعبيرًا.
عندما نتحدث عن التمويه، هذا هو المعدّل الشائع الاستخدام الذي نراه.
Modifier
.blur(
radiusX = 10.dp,
radiusY = 10.dp,
edgeTreatment = BlurredEdgeTreatment(
shape = RoundedCornerShape(8.dp),
),
)يتوفّر هذا المُعدِّل في Compose منذ الإصدار Android 12.
ويعمل بشكل رائع عندما تريد تمويهًا موحدًا، حيث يتم تطبيق نصف قطر التمويه نفسه بالضبط في جميع أنحاء الدالة المركّبة.
ومع ذلك، تتطلب تصميمات واجهة المستخدم الحديثة غالبًا انتقالاً سلسًا من المناطق الحادة إلى المناطق المموّهة بدلاً من القطع الحاد.
وهذا ينقلنا إلى ميزة "التمويه التدريجي" (Progressive Blur)، والتي أصبحت الآن واجهة برمجة تطبيقات رسمية بدءًا من الإصدار 1.13 من Compose.
بدلاً من استخدام نصف قطر ثابت واحد في الدالة المركّبة بأكملها، يمكنك الآن تغيير مستوى التمويه في المساحة.
إليك مثال من تطبيق JetChat من شاشة ملف المستخدم الشخصي.
يأخذ معدِّل التمويه الجديد مواصفات نصف قطر التمويه.
Modifier.blur {
blurRadiusSpec = BlurRadiusSpec.verticalGradient(
listOf(
BlurStop(0.5f, 0.dp),
BlurStop(1.0f, 32.dp),
)
)
edgeTreatment = BlurredEdgeTreatment.Unbounded
}نستخدم هنا تدرجًا رأسيًا مع نقطتَي توقّف للتمويه، مع الحفاظ على حدة النصف العلوي من الصورة تمامًا عند 0 بكسل مستقل الكثافة، وزيادة التمويه إلى 32 بكسل مستقل الكثافة عند الحافة السفلية.
لاحظ أنّنا ضبطنا أيضًا edgeTreatment على أنّه غير محدود، ما يطلب من التمويه بشكل أساسي أخذ عيّنات من وحدات البكسل الشفافة خارج الحدود القابلة للتركيب أيضًا.
بالإضافة إلى التلاشي العمودي، تمنحك مواصفات نصف قطر التمويه عدة طرق مدمجة لتطبيق التمويه التدريجي.
على سبيل المثال، يمكنك على اليمين رؤية تدرج أفقي، حيث يكون نصف القطر الأولي 0 بكسل مستقل الكثافة، ويتم تحديد نصف القطر النهائي من خلال قيمة شريط التمرير.
BlurRadiusSpec.horizontalGradient(
startRadius = 0.dp,
endRadius = sliderValue.dp,
)وعلى اليسار، لدينا تدرج شعاعي، حيث يكون نصف قطر التمويه في المركز 0 وحدة بكسل مستقلة الكثافة، وعند الانتقال نحو fallOffRadius، وهو 300 وحدة بكسل مستقلة الكثافة في هذه الحالة، يزداد التمويه بسلاسة ليصل إلى 24 وحدة بكسل مستقلة الكثافة.
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 مباشرةً بقناع 24 وحدة بكسل مستقلة الكثافة maxRadius.
قبل أن نتحدث عن تمويه الخلفية، لِنطّلِع على الفرق بين الميزتَين.
على اليمين، لدينا شريط التطبيق العلوي الذي طبّقنا عليه تأثير التمويه.
أي أنّه يموّه الدالة المركّبة نفسها.
كما ترون، فإنّ العنوان ورموز الإجراءات مموّهة، ولكنّ مكوّن المحادثة خلفها ليس مموّهًا على الإطلاق.
على اليسار، لدينا ميزة "تمويه الخلفية".
في هذه الحالة، يتم فقط أخذ عيّنات من رسائل المحادثة التي تظهر خلف الشريط العلوي وتمويهها.
ومع ذلك، فإنّ النص والرموز واضحة تمامًا وقابلة للقراءة.
بدءًا من الإصدار 37.2 من Android، يمكنك تمويه الخلفية باستخدام واجهة برمجة تطبيقات جديدة تُسمى RenderNode.setBackdropRenderEffect.
ولكن قبل التعمّق في كيفية تحسين ميزة التمويه، لنتعرّف على طريقة عمل Android لضمان أداء التمويه بشكل جيد.
يستخدم Android مكوّنات RenderNode في الخلفية، وهي حاويات لقوائم العرض المسرَّعة بواسطة الأجهزة، ما يعني أنّها يمكنها ضغط وتخزين مجموعة من أوامر الرسم المختلفة من خلال بدء التسجيل وإنهائه.
يحتوي RenderNode على معلومات مثل عمليات النقل الثلاثية الأبعاد وعمليات التدوير والشفافية والمقاييس وحدود الموضع.
وتستخدم ميزة "الإنشاء" في الواقع RenderNode في الخلفية.
لذا، لاستخدام تأثيرات عرض الخلفية في Compose، يمكننا إنشاء Modifier.Node مخصّص لنتمكّن من إعادة استخدام هذا المُعدِّل.
fun Modifier.backdropBlur(spec: BlurRadiusSpec, shape: Shape, tint: Color): Modifier =
this then BackdropBlurElement(spec, shape, tint)
private class BackdropBlurNode(...) : Modifier.Node(), DrawModifierNode {
private var renderNode: RenderNode? = null
override fun ContentDrawScope.draw() {
val node = getOrCreateNode()
if (isDirty || sizeChanged) {
node.setPosition(0, 0, widthPx, heightPx)
node.setBackdropRenderEffect(spec.createRenderEffect(this))
}
drawIntoCanvas { it.nativeCanvas.drawRenderNode(node) }
drawContent()
}
}نحتفظ بمرجع إلى RenderNode، ويتم ضبط RenderNode باستخدام التأثيرات التي نريدها في دالة أخرى.
على سبيل المثال، نمرر تأثير التمويه هنا.
بعد ذلك، نستدعي drawIntoCanvas، ونستدعي drawRenderNode على لوحة الرسم الأصلية.
وأخيرًا، نستدعي drawContent لرسم المحتوى الفعلي غير المموّه في الأعلى أيضًا.
لاستخدام هذا المُعدِّل الذي أنشأناه للتو، نستدعي Modifier.backdropBlur، ونمرّر مواصفات نصف قطر التمويه.
JetchatAppBar(
modifier = modifier
.backdropBlur(
tint = MaterialTheme.colorScheme
.surface.copy(alpha = 0.5f),
shape = RectangleShape,
spec = BlurRadiusSpec.uniform(5.dp)
)
)لاحظ أننا لسنا بحاجة إلى وضع أي شيء على الطبقة الأساسية هنا باستخدام هذا النهج.
RenderNode.setBackdropRenderEffect is automatically sampling the content that sits below the surface of the JetChat app bar.
والنتيجة هي تمويه الخلفية بشكل رائع.
يمكننا أيضًا ضبط مواصفات نصف قطر التمويه التدريجي بدلاً من التمويه التدريجي فقط، والحصول على تمويه عمودي على المحتوى أيضًا.
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.
هذا المثال الذي عرضته لكم سيعمل فقط على مستوى واجهة برمجة التطبيقات 37.2، ولكننا نعمل على توفير إصدار متوافق مع الإصدارات القديمة من Compose أيضًا، لذا يُرجى متابعتنا لمعرفة أي جديد.
مثال آخر ممتع على تأثيرات الخلفية في RenderNode هو أنّه يمكننا أيضًا ربطها معًا لإنشاء واجهات مستخدم رائعة المظهر، وتطبيق تأثيرات مختلفة متعددة فوقها.
لذلك، لربط المؤثرات، ننشئ هذين المؤثرَين.
val blur = RenderEffect.createBlurEffect(24f, 24f, Shader.TileMode.CLAMP)
// Boost saturation by 30% and apply blur together
val matrix = ColorMatrix().apply { setSaturation(1.3f) }
val vibrancy = RenderEffect.createColorFilterEffect(ColorMatrixColorFilter(matrix))
val vibrantBlur = RenderEffect.createChainEffect(vibrancy, blur)في هذا المثال، ننشئ تأثير تمويه وتشبع بنسبة %30.
نستدعي createChainEffect مع تطبيق تأثير التمويه أولاً، ثم تأثير التشبع بعد ذلك.
هذا مثال في JetChat.
val agslShader = RuntimeShader(CHROMATIC_ABERRATION)
val noiseEffect = RenderEffect.createRuntimeShaderEffect(agslShader, "backdropInput")
val finalEffect = RenderEffect.createChainEffect(noiseEffect, vibrantBlur)
renderNode?.setBackdropRenderEffect(finalEffect)زر الانتقال إلى الأسفل، والذي يمكننا جعله يبدو كعدسة مكبرة صغيرة من خلال الجمع بين بعض التأثيرات المختلفة معًا لجعل حواف هذه الدالة المركّبة مستديرة مثل العدسة المكبرة.
ولكن لا يمكنك فقط تطبيق تأثيرات التكبير، بل يمكن تطبيق أي نوع من أدوات التظليل معًا لتطبيقها على الخلفية.
إليك بعض الأمثلة الممتعة التي نفّذناها لما يمكن تحقيقه عند الجمع بينهما.
أصبح من الممكن الآن استخدام تأثير الزجاج المصنفر التقليدي.
ويمكننا أيضًا استخدام تأثيرات ممتعة أخرى، مثل تأثير المشكال.
أو إذا أردنا تحويل تميمة Android هذه إلى نوع من عناصر واجهة المستخدم المتغيرة التي تحوّل المحتوى الموجود تحتها، يمكننا أيضًا تطبيق تأثيرات عرض الخلفية هذه لجعل الأمر يبدو وكأننا ننظر من خلال هذا الروبوت الصغير.
ويمكننا أيضًا إجراء ذلك باستخدام تأثيرات العرض في الخلفية.
وبالتالي، لم تقتصر واجهة برمجة التطبيقات الجديدة هذه على إتاحة ميزتَي تمويه الخلفية والزجاج المصنفر، بل أتاحت أيضًا إمكانات لا حصر لها.
من واجهات برمجة التطبيقات الجديدة الأخرى التي أضفناها إلى المنصة هي SurfaceView.setBlurRegions.
يمكنك حاليًا تمويه طرق عرض الزخرفة وباستخدام أي دالة مركّبة من خلال تأثيرات الخلفية Modifier.blur وRenderNode، ولكن تمويه أجزاء من SurfaceView أمر صعب، وليس جزءًا من شجرة RenderNode لعرض التطبيق، لأنّه في الواقع يُحدث ثقبًا في النافذة بدلاً من ذلك.
وبالتالي، لا يمكن لـ Compose أخذ عيّنات من المخزن المؤقت لـ SurfaceView، ما يجعل ذلك مستحيلاً قبل الإصدار 37.2 من واجهة برمجة التطبيقات.
لذلك، تتيح واجهة برمجة التطبيقات setBlurRegions في SurfaceView للتطبيق تحديد مناطق التمويه مباشرةً.
على سبيل المثال، في مشغّل الفيديو هذا في JetChat، أضفنا تمويه السطح SurfaceView عند إظهار عناصر التحكّم وإخفائها.
لاستخدام واجهة برمجة التطبيقات الجديدة هذه، يمكنك الوصول إلى 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 والإصدارات الأحدث، يمكنك الآن تمرير تظليل يأخذ لونًا، وهو بكسل الإدخال لهذا المحتوى المحدّد، وينتج لونًا جديدًا.
على سبيل المثال، إذا أردنا تطبيق فلتر "سايبربانك" على صورة، يمكننا تحديد تظليل صغير يُستخدم كإدخال في RuntimeColorFilter.
@Language("AGSL")
private const val CYBERPUNK_SHADER = """
vec4 main(half4 in_color) {
float lum = dot(in_color.rgb, vec3(0.299, 0.587, 0.114));
vec3 shadow = vec3(0.06, 0.04, 0.25);
vec3 mid = vec3(0.92, 0.12, 0.58);
vec3 high = vec3(0.18, 0.94, 0.98);
vec3 col = lum < 0.5 ? mix(shadow, mid, lum * 2.0) : mix(mid, high, (lum - 0.5) * 2.0);
return vec4(mix(in_color.rgb, col, 0.7), in_color.a);
}
"""ينتج عن هذا التظليل مظهر سايبربانك، ولكن يمكنك تنفيذ أي تأثير مختلف آخر تريده.
لاستخدام برنامج التظليل نفسه، نطبّق RuntimeColorFilter ونحوّله إلى فلتر ألوان في Compose.
AsyncImage(
model = imageUri,
contentDescription = filter.displayName,
colorFilter = RuntimeColorFilter(CYBERPUNK_SHADER)
.asComposeColorFilter(),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)كما ترى في هذا المثال، يمكننا الآن إنشاء تأثيرات أكثر ديناميكية من ذي قبل بدون الحاجة إلى التعمق في OpenGL أو إعدادات SurfaceView أو أي شيء من هذا القبيل.
ويمكن أيضًا تطبيق ذلك على أي دالة مركّبة، وليس فقط على الصور.
شكرًا لك.
لقد أنشأنا مؤثرًا رائعًا، ولكن لنتأكّد من أنّ تطبيقنا لا يزال سريعًا وسلسًا.
عندما ننتقل عبر المحادثة، يتم تشغيل جميع تأثيراتنا معًا.
لنبدأ بقياس هذا التمرير.
@Test
fun scrollFling() = measureScroll(swipeSteps = 5)
private fun measureScroll(swipeSteps: Int) = rule.measureRepeated(
packageName = TARGET_PACKAGE,
metrics = defaultComposeScrollingMetrics(),
compilationMode = CompilationMode.Full(),
iterations = 5,
setupBlock = {
killProcess()
}
//...نستخدم مقياس أداء ماكرو يشغّل تطبيقًا في عملية جديدة ويشغّل التمرير خلال المحادثة.
بعد ذلك، نرسل طلبًا إلى التتبُّع لاحتساب عدد اللقطات التي لم تلتزم بالمواعيد النهائية.
select count(*) as late_frames
from actual_frame_timeline_slice
where upid = (select upid from process where name = 'com.example.compose.jetchat.perf')
and jank_type like '%App Deadline Missed%'وهذا يعطينا ستة إطارات متأخرة.
لنفتح أحد الإطارات المتأخرة.
لذلك، قبل هذا الإطار مباشرةً، تجلب Compose الرسالة التالية مسبقًا قبل الانتقال للأعلى في العرض.
من المفترض أن يتناسب الجلب المُسبق مع الفجوة القصيرة بين اللقطات، ولكن إعداد هذه الرسالة يفك ترميز صورتها أيضًا مباشرةً على سلسلة التعليمات الرئيسية، ما يؤدي إلى تشغيلها لفترة طويلة بدون توقّف.
بما أنّ سلسلة التعليمات الرئيسية عالقة في فك ترميز هذه الصورة، لا يمكن بدء أي لقطات جديدة.
بعد ذلك، عندما يتم الانتقال إلى تلك الرسالة، على RenderThread تخصيص ذاكرة وتحميل كل وحدات البكسل هذه إلى وحدة معالجة الرسومات، وبالتالي تتأخر الصورة نفسها في الإطار الثاني.
الآن، نرى أين ذهب وقتنا، ولكن هل نحتاج حتى إلى كل هذا العمل لرسم هذا الإطار؟
وكيف يمكننا معرفة ذلك؟
يخبرنا التتبّع بذلك.
عندما يتم رسم صورة نقطية كبيرة في مساحة أصغر بكثير على الشاشة، يسجّل Android علامة في التتبُّع مباشرةً.
يعرض رقمَين بوحدة البكسل: بُعد الصورة النقطية الكامل، والمساحة التي تغطيها فعليًا على الشاشة.
اقسم ذلك على كثافة الشاشة وستحصل على هذه القيمة بالبكسل المستقل الكثافة.
بعد ذلك، نبحث في الرمز البرمجي عن قيمة 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.
لن نصلح الأخطاء في هذين الإطارَين هنا، ولكنّ الفكرة بسيطة: حاوِل عدم احتساب وحدات بكسل لن تصل إلى الإطار أبدًا.
لذا، كان فك ترميز الصور كبيرًا بما يكفي لتأخير الإطارات، ولكن لا تظهر كل تكلفة كإطار تم إسقاطه، ولا تزال هناك تكاليف أصغر يمكننا تجنبها أثناء كتابة الرمز.
لذا، لنلقِ نظرة على اثنين منها.
عندما نستخدم RuntimeShader، يجب إنشاؤه مرة واحدة لأنّه على الرغم من أنّه يجمّع السلسلة المصدر ويخزّنها مؤقتًا فقط، إلا أنّه يحلّلها في كل مرة ننشئ فيها سلسلة.
وعندما يحرّك أحد التأثيرات كل إطار، يمكننا تضمينه في graphicsLayer، بحيث تتم إعادة تسجيل العنصر المتحرك الوحيد، بدلاً من إعادة رسم واجهة المستخدم من حوله.
أما زر "الانتقال إلى الأسفل"، فيظهر عند الانتقال إلى الأسفل.
يستخدم هذا التأثير عنصرَي RuntimeShader لإظهار هذا التأثير، وفي كل مرة يظهر فيها الزر، تتم إعادة إنشاء كل من هذين المظلِّلَين من سلسلة المصدر الخاصة بهما.
val lensShader = RuntimeShader(MAGNIFIER_LENS_SHADER).apply {
setFloatUniform("size", size.width, size.height)
setFloatUniform("zoom", zoom)
// …
val lensEffect = RenderEffect.createRuntimeShaderEffect(lensShader, "content")
// …
val glassShader = RuntimeShader(GLASS_FINISH_SHADER).apply {يستغرق تحليل هذين المظلِّلَين وإنشاء التأثير مئات الميكروثانية في سلسلة التعليمات الرئيسية في كل مرة يظهر فيها الزر.
بدلاً من ذلك، ننشئ برنامج التظليل مرة واحدة للعملية بأكملها، أو يمكننا تذكّره، ثم تعديل المتغيّر الموحّد الخاص به.
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 على المربّع مباشرةً، يتوقف الانتقال عند المخطط الأخضر، وبالتالي تتم إعادة تسجيل مربّع التوهج فقط، ويتم تقليل شريحة رسم سلسلة التعليمات الرئيسية بمقدار عُشرَين من الملّي ثانية.
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