מהנדס תוכנה
בעזרת Jetpack Compose קל לעצב ממשקי משתמש באיכות גבוהה, אבל לפעמים קשה לאתר בעיות שמשפיעות על הביצועים של ממשק המשתמש. בעיות כאלה יכולות לגרום לבעיות שהמשתמשים ישימו לב אליהן, כמו זמן אחזור בהפעלה ופריימים שהושמטו, שמופיעים כקפיצות בגלילה או כאנימציות קטועות.
בהרצאה הזו נציג כלים שיעזרו לכם לגלות מה משפיע על הביצועים של ממשק המשתמש, תוך התמקדות ב-Perfetto ובמעקב אחרי קומפוזיציה (מחדש). לאחר מכן נבחן דפוסי אנטי-תבניות ספציפיים שאתם אולי משתמשים בהם, שגורמים לכם להוצאות בלי שתדעו, איך אתם יכולים למצוא אותם בקוד שלכם, ובאילו דפוסים חיוביים אתם יכולים להחליף אותם. בסוף ההרצאה הזו, תבינו איך לחשוב ב-Compose עם חשיבה על ביצועים, ואיך ליצור פרופיל, לנתח ולאמת אופטימיזציות של ביצועים באפליקציה.
תמליל
שלום
שלום לכולם,
אני מקווה שנהנית מההפסקה.
אני מקווה ששתית קפה, מרחת קרם לחות וכל זה.
היום נתעמק קצת יותר.
אז כן, הנה זה.
היום נתעמק קצת יותר.
קוראים לי אנדריי שיקוב, ואני חולק את הבמה היום עם אנדרו ביילי.
שנינו עובדים בצוות של סביבת זמן הריצה של המהדר של Compose, אבל גם על הרבה דברים אחרים, כולל ביצועים.
הנושא של ההרצאה היום הוא מענה על השאלות שלכם לגבי ביצועים, כי Jetpack Compose הוגדר כערכת הכלים שמשמשת כברירת מחדל לפיתוח ממשק משתמש ב-Android.
ועכשיו הרבה אנשים שואלים איך אפשר לוודא שממשק המשתמש של Compose יהיה יעיל ככל האפשר.
אז היום, אנחנו מתעמקים בנושא, כמו שכבר אמרתי.
בנוסף, נציג כמה כלים שיעזרו לכם לשפר את הביצועים ב-Compose.
הדבר הראשון שאני רוצה להתחיל איתו הוא לדבר על למה הביצועים חשובים לנו.
כשחושבים על איכות ועל פיתוח אפליקציות איכותיות, בדרך כלל חושבים על אנימציות, על קבוצה עשירה של תכונות, ולפעמים שוכחים את הביצועים. אבל התפיסה של המשתמשים באמת תלויה בביצועים של האפליקציה.
לכן, אם האנימציות מגמגמות, התפיסה מיד יורדת.
אז כל המדדים והמספרים שנזכיר היום הם בעצם מדדים חלופיים שבחרנו כדי לייצג את מה שהמשתמשים מרגישים לגבי האפליקציה, לגבי כל האנימציות האלה, כל הדברים שקורים במסך.
לכן, אופטימיזציה של האפליקציה היא בעצם אופטימיזציה של חוויית המשתמש.
אז זו רק גישה שאני רוצה שתאמצו, כי אנחנו הולכים להתעמק בהרבה נושאים היום.
עכשיו, כשאתם מתחילים לבחון את הביצועים של האפליקציה, אני רוצה שתעשו שלושה דברים מהירים, שלושה דברים מהירים מאוד.
קודם כל, אם אתם מודדים ביצועים, אתם מודדים אותם במצב הפצה, כך שאין לנו הרבה מידע שניתן לנפות בו באגים וכל זה מאט את האפליקציה שלנו.
הדבר השני הוא להפעיל את R8 או כלים אחרים לצמצום נפח.
רק השנה הצגנו אופטימיזציה שמשפרת את הביצועים של coroutine פי שניים, ואנחנו עושים גם הרבה דברים אחרים.
אנחנו מסתמכים הרבה על R8 כשאנחנו מבצעים אופטימיזציה של Compose.
והדבר האחרון, במיוחד למדדי ההפעלה, אתם צריכים לוודא שאתם משתמשים בפרופיל Baseline.
כך, המחלקות שאתם טוענים בהפעלה עוברות הידור מראש ונטענות מראש כשהאפליקציה מופעלת.
כמו שאתם רואים, זה הבדל של פי 6 מהגרסה הרגילה של האפליקציה שניתן לבצע בה ניפוי באגים.
וכשאתם מודדים את כל הדברים האלה, הם יצטרפו ליתרונות האחרים שאתם יוצרים בקוד שלכם.
עכשיו, עשיתם את כל זה, האפליקציה עדיין קצת איטית, או שיש עדיין בעיות אחרות בביצועים, וכאן אתם רוצים להיכנס ללולאת הביצועים הזו.
אתם רוצים להתחיל לבחון כמה מדדים בתרחישים ספציפיים.
לדוגמה, נניח שלוקח לאפליקציה יותר מדי זמן להיטען, ואתם רוצים לאפס אותה, להפעיל אותה ממרכז האפליקציות הרגיל ולבדוק כמה זמן לוקח לה להגיע למסך הנתונים הראשון.
או שאולי האפליקציה קצת מגמגמת, אז אתם גוללים אותה ומוודאים שהגלילה הזו ניתנת לחזרה ויציבה.
לכן הסביבה היציבה בשקף הזה חשובה מאוד, כי כשמבצעים אופטימיזציות בהמשך, רוצים לוודא שרואים שיפורים במדדים, ושאפשר לאשר שהאופטימיזציות האלה באמת פועלות כמתוכנן.
מבחני ביצועים הם כלי מצוין למטרה הזו, כי הם כבר אוספים הרבה נתונים, והם עושים את זה בהרבה חזרות כדי לוודא שאין הרבה רעש במכשיר. הם גם מבצעים הרבה הגדרות במכשיר שעוזרות לכם.
בנוסף, נקודות השוואה הן גם תרחישים שניתן לחזור עליהם כברירת מחדל, וזה מה שאנחנו שואפים אליו כאן.
אבל אפשר גם לעשות את זה עם כמה סקריפטים.
והדבר האחרון, אתם יוצרים פרופיל, משפרים משהו באפליקציה, מסתכלים על הנתונים, ואז חוזרים על זה שוב, ואולי חוזרים על זה שוב עם מדדים של תרחישים שונים שבהם האפליקציה מתקשה.
כשמסתכלים על הנתונים, הכלי הראשון שנציג היום ושכדאי להכיר היטב הוא המעקבים של Perfetto.
מעקבי Perfetto מתעדים את כל מה שקורה במכשיר בזמן שאתם מקליטים את המעקב, למשל, במהלך הפעלה של בדיקת ביצועים ברמת המאקרו, או אולי סקריפט כלשהו שאתם מקליטים.
הוא גם עוזר מאוד לכל מהנדס שעובד על הביצועים של Android.
אני, למשל, מבלה את רוב יום העבודה שלי בבדיקת המעקבים של Perfetto.
הם נראים בערך כך.
זו כמות עצומה של נתונים.
כמו שאמרתי, הם מתעדים את כל מה שקורה במכשיר.
לכן, יכול להיות שבהתחלה תתקשו להבין איפה בדיוק נמצאת האפליקציה שלכם, אז אני ממליץ להשתמש בשורת החיפוש השימושית הזו.
חפשו את Compose, את חבילת ה-APK של האפליקציה ודברים דומים.
פשוט תתמקדו בדברים שאתם מכירים.
כשתתחילו להבין את התהליך של האפליקציה, תראו את קווי זמן רינדור הפריים ואת משך הזמן שלוקח להרבה דברים באפליקציה.
אם נבודד אחד מהם, נראה משהו כזה.
הראשון הוא העבודה של ה-thread הראשי.
כאן Compose מבצע את רוב הפעולות שלו.
ויש גם את RenderThread.
כאן אנחנו מעבדים את פקודת ה-canvas ומקודדים אותה ל-GPU.
לאחר מכן, ה-GPU מבצע את העבודה שלו, והמערכת משלבת את זה.
היום נתמקד רק בשרשור הראשי.
כי זה המקום שבו Compose פועל.
אנחנו מדברים על Compose, אז הגיוני להתמקד רק בזה.
כשאנחנו מבודדים את הפריים הזה ב-main thread, אנחנו מתחילים לראות פרוסות של Compose.
בחרתי כמה מהם בשבילך.
זה כמו קומפוזיציה, מדידה, פריסה, ציור.
אם שמעתם על שלבי Compose, אתם מסתכלים על הביצועים לפני כן, סביר להניח שתראו אותם.
אז יש להם פרוסות נפרדות במעקב Perfetto, וקל מאוד לגלות כמה זמן לוקח כל אחד מהשלבים, ואילו שלבים פועלים בכל פריים מסוים.
אז דיברנו הרבה ובאריכות על אופטימיזציה של חלק מהעבודה הזו, ואתם רוצים לוודא שאתם עושים את העבודה הנכונה בשלב הנכון.
לדוגמה, אם יש לכם אנימציה שמעדכנת את הצבע של התיבה או משהו כזה, אתם רוצים לבטל רק את שלב השרטוט, ולא את הקומפוזיציה, הפריסה ודברים כאלה.
זהו שיעור בסיסי בביצועים, והיום אנחנו מדברים קצת יותר לעומק, או שאנחנו צוללים קצת יותר לעומק לכמה דברים, ואנחנו מתמקדים בקומפוזיציה, בפרט.
עכשיו נעבור לאנדרו, שיסביר לנו על זה.
תודה, Andrei.
כמו שאנדריי הזכיר, אנחנו מקדישים הרבה זמן לדיבורים על אופטימיזציות בהקשר של ביצועים, כמו הימנעות מקריאות לאחור, הקפדה על דחיית קריאות המצב לשלב הנכון במחזור החיים של הקומפוזיציה, אופטימיזציה והקפדה על כך שהרכיבים הניתנים ליצירה לא יהיו חוקיים כפי שאתם מצפים מהם, כדי שלא תעשו יותר מדי עבודה בכל פריים.
זו נקודת התחלה ממש טובה, אבל כבר יש לנו הרבה חומר בנושא, ואנחנו רוצים להתקדם קצת עם השיחה הזו.
אז היום נתמודד עם מה שנשאר בקומפוזיציה אחרי שמבצעים את האופטימיזציות האלה, ונתמודד עם מה שה-composables עצמם עושים.
המציאות המצערת של כל מה שנמצא בתוך קוד Compose, ולמעשה כל מה שאתם שולחים עם האפליקציה שלכם מבחינת הביצועים, היא שלכל דבר יש עלות שקשורה אליו.
אף אחד מהקודים שאתם שולחים באפליקציה שלכם הוא לא בחינם, ואפילו דברים טריוויאליים כמו קריאת מצב, ויצירת דברים כמו אפקט לוואי בתוך הקומפוזיציה, יש כמה עלויות מוחשיות שקשורות אליהם, וחשוב להיות מודעים להם, במיוחד כשאתם מתחילים לנתח מה הפונקציות הניתנות ליצירה שלכם עושות בפועל, ומתחילים לסחוט יותר ביצועים בתוך האפליקציה עצמה.
כדי להראות איך זה נראה בפועל בקנה מידה מיקרוסקופי, הנה כמה מהפעולות הקטנות ביותר שאפשר לבצע בכל מקום בקוד, ובמיוחד בתוך ה-composables, וברזולוציה הזו, אנחנו מדברים על קנה מידה של ננו-שניות מבחינת העלות של הדברים האלה בערך כחשיבה.
אם אתם רוצים לשפר את הביצועים, יש לכם שתי אפשרויות.
אפשר להסיר מספר גדול מאוד של פעולות זולות, או מספר קטן יותר של פעולות יקרות יותר.
בתרשים הזה ספציפית, שגרות משותפות הן m-- בצד היקר יותר בכל מה שקשור לפעולות האלה, וזו דוגמה טובה למקום שבו כדאי להתחיל לחפש, כי אם נסיר כמה מהמופעים האלה, נוכל להתחיל לראות יתרונות מוחשיים יותר באפליקציה שלנו מהר יותר.
עוד דבר שחשוב לזכור לגבי פונקציות coroutine באופן ספציפי, הוא שכמו שמופיע בשקף, לא רק שהפעלת פונקציית coroutine כרוכה בעלות, אלא גם ביטול של פונקציית coroutine.
עלות הביטול חשובה במיוחד כי היא מתרחשת בעיקר כשאתם נפטרים מתוכן ואולי עוברים לאזורים אחרים באפליקציה.
מעבר למסך אחר הוא פעולה יקרה מאוד באפליקציות רבות שבהן צריך לנצל את זמן רינדור הפריים בצורה הטובה ביותר, ולוודא שאתם משתמשים ברוב מה שיש לכם.
לכן חשוב לשים לב לשתי העלויות האלה.
כל אלה, לא משנה במה אנחנו מסתכלים, יצטברו בקנה המידה הזה של כ-16 מיליון ננו-שניות לעבודה.
אז יש לנו כאן תקציב שאנחנו צריכים להקטין, ו-coroutines הן דוגמה טובה להתחיל איתה.
ב-Compose, קורוטינות מופיעות בדרך כלל עם ממשקי ה-API של האפקטים.
יש כאן מגוון של ממשקי API שאפשר להשתמש בהם עם SideEffects, החל מפונקציונליות גבוהה ועד לזמן אחזור קצר.
בצד הפונקציונלי ביותר של הספקטרום הזה, יש לנו את LaunchedEffect, שכולל את כל מה שצריך לעבודה אסינכרונית, במיוחד אנימציות, וכל מה שצריך בשבילו היקף של פונקציית coroutine.
אם אתם לא צריכים את זה, ואתם יכולים לבצע את המשימה באמצעות API זול יותר, כדאי לשקול להשתמש בו במקום זאת, אם הוא מתאים לצרכים שלכם.
כדי להראות איך זה נראה בפועל, יש לי כאן נגן מדיה קומפוזבילי שמדגים את כל ה-SideEffects השונים ומתי הם עשויים להופיע.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Asynchronous work and animations
LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
}אנחנו יכולים להתחיל עם LaunchedEffect.
בדוגמה הזו, משתמשים ב-In כדי לטעון נתונים באופן אסינכרוני בתוך נגן המדיה.
אם אתם צריכים היקף של coroutine, או אם אתם צריכים coroutines באופן כללי או כדי לבצע קריאה מושהית, השתמשו ב-LaunchedEffect.
למרות שזה עדיין יקר יותר ויש עלות שקשורה לשגרות המשותפות, כל עוד אתם מנצלים את זה ומצדיקים את זה, השתמשו ב-API המתאים לעבודה.
אבל אם אתם משתמשים ב-LaunchedEffect ולא מנצלים את ה-coroutine, יכול להיות שכדאי לכם לשקול ממשקי API אחרים שיכולים לעשות את אותו הדבר, אבל בעלות נמוכה יותר.
כדי להבין את זה, נמשיך לאורך הספקטרום ונראה דוגמאות אחרות לתופעות לוואי כמו DisposableEffect.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val activity = LocalActivity.current
// Setup and teardown
DisposableEffect(Unit) {
activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
onDispose {
activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
}
}
}הפונקציה DisposableEffect שימושית כשיש תהליך הגדרה ופירוק שמשויך לאפקט.
כאן, באותה דוגמה של נגן מדיה, יש לי דוגמה שנועלת את כיוון הפעילות לרוחב כשהפונקציה הקומפוזבילית קיימת, ואז משחררת את נעילת הכיוון כשהיא נעלמת זמן מה לאחר מכן.
אם אתם רוצים את האפקט החד-פעמי של LaunchedEffect ו-DisposableEffect, אבל אתם לא צריכים קורוטינות ואין לכם אפקט dispose, אתם יכולים גם להשתמש ב-API החדש SideEffect עם מפתחות שהצגנו ב-Compose 1.12.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// One-shot events
SideEffect(Unit) { mediaPlayer.play() }
}זוהי חלופה מצוינת לשני ממשקי ה-API האחרים אם השתמשתם בהם, LaunchedEffect מטעמי נוחות או DisposableEffect מתוך צורך באפקט חד-פעמי. ניווט או העברה אל SideEffect באמצעות ממשקי API של מפתחות יכולים להשיג את אותו הדבר עם פחות תקורה וביצועים משופרים, במיוחד אם מעבירים כמה פעמים לאורך כל האפליקציה.
ולבסוף, ה-API הכי פחות יקר הוא ה-API הרגיל של SideEffect.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Synchronize the outside world to the state of composition
SideEffect { mediaPlayer.playbackRate = playbackRate }
}האפשרות הזו הכי מתאימה כשרוצים לסנכרן את המצב החיצוני של העולם עם מה שקורה בקומפוזיציה.
אז כאן, שוב בדוגמה של נגן המדיה, יש לי עוד דוגמה שמראה סנכרון של מצב הפעלה עם פרמטר שמתקבל על ידי קומפוזיציה.
חשוב לזכור שאם תחליטו לבדוק את השימוש שלכם בפעולות משניות ולעבור לפעולה פחות יקרה, בנוסף להתנהגויות ולתרחישי השימוש האחרים שדיברנו עליהם, יש גם הבדל בהתחייבויות להזמנה עם פעולות משניות.
כל האפקטים החד-פעמיים מופעלים קודם, ואחריהם תופעות הלוואי הרגילות, עם או בלי מפתח, ואז אחרי שהפריים מסתיים, כל האפקטים שהופעלו פועלים כי יש שליחה.
אם אתם מסתמכים על התזמון הזה לאפקטים שהושקו, כדאי לזכור את זה אם אתם שוקלים לעבור ל-Compose.
בכל העברה, חשוב לבדוק את הקוד ולוודא שאתם לא מסתמכים על אף אחת מההתחייבויות האלה לגבי תזמון, ושהקוד עדיין פועל לאחר מכן.
אם אתם לא יכולים להפחית את המורכבות של תופעת הלוואי עצמה, אתם עדיין יכולים להפחית את מספר תופעות הלוואי על ידי מיזוג שלהן.
@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) { viewModel.loadCart() }
LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }
CartContent(...)
}כדאי לשקול את זה במיוחד אם יש לכם כמה אפקטים שמשתמשים באותו מקש.
כדאי לשקול למזג אותם כי הם יכולים לפעול בו-זמנית.
בדוגמה הזו, יש לי כמה אפקטים שהופעלו, וכל אחד מהם מבצע קריאה אסינכרונית למודל תצוגה כלשהו.
אם אחליף אותם ב-LaunchedEffect אחד ואז בקריאות הפעלה אחרות בתוכו, למרות שעדיין יש לי אותו מספר של פונקציות coroutine שפועלות כאן, עדיין יש פחות תקורה כי זה מוציא את העבודה מהקומפוזיציה ומעביר אותה לפונקציות coroutine במקום זאת.
@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) {
launch { viewModel.loadCart() }
launch { viewModel.loadPaymentMethods() }
launch { viewModel.loadDeliveryAddresses() }
}
CartContent(...)
}לכן, במצבים כאלה כדאי לשקול להעביר את האפקטים למקום אחר כדי לצמצם את מספר האפקטים כשאפשר.
אפקטים ו-coroutines הם רק דוגמה אחת למקום אחד שאפשר לבדוק באפליקציה, אבל כל composable וכל אפליקציה יהיו שונים ויהיו בהם הזדמנויות שונות.
כדי להבין מה רלוונטי לאפליקציה שלכם, אנחנו צריכים לדבר על הכלי הבא שאנדריי יציג, שהוא מעקב אחר קומפוזיציה.
טוב.
אז מעקב אחר קומפוזיציה, אם תסתכלו על השחזור הנאמן הזה של מסגרת Perfetto שראינו קודם בשקף אחר, תראו שהוא נותן לנו מידע על תזמון עם שלבים שונים, אבל לא ממש נותן לנו הרבה פרטים על מה שקורה.
פריט הגרפיקה
כן, מה שקורה בתוך הקומפוזיציה שבה פועלות הפונקציות הקומפוזביליות.
מעקב אחרי קומפוזיציות עוזר לנו לעשות את זה.
לכן, אנחנו מוסיפים כמה סמנים בקומפיילר של Compose, שבעצם מוסיפים הערות לכל פונקציה והופכים אותה לגלויה ב-Perfetto trace, כדי שתוכלו לבדוק כמה זמן לוקח לכל פונקציה.
אתם יכולים להשתמש בזה כדי להבין אילו פונקציות בדיוק עוברות הרכבה מחדש.
אבל יש לזה קצת תקורה, אז אם אתם רוצים לתעד את נתוני הביצועים הכי מדויקים, כדאי להשתמש ב-Perfetto רגיל או פשוט להריץ בלי תיעודים של Perfetto ולהשתמש במדדים אחרים.
אבל מעקב אחרי קומפוזיציה שימושי מאוד אם רוצים להבין כמה זמן לוקח לכל פונקציה קומפוזבילית בנפרד.
עכשיו, לפעמים אתם גם רוצים להבין למה דברים עוברים קומפוזיציה מחדש, וזה מה שאנחנו מנסים לפתור בגרסה הבאה של Compose Tracing, או יותר נכון, בגרסה 1.13.
עכשיו אנחנו משיקים את מעקב הרה-קומפוזיציה, שמוסיף הערות לכל קריאת מצב וכתיבת מצב שגרמו לרה-קומפוזיציה.
אז תקבלו משהו כזה במעקב של Perfetto, שבו תראו כל אחת מכתיבות המצב ואת סטאק הביצוע שבו הכתיבה מתרחשת והקריאה מתרחשת, כדי שתוכלו להתאים אותם לשורות בפועל בקוד שבהן ניגשתם למצבים האלה או שיניתם אותם.
אם אתם רוצים לקבל מידע נוסף, אתם יכולים להיכנס לקישור הזה.
הוא מכיל את כל המסמכים בנושא מעקב אחרי קומפוזיציה ומעקב אחרי קומפוזיציה מחדש.
עכשיו, נעבור לאנדרו שיסביר איך אפשר להשתמש בזה בצורה יעילה יותר כדי להימנע מכתיבת מצב.
תודה, Andrei.
יכול להיות שתגלו שיש בקומפוזיציה ביטולים שלא מועילים לאפליקציה, ושאפשר היה להימנע מהם מלכתחילה.
לדוגמה, המקרה הכי נפוץ שנתקלתי בו הוא לחצן גלילה למעלה, שהחשיפה שלו תלויה בחשיפה או בהתקדמות הגלילה.
@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton = scrollState.value > 0
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}כאן, בגלל שאני קורא את הערך של מצב גלילה בתוך קומפוזיציה, זה מצב שמשתנה לעיתים קרובות מאוד, הוא יכול להשתנות אפילו פעם אחת בכל פריים, וזה יגרום להרבה קומפוזיציות מחדש שאפשר להימנע מהן כי התנאי הזה, למרות שמצב הגלילה עצמו משתנה, התנאי עצמו שחשוב לנו כאן לא משתנה לעיתים קרובות כל כך.
זו דוגמה למצב שבו מומלץ להשתמש במשהו כמו מצב מחושב, שיכול למנוע ביטולים של שינויי מצב שלא משפיעים על האפליקציה.
אז בדוגמה הזו, מה שעשיתי כאן זה שעטפתי את החישוב עצמו בתוך ערך דינמי מחושב, וכשאנחנו קוראים את הערך הדינמי הזה, במקום לבטל את התוקף על סמך מיקום הגלילה עצמו, הוא יבטל את התוקף של הפונקציה הקומפוזבילית הזו באופן מותנה על סמך התנאי כולו ויפיק אותו מהערך הדינמי האחר עצמו.
@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}אם ראיתם את התבנית הזו בעבר, סביר להניח שראיתם אותה בשימוש עם מצב נגזר.
ComputedState הוא ממשק חדש ב-Compose 1.13. למרות של-ComputedState ול-DerivedState יש מטרה דומה – לבטל את התוקף באופן מותנה כשהחישוב שלהם משתנה – יש הבדל קטן מאוד באופן שבו הם עושים את זה.
DerivedState, שהשקנו כבר בתחילת הדרך של Compose, שומר במטמון את הערך שהוא מחשב ומשתמש בו שוב עד שהתלויות שלו משתנות.
עם ComputedState, המערכת מחשבת את הערך בכל פעם שאתם קוראים אותו.
השמירה במטמון נשמעת כמו משהו שיכול להיות שימושי, אבל למעשה במצבים רבים, השמירה במטמון יקרה יותר מאשר רק קריאת התוצאה.
כדי להבין איך זה קורה, אפשר להסתכל על דוגמה לאופן שבו המצבים האלה עשויים לשמש בתוך האפליקציה שלכם, ולבחון מה כל אחד מהם עושה בפועל.
כאן יש לי את אותו מצב שמשקף בערך את מה שעשינו בכפתור הגלילה למעלה, שבו יש לו תנאי כלשהו שמבוסס על מצב אחר, ויש לי אותו גם כ-DerivedState וגם כ-ComputedState.
var a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
// First read
derivedState.value
computedState.value
// Second read
derivedState.value
computedState.valueאני גם קורא את המצב פעמיים ונקבל את אותו ערך בשתי הפעמים, אבל מה שקורה מאחורי הקלעים שונה בין שתי הפעמים.
כדי לפשט את המעקב אחרי מה שיופיע כאן בפועל בכלי כמו Perfetto, אנחנו יכולים לבחון מה קורה בפועל כשקוראים את שני המצבים האלה.
כאן אנחנו מתחילים בקריאה ל-computedState.value.
הוא יפעיל את פונקציית ה-lambda של החישוב, שתקרא את ערך המצב, ויש גם קצת תחזוקה כדי להודיע שהקריאה התרחשה.
זה נכון לגבי כל המצבים של תמונת המצב ב-Compose.
הקריאה השנייה מתנהגת בדיוק כמו הקריאה הראשונה, ויש לה את אותם מאפייני ביצועים לכל אורך הדרך.
לשם השוואה, הקריאה הראשונה של DerivedState עושה דבר דומה, שבו היא עדיין צריכה לקרוא ללמבדה של החישוב ועדיין מבצעת את ההתראה השנייה, אבל יש לה קצת תקורה של ניהול מטמון, שקצת מקוצרת בפעם הראשונה כי אין מטמון לבדוק.
אז היא מבצעת קצת תחזוקה של המטמון עצמו, ואז מבצעת את אותה פעולה בזמן קצת יותר ארוך.
אבל הקריאה השנייה של DerivedState מהירה יותר מהקריאה השנייה של ComputedState, למרות ששני המצבים האלה די פשוטים.
כמו שאמרתי בשקפים הקודמים, העלות של קריאת תמונת מצב היא לא טריוויאלית במקרים מסוימים, והקריאה מהמטמון מהירה יותר מהקריאה עצמה, ולכן אנחנו רואים את הניצחון הקל הזה בקריאה השנייה עצמה של DerivedState.
אבל התרחיש המעניין באמת הוא מה שקורה כשצריך לבטל את המצב.
זה מה שאנחנו מכנים תרחיש של קריאה אחרי כתיבה, שבו מעדכנים את אחד המצבים שמשמשים בחישוב, וצריך להריץ את החישוב שוב.
בקוד זה נראה כך: אנחנו עושים משהו כמו לשנות את a, אולי להגדיל אותו, ואז לקרוא את הערך שוב או אולי ליצור מחדש.
var a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
...
// Read-after-write
a++
derivedState.value
computedState.valueהאופן שבו זה נראה בתרשים בפועל יהיה, שוב, זהה לחלוטין ל-ComputedState.
אין לו מטמון לניהול, זה נראה בדיוק אותו דבר, בקריאה הראשונה, השנייה, השלישית, או בכל קריאה שהיא.
אבל DerivedState צריך לעבוד קשה.
הוא עובר דרך נתיב יקר מאוד כדי לראות שהמטמון לא חוקי עוד לפני שהוא מעדכן וקורא לחישוב עצמו, ואז הוא צריך לבצע מעקב אחר תלות ולנהל הרבה תצפיות תלויות כדי לכתוב בחזרה את ערך המטמון ולהבין שכל הערכים האלה השתנו בפועל ולנהל את התחזוקה עצמה.
כל זה מצטבר והופך את העלות של ביטול DerivedState ליקרה מאוד בהשוואה ל-ComputedState.
כדי להמחיש את כל הטענות האלה במספרים, בדרך כלל ComputedState מהיר יותר בקריאה הראשונה בכ-200 ננו-שניות.
ComputedState מפסיד בכ-350 ננו-שניות ב-Pixel 6, באופן ספציפי, בתנאי השוואה ספציפיים.
לכן, בקריאה השנייה, ComputedState יפסיד קצת בהשוואה ל-DerivedState.
אבל במקרה שבאמת מעניין אותנו, שבו אנחנו מבטלים את התוקף, וכנראה המקרה שבו באמת יש לכם עבודה לעשות על הפריימים, ComputedState מהיר יותר כמעט פי שניים, ולמעשה יותר מפי שניים, בהשוואה ל-DerivedState.
אז בהתאם למה שאתם משתמשים במצבים שלכם, ComputedState יכול להיות בחירה ראשונה טובה מאוד.
באופן כללי, עדיין צריך לקבל החלטה כשמשתמשים ב-DerivedState וב-ComputedState.
אנחנו לא מוציאים משימוש את ComputedState, עדיין יש שימושים לשניהם.
באופן כללי, אם אתם לא בטוחים באיזו אפשרות לבחור, מצב מחושב הוא ברירת מחדל טובה.
מצב נגזר הוא הכי שימושי כשמנצלים את היתרונות של השמירה במטמון, כלומר כשעושים משהו לא טריוויאלי כמו אלגוריתם מסובך, הקצאה גדולה, הרבה קריאות מצב בפנים שבאמת נהנות מהשמירה במטמון כאן, או כל דבר לא טריוויאלי או שתלוי בדברים כמו שוויון אובייקטים ושוויון זהות אובייקטים, שבהם אתם מפצים על ההבדל ומרוויחים את היתרונות של השמירה במטמון שמצב נגזר מציע.
הנושא השני שרציתי לדבר עליו הוא שמצב נגזר ומצב מחושב מנסים להימנע מביטול התוקף של ה-composable עצמו.
זה שונה מדילוג על פונקציה קומפוזבילית, שזה מה שאנחנו בדרך כלל מדברים עליו כשאנחנו חושבים על ביטול מותנה, או על אי ביצוע עבודה בתוך פונקציה קומפוזבילית.
האמת שאני רוצה לחשוף כאן היא שלמרות שמדברים הרבה על יציבות ודילוג בתוך Compose, זה לא הדבר הכי חשוב, ולא תמיד זה הדבר הנכון לבצע אופטימיזציה בשבילו.
כמו שאמרנו קודם, לכל דבר בקומפוזיציה יש עלויות מסוימות, וזה כולל דילוג.
// What you write
@Composable fun ContactListItem(
contact: Contact
) { ... }
// What you get
@Composable fun ContactListItem(
contact: Contact,
$composer: Composer?
$changed: Int
) {
val $dirty = $changed or
$composer.changed(contact)
if ($dirty) { /* Your function body */ }
else $composer.skipToGroupEnd()
}דילוג על שלבים הוא לא בהכרח דבר פשוט.
אנחנו צריכים לבצע הרבה פעולות תחזוקה כדי לאפשר את זה מלכתחילה.
אנחנו צריכים לבצע פעולות כמו אחסון כל הפרמטרים שלכם ומציאת מקום בשבילם בקומפוזיציה הראשונית, ואז לעדכן את הרשומה הזו כשאתם מבצעים קומפוזיציה מחדש.
אנחנו גם צריכים להשוות את הערכים שלכם לערכים הקודמים שלהם כדי להבין אם הפונקציה הניתנת לשילוב צריכה לדלג או לא.
ואז, כל זה הוא רק הכוח של בדיקת if שמדלגת על הקוד או לא.
זה יכול להיות יקר, תלוי מה קורה כאן.
יש אופטימיזציה מאחורי הקלעים של הקומפיילר, שבה אם מעבירים את אותו פרמטר, אנחנו יכולים להשתמש שוב בבדיקות שוויון, אבל זה לא הפתרון האולטימטיבי, ודי קל לעקוף את זה.
כדי לראות דוגמה למצב שבו דילוג על קומפוזיציה חוזרת עלול להטעות אותנו, אפשר להסתכל על דוגמה קלאסית של רשימה נגללת שמכילה אובייקט עצום עם כל הנתונים שצריך להרכיב על המסך.
data class ContactsModel(
val header: String,
val contacts: List<Contact>
)
data class Contact(...)
@Composable
fun ContactsScreen(model: ContactsModel) {
Column {
TopAppBar(title = { Text(model.header) })
ContactsList(model.contacts)
}
}
@Composable
fun ContactsList(contacts: List<Contact>) {
LazyColumn {
items(contacts) { ContactListItem(it) }
}
}
@Composable
fun ContactListItem(contact: Contact) { ... }אז כאן, מודל המסך מכיל משהו כמו מסך כותרת ואז את כל רשימת אנשי הקשר שהוא מעביר דרך כמה פונקציות קומפוזביליות שבסופו של דבר קוראות לרשימה עצלה ואז זורקות אותן על המסך עם כמה תאי פריטים.
היררכיית הקומפוזיציה של הקוד הזה נראית בערך ככה: אנחנו מתחילים שוב במסך של איש הקשר, מתפצלים קצת, ואז יש עוד כמה פונקציות קומפוזביליות שקוראות לפריטים שלנו ומציגות אותם.
אם נבחן את האופן שבו הנתונים מועברים, נתחיל במודל שלמעלה.
אנחנו מפרקים אותו למחרוזת ואז לרשימה, ואז הרשימה הזו מומרת לביטוי למבדה של פריטים בשביל הרשימה הנטענת לפי הצורך, ואז הרשימה הנטענת לפי הצורך תיצור את כל הפריטים שמופיעים במסך.
בואו נראה מה קורה כשמשנים את איש הקשר האחרון ברשימה הזו.
מה שאנחנו רוצים שיקרה זה פשוט לבטל את הפריט האחרון ברשימה, אבל זה לא מה שיקרה כאן.
אם נסתכל על מעקב הקומפוזיציה של ה-recomposition הזו, הוא ייראה בערך ככה: אנחנו מתחילים במסך של איש הקשר, ובסופו של דבר מגיעים לפריט ברשימה.
וזו דרך די יקרה להגיע לכאן.
אנחנו יכולים גם לראות שיש את כל הקטעים האלה שבהם נראה ששום דבר לא קורה בתוך המעקב.
ולמרות שזה לא ברור כאן, זה בעצם המקום שבו הפונקציות הניתנות לקישור מחליטות אם הן עומדות לדלג או לא.
בדוגמה הזו, רק שני רכיבים קומפוזביליים לא נכללים בגלל האופן שבו הרשימה הנטענת באופן מדורג עוברת קומפוזיציה מחדש.
כשנותנים לו למבדה של תוכן חדש, הוא מבטל את כל הפריטים שיש לו.
לכן, הוא יבטל את שני אנשי הקשר שלא השתנו בנוסף לזה שהשתנה, ושני אנשי הקשר האלה הם הפריטים היחידים כאן שניתן לדלג עליהם.
כל שאר הדברים מבצעים את השוואת השוויון הזו רק כדי להבין שהם צריכים לפעול.
אם האובייקטים יציבים, ההשוואה הזו יכולה להיות די יקרה.
אם תאלצו אותנו לעבור על כל מבני הנתונים שלכם, זה יכול להצטבר ולהיות עלות לא טריוויאלית רק כדי לדעת אם שני אובייקטים שווים.
וגרוע מכך, בגלל שפירקנו את המבנה, אנחנו צריכים לבצע את אותה השוואה ברשימה פעמיים.
אם במקום זאת תאפשרו לקומפיילר לפעול עם התנהגות ברירת המחדל ותשתמשו בדילוג חזק, עדיין תוכלו לקבל דילוג ועדיין תוכלו לקבל בדיקות כדי לדלג על ה-composables שלכם באופן מותנה, אבל הם יעברו את המסלול המהיר בהרבה של שימוש בשוויון זהות אובייקט במקום שוויון מבני.
זה הרבה יותר מהיר לחישוב, ולמרות שאנחנו מרכיבים מחדש את אותם פריטים בפריים הזה, שבו באמת חשוב שנכנס לשם מהר, אנחנו לא מבזבזים יותר מדי זמן על ההבנה שלא יכולנו לדלג על הפונקציות הקומפוזביליות האלה.
זו גם הסיבה שאנחנו מדגישים את השימוש בכלים כמו נקודות השוואה ומעקבים כדי להבין מה קורה בתוך האפליקציה, במקום להסתמך ישירות על ספירת ההרכבות מחדש.
הם יכולים להיות מדד שימושי, אבל לא צריך להתמקד בהם יותר מדי או להסתמך עליהם יותר מדי במהלך האופטימיזציה של האפליקציות.
חשוב לזכור: במקום לנסות לדלג על ה-composable, כדאי להימנע מביטול התוקף שלו מלכתחילה.
בדוגמה הזו ראינו שמעקבי קומפוזיציה יכולים לספק הרבה מידע על מה שקורה, אבל הם גם לא בהכרח מציגים את התמונה המלאה.
לשם כך, אנדריי יספר לנו על תיעודי method.
טוב.
כמו שאנדרו כבר אמר, מעקב אחרי קומפוזיציה לא תמיד מראה את כל מה שקורה, כי הוא מוסיף הערות רק לפונקציות קומפוזביליות, ויכולים להיות מקרים שבהם רואים רק שטח ריק במעקב אחרי קומפוזיציה שכבר מגדיל את המעקב של Perfetto, אבל הוא עדיין לא מראה את כל מה שקורה שם.
ולפעמים זה יכול להיות משהו ש-Compose עושה, לפעמים זה יכול להיות משהו בקוד שלכם, ממש קשה להבין את זה רק על ידי התבוננות במעקב אחר הקומפוזיציה כאן.
עכשיו אתם צריכים לקבל את תיעוד ה-method.
יש משהו בכלי לניתוח ביצועים (profiler), כמו פריט בכלי לניתוח ביצועים (profiler) ב-Android Studio, או שאפשר גם להשתמש בפקודות adb כדי לתפוס את נתוני המעקב, ומה שתקבלו זה כל בקשה להפעלת פונקציה שמתרחשת בעולם של Java.
הוא כולל את כל מה שהקומפיילר של Compose מקודד, את כל מה ש-Compose Runtime עושה, וגם את כל מה שהקוד שלכם עושה. הוא שימושי מאוד למעקב ולבדיקה של מה בדיוק פועל, אילו פונקציות נקראו במרחב הזה.
דבר אחד, אני רוצה לראות את הפריט הזה באמצע.
חשוב לציין שלמעקב אחר שיטות יש תקורה גבוהה מאוד.
יש לכם תקורה גדולה פי 10 עד 20, תלוי במורכבות של עץ הפונקציות, כי כל קריאה להפעלת פונקציה מוסיפה קצת לתקורה.
כדי להמחיש את זה, אפשר להסתכל על צילום המסך.
ככה נראה בדרך כלל תיעוד method של תחילת האפליקציה, ואולי אתם שואלים את עצמכם "איך אפשר להבין את זה?"
בדרך כלל מתחילים את המעקב אחרי הקומפוזיציה, מסתכלים על הפונקציות שיש כאן.
אז זה כמו מעקב קומפוזיציה פשוט, שבו יש ScreenContent, כפתור כלשהו ועוד כמה פונקציות.
נניח שאנחנו רוצים להבין מה קורה ב-ScreenContent.
ב-Perfetto יש שורת חיפוש.
אתם טוענים את תיעוד ה-method במציג של Perfetto, מחפשים את אותה פונקציה כמו כאן, ScreenContent, והוא יגיע למשהו כזה, שהוא תיעוד ה-method של המסך.
אז אפשר לראות כאן שיש Composer.startGroup ושיחות קבוצתיות.
זה מה שסביבות זמן הריצה והקומפיילר עושים בשבילכם ברקע כדי לוודא שהקומפוזיציה שלכם באמת פועלת, אבל הקריאה המעניינת כאן היא Composer.changed.
כך אנחנו בודקים אם הפרמטרים השתנו בין הקריאות הקודמות לקריאה הנוכחית של ה-composable.
וברוב המקרים, Composer.changed נשלט על ידי ScreenModel.equals.
אז אולי ScreenModel הוא סוג של מחלקת נתונים בהיקפים גדולים (Big Data), ואולי הפכנו אותו ליציב כדי לעזור עם ספירת ה-recomposition, אז אולי זה משהו שהופיע בדוח של הקומפיילר.
יכול להיות שהסרת ההערה הזו תעזור לשפר את הביצועים במקרה הספציפי הזה.
לכן, כדאי לחפש את זה, והכלי הכי טוב למצוא את זה הוא תיעוד method של קומפוזיציה.
טוב.
עברנו על הרבה דברים היום, ועכשיו אנדרו יסכם את הדברים.
לסיכום, הנה כמה דברים שכדאי לזכור כשמעצבים רכיבים קומפוזביליים וכשמבצעים אופטימיזציה לאפליקציות שכבר יש לכם.
קודם כל, חשוב לזכור שרכיבי Composable זולים יותר ורכיבי Composable שלא מבטלים את התוקף שלהם עדיפים על רכיבי Composable שמדלגים עליהם.
חשוב לזכור שכאשר אתם מתכננים רכיבים קומפוזביליים, עדיף להשתמש בארגומנטים קטנים יותר שקל יותר להשוות ביניהם, ואולי לעשות שימוש חוזר בהשוואות מול קריאות אחרות לפונקציות כדי לנצל את האופטימיזציה.
כשמשתמשים בממשקי API בתוך פונקציות ה-composable, חשוב לזכור שהביצועים או זמן רינדור הפריים יהיו תוצר של עלות ממשקי ה-API עצמם ותדירות השימוש בהם.
חשוב לשים לב איך העלויות האלה מצטברות, וגם שיכול להיות שיהיו עלויות פירוק שקשורות אליהן, שעלולות להופיע בזמן הכי פחות מתאים לאפליקציה שלכם.
בנוסף, חשוב לזכור שצריך להיות אזרח טוב כשמבצעים אופטימיזציות בתוך האפליקציה עצמה.
כדי לקבל החלטות מושכלות, קודם כל צריך להריץ בדיקות ביצועים, ליצור פרופיל של האפליקציה כדי לוודא שאתם מטרגטים את האזורים הנכונים בקוד, ולוודא שאתם מבצעים את השינויים ורואים את התוצאות שאתם מצפים להן מהשינויים שאתם מבצעים כתוצאה מבדיקות הביצועים ויצירת הפרופילים.
חשוב גם לזכור שיכול להיות שתצטרכו לבחור בין יכולת התחזוקה לבין הביצועים של האפליקציה.
לכן, עשו מה שהכי טוב לכם ולצוות שלכם כדי לוודא שאתם לא מבצעים אופטימיזציית יתר ודוחקים את עצמכם לפינה עם יכולת התחזוקה.
בנוסף, יש לכם עוד כלי שיכול לעזור לכם – סוכן ה-AI שכבר יש לכם.
השקנו כחלק מ-Android CLI וכחלק מהמיומנויות שאנחנו שולחים לשם מיומנות ביצועים של Jetpack Compose שיש בה הרבה מהמידע שדיברנו עליו היום ויכולה לעבור ולבדוק את האפליקציה שלכם ולהציע הצעות לאופטימיזציות מהסוג הזה.
אתם יכולים להתקין אותו בנציג הקיים באמצעות Android CLI.
android skills add jetpack-compose-performanceמידע נוסף על Android CLI זמין בכתובת developer.android.com.
במאגר GitHub שלנו אפשר גם לקרוא מידע נוסף על המיומנויות עצמן.
זהו, עכשיו אתם יכולים לבצע השוואת ביצועים בטוחה וליצור קומפוזיציות בקלות.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Asynchronous work and animations
LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
}@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val activity = LocalActivity.current
// Setup and teardown
DisposableEffect(Unit) {
activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
onDispose {
activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
}
}
}@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// One-shot events
SideEffect(Unit) { mediaPlayer.play() }
}@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Synchronize the outside world to the state of composition
SideEffect { mediaPlayer.playbackRate = playbackRate }
}@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) { viewModel.loadCart() }
LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }
CartContent(...)
}@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) {
launch { viewModel.loadCart() }
launch { viewModel.loadPaymentMethods() }
launch { viewModel.loadDeliveryAddresses() }
}
CartContent(...)
}@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton = scrollState.value > 0
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}var a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
// First read
derivedState.value
computedState.value
// Second read
derivedState.value
computedState.valuevar a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
...
// Read-after-write
a++
derivedState.value
computedState.value// What you write
@Composable fun ContactListItem(
contact: Contact
) { ... }
// What you get
@Composable fun ContactListItem(
contact: Contact,
$composer: Composer?
$changed: Int
) {
val $dirty = $changed or
$composer.changed(contact)
if ($dirty) { /* Your function body */ }
else $composer.skipToGroupEnd()
}data class ContactsModel(
val header: String,
val contacts: List<Contact>
)
data class Contact(...)
@Composable
fun ContactsScreen(model: ContactsModel) {
Column {
TopAppBar(title = { Text(model.header) })
ContactsList(model.contacts)
}
}
@Composable
fun ContactsList(contacts: List<Contact>) {
LazyColumn {
items(contacts) { ContactListItem(it) }
}
}
@Composable
fun ContactListItem(contact: Contact) { ... }android skills add jetpack-compose-performance