פתרון של בעיות ביציבות

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

הפעלת דילוג חזק

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

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

מידע נוסף זמין במאמר דילוג חזק.

הפיכת המחלקה לבלתי ניתנת לשינוי

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

  • לא ניתן לשינוי: מציין סוג שבו הערך של מאפיינים כלשהם לא יכול להשתנות אף פעם אחרי שנוצר מופע של הסוג הזה, וכל השיטות שקופות מבחינת הפניות.
    • צריך לוודא שכל המאפיינים של המחלקה הם val ולא var, ושהם מסוגים שלא ניתנים לשינוי.
    • סוגים פרימיטיביים כמו String,‏ Int ו-Float הם תמיד לא ניתנים לשינוי.
    • סוגי data class שטוחים עם מאפיינים פרימיטיביים של val או String במודולים שנוצרו באמצעות מהדר Compose כבר מוסקים כיציבים ומושווים ל-.equals() באופן אוטומטי – אין צורך להוסיף להם הערות עם @Immutable.
    • אם אי אפשר להגדיר את המצב כבלתי ניתן לשינוי, צריך להשתמש במצב של Compose לכל מאפיין שניתן לשינוי.
  • יציב: מציין סוג שניתן לשינוי. סביבת זמן הריצה של Compose לא מודעת למקרים שבהם אחד מהמאפיינים הציבוריים או התנהגות השיטה של הסוג הזה יניבו תוצאות שונות מהפעלה קודמת.

אוספים ויציבות

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

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

unstable class Snack {
  …
  unstable val tags: Set<String>
  …
}

כאשר מצב דילוג חזק מופעל (ברירת המחדל ב-Kotlin 2.0.20 ואילך), אוספים לא יציבים ומחלקות שמכילות אוספים כבר מדלגים על הרכבה מחדש באמצעות שוויון מופעים (===) בכל פעם שמועברת אותה הפניה.

חשוב להיזהר לפני שמחליפים את kotlin.collections.List,‏ Set או Map באוספים לא ניתנים לשינוי של Kotlinx (ImmutableList,‏ ImmutableSet,‏ ImmutableMap), או לפני שמוסיפים כללים של @Immutable,‏ @Stable או stability_config.conf למודלים שמכילים אוספים. במצב דילוג חזק, List הרגיל מדלג באמצעות שוויון של קובץ עזר (===), ואילו ImmutableList ומודלים של אוספים עם הערות מריצים השוואות של .equals() רכיב אחר רכיב בכל מעבר של הרכבה מחדש.

באוספים גדולים או בפריסות עצלות (LazyColumn, ‏ LazyRow, ‏ LazyGrid), בדיקת .equals() בכל פריט יכולה להימשך יותר זמן מאשר הרכבה מחדש. העלות הזו מוכפלת אם מעבירים מאפיין ImmutableList מ-data class לפונקציה קומפוזבילית צאצא:

data class SnackCategory(
    val name: String,
    val snacks: ImmutableList<Snack>
)

@Composable
fun CategorySection(category: SnackCategory) {
    Text(category.name)
    // Compose compares category.snacks twice: once in SnackCategory.equals()
    // for CategorySection, and again for HighlightedSnacks.
    HighlightedSnacks(snacks = category.snacks)
}

במקום לאלץ שוויון מבני באוספים, כדאי לשמור את ההפניה למופע של האוסף בין הפליטות (לדוגמה, על ידי שמירת טרנספורמציות במטמון ב-ViewModel או באמצעות remember) כדי ש-Compose יוכל לדלג על השימוש בשוויון מופעים (===). כדאי לשקול להשתמש ב-ImmutableList או בהערות יציבות במודלים שמכילים אוספים רק כשאי אפשר לשמור את ההפניה למופע של האוסף, ובדיקות השוואה מאשרות שבדיקות שוויון מבני מהירות יותר מאשר קומפוזיציה מחדש. אם אתם משתמשים בגרסה של Kotlin שהיא ישנה יותר מ-2.0.20, קודם צריך להפעיל את מצב הדילוג החזק.

הוספת הערות באמצעות Stable או Immutable

דרך אפשרית לפתור בעיות יציבות במחלקות קטנות עם בדיקות .equals() לא יקרות היא להוסיף הערות למחלקות לא יציבות באמצעות @Stable או @Immutable.

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

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

  • אל תוסיפו הערות למחלקות שכבר הוסקו כיציבות: מחלקות עם מאפייני val של סוגים יציבים במודולים שנוצרו באמצעות המהדר של פיתוח נייטיב כבר הוסקו כיציבות.
  • הוסיפו הערות רק למחלקות עם בדיקות .equals() פשוטות כשנוצרים מופעים חדשים: הוסיפו את @Stable או את @Immutable כשהקומפיילר לא יכול להסיק יציבות (לדוגמה, ממשקים או מחלקות ממודולים שלא שייכים ל-Compose) וגם מקור נתונים מקצה מופעי אובייקטים חדשים לפריטים שלא השתנו בכל פליטה, בתנאי שהמחלקה קטנה ויש לה בדיקת .equals() פשוטה.
  • היזהרו עם מודלים שמכילים אוספים: אל תוסיפו את @Stable או את @Immutable למודלים של נתונים שמכילים אוספים גדולים (List,‏ Set,‏ Map), במיוחד למודלים שמשמשים בפריסות עם טעינה מדורגת (LazyColumn,‏ LazyRow,‏ LazyGrid,‏ Pager). אם לא מוסיפים הערות למודלים האלה, Compose יכול להשוות ביניהם באמצעות שוויון מופעים (===) במקום להריץ בדיקות .equals() מבניות בכל רכיב.

קטע הקוד הבא הוא דוגמה מינימלית למחלקת נתונים שטוחה (ללא מאפייני אוסף) שמסומנת כבלתי ניתנת לשינוי כשהיא מוגדרת במודול שבו המהדר לא יכול להסיק יציבות:

@Immutable
data class SnackSummary(
    val id: Long,
    val name: String,
    val price: Long
)

לא משנה אם משתמשים בהערה @Immutable או @Stable, המהדר של Compose מסמן את המחלקה SnackSummary כיציבה ומשווה אותה באמצעות Object.equals().

כיתות עם הערות באוספים

נניח שיש פונקציה קומפוזבילית שכוללת פרמטר מסוג List<Snack>:

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun HighlightedSnacks(
  stable index: Int
  unstable snacks: List<Snack>
  stable onSnackClick: Function1<Long, Unit>
  stable modifier: Modifier? = @static Companion
)

גם אם Snack יציב או מסומן באמצעות @Immutable, המהדר של פיתוח נייטיב עדיין מסמן את הפרמטר snacks ב-HighlightedSnacks כ-unstable כי List הוא ממשק.

כשהתכונה מצב דילוג חזק מופעלת, HighlightedSnacks עדיין מסומן בתור skippable ומדלג על הרכבה מחדש באמצעות שוויון מופעים (===) כשמועבר אותו מופע של List. כדי להפוך את הפרמטר snacks לstable, אפשר לעטוף את List<Snack> במחלקת wrapper מסוג @Immutable, להחליף את List<Snack> ב-ImmutableList<Snack> או להוסיף את kotlin.collections.* לקובץ התצורה של היציבות.

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

קובץ תצורה של יציבות

החל מגרסה 1.5.5 של Compose Compiler, אפשר לספק קובץ תצורה של מחלקות שייחשבו יציבות בזמן הקימפול. כך אפשר להתייחס למחלקות שאין לכם שליטה עליהן, כמו מחלקות של ספרייה רגילה כמו LocalDateTime, כיציבות.

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

הגדרה לדוגמה:

// Consider LocalDateTime stable
java.time.LocalDateTime
// Consider my datalayer stable
com.datalayer.*
// Consider my datalayer and all submodules stable
com.datalayer.**
// Consider my generic type stable based off it's first type parameter only
com.example.GenericClass<*,_>

כדי להפעיל את התכונה הזו, מעבירים את הנתיב של קובץ התצורה לבלוק האפשרויות composeCompiler של הגדרת הפלאגין של Gradle לקומפיילר של Compose.

composeCompiler {
  stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf")
}

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

מספר מודולים

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

אם שכבת הנתונים נמצאת במודול נפרד משכבת ממשק המשתמש, וזו הגישה המומלצת, יכול להיות שתיתקלו בבעיה הזו. במצב דילוג חזק, מחלקות ממודולים שהם לא Compose נחשבות לא יציבות, ועדיין מדלגות על הרכבה מחדש באמצעות שוויון מופעים (===) כשמועבר אותו מופע. עם זאת, אם שכבת הנתונים מקצה לעיתים קרובות מופעים חדשים של מודלים קטנים ושטוחים עם ערכים זהים בכל פליטה, אתם יכולים להביע הסכמה לשוויון מבני (Object.equals()).

פתרון

כדי לפתור את הבעיה, אתם יכולים לבחור באחת מהאפשרויות הבאות:

  1. מוסיפים מחלקות קטנות ושטוחות עם בדיקות .equals() זולות לקובץ התצורה של המהדר.
  2. מפעילים את המהדר של Compose במודולים של שכבת הנתונים, או מתייגים את המחלקות באמצעות @Stable או @Immutable במקומות המתאימים.
    • כדי לעשות את זה, צריך להוסיף תלות של Compose לשכבת הנתונים. עם זאת, הוא רק יחסי התלות של סביבת זמן הריצה של Compose ולא של Compose-UI.
  3. בתוך מודול ממשק המשתמש, עוטפים את המחלקות של שכבת הנתונים במחלקות wrapper ספציפיות לממשק המשתמש.

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

לא כל פונקציה קומפוזבילית צריכה להיות ניתנת לדילוג

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

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

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

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