שיטות מומלצות

יכול להיות שתיתקלו בבעיות נפוצות ב-Compose. הטעויות האלה עלולות לגרום לכך שהקוד יפעל בצורה תקינה, אבל יפגע בביצועים של ממשק המשתמש. כדאי לפעול לפי השיטות המומלצות כדי לבצע אופטימיזציה של האפליקציה ב-Compose.

שימוש ב-remember כדי לצמצם חישובים יקרים

פונקציות קומפוזביליות יכולות לפעול בתדירות גבוהה מאוד, אפילו בכל פריים של אנימציה. לכן, כדאי לבצע כמה שפחות חישובים בגוף של הפונקציה הקומפוזבילית.

טכניקה חשובה היא לאחסן את תוצאות החישובים באמצעות remember. כך החישוב מתבצע פעם אחת, ואפשר לאחזר את התוצאות בכל פעם שצריך אותן.

לדוגמה, הנה קוד שמציג רשימה ממוינת של שמות, אבל המיון מתבצע בצורה מאוד יקרה:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier) {
        // DON’T DO THIS
        items(contacts.sortedWith(comparator)) { contact ->
            // ...
        }
    }
}

בכל פעם שמתבצעת קומפוזיציה מחדש של ContactsList, כל רשימת אנשי הקשר ממוינת מחדש, למרות שהרשימה לא השתנתה. אם המשתמש גולל ברשימה, ה-Composable מורכב מחדש בכל פעם שמופיעה שורה חדשה.

כדי לפתור את הבעיה, ממיינים את הרשימה מחוץ ל-LazyColumn, ושומרים את הרשימה הממוינת באמצעות remember:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    val sortedContacts = remember(contacts, comparator) {
        contacts.sortedWith(comparator)
    }

    LazyColumn(modifier) {
        items(sortedContacts) {
            // ...
        }
    }
}

עכשיו הרשימה ממוינת פעם אחת, כשהקומפוזיציה של ContactList נוצרת בפעם הראשונה. אם אנשי הקשר או ההשוואה משתנים, הרשימה הממוינת נוצרת מחדש. אחרת, הפונקציה הקומפוזבילית יכולה להמשיך להשתמש ברשימה הממוינת שנשמרה במטמון.

תמיד צריך להעביר את כל הקלטים שאליהם מפנים בתוך בלוק החישוב remember כמפתחות, כדי שהערך שנשמר במטמון יתעדכן כשהקלטים האלה ישתנו. ‫remember שומר את המפתחות והתוצאה שנשמרה במטמון בטבלת המשבצות ומשווה בין המפתחות באמצעות Object.equals() בכל מעבר של הרכבה מחדש, לכן כדאי להשתמש ב-remember רק כששומרים במטמון חישובים יקרים או הקצאות של אובייקטים כבדים (כמו מעצבי תאריכים, תוכנות הצללה או מיון רשימות) או כשצריך לשמור על זהות האובייקט.

היזהרו כשאתם מעבירים אוסף גדול או אובייקט עם הטמעה יקרה של equals() כמפתח ל-remember לחישוב זול, כי השוואת המפתח יכולה לעלות יותר מהפעלת החישוב ישירות:

@Composable
fun ContactHeader(contacts: List<Contact>) {
    // Avoid: Comparing the `contacts` list with .equals() on recomposition
    // is more expensive than reading contacts.size or contacts.firstOrNull() directly.
    val firstContactName = remember(contacts) {
        contacts.firstOrNull()?.name.orEmpty()
    }
    Text(firstContactName)
}

שימוש במקשים ובסוגי תוכן של פריסה עצלה

פריסות עם טעינה מדורגת עושות שימוש חוזר בפריטים בצורה יעילה, ויוצרות או מרכיבות אותם מחדש רק כשצריך. עם זאת, אתם יכולים לבצע אופטימיזציה של פריסות עם טעינה מדורגת כדי לשפר את היצירה מחדש ואת השימוש החוזר במשבצות.

נניח שפעולה של משתמש גורמת לפריט לזוז ברשימה. לדוגמה, נניח שאתם מציגים רשימה של פתקים שממוינים לפי זמן השינוי, כשהפתק ששונה לאחרונה נמצא בראש הרשימה.

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes
        ) { note ->
            NoteRow(note)
        }
    }
}

אבל יש בעיה בקוד הזה. נניח שהתו התחתון משתנה. עכשיו זה הפתק ששונה לאחרונה, אז הוא עובר לראש הרשימה, וכל שאר הפתקים יורדים מקום אחד למטה.

בלי העזרה שלכם, Compose לא מבין שפריטים שלא השתנו פשוט הועברו ברשימה. במקום זאת, Compose מניח ש "פריט 2" הישן נמחק ונוצר פריט חדש לפריט 3, לפריט 4 וכן הלאה. התוצאה היא שפיתוח נייטיב מבצע קומפוזיציה מחדש של כל פריט ברשימה, למרות שרק אחד מהם השתנה בפועל.

הפתרון הוא לספק מפתחות פריטים. אם תספקו מפתח יציב לכל פריט, תוכלו למנוע קומפוזיציות מחדש מיותרות ב-Compose. במקרה כזה, Compose יכול לקבוע שהפריט שנמצא עכשיו במיקום 3 הוא אותו פריט שהיה במיקום 2. מכיוון שאף אחד מהנתונים של הפריט הזה לא השתנה, אין צורך ליצור אותו מחדש.

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes,
            key = { note ->
                // Return a stable, unique key for the note
                note.id
            }
        ) { note ->
            NoteRow(note)
        }
    }
}

כשעובדים עם פריסות עצלות (LazyColumn, ‏ LazyRow, ‏ LazyGrid), חשוב גם לפעול לפי ההנחיות הבאות:

  • שימוש במפתחות ייחודיים שניתן לשמור ב-Bundle: המפתחות חייבים להיות ייחודיים, דטרמיניסטיים וניתנים לשמירה ב-Bundle של Android, כי פריסות עם טעינה מדורגת משתמשות ב-rememberSaveable באופן פנימי. עדיף להשתמש בסוגים פרימיטיביים כמו Int,‏ Long או String, ואף פעם לא להשתמש באינדקסים של רשימות או ב-hashCode() כמפתחות.
  • תמיד צריך לציין את contentType לרשימות הטרוגניות: מעבירים את contentType = { it.type } אל items או אל itemsIndexed כדי ש-Compose יעשה שימוש חוזר רק במשבצות קומפוזיציה בין פריטים שחולקים את אותו מבנה חזותי במהלך הגלילה.
  • השתמשו ב-itemsIndexed במקום ב-indexOf: הימנעו מקריאה ל-list.indexOf(item) בתוך בלוק items, שהופך את הפריסה לסריקה של O(N²). במקום זאת, השתמשו ב-itemsIndexed.
  • העלאת ערך דינמי משותף וקריאות CompositionLocal: אוספים מופעים משותפים של Flow וקוראים ערכי CompositionLocal (כמו LocalContext.current או LocalDensity.current) פעם אחת ב-פונקציה קומפוזבילית ההורה מעל הפריסה ה-lazy, ומעבירים את הערכים שנקבעו ל-פונקציות קומפוזביליות של הפריטים.
  • שימוש ב-Row או ב-Column לרשימות קטנות ומוגבלות: לאוספים סטטיים קטנים או מוגבלים (למשל, פריט אחד עד 10 פריטים), שימוש ב-Row או ב-Column רגילים עם forEach מונע את התקורה של וירטואליזציה עצלה ואחזור מראש.

שימוש ב-computedStateOf או ב-derivedStateOf כדי להגביל את ההרכבות מחדש

אחד הסיכונים בשימוש במצב בקומפוזיציות הוא שאם המצב משתנה במהירות, יכול להיות שהממשק שלכם יורכב מחדש יותר ממה שצריך. לדוגמה, נניח שאתם מציגים רשימה עם אפשרות גלילה. אתם בודקים את מצב הרשימה כדי לראות איזה פריט הוא הפריט הראשון שמופיע ברשימה:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

הבעיה היא שאם המשתמש גולל את הרשימה, הערך של listState משתנה כל הזמן כשהמשתמש גורר את האצבע. זה אומר שהרשימה משתנה כל הזמן. אבל לא צריך ליצור אותו מחדש בתדירות כזו – צריך ליצור אותו מחדש רק כשפריט חדש מופיע בחלק העליון. זה אומר שצריך לבצע הרבה חישובים נוספים, וזה פוגע בביצועים של ממשק המשתמש.

הפתרון הוא להשתמש במצב נגזר (או ב-computedStateOf ב-Compose Runtime בגרסה 1.13 ואילך). מצב נגזר מאפשר לכם להגדיר ל-Compose אילו שינויים במצב אמורים להפעיל קומפוזיציה מחדש. במקרה כזה, צריך לציין שאתם רוצים לדעת מתי הפריט הגלוי הראשון משתנה. כשהערך של המצב הזה משתנה, צריך ליצור מחדש את ממשק המשתמש, אבל אם המשתמש עדיין לא גלל מספיק כדי להביא פריט חדש לחלק העליון, לא צריך ליצור אותו מחדש.

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

בחרו את הגישה המתאימה להפקת מצב בהתאם לגרסת Compose ולעלות החישוב:

  • remember { computedStateOf { ... } } (Compose Runtime מגרסה 1.13 ואילך): מבצע הערכה מחדש של החישוב בכל קריאה בלי לשמור את התוצאה במטמון, ומבטל את תוקף הקומפוזיציה רק כשהפלט המחושב משתנה. מכיוון שהיא לא דורשת ניהול מטמון וביטול תוקף של תקורה של כתיבה חזרה, עדיף להשתמש ב-remember { computedStateOf { listState.firstVisibleItemIndex > 0 } } לבדיקות תנאים בסיסיות ולנגזרות קלות שמופעלות על ידי מצבים שמשתנים במהירות (כמו היסטים של גלילה).
  • remember { derivedStateOf { ... } }: שומרת במטמון את תוצאת החישוב שלה בכמה קריאות. משתמשים ב-derivedStateOf בגרסאות של Compose שקודמות ל-1.13, או כשהחישוב עצמו דורש הרבה משאבי מחשוב ונקרא כמה פעמים במהלך שלב הקומפוזיציה. אל תעבירו את ערכי המצב של תמונת המצב שנקראו בתוך derivedStateOf (כמו listState.firstVisibleItemIndex) כמפתחות remember, כי הפעולה הזו יוצרת מחדש את DerivedState בכל שינוי מצב ומונעת דילוג.
  • פונקציות getter פשוטות של Kotlin (get()) וקריאות ישירות של מאפיינים: עדיף להשתמש בפונקציית getter פשוטה בהתאמה אישית לביטויים בוליאניים פשוטים, שבהם לא צריך להפחית את מספר הפעמים שהמצב לא תקין, ולקרוא את האוסף .size ישירות במקום לעטוף אותו ב-derivedStateOf. כדאי להימנע משימוש ב-produceState עם snapshotFlow להפקת ערך דינמי סינכרוני, כי זה מוסיף תקורה מיותרת של שליחת שגרת המשך.

דחיית פעולות קריאה כמה שיותר זמן

כשמזהים בעיה בביצועים, דחיית קריאות המצב יכולה לעזור. דחיית קריאות המצב תבטיח ש-Compose יריץ מחדש את הקוד המינימלי האפשרי במהלך הרכבה מחדש. לדוגמה, אם בממשק המשתמש יש ערך דינמי שמוגדר ברמה גבוהה בעץ הפונקציות הקומפוזביליות ואתם קוראים את הערך הדינמי בפונקציה קומפוזבילית צאצא, אתם יכולים לעטוף את קריאת הערך הדינמי בפונקציית lambda. כך הקריאה מתבצעת רק כשבאמת צריך אותה. לעיון, אפשר לראות את ההטמעה באפליקציה לדוגמה Jetsnack. ב-Jetsnack מוטמע אפקט שדומה לסרגל כלים שניתן לכיווץ במסך הפרטים. כדי להבין למה השיטה הזו עובדת, קראו את הפוסט בבלוג Jetpack Compose: Debugging Recomposition.

כדי להשיג את האפקט הזה, הפונקציה הקומפוזבילית Title צריכה את היסט הגלילה כדי להסיט את עצמה באמצעות Modifier. זו גרסה פשוטה של הקוד של Jetsnack לפני האופטימיזציה:

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value)
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
    // ...
    val offset = with(LocalDensity.current) { scroll.toDp() }

    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

כשמצב הגלילה משתנה, Compose מבטל את היקף הקומפוזיציה מחדש של ההורה הקרוב ביותר. במקרה הזה, ההיקף הקרוב ביותר הוא ה-composable SnackDetail. שימו לב שהפונקציה Box היא פונקציה מוטבעת, ולכן היא לא היקף של הרכבה מחדש. לכן, Compose יוצר מחדש את SnackDetail ואת כל הפונקציות הניתנות ליצירה בתוך SnackDetail. אם תשנו את הקוד כך שיקרא רק את המצב שבו אתם משתמשים בו בפועל, תוכלו לצמצם את מספר הרכיבים שצריך ליצור מחדש.

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack) { scroll.value }
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    val offset = with(LocalDensity.current) { scrollProvider().toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

הפרמטר של הגלילה הוא עכשיו פונקציית lambda. זה אומר ש-Title עדיין יכול להפנות למצב המועבר, אבל הערך נקרא רק בתוך Title, שם הוא באמת נדרש. כתוצאה מכך, כשערך הגלילה משתנה, היקף ה-recomposition הקרוב ביותר הוא עכשיו ה-composable של Title – אין יותר צורך ב-recomposition של כל ה-Box.

זה שיפור טוב, אבל אפשר להשתפר עוד יותר. אם אתם גורמים ליצירה מחדש רק כדי לשנות את הפריסה או לשרטט מחדש רכיב Composable, כדאי שתבדקו את זה. במקרה הזה, כל מה שאתם עושים הוא לשנות את ההיסט של ה-composable‏ Title, שאפשר לעשות בשלב הפריסה.

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    Column(
        modifier = Modifier
            .offset { IntOffset(x = 0, y = scrollProvider()) }
    ) {
        // ...
    }
}

בעבר, הקוד השתמש ב-Modifier.offset(x: Dp, y: Dp), שמקבל את ההיסט כפרמטר. אם עוברים לגרסת הלמבדה של המשתנה, אפשר לוודא שהפונקציה קוראת את מצב הגלילה בשלב הפריסה. כתוצאה מכך, כשמצב הגלילה משתנה, Compose יכול לדלג על שלב הקומפוזיציה לחלוטין ולעבור ישירות לשלב הפריסה. כשמעבירים משתני State שמשתנים לעיתים קרובות לתוך משתני שינוי, צריך להשתמש בגרסאות הלמבדה של משתני השינוי בכל פעם שאפשר.

הנה דוגמה נוספת לגישה הזו. הקוד הזה עדיין לא עבר אופטימיזציה:

// Here, assume animateColorBetween() is a function that swaps between
// two colors
val color by animateColorBetween(Color.Cyan, Color.Magenta)

Box(
    Modifier
        .fillMaxSize()
        .background(color)
)

כאן, צבע הרקע של התיבה מתחלף במהירות בין שני צבעים. לכן, המצב הזה משתנה לעיתים קרובות מאוד. לאחר מכן, הרכיב הקומפוזבילי קורא את המצב הזה ב-modifier של הרקע. כתוצאה מכך, צריך ליצור מחדש את התיבה בכל פריים, כי הצבע משתנה בכל פריים.

כדי לשפר את זה, משתמשים ב-modifier שמבוסס על lambda – במקרה הזה, drawBehind. זה אומר שמצב הצבע נקרא רק בשלב הציור. כתוצאה מכך, Compose יכול לדלג לגמרי על שלבי הקומפוזיציה והפריסה – כשהצבע משתנה, Compose עובר ישירות לשלב השרטוט.

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
    Modifier
        .fillMaxSize()
        .drawBehind {
            drawRect(color)
        }
)

הימנעו מכתיבה לאחור

הנחת היסוד של Compose היא שאף פעם לא תכתבו למצב שכבר נקרא. כשעושים את זה, הפעולה נקראת כתיבה לאחור והיא יכולה לגרום ליצירה מחדש בכל פריים, בלי סוף.

הקומפוזבל הבא מציג דוגמה לטעות מהסוג הזה.

@Composable
fun BadComposable() {
    var count by remember { mutableIntStateOf(0) }

    // Causes recomposition on click
    Button(onClick = { count++ }, Modifier.wrapContentSize()) {
        Text("Recompose")
    }

    Text("$count")
    count++ // Backwards write, writing to state after it has been read</b>
}

הקוד הזה מעדכן את הספירה בסוף ה-composable אחרי קריאה שלה בשורה הקודמת. אם תריצו את הקוד הזה, תראו שאחרי שתלחצו על הכפתור, מה שיגרום ליצירה מחדש של הקומפוזיציה, המונה יגדל במהירות בלולאה אינסופית כי Compose יוצר מחדש את ה-Composable הזה, רואה קריאת מצב לא עדכנית, ולכן מתזמן יצירה מחדש נוספת של הקומפוזיציה.

כדי להימנע לחלוטין מכתיבה לאחור, אל תכתבו אף פעם למצב ב-Composition. אם אפשר, תמיד כדאי לכתוב למצב בתגובה לאירוע ובביטוי למבדה, כמו בדוגמה של onClick למעלה.

במדריך המפורט כתיבה לאחור בפיתוח נייטיב מוסבר על כתיבה לאחור, לולאות של יצירה מחדש ותיאום שלבים.

מקורות מידע נוספים