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

CompositionLocal הוא כלי להעברת נתונים למטה דרך Composition באופן סמלי. בדף הזה נסביר בפירוט מהו 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
    )
}

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

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

CompositionLocal הוא הרכיב שבו נעשה שימוש ברקע של העיצוב של Material. MaterialTheme הוא אובייקט שמספק שלוש מכונות CompositionLocal: colorScheme,‏ typography ו-shapes, שמאפשרות לאחזר אותן מאוחר יותר בכל חלק צאצא של Composition. באופן ספציפי, אלה המאפיינים 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. פונקציית ה-lambda content של CompositionLocalProvider תקבל את הערך שסופק כשניגשים לנכס current של CompositionLocal. כשמציינים ערך חדש, Compose יוצר מחדש חלקים מהקומפוזיציה שקוראים את CompositionLocal.

לדוגמה, השדה CompositionLocal ב-LocalContentColor מכיל את צבע התוכן המועדף לשימוש בטקסט ובסמלי אייקונים, כדי להבטיח שהוא יהיה בניגוד לצבע הרקע הנוכחי. בדוגמה הבאה, המאפיין 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")
}

איור 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 הוא כשהפרמטר הוא חוצה-תחומים, ושכבות ביניים של ההטמעה לא צריכות לדעת שהוא קיים, כי המודעוּת של השכבות הבינוניות האלה תגביל את התועלת של הרכיב הניתן לקיבוץ. לדוגמה, שאילתות לגבי הרשאות Android מתבצעות באמצעות CompositionLocal ברקע. בורר מדיה שאפשר לשנות אותו יכול להוסיף פונקציונליות חדשה כדי לגשת לתוכן שמוגן בהרשאות במכשיר, בלי לשנות את ה-API שלו ולחייב את מבצעי הקריאה של בורר המדיה להיות מודעים להקשר הנוסף הזה שנעשה בו שימוש מהסביבה.

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

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

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

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

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

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

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

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

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

יצירת CompositionLocal

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

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

  • staticCompositionLocalOf: בניגוד ל-compositionLocalOf, Compose לא עוקב אחרי קריאות של staticCompositionLocalOf. שינוי הערך גורם להרכבת מחדש של כל ה-lambda של 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
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
}

היפוך שליטה

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

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

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

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

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

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

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

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

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

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

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