נתונים בהיקף מקומי באמצעות CompositionLocal

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

מבוא ל-CompositionLocal

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

@Composable
fun MyApp() {
    // Theme information tends to be defined near the root of the application
    val colors = colors()
}

// Some composable deep in the hierarchy
@Composable
fun SomeTextLabel(labelText: String) {
    Text(
        text = labelText,
        color = colors.onPrimary // ← need to access colors here
    )
}

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

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

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

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

CompositionLocal הוא מה שהתאמת העיצוב של Google Material Design משתמשת בו מתחת לפני השטח. ‫MaterialTheme הוא אובייקט שמספק שלושה מופעים של CompositionLocal: ‏colorScheme, ‏typography ו-shapes, ומאפשר לאחזר אותם מאוחר יותר בכל חלק צאצא של הקומפוזיציה. באופן ספציפי, אלה המאפיינים LocalColorScheme,‏ LocalShapes ו-LocalTypography שאפשר לגשת אליהם דרך המאפיינים MaterialTheme,‏ colorScheme,‏ shapes ו-typography.

@Composable
fun MyApp() {
    // Provides a Theme whose values are propagated down its `content`
    MaterialTheme {
        // New values for colorScheme, typography, and shapes are available
        // in MaterialTheme's content lambda.

        // ... content here ...
    }
}

// Some composable deep in the hierarchy of MaterialTheme
@Composable
fun SomeTextLabel(labelText: String) {
    Text(
        text = labelText,
        // `primary` is obtained from MaterialTheme's
        // LocalColors CompositionLocal
        color = MaterialTheme.colorScheme.primary
    )
}

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

כדי לספק ערך חדש ל-CompositionLocal, משתמשים ב-CompositionLocalProvider ובפונקציית האינפיקס provides שמשייכת מפתח CompositionLocal ל-value. הלמבדה content של CompositionLocalProvider תקבל את הערך שסופק כשתהיה גישה למאפיין current של CompositionLocal. כשמספקים ערך חדש, Compose יוצר מחדש חלקים מהקומפוזיציה שקוראים את CompositionLocal.

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

@Composable
fun CompositionLocalExample() {
    MaterialTheme {
        // Surface provides contentColorFor(MaterialTheme.colorScheme.surface) by default
        // This is to automatically make text and other content contrast to the background
        // correctly.
        Surface {
            Column {
                Text("Uses Surface's provided content color")
                CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) {
                    Text("Primary color provided by LocalContentColor")
                    Text("This Text also uses primary as textColor")
                    CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.error) {
                        DescendantExample()
                    }
                }
            }
        }
    }
}

@Composable
fun DescendantExample() {
    // CompositionLocalProviders also work across composable functions
    Text("This Text uses the error color now")
}

תצוגה מקדימה של הרכיב הקומפוזבילי CompositionLocalExample.
איור 1. תצוגה מקדימה של ה-composable‏ CompositionLocalExample.

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

@Composable
fun FruitText(fruitSize: Int) {
    // Get `resources` from the current value of LocalContext
    val resources = LocalContext.current.resources
    val fruitText = remember(resources, fruitSize) {
        resources.getQuantityString(R.plurals.fruit_title, fruitSize)
    }
    Text(text = fruitText)
}

רוצה ליצור CompositionLocal משלך?

‫CompositionLocal הוא כלי להעברת נתונים דרך הקומפוזיציה באופן משתמע.

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

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

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

בנוסף, יכול להיות שלא יהיה מקור מידע ברור לתלות הזו, כי היא יכולה להשתנות בכל חלק של הקומפוזיציה. לכן, ניפוי באגים באפליקציה כשמתרחשת בעיה יכול להיות מאתגר יותר כי צריך לנווט למעלה בקומפוזיציה כדי לראות איפה הערך של current סופק. כלים כמו Find usages ב-IDE או Compose layout inspector מספקים מספיק מידע כדי לפתור את הבעיה הזו.

החלטה אם להשתמש ב-CompositionLocal

יש תנאים מסוימים שבהם CompositionLocal הוא פתרון טוב לתרחיש לדוגמה שלכם:

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

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

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

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

צור CompositionLocal

יש שני ממשקי API ליצירת CompositionLocal:

  • compositionLocalOf: שינוי הערך שסופק במהלך ה-recomposition מבטל רק את התוכן שקורא את הערך current שלו.

  • staticCompositionLocalOf: בניגוד ל-compositionLocalOf, קריאות של staticCompositionLocalOf לא מתועדות על ידי Compose. שינוי הערך גורם ליצירה מחדש של כל פונקציית הלמבדה content שבה מסופק CompositionLocal, במקום רק המקומות שבהם הערך של current נקרא בקומפוזיציה.

אם הערך שסופק ל-CompositionLocal לא צפוי להשתנות או לא ישתנה אף פעם, כדאי להשתמש ב-staticCompositionLocalOf כדי לשפר את הביצועים.

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

// LocalElevations.kt file

data class Elevations(val card: Dp = 0.dp, val default: Dp = 0.dp)

// Define a CompositionLocal global object with a default
// This instance can be accessed by all composables in the app
val LocalElevations = compositionLocalOf { Elevations() }

הזנת ערכים בCompositionLocal

הרכיב הקומפוזבילי CompositionLocalProvider מקשר ערכים למופעים של CompositionLocal בהיררכיה הנתונה. כדי לספק ערך חדש ל-CompositionLocal, משתמשים בפונקציית האינפיקס provides שמשייכת מפתח CompositionLocal ל-value, באופן הבא:

// MyActivity.kt file

class MyActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            // Calculate elevations based on the system theme
            val elevations = if (isSystemInDarkTheme()) {
                Elevations(card = 1.dp, default = 1.dp)
            } else {
                Elevations(card = 0.dp, default = 0.dp)
            }

            // Bind elevation as the value for LocalElevations
            CompositionLocalProvider(LocalElevations provides elevations) {
                // ... Content goes here ...
                // This part of Composition will see the `elevations` instance
                // when accessing LocalElevations.current
            }
        }
    }
}

שימוש בCompositionLocal

הפונקציה CompositionLocal.current מחזירה את הערך שסופק על ידי ה-CompositionLocalProvider הקרוב ביותר שמספק ערך לאותו CompositionLocal:

@Composable
fun SomeComposable() {
    // Access the globally defined LocalElevations variable to get the
    // current Elevations in this part of the Composition
    MyCard(elevation = LocalElevations.current.card) {
        // Content
    }
}

חלופות שכדאי לשקול

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

העברת פרמטרים מפורשים

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

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    MyDescendant(myViewModel.data)
}

// Don't pass the whole object! Just what the descendant needs.
// Also, don't  pass the ViewModel as an implicit dependency using
// a CompositionLocal.
@Composable
fun MyDescendant(myViewModel: MyViewModel) { /* ... */ }

// Pass only what the descendant needs
@Composable
fun MyDescendant(data: DataToDisplay) {
    // Display data
}

היפוך שליטה (Inversion of control)

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

בדוגמה הבאה, צאצא צריך להפעיל את הבקשה לטעינת נתונים:

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    MyDescendant(myViewModel)
}

@Composable
fun MyDescendant(myViewModel: MyViewModel) {
    Button(onClick = { myViewModel.loadData() }) {
        Text("Load data")
    }
}

בהתאם למקרה, יכול להיות ש-MyDescendant יישא באחריות רבה. בנוסף, העברת MyViewModel כתלות הופכת את MyDescendant לפחות ניתן לשימוש חוזר כי עכשיו הם משולבים יחד. במקום זאת, אפשר להשתמש בעקרונות של היפוך בקרה (Inversion of Control) כדי שהרכיב האב יהיה אחראי על הפעלת הלוגיקה, ולא להעביר את התלות לרכיב הצאצא:

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    ReusableLoadDataButton(
        onLoadClick = {
            myViewModel.loadData()
        }
    )
}

@Composable
fun ReusableLoadDataButton(onLoadClick: () -> Unit) {
    Button(onClick = onLoadClick) {
        Text("Load data")
    }
}

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

באופן דומה, אפשר להשתמש בביטויי למבדה של תוכן ב-@Composable באותה דרך כדי לקבל את אותם יתרונות:

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    ReusablePartOfTheScreen(
        content = {
            Button(
                onClick = {
                    myViewModel.loadData()
                }
            ) {
                Text("Confirm")
            }
        }
    )
}

@Composable
fun ReusablePartOfTheScreen(content: @Composable () -> Unit) {
    Column {
        // ...
        content()
    }
}