נתונים בהיקף מקומי באמצעות 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
    )
}

כדי שלא תצטרכו להעביר את הצבעים כיחסי תלות מפורשים של פרמטרים לרוב הרכיבים הניתנים ליצירה, Compose מציע את 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. הפונקציה הלוגרית 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. לכן, ניפוי באגים באפליקציה כשמתרחשת בעיה יכול להיות מאתגר יותר, כי צריך לנווט למעלה ב-Composition כדי לראות איפה הוענק הערך current. כלים כמו Find usages בסביבת הפיתוח המשולבת או Compose layout inspector מספקים מספיק מידע כדי למזער את הבעיה הזו.

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

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

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

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

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

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

יצירת CompositionLocal

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

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

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

אם סביר מאוד שהערך שצוין ב-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 גמישים יותר ברמה נמוכה יותר.

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

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

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